Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
- Amp CLI and IDE extensions support with provider routing
- Streaming and non-streaming responses
- Function calling/tools support
- Google Search grounding, code execution, and URL context tool passthrough
- Multimodal input support (text and images)
- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude, Qwen and iFlow)
- Simple CLI authentication flows (Gemini, OpenAI, Claude, Qwen and iFlow)
Expand Down Expand Up @@ -76,6 +77,89 @@ CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and A

**→ [Complete Amp CLI Integration Guide](https://help.router-for.me/agent-client/amp-cli.html)**

## Google Search Grounding

Gemini models support [Google Search grounding](https://ai.google.dev/gemini-api/docs/google-search) which lets the model search the web before responding. CLIProxyAPI passes through `google_search`, `code_execution`, and `url_context` tools across all API formats (OpenAI Chat Completions, OpenAI Responses, and Claude Messages).

### Enabling Google Search

Include `{"google_search": {}}` in your `tools` array. Works with all three API formats:

**OpenAI Chat Completions:**
```bash
curl http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gemini-2.5-flash",
"messages": [{"role": "user", "content": "What happened in the news today?"}],
"tools": [{"google_search": {}}]
}'
```

**Claude Messages:**
```bash
curl http://localhost:8080/v1/messages \
-H "x-api-key: YOUR_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d '{
"model": "gemini-2.5-flash",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "What happened in the news today?"}],
"tools": [{"google_search": {}}]
}'
```

### Grounding Metadata in Responses

When Google Search is used, the upstream Gemini response includes `groundingMetadata` with search queries, source URLs, and text-to-source mappings. CLIProxyAPI passes this through as a `grounding_metadata` field on the response:

**OpenAI Chat Completions** — on each choice object (`choices[i].grounding_metadata`):
```json
{
"choices": [{
"message": {"role": "assistant", "content": "..."},
"finish_reason": "stop",
"grounding_metadata": {
"webSearchQueries": ["query used by the model"],
"groundingChunks": [{"web": {"uri": "https://...", "title": "...", "domain": "..."}}],
"groundingSupports": [{"segment": {"text": "...", "startIndex": 0, "endIndex": 100}, "groundingChunkIndices": [0]}],
"searchEntryPoint": {"renderedContent": "<!-- Google Search widget HTML -->"},
"retrievalMetadata": {}
}
}]
}
```

**Claude Messages** — on the top-level response object (`grounding_metadata`):
```json
{
"type": "message",
"content": [{"type": "text", "text": "..."}],
"grounding_metadata": {
"webSearchQueries": ["..."],
"groundingChunks": [{"web": {"uri": "...", "title": "..."}}],
"groundingSupports": [...]
}
}
```

**OpenAI Responses API** — on the response object (`grounding_metadata`):
```json
{
"id": "resp_...",
"output": [...],
"grounding_metadata": {
"webSearchQueries": ["..."],
"groundingChunks": [{"web": {"uri": "...", "title": "..."}}],
"groundingSupports": [...]
}
}
```

> **Note:** `grounding_metadata` is a passthrough of Google's raw `groundingMetadata` object. The field is additive and will be silently ignored by standard OpenAI/Claude SDKs. See the [Gemini grounding docs](https://ai.google.dev/gemini-api/docs/google-search) for the full schema.

## SDK Docs

- Usage: [docs/sdk-usage.md](docs/sdk-usage.md)
Expand Down
84 changes: 84 additions & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元
- 新增 iFlow 支持(OAuth 登录)
- 支持流式与非流式响应
- 函数调用/工具支持
- Google 搜索 grounding、代码执行、URL 上下文工具透传
- 多模态输入(文本、图片)
- 多账户支持与轮询负载均衡(Gemini、OpenAI、Claude、Qwen 与 iFlow)
- 简单的 CLI 身份验证流程(Gemini、OpenAI、Claude、Qwen 与 iFlow)
Expand Down Expand Up @@ -75,6 +76,89 @@ CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支

**→ [Amp CLI 完整集成指南](https://help.router-for.me/cn/agent-client/amp-cli.html)**

## Google 搜索 Grounding

Gemini 模型支持 [Google 搜索 grounding](https://ai.google.dev/gemini-api/docs/google-search),允许模型在回答前搜索网络。CLIProxyAPI 在所有 API 格式(OpenAI Chat Completions、OpenAI Responses 和 Claude Messages)中透传 `google_search`、`code_execution` 和 `url_context` 工具。

### 启用 Google 搜索

在 `tools` 数组中加入 `{"google_search": {}}`,适用于所有三种 API 格式:

**OpenAI Chat Completions:**
```bash
curl http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gemini-2.5-flash",
"messages": [{"role": "user", "content": "今天有什么新闻?"}],
"tools": [{"google_search": {}}]
}'
```

**Claude Messages:**
```bash
curl http://localhost:8080/v1/messages \
-H "x-api-key: YOUR_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d '{
"model": "gemini-2.5-flash",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "今天有什么新闻?"}],
"tools": [{"google_search": {}}]
}'
```

### 响应中的 Grounding 元数据

当使用 Google 搜索时,上游 Gemini 响应包含 `groundingMetadata`(搜索查询、来源 URL、文本与来源的映射关系)。CLIProxyAPI 将其作为 `grounding_metadata` 字段透传到响应中:

**OpenAI Chat Completions** — 位于每个 choice 对象上 (`choices[i].grounding_metadata`):
```json
{
"choices": [{
"message": {"role": "assistant", "content": "..."},
"finish_reason": "stop",
"grounding_metadata": {
"webSearchQueries": ["模型使用的搜索查询"],
"groundingChunks": [{"web": {"uri": "https://...", "title": "...", "domain": "..."}}],
"groundingSupports": [{"segment": {"text": "...", "startIndex": 0, "endIndex": 100}, "groundingChunkIndices": [0]}],
"searchEntryPoint": {"renderedContent": "<!-- Google 搜索小部件 HTML -->"},
"retrievalMetadata": {}
}
}]
}
```

**Claude Messages** — 位于顶层响应对象上 (`grounding_metadata`):
```json
{
"type": "message",
"content": [{"type": "text", "text": "..."}],
"grounding_metadata": {
"webSearchQueries": ["..."],
"groundingChunks": [{"web": {"uri": "...", "title": "..."}}],
"groundingSupports": [...]
}
}
```

**OpenAI Responses API** — 位于响应对象上 (`grounding_metadata`):
```json
{
"id": "resp_...",
"output": [...],
"grounding_metadata": {
"webSearchQueries": ["..."],
"groundingChunks": [{"web": {"uri": "...", "title": "..."}}],
"groundingSupports": [...]
}
}
```

> **注意:** `grounding_metadata` 是 Google 原始 `groundingMetadata` 对象的直接透传。该字段是附加性的,标准 OpenAI/Claude SDK 会静默忽略它。完整 schema 请参阅 [Gemini grounding 文档](https://ai.google.dev/gemini-api/docs/google-search)。

## SDK 文档

- 使用文档:[docs/sdk-usage_CN.md](docs/sdk-usage_CN.md)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,15 +310,33 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
}

// tools
toolsJSON := ""
toolDeclCount := 0
allowedToolKeys := []string{"name", "description", "behavior", "parameters", "parametersJsonSchema", "response", "responseJsonSchema"}
functionToolNode := `{}`
var extraToolNodes []string
toolsResult := gjson.GetBytes(rawJSON, "tools")
if toolsResult.IsArray() {
toolsJSON = `[{"functionDeclarations":[]}]`
toolsResults := toolsResult.Array()
for i := 0; i < len(toolsResults); i++ {
toolResult := toolsResults[i]
// Handle google_search passthrough
if gs := toolResult.Get("google_search"); gs.Exists() {
node, _ := sjson.SetRaw(`{}`, "googleSearch", gs.Raw)
extraToolNodes = append(extraToolNodes, node)
continue
}
// Handle code_execution passthrough
if ce := toolResult.Get("code_execution"); ce.Exists() {
node, _ := sjson.SetRaw(`{}`, "codeExecution", ce.Raw)
extraToolNodes = append(extraToolNodes, node)
continue
}
// Handle url_context passthrough
if uc := toolResult.Get("url_context"); uc.Exists() {
node, _ := sjson.SetRaw(`{}`, "urlContext", uc.Raw)
extraToolNodes = append(extraToolNodes, node)
continue
}
inputSchemaResult := toolResult.Get("input_schema")
if inputSchemaResult.Exists() && inputSchemaResult.IsObject() {
// Sanitize the input schema for Antigravity API compatibility
Expand All @@ -331,18 +349,32 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
}
tool, _ = sjson.Delete(tool, toolKey)
}
toolsJSON, _ = sjson.SetRaw(toolsJSON, "0.functionDeclarations.-1", tool)
if toolDeclCount == 0 {
functionToolNode, _ = sjson.SetRaw(functionToolNode, "functionDeclarations", `[]`)
}
functionToolNode, _ = sjson.SetRaw(functionToolNode, "functionDeclarations.-1", tool)
toolDeclCount++
}
}
}
toolsJSON := ""
if toolDeclCount > 0 || len(extraToolNodes) > 0 {
toolsNode := `[]`
if toolDeclCount > 0 {
toolsNode, _ = sjson.SetRaw(toolsNode, "-1", functionToolNode)
}
for _, node := range extraToolNodes {
toolsNode, _ = sjson.SetRaw(toolsNode, "-1", node)
}
toolsJSON = toolsNode
}

// Build output Gemini CLI request JSON
out := `{"model":"","request":{"contents":[]}}`
out, _ = sjson.Set(out, "model", modelName)

// Inject interleaved thinking hint when both tools and thinking are active
hasTools := toolDeclCount > 0
hasTools := toolDeclCount > 0 || len(extraToolNodes) > 0
thinkingResult := gjson.GetBytes(rawJSON, "thinking")
thinkingType := thinkingResult.Get("type").String()
hasThinking := thinkingResult.Exists() && thinkingResult.IsObject() && (thinkingType == "enabled" || thinkingType == "adaptive")
Expand Down Expand Up @@ -372,7 +404,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
if hasContents {
out, _ = sjson.SetRaw(out, "request.contents", contentsJSON)
}
if toolDeclCount > 0 {
if toolDeclCount > 0 || len(extraToolNodes) > 0 {
out, _ = sjson.SetRaw(out, "request.tools", toolsJSON)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type Params struct {
HasSentFinalEvents bool // Indicates if final content/message events have been sent
HasToolUse bool // Indicates if tool use was observed in the stream
HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output
GroundingMetadataRaw string // Cached compact groundingMetadata JSON for the message_delta event

// Signature caching support
CurrentThinkingText strings.Builder // Accumulates thinking text for signature caching
Expand Down Expand Up @@ -291,6 +292,11 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq
}
}

// Cache groundingMetadata for inclusion in the message_delta event.
if gm := gjson.GetBytes(rawJSON, "response.candidates.0.groundingMetadata"); gm.Exists() {
params.GroundingMetadataRaw = strings.ReplaceAll(strings.ReplaceAll(gm.Raw, "\n", ""), "\r", "")
}

if params.HasUsageMetadata && params.HasFinishReason {
appendFinalEvents(params, &output, false)
}
Expand Down Expand Up @@ -339,6 +345,9 @@ func appendFinalEvents(params *Params, output *string, force bool) {
log.Warnf("antigravity claude response: failed to set cache_read_input_tokens: %v", err)
}
}
if params.GroundingMetadataRaw != "" {
delta, _ = sjson.SetRaw(delta, "grounding_metadata", params.GroundingMetadataRaw)
}
*output = *output + delta + "\n\n\n"

params.HasSentFinalEvents = true
Expand Down Expand Up @@ -515,6 +524,11 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or
}
}

