diff --git a/config.example.yaml b/config.example.yaml index 0f0a429f..9c4313b3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -233,6 +233,9 @@ nonstream-keepalive-interval: 0 # alias: "vertex-flash" # client-visible alias # - name: "gemini-2.5-pro" # alias: "vertex-pro" +# excluded-models: # optional: models to exclude from listing +# - "imagen-3.0-generate-002" +# - "imagen-*" # Amp Integration # ampcode: diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 0153a381..d83d583a 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -516,12 +516,13 @@ func (h *Handler) PutVertexCompatKeys(c *gin.Context) { } func (h *Handler) PatchVertexCompatKey(c *gin.Context) { type vertexCompatPatch struct { - APIKey *string `json:"api-key"` - Prefix *string `json:"prefix"` - BaseURL *string `json:"base-url"` - ProxyURL *string `json:"proxy-url"` - Headers *map[string]string `json:"headers"` - Models *[]config.VertexCompatModel `json:"models"` + APIKey *string `json:"api-key"` + Prefix *string `json:"prefix"` + BaseURL *string `json:"base-url"` + ProxyURL *string `json:"proxy-url"` + Headers *map[string]string `json:"headers"` + Models *[]config.VertexCompatModel `json:"models"` + ExcludedModels *[]string `json:"excluded-models"` } var body struct { Index *int `json:"index"` @@ -585,6 +586,9 @@ func (h *Handler) PatchVertexCompatKey(c *gin.Context) { if body.Value.Models != nil { entry.Models = append([]config.VertexCompatModel(nil), (*body.Value.Models)...) } + if body.Value.ExcludedModels != nil { + entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels) + } normalizeVertexCompatKey(&entry) h.cfg.VertexCompatAPIKey[targetIndex] = entry h.cfg.SanitizeVertexCompatKeys() @@ -1029,6 +1033,7 @@ func normalizeVertexCompatKey(entry *config.VertexCompatKey) { entry.BaseURL = strings.TrimSpace(entry.BaseURL) entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) entry.Headers = config.NormalizeHeaders(entry.Headers) + entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels) if len(entry.Models) == 0 { return } diff --git a/internal/config/vertex_compat.go b/internal/config/vertex_compat.go index 786c5318..5f6c7c88 100644 --- a/internal/config/vertex_compat.go +++ b/internal/config/vertex_compat.go @@ -34,6 +34,9 @@ type VertexCompatKey struct { // Models defines the model configurations including aliases for routing. Models []VertexCompatModel `yaml:"models,omitempty" json:"models,omitempty"` + + // ExcludedModels lists model IDs that should be excluded for this provider. + ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"` } func (k VertexCompatKey) GetAPIKey() string { return k.APIKey } @@ -74,6 +77,7 @@ func (cfg *Config) SanitizeVertexCompatKeys() { } entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) entry.Headers = NormalizeHeaders(entry.Headers) + entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels) // Sanitize models: remove entries without valid alias sanitizedModels := make([]VertexCompatModel, 0, len(entry.Models)) diff --git a/internal/runtime/executor/antigravity_executor_buildrequest_test.go b/internal/runtime/executor/antigravity_executor_buildrequest_test.go index c5cba4ee..27dbeca4 100644 --- a/internal/runtime/executor/antigravity_executor_buildrequest_test.go +++ b/internal/runtime/executor/antigravity_executor_buildrequest_test.go @@ -59,6 +59,7 @@ func buildRequestBodyFromPayload(t *testing.T, modelName string) map[string]any "properties": { "mode": { "type": "string", + "deprecated": true, "enum": ["a", "b"], "enumTitles": ["A", "B"] } @@ -156,4 +157,7 @@ func assertSchemaSanitizedAndPropertyPreserved(t *testing.T, params map[string]a if _, ok := mode["enumTitles"]; ok { t.Fatalf("enumTitles should be removed from nested schema") } + if _, ok := mode["deprecated"]; ok { + t.Fatalf("deprecated should be removed from nested schema") + } } diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 35387488..8c1a38c5 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -431,6 +431,33 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ out, _ = sjson.SetRaw(out, "request.tools", toolsJSON) } + // tool_choice + toolChoiceResult := gjson.GetBytes(rawJSON, "tool_choice") + if toolChoiceResult.Exists() { + toolChoiceType := "" + toolChoiceName := "" + if toolChoiceResult.IsObject() { + toolChoiceType = toolChoiceResult.Get("type").String() + toolChoiceName = toolChoiceResult.Get("name").String() + } else if toolChoiceResult.Type == gjson.String { + toolChoiceType = toolChoiceResult.String() + } + + switch toolChoiceType { + case "auto": + out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.mode", "AUTO") + case "none": + out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.mode", "NONE") + case "any": + out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.mode", "ANY") + case "tool": + out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.mode", "ANY") + if toolChoiceName != "" { + out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{toolChoiceName}) + } + } + } + // Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled if t := gjson.GetBytes(rawJSON, "thinking"); enableThoughtTranslate && t.Exists() && t.IsObject() { switch t.Get("type").String() { @@ -441,15 +468,18 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true) } case "adaptive", "auto": - // Adaptive/auto thinking: - // - If output_config.effort is present, pass it through as thinkingLevel. - // - Otherwise, default to "high". - // ApplyThinking later normalizes/clamps and may convert level → budget per target model. + // For adaptive thinking: + // - If output_config.effort is explicitly present, pass through as thinkingLevel. + // - Otherwise, treat it as "enabled with target-model maximum" and emit high. + // ApplyThinking handles clamping to target model's supported levels. effort := "" if v := gjson.GetBytes(rawJSON, "output_config.effort"); v.Exists() && v.Type == gjson.String { effort = strings.ToLower(strings.TrimSpace(v.String())) } if effort != "" { + if effort == "max" { + effort = "high" + } out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", effort) } else { out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high") diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index 865db668..39dc493d 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -193,6 +193,42 @@ func TestConvertClaudeRequestToAntigravity_ToolDeclarations(t *testing.T) { } } +func TestConvertClaudeRequestToAntigravity_ToolChoice_SpecificTool(t *testing.T) { + inputJSON := []byte(`{ + "model": "gemini-3-flash-preview", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hi"} + ] + } + ], + "tools": [ + { + "name": "json", + "description": "A JSON tool", + "input_schema": { + "type": "object", + "properties": {} + } + } + ], + "tool_choice": {"type": "tool", "name": "json"} + }`) + + output := ConvertClaudeRequestToAntigravity("gemini-3-flash-preview", inputJSON, false) + outputStr := string(output) + + if got := gjson.Get(outputStr, "request.toolConfig.functionCallingConfig.mode").String(); got != "ANY" { + t.Fatalf("Expected toolConfig.functionCallingConfig.mode 'ANY', got '%s'", got) + } + allowed := gjson.Get(outputStr, "request.toolConfig.functionCallingConfig.allowedFunctionNames").Array() + if len(allowed) != 1 || allowed[0].String() != "json" { + t.Fatalf("Expected allowedFunctionNames ['json'], got %s", gjson.Get(outputStr, "request.toolConfig.functionCallingConfig.allowedFunctionNames").Raw) + } +} + func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) { inputJSON := []byte(`{ "model": "claude-3-5-sonnet-20240620", @@ -1199,3 +1235,64 @@ func TestConvertClaudeRequestToAntigravity_ToolAndThinking_NoExistingSystem(t *t t.Errorf("Interleaved thinking hint should be in created systemInstruction, got: %v", sysInstruction.Raw) } } + +func TestConvertClaudeRequestToAntigravity_AdaptiveThinking_EffortLevels(t *testing.T) { + tests := []struct { + name string + effort string + expected string + }{ + {"low", "low", "low"}, + {"medium", "medium", "medium"}, + {"high", "high", "high"}, + {"max", "max", "high"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + inputJSON := []byte(`{ + "model": "claude-opus-4-6-thinking", + "messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}], + "thinking": {"type": "adaptive"}, + "output_config": {"effort": "` + tt.effort + `"} + }`) + + output := ConvertClaudeRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false) + outputStr := string(output) + + thinkingConfig := gjson.Get(outputStr, "request.generationConfig.thinkingConfig") + if !thinkingConfig.Exists() { + t.Fatal("thinkingConfig should exist for adaptive thinking") + } + if thinkingConfig.Get("thinkingLevel").String() != tt.expected { + t.Errorf("Expected thinkingLevel %q, got %q", tt.expected, thinkingConfig.Get("thinkingLevel").String()) + } + if !thinkingConfig.Get("includeThoughts").Bool() { + t.Error("includeThoughts should be true") + } + }) + } +} + +func TestConvertClaudeRequestToAntigravity_AdaptiveThinking_NoEffort(t *testing.T) { + inputJSON := []byte(`{ + "model": "claude-opus-4-6-thinking", + "messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}], + "thinking": {"type": "adaptive"} + }`) + + output := ConvertClaudeRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false) + outputStr := string(output) + + thinkingConfig := gjson.Get(outputStr, "request.generationConfig.thinkingConfig") + if !thinkingConfig.Exists() { + t.Fatal("thinkingConfig should exist for adaptive thinking without effort") + } + if thinkingConfig.Get("thinkingLevel").String() != "high" { + t.Errorf("Expected default thinkingLevel \"high\", got %q", thinkingConfig.Get("thinkingLevel").String()) + } + if !thinkingConfig.Get("includeThoughts").Bool() { + t.Error("includeThoughts should be true") + } +} diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index e3ddd0b8..6373e693 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -255,6 +255,8 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) tool, _ = sjson.SetRaw(tool, "parameters", normalizeToolParameters(toolResult.Get("input_schema").Raw)) tool, _ = sjson.Delete(tool, "input_schema") tool, _ = sjson.Delete(tool, "parameters.$schema") + tool, _ = sjson.Delete(tool, "cache_control") + tool, _ = sjson.Delete(tool, "defer_loading") tool, _ = sjson.Set(tool, "strict", false) template, _ = sjson.SetRaw(template, "tools.-1", tool) } diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response.go b/internal/translator/codex/openai/chat-completions/codex_openai_response.go index f0e264c8..0054d995 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_response.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_response.go @@ -74,8 +74,13 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR } // Extract and set the model version. + cachedModel := (*param).(*ConvertCliToOpenAIParams).Model if modelResult := gjson.GetBytes(rawJSON, "model"); modelResult.Exists() { template, _ = sjson.Set(template, "model", modelResult.String()) + } else if cachedModel != "" { + template, _ = sjson.Set(template, "model", cachedModel) + } else if modelName != "" { + template, _ = sjson.Set(template, "model", modelName) } template, _ = sjson.Set(template, "created", (*param).(*ConvertCliToOpenAIParams).CreatedAt) diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go b/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go new file mode 100644 index 00000000..70aaea06 --- /dev/null +++ b/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go @@ -0,0 +1,47 @@ +package chat_completions + +import ( + "context" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertCodexResponseToOpenAI_StreamSetsModelFromResponseCreated(t *testing.T) { + ctx := context.Background() + var param any + + modelName := "gpt-5.3-codex" + + out := ConvertCodexResponseToOpenAI(ctx, modelName, nil, nil, []byte(`data: {"type":"response.created","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.3-codex"}}`), ¶m) + if len(out) != 0 { + t.Fatalf("expected no output for response.created, got %d chunks", len(out)) + } + + out = ConvertCodexResponseToOpenAI(ctx, modelName, nil, nil, []byte(`data: {"type":"response.output_text.delta","delta":"hello"}`), ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(out)) + } + + gotModel := gjson.Get(out[0], "model").String() + if gotModel != modelName { + t.Fatalf("expected model %q, got %q", modelName, gotModel) + } +} + +func TestConvertCodexResponseToOpenAI_FirstChunkUsesRequestModelName(t *testing.T) { + ctx := context.Background() + var param any + + modelName := "gpt-5.3-codex" + + out := ConvertCodexResponseToOpenAI(ctx, modelName, nil, nil, []byte(`data: {"type":"response.output_text.delta","delta":"hello"}`), ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 chunk, got %d", len(out)) + } + + gotModel := gjson.Get(out[0], "model").String() + if gotModel != modelName { + t.Fatalf("expected model %q, got %q", modelName, gotModel) + } +} diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go index 3f8921dc..e3753b03 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go @@ -156,6 +156,7 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] tool, _ = sjson.Delete(tool, "input_examples") tool, _ = sjson.Delete(tool, "type") tool, _ = sjson.Delete(tool, "cache_control") + tool, _ = sjson.Delete(tool, "defer_loading") if gjson.Valid(tool) && gjson.Parse(tool).IsObject() { if !hasTools { out, _ = sjson.SetRaw(out, "request.tools", `[{"functionDeclarations":[]}]`) @@ -171,6 +172,33 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] } } + // tool_choice + toolChoiceResult := gjson.GetBytes(rawJSON, "tool_choice") + if toolChoiceResult.Exists() { + toolChoiceType := "" + toolChoiceName := "" + if toolChoiceResult.IsObject() { + toolChoiceType = toolChoiceResult.Get("type").String() + toolChoiceName = toolChoiceResult.Get("name").String() + } else if toolChoiceResult.Type == gjson.String { + toolChoiceType = toolChoiceResult.String() + } + + switch toolChoiceType { + case "auto": + out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.mode", "AUTO") + case "none": + out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.mode", "NONE") + case "any": + out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.mode", "ANY") + case "tool": + out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.mode", "ANY") + if toolChoiceName != "" { + out, _ = sjson.Set(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{toolChoiceName}) + } + } + } + // Map Anthropic thinking -> Gemini CLI thinkingConfig when enabled // Translator only does format conversion, ApplyThinking handles model capability validation. if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() { diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request_test.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request_test.go new file mode 100644 index 00000000..10364e75 --- /dev/null +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request_test.go @@ -0,0 +1,42 @@ +package claude + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertClaudeRequestToCLI_ToolChoice_SpecificTool(t *testing.T) { + inputJSON := []byte(`{ + "model": "gemini-3-flash-preview", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hi"} + ] + } + ], + "tools": [ + { + "name": "json", + "description": "A JSON tool", + "input_schema": { + "type": "object", + "properties": {} + } + } + ], + "tool_choice": {"type": "tool", "name": "json"} + }`) + + output := ConvertClaudeRequestToCLI("gemini-3-flash-preview", inputJSON, false) + + if got := gjson.GetBytes(output, "request.toolConfig.functionCallingConfig.mode").String(); got != "ANY" { + t.Fatalf("Expected request.toolConfig.functionCallingConfig.mode 'ANY', got '%s'", got) + } + allowed := gjson.GetBytes(output, "request.toolConfig.functionCallingConfig.allowedFunctionNames").Array() + if len(allowed) != 1 || allowed[0].String() != "json" { + t.Fatalf("Expected allowedFunctionNames ['json'], got %s", gjson.GetBytes(output, "request.toolConfig.functionCallingConfig.allowedFunctionNames").Raw) + } +} diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index 172884bd..ff276ce3 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -137,6 +137,7 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) tool, _ = sjson.Delete(tool, "input_examples") tool, _ = sjson.Delete(tool, "type") tool, _ = sjson.Delete(tool, "cache_control") + tool, _ = sjson.Delete(tool, "defer_loading") if gjson.Valid(tool) && gjson.Parse(tool).IsObject() { if !hasTools { out, _ = sjson.SetRaw(out, "tools", `[{"functionDeclarations":[]}]`) @@ -152,6 +153,33 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) } } + // tool_choice + toolChoiceResult := gjson.GetBytes(rawJSON, "tool_choice") + if toolChoiceResult.Exists() { + toolChoiceType := "" + toolChoiceName := "" + if toolChoiceResult.IsObject() { + toolChoiceType = toolChoiceResult.Get("type").String() + toolChoiceName = toolChoiceResult.Get("name").String() + } else if toolChoiceResult.Type == gjson.String { + toolChoiceType = toolChoiceResult.String() + } + + switch toolChoiceType { + case "auto": + out, _ = sjson.Set(out, "toolConfig.functionCallingConfig.mode", "AUTO") + case "none": + out, _ = sjson.Set(out, "toolConfig.functionCallingConfig.mode", "NONE") + case "any": + out, _ = sjson.Set(out, "toolConfig.functionCallingConfig.mode", "ANY") + case "tool": + out, _ = sjson.Set(out, "toolConfig.functionCallingConfig.mode", "ANY") + if toolChoiceName != "" { + out, _ = sjson.Set(out, "toolConfig.functionCallingConfig.allowedFunctionNames", []string{toolChoiceName}) + } + } + } + // Map Anthropic thinking -> Gemini thinking config when enabled // Translator only does format conversion, ApplyThinking handles model capability validation. if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() { diff --git a/internal/translator/gemini/claude/gemini_claude_request_test.go b/internal/translator/gemini/claude/gemini_claude_request_test.go new file mode 100644 index 00000000..e242c42c --- /dev/null +++ b/internal/translator/gemini/claude/gemini_claude_request_test.go @@ -0,0 +1,42 @@ +package claude + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertClaudeRequestToGemini_ToolChoice_SpecificTool(t *testing.T) { + inputJSON := []byte(`{ + "model": "gemini-3-flash-preview", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hi"} + ] + } + ], + "tools": [ + { + "name": "json", + "description": "A JSON tool", + "input_schema": { + "type": "object", + "properties": {} + } + } + ], + "tool_choice": {"type": "tool", "name": "json"} + }`) + + output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false) + + if got := gjson.GetBytes(output, "toolConfig.functionCallingConfig.mode").String(); got != "ANY" { + t.Fatalf("Expected toolConfig.functionCallingConfig.mode 'ANY', got '%s'", got) + } + allowed := gjson.GetBytes(output, "toolConfig.functionCallingConfig.allowedFunctionNames").Array() + if len(allowed) != 1 || allowed[0].String() != "json" { + t.Fatalf("Expected allowedFunctionNames ['json'], got %s", gjson.GetBytes(output, "toolConfig.functionCallingConfig.allowedFunctionNames").Raw) + } +} diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index aca01717..77a462ff 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -354,22 +354,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte funcDecl, _ = sjson.Set(funcDecl, "description", desc.String()) } if params := tool.Get("parameters"); params.Exists() { - // Convert parameter types from OpenAI format to Gemini format - cleaned := params.Raw - // Convert type values to uppercase for Gemini - paramsResult := gjson.Parse(cleaned) - if properties := paramsResult.Get("properties"); properties.Exists() { - properties.ForEach(func(key, value gjson.Result) bool { - if propType := value.Get("type"); propType.Exists() { - upperType := strings.ToUpper(propType.String()) - cleaned, _ = sjson.Set(cleaned, "properties."+key.String()+".type", upperType) - } - return true - }) - } - // Set the overall type to OBJECT - cleaned, _ = sjson.Set(cleaned, "type", "OBJECT") - funcDecl, _ = sjson.SetRaw(funcDecl, "parametersJsonSchema", cleaned) + funcDecl, _ = sjson.SetRaw(funcDecl, "parametersJsonSchema", params.Raw) } geminiTools, _ = sjson.SetRaw(geminiTools, "0.functionDeclarations.-1", funcDecl) diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index b8d07bf4..8617b846 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -430,7 +430,7 @@ func removeUnsupportedKeywords(jsonStr string) string { keywords := append(unsupportedConstraints, "$schema", "$defs", "definitions", "const", "$ref", "$id", "additionalProperties", "propertyNames", "patternProperties", // Gemini doesn't support these schema keywords - "enumTitles", "prefill", // Claude/OpenCode schema metadata fields unsupported by Gemini + "enumTitles", "prefill", "deprecated", // Schema metadata fields unsupported by Gemini ) deletePaths := make([]string, 0) diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index b7d537da..7997f04e 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -304,6 +304,11 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if oldModels.hash != newModels.hash { changes = append(changes, fmt.Sprintf("vertex[%d].models: updated (%d -> %d entries)", i, oldModels.count, newModels.count)) } + oldExcluded := SummarizeExcludedModels(o.ExcludedModels) + newExcluded := SummarizeExcludedModels(n.ExcludedModels) + if oldExcluded.hash != newExcluded.hash { + changes = append(changes, fmt.Sprintf("vertex[%d].excluded-models: updated (%d -> %d entries)", i, oldExcluded.count, newExcluded.count)) + } if !equalStringMap(o.Headers, n.Headers) { changes = append(changes, fmt.Sprintf("vertex[%d].headers: updated", i)) } diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go index e044117f..65120dcb 100644 --- a/internal/watcher/synthesizer/config.go +++ b/internal/watcher/synthesizer/config.go @@ -319,7 +319,7 @@ func (s *ConfigSynthesizer) synthesizeVertexCompat(ctx *SynthesisContext) []*cor CreatedAt: now, UpdatedAt: now, } - ApplyAuthExcludedModelsMeta(a, cfg, nil, "apikey") + ApplyAuthExcludedModelsMeta(a, cfg, compat.ExcludedModels, "apikey") out = append(out, a) } return out diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 3ffa2e07..b53aea90 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -820,10 +820,13 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { case "vertex": // Vertex AI Gemini supports the same model identifiers as Gemini. models = registry.GetGeminiVertexModels() - if authKind == "apikey" { - if entry := s.resolveConfigVertexCompatKey(a); entry != nil && len(entry.Models) > 0 { + if entry := s.resolveConfigVertexCompatKey(a); entry != nil { + if len(entry.Models) > 0 { models = buildVertexCompatConfigModels(entry) } + if authKind == "apikey" { + excluded = entry.ExcludedModels + } } models = applyExcludedModels(models, excluded) case "gemini-cli":