Compare commits

..

18 Commits

Author SHA1 Message Date
Luis Pater
b93cce5412 Merge pull request #444 from router-for-me/plus
v6.8.53
2026-03-15 01:50:42 +08:00
Luis Pater
c6cb24039d Merge branch 'main' into plus 2026-03-15 01:50:32 +08:00
Luis Pater
5382408489 Merge pull request #441 from GrothKeiran/fix/copilot-token-metadata
fix: persist copilot token metadata
2026-03-15 01:47:41 +08:00
Luis Pater
67669196ed Merge pull request #2131 from HEUDavid/docs/add-who-is-with-us
docs: Add Shadow AI to 'Who is with us?' section
2026-03-15 01:44:46 +08:00
hkfires
58fd9bf964 fix(codex): add 'go' plan_type in registerModelsForAuth 2026-03-14 22:09:14 +08:00
HEUDavid
7b3dfc67bc docs: Add Shadow AI to 'Who is with us?' section 2026-03-14 21:01:07 +08:00
HEUDavid
cdd24052d3 docs: Add Shadow AI to 'Who is with us?' section 2026-03-14 20:53:43 +08:00
Luis Pater
733fd8edab Merge pull request #2111 from qzydustin/main
Fix missing streaming usage tracking for OpenAI-compatible providers
2026-03-14 18:17:08 +08:00
Luis Pater
af27f2b8bc Merge pull request #2110 from router-for-me/codex
feat(service): extend model registration for team and business types
2026-03-14 18:10:01 +08:00
Luis Pater
2e1925d762 Merge pull request #2108 from sususu98/fix/gemini-cli-tool-schema-and-empty-parts
fix(gemini-cli): sanitize tool schemas and filter empty parts
2026-03-14 18:02:52 +08:00
Luis Pater
77254bd074 Merge pull request #2116 from router-for-me/vertex
fix(config): allow vertex keys without base-url
2026-03-14 17:27:48 +08:00
GrothKeiran
3960c93d51 refactor: derive copilot metadata from storage 2026-03-13 13:10:01 +00:00
GrothKeiran
339a81b650 fix: persist copilot token metadata 2026-03-13 12:54:43 +00:00
hkfires
560c020477 fix(config): allow vertex keys without base-url 2026-03-13 19:09:26 +08:00
Zhenyu Qi
aec65e3be3 fix(openai_compat): add stream_options.include_usage for streaming usage tracking 2026-03-13 00:48:17 -07:00
hkfires
f44f0702f8 feat(service): extend model registration for team and business types 2026-03-13 14:12:19 +08:00
sususu98
b76b79068f fix(gemini-cli): sanitize tool schemas and filter empty parts
1. Claude translator: add CleanJSONSchemaForGemini() to sanitize tool
   input schemas (removes $schema, anyOf, const, format, etc.) and
   delete eager_input_streaming from tool declarations. Remove fragile
   bytes.Replace for format:"uri" now covered by schema cleaner.

2. Gemini native translator: filter out content entries with empty or
   missing parts arrays to prevent Gemini API 400 error "required
   oneof field 'data' must have one initialized field".

Both fixes align gemini-cli with protections already present in the
antigravity translator.
2026-03-13 12:37:37 +08:00
Luis Pater
34c8ccb961 Fixed: #437
feat(runtime): strip `service_tier` in GitHub Copilot response normalization
2026-03-13 11:50:21 +08:00
11 changed files with 78 additions and 19 deletions

View File

@@ -244,11 +244,11 @@ nonstream-keepalive-interval: 0
# - name: "kimi-k2.5"
# alias: "claude-opus-4.66"
# Vertex API keys (Vertex-compatible endpoints, use API key + base URL)
# Vertex API keys (Vertex-compatible endpoints, base-url is optional)
# vertex-api-key:
# - api-key: "vk-123..." # x-goog-api-key header
# prefix: "test" # optional: require calls like "test/vertex-pro" to target this credential
# base-url: "https://example.com/api" # e.g. https://zenmux.ai/api
# base-url: "https://example.com/api" # optional, e.g. https://zenmux.ai/api; falls back to Google Vertex when omitted
# proxy-url: "socks5://proxy.example.com:1080" # optional per-key proxy override
# # proxy-url: "direct" # optional: explicit direct connect for this credential
# headers:

View File

@@ -2438,17 +2438,20 @@ func (h *Handler) RequestGitHubToken(c *gin.Context) {
if label == "" {
label = username
}
metadata, errMeta := copilotTokenMetadata(tokenStorage)
if errMeta != nil {
log.Errorf("Failed to build token metadata: %v", errMeta)
SetOAuthSessionError(state, "Failed to build token metadata")
return
}
record := &coreauth.Auth{
ID: fileName,
Provider: "github-copilot",
Label: label,
FileName: fileName,
Storage: tokenStorage,
Metadata: map[string]any{
"email": userInfo.Email,
"username": username,
"name": userInfo.Name,
},
Metadata: metadata,
}
savedPath, errSave := h.saveTokenRecord(ctx, record)
@@ -2473,6 +2476,21 @@ func (h *Handler) RequestGitHubToken(c *gin.Context) {
})
}
func copilotTokenMetadata(storage *copilot.CopilotTokenStorage) (map[string]any, error) {
if storage == nil {
return nil, fmt.Errorf("token storage is nil")
}
payload, errMarshal := json.Marshal(storage)
if errMarshal != nil {
return nil, fmt.Errorf("marshal token storage: %w", errMarshal)
}
metadata := make(map[string]any)
if errUnmarshal := json.Unmarshal(payload, &metadata); errUnmarshal != nil {
return nil, fmt.Errorf("unmarshal token storage: %w", errUnmarshal)
}
return metadata, nil
}
func (h *Handler) RequestIFlowCookieToken(c *gin.Context) {
ctx := context.Background()

View File

@@ -509,8 +509,12 @@ func (h *Handler) PutVertexCompatKeys(c *gin.Context) {
}
for i := range arr {
normalizeVertexCompatKey(&arr[i])
if arr[i].APIKey == "" {
c.JSON(400, gin.H{"error": fmt.Sprintf("vertex-api-key[%d].api-key is required", i)})
return
}
}
h.cfg.VertexCompatAPIKey = arr
h.cfg.VertexCompatAPIKey = append([]config.VertexCompatKey(nil), arr...)
h.cfg.SanitizeVertexCompatKeys()
h.persist(c)
}

View File

@@ -685,7 +685,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
// Sanitize Gemini API key configuration and migrate legacy entries.
cfg.SanitizeGeminiKeys()
// Sanitize Vertex-compatible API keys: drop entries without base-url
// Sanitize Vertex-compatible API keys.
cfg.SanitizeVertexCompatKeys()
// Sanitize Codex keys: drop entries without base-url

View File

@@ -20,9 +20,9 @@ type VertexCompatKey struct {
// Prefix optionally namespaces model aliases for this credential (e.g., "teamA/vertex-pro").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// BaseURL is the base URL for the Vertex-compatible API endpoint.
// BaseURL optionally overrides the Vertex-compatible API endpoint.
// The executor will append "/v1/publishers/google/models/{model}:action" to this.
// Example: "https://zenmux.ai/api" becomes "https://zenmux.ai/api/v1/publishers/google/models/..."
// When empty, requests fall back to the default Vertex API base URL.
BaseURL string `yaml:"base-url,omitempty" json:"base-url,omitempty"`
// ProxyURL optionally overrides the global proxy for this API key.
@@ -71,10 +71,6 @@ func (cfg *Config) SanitizeVertexCompatKeys() {
}
entry.Prefix = normalizeModelPrefix(entry.Prefix)
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
if entry.BaseURL == "" {
// BaseURL is required for Vertex API key entries
continue
}
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
entry.Headers = NormalizeHeaders(entry.Headers)
entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)

View File