// Extract and attach groundingMetadata if present.
if gm := root.Get("response.candidates.0.groundingMetadata"); gm.Exists() {
responseJSON, _ = sjson.SetRaw(responseJSON, "grounding_metadata", gm.Raw)
}

return responseJSON
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,11 @@ func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalReq
template, _ = sjson.Set(template, "choices.0.native_finish_reason", strings.ToLower(upstreamFinishReason))
}

// Extract and attach groundingMetadata if present (compact to prevent SSE newline framing issues).
if gm := gjson.GetBytes(rawJSON, "response.candidates.0.groundingMetadata"); gm.Exists() {
template, _ = sjson.SetRaw(template, "choices.0.grounding_metadata", strings.ReplaceAll(strings.ReplaceAll(gm.Raw, "\n", ""), "\r", ""))
}

return []string{template}
}

Expand Down
3 changes: 3 additions & 0 deletions internal/translator/claude/gemini/claude_gemini_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,9 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
anthropicTools = append(anthropicTools, gjson.Parse(anthropicTool).Value())
return true
})
} else if toolType := tool.Get("type").String(); toolType != "" {
// Pass through Claude built-in tools (web_search_20250305, code_execution, etc.) as-is
anthropicTools = append(anthropicTools, tool.Value())
}
return true
})
Expand Down
Loading
Loading