@@ -653,6 +653,7 @@ func normalizeGitHubCopilotChatTools(body []byte) []byte {
}
func normalizeGitHubCopilotResponsesInput(body []byte) []byte {
body = stripGitHubCopilotResponsesUnsupportedFields(body)
input := gjson.GetBytes(body, "input")
if input.Exists() {
// If input is already a string or array, keep it as-is.
@@ -825,6 +826,12 @@ func normalizeGitHubCopilotResponsesInput(body []byte) []byte {
return body
}
func stripGitHubCopilotResponsesUnsupportedFields(body []byte) []byte {
// GitHub Copilot /responses rejects service_tier, so always remove it.
body, _ = sjson.DeleteBytes(body, "service_tier")
return body
}
func normalizeGitHubCopilotResponsesTools(body []byte) []byte {
tools := gjson.GetBytes(body, "tools")
if tools.Exists() {

View File

@@ -132,6 +132,19 @@ func TestNormalizeGitHubCopilotResponsesInput_NonStringInputStringified(t *testi
}
}
func TestNormalizeGitHubCopilotResponsesInput_StripsServiceTier(t *testing.T) {
t.Parallel()
body := []byte(`{"input":"user text","service_tier":"default"}`)
got := normalizeGitHubCopilotResponsesInput(body)
if gjson.GetBytes(got, "service_tier").Exists() {
t.Fatalf("service_tier should be removed, got %s", gjson.GetBytes(got, "service_tier").Raw)
}
if gjson.GetBytes(got, "input").String() != "user text" {
t.Fatalf("input = %q, want %q", gjson.GetBytes(got, "input").String(), "user text")
}
}
func TestNormalizeGitHubCopilotResponsesTools_FlattenFunctionTools(t *testing.T) {
t.Parallel()
body := []byte(`{"tools":[{"type":"function","function":{"name":"sum","description":"d","parameters":{"type":"object"}}},{"type":"web_search"}]}`)

View File

@@ -205,6 +205,10 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
return nil, err
}
// Request usage data in the final streaming chunk so that token statistics
// are captured even when the upstream is an OpenAI-compatible provider.
translated, _ = sjson.SetBytes(translated, "stream_options.include_usage", true)
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))
if err != nil {

View File

@@ -6,10 +6,10 @@
package claude
import (
"bytes"
"strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -36,7 +36,6 @@ const geminiCLIClaudeThoughtSignature = "skip_thought_signature_validator"
// - []byte: The transformed request data in Gemini CLI API format
func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := inputRawJSON
rawJSON = bytes.Replace(rawJSON, []byte(`"url":{"type":"string","format":"uri",`), []byte(`"url":{"type":"string",`), -1)
// Build output Gemini CLI request JSON
out := `{"model":"","request":{"contents":[]}}`
@@ -149,7 +148,7 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
toolsResult.ForEach(func(_, toolResult gjson.Result) bool {
inputSchemaResult := toolResult.Get("input_schema")
if inputSchemaResult.Exists() && inputSchemaResult.IsObject() {
inputSchema := inputSchemaResult.Raw
inputSchema := util.CleanJSONSchemaForGemini(inputSchemaResult.Raw)
tool, _ := sjson.Delete(toolResult.Raw, "input_schema")
tool, _ = sjson.SetRaw(tool, "parametersJsonSchema", inputSchema)
tool, _ = sjson.Delete(tool, "strict")
@@ -157,6 +156,7 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
tool, _ = sjson.Delete(tool, "type")
tool, _ = sjson.Delete(tool, "cache_control")
tool, _ = sjson.Delete(tool, "defer_loading")
tool, _ = sjson.Delete(tool, "eager_input_streaming")
if gjson.Valid(tool) && gjson.Parse(tool).IsObject() {
if !hasTools {
out, _ = sjson.SetRaw(out, "request.tools", `[{"functionDeclarations":[]}]`)

View File

@@ -111,6 +111,23 @@ func ConvertGeminiRequestToGeminiCLI(_ string, inputRawJSON []byte, _ bool) []by
return true
})
// Filter out contents with empty parts to avoid Gemini API error:
// "required oneof field 'data' must have one initialized field"
filteredContents := "[]"
hasFiltered := false
gjson.GetBytes(rawJSON, "request.contents").ForEach(func(_, content gjson.Result) bool {
parts := content.Get("parts")
if !parts.IsArray() || len(parts.Array()) == 0 {
hasFiltered = true
return true
}
filteredContents, _ = sjson.SetRaw(filteredContents, "-1", content.Raw)
return true
})
if hasFiltered {
rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.contents", []byte(filteredContents))
}
return common.AttachDefaultSafetySettings(rawJSON, "request.safetySettings")
}

View File

@@ -915,7 +915,7 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
models = registry.GetCodexProModels()
case "plus":
models = registry.GetCodexPlusModels()
case "team":
case "team", "business", "go":
models = registry.GetCodexTeamModels()
case "free":
models = registry.GetCodexFreeModels()