From 8b9c4da2452b42aaa917a80daadf72aadc843a13 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 5 Jul 2026 20:20:23 +0800 Subject: [PATCH] feat(interactions): add support for Google Interactions - Introduced API handlers and executor logic for Google Interactions - Added request and response transformations for OpenAI and Claude Interactions. - Integrated Gemini API with Interactions support. - Updated tests to validate Interactions request parsing and error handling. - Refactored translator logic for Interactions data flows. --- config.example.yaml | 20 +- internal/api/handlers/management/api_tools.go | 4 + .../management/config_apikey_disable.go | 8 + .../handlers/management/config_auth_index.go | 29 + .../api/handlers/management/config_lists.go | 161 ++ internal/api/server.go | 12 +- internal/api/server_test.go | 11 + internal/config/config.go | 44 +- internal/config/parse.go | 1 + internal/constant/constant.go | 6 + .../runtime/executor/antigravity_executor.go | 1 + .../antigravity_executor_interactions_test.go | 98 ++ internal/runtime/executor/gemini_executor.go | 376 ++++- .../runtime/executor/gemini_executor_test.go | 904 +++++++++++ .../executor/helps/thinking_providers.go | 1 + .../runtime/executor/helps/usage_helpers.go | 47 + .../executor/helps/usage_helpers_test.go | 51 + internal/thinking/apply.go | 52 + .../thinking/provider/interactions/apply.go | 176 +++ internal/thinking/strip.go | 11 + .../antigravity/interactions/init.go | 19 + .../interactions_antigravity_request.go | 719 +++++++++ .../interactions_antigravity_response.go | 457 ++++++ .../interactions_antigravity_test.go | 121 ++ .../claude/gemini/claude_gemini_request.go | 155 +- .../gemini/claude_gemini_request_test.go | 27 + .../translator/claude/interactions/init.go | 19 + .../interactions_claude_request.go | 451 ++++++ .../interactions_claude_response.go | 583 +++++++ .../interactions/interactions_claude_test.go | 181 +++ .../codex/gemini/codex_gemini_request.go | 176 ++- .../codex/gemini/codex_gemini_request_test.go | 24 + .../translator/codex/interactions/init.go | 19 + .../interactions_codex_request.go | 717 +++++++++ .../interactions_codex_response.go | 552 +++++++ .../interactions/interactions_codex_test.go | 202 +++ .../translator/common/interactions_usage.go | 19 + .../translator/gemini/interactions/init.go | 37 + .../interactions_gemini_common.go | 1334 +++++++++++++++++ .../interactions_gemini_common_test.go | 715 +++++++++ .../interactions_gemini_response.go | 363 +++++ internal/translator/init.go | 8 + .../translator/interactions/claude/init.go | 19 + .../claude/interactions_claude_request.go | 299 ++++ .../claude/interactions_claude_response.go | 399 +++++ .../claude/interactions_claude_test.go | 164 ++ .../interactions/import_boundary_test.go | 50 + .../openai/gemini/openai_gemini_request.go | 204 ++- .../gemini/openai_gemini_request_test.go | 65 + .../openai/gemini/openai_gemini_response.go | 75 +- .../gemini/openai_gemini_response_test.go | 34 + .../interactions/chat-completions/init.go | 28 + .../interactions_openai_request.go | 396 +++++ .../interactions_openai_request_test.go | 121 ++ .../interactions_openai_response.go | 402 +++++ .../interactions_openai_response_test.go | 223 +++ .../openai_interactions_request.go | 306 ++++ .../openai_interactions_response.go | 343 +++++ .../openai/interactions/responses/init.go | 28 + .../interactions_openai_responses_request.go | 676 +++++++++ ...eractions_openai_responses_request_test.go | 297 ++++ .../interactions_openai_responses_response.go | 994 ++++++++++++ ...ractions_openai_responses_response_test.go | 487 ++++++ internal/tui/client.go | 6 + internal/tui/keys_tab.go | 49 +- internal/watcher/clients.go | 3 + internal/watcher/diff/config_diff.go | 33 + internal/watcher/synthesizer/config.go | 29 +- internal/watcher/synthesizer/config_test.go | 47 + internal/watcher/watcher_test.go | 5 +- .../handlers/gemini/interactions_handlers.go | 202 +++ .../gemini/interactions_handlers_test.go | 320 ++++ sdk/api/handlers/handlers.go | 136 +- .../handlers/handlers_model_router_test.go | 6 +- sdk/api/handlers/model_execution.go | 59 + sdk/api/handlers/model_execution_test.go | 268 ++++ sdk/cliproxy/auth/api_key_model_alias_test.go | 21 + sdk/cliproxy/auth/conductor.go | 102 +- sdk/cliproxy/auth/types.go | 2 + sdk/cliproxy/executor/types.go | 3 + sdk/cliproxy/service.go | 39 +- .../service_executor_registration_test.go | 1 + sdk/cliproxy/types.go | 3 +- sdk/translator/formats.go | 1 + test/thinking_conversion_test.go | 25 + 85 files changed, 15732 insertions(+), 149 deletions(-) create mode 100644 internal/runtime/executor/antigravity_executor_interactions_test.go create mode 100644 internal/thinking/provider/interactions/apply.go create mode 100644 internal/translator/antigravity/interactions/init.go create mode 100644 internal/translator/antigravity/interactions/interactions_antigravity_request.go create mode 100644 internal/translator/antigravity/interactions/interactions_antigravity_response.go create mode 100644 internal/translator/antigravity/interactions/interactions_antigravity_test.go create mode 100644 internal/translator/claude/interactions/init.go create mode 100644 internal/translator/claude/interactions/interactions_claude_request.go create mode 100644 internal/translator/claude/interactions/interactions_claude_response.go create mode 100644 internal/translator/claude/interactions/interactions_claude_test.go create mode 100644 internal/translator/codex/interactions/init.go create mode 100644 internal/translator/codex/interactions/interactions_codex_request.go create mode 100644 internal/translator/codex/interactions/interactions_codex_response.go create mode 100644 internal/translator/codex/interactions/interactions_codex_test.go create mode 100644 internal/translator/common/interactions_usage.go create mode 100644 internal/translator/gemini/interactions/init.go create mode 100644 internal/translator/gemini/interactions/interactions_gemini_common.go create mode 100644 internal/translator/gemini/interactions/interactions_gemini_common_test.go create mode 100644 internal/translator/gemini/interactions/interactions_gemini_response.go create mode 100644 internal/translator/interactions/claude/init.go create mode 100644 internal/translator/interactions/claude/interactions_claude_request.go create mode 100644 internal/translator/interactions/claude/interactions_claude_response.go create mode 100644 internal/translator/interactions/claude/interactions_claude_test.go create mode 100644 internal/translator/interactions/import_boundary_test.go create mode 100644 internal/translator/openai/gemini/openai_gemini_response_test.go create mode 100644 internal/translator/openai/interactions/chat-completions/init.go create mode 100644 internal/translator/openai/interactions/chat-completions/interactions_openai_request.go create mode 100644 internal/translator/openai/interactions/chat-completions/interactions_openai_request_test.go create mode 100644 internal/translator/openai/interactions/chat-completions/interactions_openai_response.go create mode 100644 internal/translator/openai/interactions/chat-completions/interactions_openai_response_test.go create mode 100644 internal/translator/openai/interactions/chat-completions/openai_interactions_request.go create mode 100644 internal/translator/openai/interactions/chat-completions/openai_interactions_response.go create mode 100644 internal/translator/openai/interactions/responses/init.go create mode 100644 internal/translator/openai/interactions/responses/interactions_openai_responses_request.go create mode 100644 internal/translator/openai/interactions/responses/interactions_openai_responses_request_test.go create mode 100644 internal/translator/openai/interactions/responses/interactions_openai_responses_response.go create mode 100644 internal/translator/openai/interactions/responses/interactions_openai_responses_response_test.go create mode 100644 sdk/api/handlers/gemini/interactions_handlers.go create mode 100644 sdk/api/handlers/gemini/interactions_handlers_test.go diff --git a/config.example.yaml b/config.example.yaml index f8d6ed835..d593c2f78 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -225,6 +225,24 @@ nonstream-keepalive-interval: 0 # - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite) # - api-key: "AIzaSy...02" +# Native Interactions API keys +# These keys are used only for direct /v1beta/interactions execution. Regular gemini-api-key entries still +# send Gemini generateContent/streamGenerateContent requests when the client enters through the interactions API. +# interactions-api-key: +# - api-key: "AIzaSy...03" +# prefix: "native" # optional: require calls like "native/gemini-3-pro-preview" to target this credential +# disable-cooling: false # optional: per-auth override for auth/model cooldown scheduling +# base-url: "https://generativelanguage.googleapis.com" +# headers: +# X-Custom-Header: "custom-value" +# proxy-url: "socks5://proxy.example.com:1080" +# # proxy-url: "direct" # optional: explicit direct connect for this credential +# models: +# - name: "gemini-2.5-flash" # upstream model name +# alias: "native-gemini-flash" # client alias mapped to the upstream model +# excluded-models: +# - "gemini-2.5-pro" + # Codex API keys # codex-api-key: # - api-key: "sk-atSM..." @@ -359,7 +377,7 @@ nonstream-keepalive-interval: 0 # Global OAuth model name aliases (per channel) # These aliases rename model IDs for both model listing and request routing. # Supported channels: vertex, aistudio, antigravity, claude, codex, kimi, xai. -# NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, or vertex-api-key. +# NOTE: Aliases do not apply to gemini-api-key, interactions-api-key, codex-api-key, claude-api-key, openai-compatibility, or vertex-api-key. # NOTE: Because aliases affect the merged /v1 model list and merged request routing, overlapping # client-visible names can become ambiguous across providers. For strict backend pinning, use # unique aliases/prefixes or avoid overlapping names. diff --git a/internal/api/handlers/management/api_tools.go b/internal/api/handlers/management/api_tools.go index 334099c42..133357603 100644 --- a/internal/api/handlers/management/api_tools.go +++ b/internal/api/handlers/management/api_tools.go @@ -571,6 +571,10 @@ func proxyURLFromAPIKeyConfig(cfg *config.Config, auth *coreauth.Auth) string { if entry := resolveAPIKeyConfig(cfg.GeminiKey, auth); entry != nil { return strings.TrimSpace(entry.ProxyURL) } + case "gemini-interactions": + if entry := resolveAPIKeyConfig(cfg.InteractionsKey, auth); entry != nil { + return strings.TrimSpace(entry.ProxyURL) + } case "claude": if entry := resolveAPIKeyConfig(cfg.ClaudeKey, auth); entry != nil { return strings.TrimSpace(entry.ProxyURL) diff --git a/internal/api/handlers/management/config_apikey_disable.go b/internal/api/handlers/management/config_apikey_disable.go index 5a6c597dd..e4431b272 100644 --- a/internal/api/handlers/management/config_apikey_disable.go +++ b/internal/api/handlers/management/config_apikey_disable.go @@ -49,6 +49,14 @@ func toggleConfigAPIKeyExcludedAll(cfg *config.Config, auth *coreauth.Auth, disa return true, nil } } + for i := range cfg.InteractionsKey { + entry := &cfg.InteractionsKey[i] + id, _ := idGen.Next("gemini-interactions:apikey", entry.APIKey, entry.BaseURL) + if id == authID { + entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable) + return true, nil + } + } for i := range cfg.ClaudeKey { entry := &cfg.ClaudeKey[i] id, _ := idGen.Next("claude:apikey", entry.APIKey, entry.BaseURL) diff --git a/internal/api/handlers/management/config_auth_index.go b/internal/api/handlers/management/config_auth_index.go index e7c7a2b44..2d158aced 100644 --- a/internal/api/handlers/management/config_auth_index.go +++ b/internal/api/handlers/management/config_auth_index.go @@ -107,6 +107,35 @@ func (h *Handler) geminiKeysWithAuthIndex() []geminiKeyWithAuthIndex { return out } +func (h *Handler) interactionsKeysWithAuthIndex() []geminiKeyWithAuthIndex { + if h == nil { + return nil + } + liveIndexByID := h.liveAuthIndexByID() + + h.mu.Lock() + defer h.mu.Unlock() + if h.cfg == nil { + return nil + } + + idGen := synthesizer.NewStableIDGenerator() + out := make([]geminiKeyWithAuthIndex, len(h.cfg.InteractionsKey)) + for i := range h.cfg.InteractionsKey { + entry := h.cfg.InteractionsKey[i] + authIndex := "" + if key := strings.TrimSpace(entry.APIKey); key != "" { + id, _ := idGen.Next("gemini-interactions:apikey", key, entry.BaseURL) + authIndex = liveIndexByID[id] + } + out[i] = geminiKeyWithAuthIndex{ + GeminiKey: entry, + AuthIndex: authIndex, + } + } + return out +} + func (h *Handler) claudeKeysWithAuthIndex() []claudeKeyWithAuthIndex { if h == nil { return nil diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 5456e7cef..03476e63f 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -275,6 +275,167 @@ func (h *Handler) DeleteGeminiKey(c *gin.Context) { c.JSON(400, gin.H{"error": "missing api-key or index"}) } +// interactions-api-key: []GeminiKey +func (h *Handler) GetInteractionsKeys(c *gin.Context) { + c.JSON(200, gin.H{"interactions-api-key": h.interactionsKeysWithAuthIndex()}) +} +func (h *Handler) PutInteractionsKeys(c *gin.Context) { + data, errRead := c.GetRawData() + if errRead != nil { + c.JSON(400, gin.H{"error": "failed to read body"}) + return + } + var arr []config.GeminiKey + errUnmarshal := json.Unmarshal(data, &arr) + if errUnmarshal != nil { + var obj struct { + Items []config.GeminiKey `json:"items"` + } + errObjUnmarshal := json.Unmarshal(data, &obj) + if errObjUnmarshal != nil || len(obj.Items) == 0 { + c.JSON(400, gin.H{"error": "invalid body"}) + return + } + arr = obj.Items + } + h.mu.Lock() + defer h.mu.Unlock() + h.cfg.InteractionsKey = append([]config.GeminiKey(nil), arr...) + h.cfg.SanitizeInteractionsKeys() + h.persistLocked(c) +} +func (h *Handler) PatchInteractionsKey(c *gin.Context) { + type geminiKeyPatch 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"` + ExcludedModels *[]string `json:"excluded-models"` + } + var body struct { + Index *int `json:"index"` + Match *string `json:"match"` + Value *geminiKeyPatch `json:"value"` + } + errBind := c.ShouldBindJSON(&body) + if errBind != nil || body.Value == nil { + c.JSON(400, gin.H{"error": "invalid body"}) + return + } + + h.mu.Lock() + defer h.mu.Unlock() + targetIndex := -1 + if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.InteractionsKey) { + targetIndex = *body.Index + } + if targetIndex == -1 && body.Match != nil { + match := strings.TrimSpace(*body.Match) + if match != "" { + for i := range h.cfg.InteractionsKey { + if h.cfg.InteractionsKey[i].APIKey == match { + targetIndex = i + break + } + } + } + } + if targetIndex == -1 { + c.JSON(404, gin.H{"error": "item not found"}) + return + } + + entry := h.cfg.InteractionsKey[targetIndex] + if body.Value.APIKey != nil { + trimmed := strings.TrimSpace(*body.Value.APIKey) + if trimmed == "" { + h.cfg.InteractionsKey = append(h.cfg.InteractionsKey[:targetIndex], h.cfg.InteractionsKey[targetIndex+1:]...) + h.cfg.SanitizeInteractionsKeys() + h.persistLocked(c) + return + } + entry.APIKey = trimmed + } + if body.Value.Prefix != nil { + entry.Prefix = strings.TrimSpace(*body.Value.Prefix) + } + if body.Value.BaseURL != nil { + entry.BaseURL = strings.TrimSpace(*body.Value.BaseURL) + } + if body.Value.ProxyURL != nil { + entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL) + } + if body.Value.Headers != nil { + entry.Headers = config.NormalizeHeaders(*body.Value.Headers) + } + if body.Value.ExcludedModels != nil { + entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels) + } + h.cfg.InteractionsKey[targetIndex] = entry + h.cfg.SanitizeInteractionsKeys() + h.persistLocked(c) +} + +func (h *Handler) DeleteInteractionsKey(c *gin.Context) { + h.mu.Lock() + defer h.mu.Unlock() + if val := strings.TrimSpace(c.Query("api-key")); val != "" { + if baseRaw, okBase := c.GetQuery("base-url"); okBase { + base := strings.TrimSpace(baseRaw) + out := make([]config.GeminiKey, 0, len(h.cfg.InteractionsKey)) + for _, v := range h.cfg.InteractionsKey { + if strings.TrimSpace(v.APIKey) == val && strings.TrimSpace(v.BaseURL) == base { + continue + } + out = append(out, v) + } + if len(out) != len(h.cfg.InteractionsKey) { + h.cfg.InteractionsKey = out + h.cfg.SanitizeInteractionsKeys() + h.persistLocked(c) + } else { + c.JSON(404, gin.H{"error": "item not found"}) + } + return + } + + matchIndex := -1 + matchCount := 0 + for i := range h.cfg.InteractionsKey { + if strings.TrimSpace(h.cfg.InteractionsKey[i].APIKey) == val { + matchCount++ + if matchIndex == -1 { + matchIndex = i + } + } + } + if matchCount == 0 { + c.JSON(404, gin.H{"error": "item not found"}) + return + } + if matchCount > 1 { + c.JSON(400, gin.H{"error": "multiple items match api-key; base-url is required"}) + return + } + h.cfg.InteractionsKey = append(h.cfg.InteractionsKey[:matchIndex], h.cfg.InteractionsKey[matchIndex+1:]...) + h.cfg.SanitizeInteractionsKeys() + h.persistLocked(c) + return + } + if idxStr := c.Query("index"); idxStr != "" { + var idx int + _, errScan := fmt.Sscanf(idxStr, "%d", &idx) + if errScan == nil && idx >= 0 && idx < len(h.cfg.InteractionsKey) { + h.cfg.InteractionsKey = append(h.cfg.InteractionsKey[:idx], h.cfg.InteractionsKey[idx+1:]...) + h.cfg.SanitizeInteractionsKeys() + h.persistLocked(c) + return + } + } + c.JSON(400, gin.H{"error": "missing api-key or index"}) +} + // claude-api-key: []ClaudeKey func (h *Handler) GetClaudeKeys(c *gin.Context) { c.JSON(200, gin.H{"claude-api-key": h.claudeKeysWithAuthIndex()}) diff --git a/internal/api/server.go b/internal/api/server.go index 6e8b6ae20..35eacd34e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -556,6 +556,7 @@ func (s *Server) setupRoutes() { v1beta.Use(AuthMiddleware(s.accessManager)) { v1beta.GET("/models", s.geminiModelsHandler(geminiHandlers)) + v1beta.POST("/interactions", geminiHandlers.Interactions) v1beta.POST("/models/*action", geminiHandlers.GeminiHandler) v1beta.GET("/models/*action", s.geminiGetHandler(geminiHandlers)) } @@ -748,6 +749,11 @@ func (s *Server) registerManagementRoutes() { mgmt.PATCH("/gemini-api-key", s.mgmt.PatchGeminiKey) mgmt.DELETE("/gemini-api-key", s.mgmt.DeleteGeminiKey) + mgmt.GET("/interactions-api-key", s.mgmt.GetInteractionsKeys) + mgmt.PUT("/interactions-api-key", s.mgmt.PutInteractionsKeys) + mgmt.PATCH("/interactions-api-key", s.mgmt.PatchInteractionsKey) + mgmt.DELETE("/interactions-api-key", s.mgmt.DeleteInteractionsKey) + mgmt.GET("/logs", s.mgmt.GetLogs) mgmt.DELETE("/logs", s.mgmt.DeleteLogs) mgmt.GET("/request-error-logs", s.mgmt.GetRequestErrorLogs) @@ -1804,6 +1810,7 @@ func (s *Server) UpdateClients(cfg *config.Config) { authEntries = util.CountAuthFiles(context.Background(), tokenStore) } geminiAPIKeyCount := len(cfg.GeminiKey) + interactionsAPIKeyCount := len(cfg.InteractionsKey) claudeAPIKeyCount := len(cfg.ClaudeKey) codexAPIKeyCount := len(cfg.CodexKey) vertexAICompatCount := len(cfg.VertexCompatAPIKey) @@ -1816,11 +1823,12 @@ func (s *Server) UpdateClients(cfg *config.Config) { openAICompatCount += len(entry.APIKeyEntries) } - total := authEntries + geminiAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + vertexAICompatCount + openAICompatCount - fmt.Printf("server clients and configuration updated: %d clients (%d auth entries + %d Gemini API keys + %d Claude API keys + %d Codex keys + %d Vertex-compat + %d OpenAI-compat)\n", + total := authEntries + geminiAPIKeyCount + interactionsAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + vertexAICompatCount + openAICompatCount + fmt.Printf("server clients and configuration updated: %d clients (%d auth entries + %d Gemini API keys + %d Interactions API keys + %d Claude API keys + %d Codex keys + %d Vertex-compat + %d OpenAI-compat)\n", total, authEntries, geminiAPIKeyCount, + interactionsAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, vertexAICompatCount, diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 3a93870fd..0bb8b5a12 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -959,3 +959,14 @@ func TestHomeModelsErrorMessage(t *testing.T) { t.Fatalf("default message = %q, want fallback", msg) } } + +func TestInteractionsRouteRegistered(t *testing.T) { + server := newTestServer(t) + req := httptest.NewRequest(http.MethodPost, "/v1beta/interactions", strings.NewReader(`{"model":"gemini-3.5-flash","input":"hi"}`)) + req.Header.Set("Authorization", "Bearer test-key") + rr := httptest.NewRecorder() + server.engine.ServeHTTP(rr, req) + if rr.Code == http.StatusNotFound { + t.Fatalf("status = %d, want route registered; body=%s", rr.Code, rr.Body.String()) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 6259c8d3d..b8e603e3c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -119,6 +119,9 @@ type Config struct { // GeminiKey defines Gemini API key configurations with optional routing overrides. GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"` + // InteractionsKey defines native Google Interactions API key configurations. + InteractionsKey []GeminiKey `yaml:"interactions-api-key" json:"interactions-api-key"` + // Codex defines a list of Codex API key configurations as specified in the YAML configuration file. CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"` @@ -159,7 +162,7 @@ type Config struct { // vertex, aistudio, antigravity, claude, codex, kimi, xai. // // NOTE: This does not apply to existing per-credential model alias features under: - // gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, and vertex-api-key. + // gemini-api-key, interactions-api-key, codex-api-key, claude-api-key, openai-compatibility, and vertex-api-key. OAuthModelAlias map[string][]OAuthModelAlias `yaml:"oauth-model-alias,omitempty" json:"oauth-model-alias,omitempty"` // Payload defines default and override rules for provider payload parameters. @@ -780,6 +783,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { // Sanitize Gemini API key configuration and migrate legacy entries. cfg.SanitizeGeminiKeys() + // Sanitize native Interactions API key configuration. + cfg.SanitizeInteractionsKeys() + // Sanitize Vertex-compatible API keys. cfg.SanitizeVertexCompatKeys() @@ -1011,17 +1017,11 @@ func (cfg *Config) SanitizeClaudeKeys() { } } -// SanitizeGeminiKeys deduplicates and normalizes Gemini credentials. -// It uses API key + base URL as the uniqueness key. -func (cfg *Config) SanitizeGeminiKeys() { - if cfg == nil { - return - } - - seen := make(map[string]struct{}, len(cfg.GeminiKey)) - out := cfg.GeminiKey[:0] - for i := range cfg.GeminiKey { - entry := cfg.GeminiKey[i] +func sanitizeGeminiKeyEntries(entries []GeminiKey) []GeminiKey { + seen := make(map[string]struct{}, len(entries)) + out := entries[:0] + for i := range entries { + entry := entries[i] entry.APIKey = strings.TrimSpace(entry.APIKey) if entry.APIKey == "" { continue @@ -1038,7 +1038,25 @@ func (cfg *Config) SanitizeGeminiKeys() { seen[uniqueKey] = struct{}{} out = append(out, entry) } - cfg.GeminiKey = out + return out +} + +// SanitizeGeminiKeys deduplicates and normalizes Gemini credentials. +// It uses API key + base URL as the uniqueness key. +func (cfg *Config) SanitizeGeminiKeys() { + if cfg == nil { + return + } + cfg.GeminiKey = sanitizeGeminiKeyEntries(cfg.GeminiKey) +} + +// SanitizeInteractionsKeys deduplicates and normalizes native Interactions credentials. +// It uses API key + base URL as the uniqueness key. +func (cfg *Config) SanitizeInteractionsKeys() { + if cfg == nil { + return + } + cfg.InteractionsKey = sanitizeGeminiKeyEntries(cfg.InteractionsKey) } func normalizeModelPrefix(prefix string) string { diff --git a/internal/config/parse.go b/internal/config/parse.go index 731cc1273..5ccd1709e 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -79,6 +79,7 @@ func ParseConfigBytes(data []byte) (*Config, error) { // Apply the same sanitization pipeline. cfg.SanitizeGeminiKeys() + cfg.SanitizeInteractionsKeys() cfg.SanitizeVertexCompatKeys() cfg.SanitizeCodexKeys() cfg.SanitizeCodexHeaderDefaults() diff --git a/internal/constant/constant.go b/internal/constant/constant.go index 6a977077e..0efbc87d0 100644 --- a/internal/constant/constant.go +++ b/internal/constant/constant.go @@ -7,6 +7,9 @@ const ( // Gemini represents the Google Gemini provider identifier. Gemini = "gemini" + // GeminiInteractions represents the native Google Interactions API provider identifier. + GeminiInteractions = "gemini-interactions" + // Codex represents the OpenAI Codex provider identifier. Codex = "codex" @@ -21,4 +24,7 @@ const ( // Antigravity represents the Antigravity response format identifier. Antigravity = "antigravity" + + // Interactions represents the Google Interactions API format identifier. + Interactions = "interactions" ) diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index f1607c91a..06b2d8b28 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -1356,6 +1356,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers) + translated, _ = sjson.DeleteBytes(translated, "request.stream") reporter.SetTranslatedReasoningEffort(translated, to.String()) useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg) diff --git a/internal/runtime/executor/antigravity_executor_interactions_test.go b/internal/runtime/executor/antigravity_executor_interactions_test.go new file mode 100644 index 000000000..4e3dd9cc4 --- /dev/null +++ b/internal/runtime/executor/antigravity_executor_interactions_test.go @@ -0,0 +1,98 @@ +package executor + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestAntigravityExecutorExecuteStreamTranslatesInteractionsRequest(t *testing.T) { + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1internal:streamGenerateContent" { + t.Fatalf("path = %q, want /v1internal:streamGenerateContent", r.URL.Path) + } + if gotAlt := r.URL.Query().Get("alt"); gotAlt != "sse" { + t.Fatalf("alt = %q, want sse", gotAlt) + } + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read upstream body: %v", errRead) + } + upstreamBody = append([]byte(nil), body...) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1,\"totalTokenCount\":2}}}\n\n")) + })) + defer server.Close() + + exec := NewAntigravityExecutor(&config.Config{RequestRetry: 1}) + auth := &cliproxyauth.Auth{ + ID: "interactions-antigravity-stream-auth", + Provider: "antigravity", + Attributes: map[string]string{ + "base_url": server.URL, + }, + Metadata: map[string]any{ + "access_token": "token", + "project_id": "project-1", + "expired": time.Now().Add(time.Hour).Format(time.RFC3339), + }, + } + payload := []byte(`{"model":"gemini-3.5-flash-low","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]}],"tools":[{"name":"get_weather","description":"weather","type":"function","parameters":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}}],"generation_config":{"tool_choice":"auto","thinking_level":"high","thinking_summaries":"auto"},"stream":true,"store":false}`) + result, errExecute := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gemini-3.5-flash-low", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatInteractions, + ResponseFormat: sdktranslator.FormatInteractions, + Stream: true, + OriginalRequest: payload, + }) + if errExecute != nil { + t.Fatalf("ExecuteStream() error = %v", errExecute) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error: %v", chunk.Err) + } + } + if len(upstreamBody) == 0 { + t.Fatal("upstream body was not captured") + } + + for _, path := range []string{ + "request.stream", + "request.generationConfig.toolChoice", + "request.generationConfig.thinkingLevel", + "request.generationConfig.thinkingSummaries", + } { + if gjson.GetBytes(upstreamBody, path).Exists() { + t.Fatalf("%s should not be sent upstream: %s", path, string(upstreamBody)) + } + } + if gjson.GetBytes(upstreamBody, "input").Exists() { + t.Fatalf("raw interactions input should not be sent upstream: %s", string(upstreamBody)) + } + if got := gjson.GetBytes(upstreamBody, "request.contents.0.parts.0.text").String(); got != "hi" { + t.Fatalf("request.contents.0.parts.0.text = %q, want hi. Body: %s", got, string(upstreamBody)) + } + if got := gjson.GetBytes(upstreamBody, "request.toolConfig.functionCallingConfig.mode").String(); got != "AUTO" { + t.Fatalf("request.toolConfig.functionCallingConfig.mode = %q, want AUTO. Body: %s", got, string(upstreamBody)) + } + if got := gjson.GetBytes(upstreamBody, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" { + t.Fatalf("request.generationConfig.thinkingConfig.thinkingLevel = %q, want high. Body: %s", got, string(upstreamBody)) + } + if got := gjson.GetBytes(upstreamBody, "request.generationConfig.thinkingConfig.includeThoughts").Bool(); !got { + t.Fatalf("request.generationConfig.thinkingConfig.includeThoughts = false, want true. Body: %s", string(upstreamBody)) + } +} diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index f68a7073a..0607de863 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -34,13 +34,17 @@ const ( // streamScannerBuffer is the buffer size for SSE stream scanning. streamScannerBuffer = 52_428_800 + + // geminiInteractionsAPIRevision is the default API revision for native Interactions requests. + geminiInteractionsAPIRevision = "2026-05-20" ) // GeminiExecutor is a stateless executor for the official Gemini API using API keys. // It supports regular and streaming requests to the Google Generative Language API. type GeminiExecutor struct { // cfg holds the application configuration. - cfg *config.Config + cfg *config.Config + identifier string } // NewGeminiExecutor creates a new Gemini executor instance. @@ -51,11 +55,29 @@ type GeminiExecutor struct { // Returns: // - *GeminiExecutor: A new Gemini executor instance func NewGeminiExecutor(cfg *config.Config) *GeminiExecutor { - return &GeminiExecutor{cfg: cfg} + return &GeminiExecutor{cfg: cfg, identifier: "gemini"} +} + +// NewGeminiInteractionsExecutor creates a Gemini executor bound to the native Interactions provider. +func NewGeminiInteractionsExecutor(cfg *config.Config) *GeminiExecutor { + return &GeminiExecutor{cfg: cfg, identifier: "gemini-interactions"} } // Identifier returns the executor identifier. -func (e *GeminiExecutor) Identifier() string { return "gemini" } +func (e *GeminiExecutor) Identifier() string { + if e == nil || strings.TrimSpace(e.identifier) == "" { + return "gemini" + } + return e.identifier +} + +// RequestToFormat reports the upstream request format used after auth selection. +func (e *GeminiExecutor) RequestToFormat(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) sdktranslator.Format { + if strings.EqualFold(strings.TrimSpace(e.Identifier()), "gemini-interactions") && nativeInteractionsSourceFormat(opts.SourceFormat) { + return sdktranslator.FormatInteractions + } + return sdktranslator.FormatGemini +} // PrepareRequest injects Gemini credentials into the outgoing HTTP request. func (e *GeminiExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { @@ -104,6 +126,9 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r if opts.Alt == "responses/compact" { return resp, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"} } + if shouldExecuteNativeInteractions(auth, opts) { + return e.executeInteractions(ctx, auth, req, opts) + } baseModel := thinking.ParseSuffix(req.Model).ModelName apiKey := geminiAPIKey(auth) @@ -215,6 +240,9 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A if opts.Alt == "responses/compact" { return nil, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"} } + if shouldExecuteNativeInteractions(auth, opts) { + return e.executeInteractionsStream(ctx, auth, req, opts) + } baseModel := thinking.ParseSuffix(req.Model).ModelName apiKey := geminiAPIKey(auth) @@ -352,6 +380,230 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil } +func (e *GeminiExecutor) executeInteractions(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { + targetName := thinking.ParseSuffix(req.Model).ModelName + apiKey := geminiAPIKey(auth) + reporter := helps.NewExecutorUsageReporter(ctx, e, targetName, auth) + defer reporter.TrackFailure(ctx, &err) + + body := translateGeminiInteractionsRequestBody(targetName, req.Payload, opts, false) + if gjson.GetBytes(body, "model").Exists() && targetName != "" { + body, _ = sjson.SetBytes(body, "model", targetName) + } + body, err = applyGeminiInteractionsThinking(body, req.Model) + if err != nil { + return resp, err + } + requestedModel := helps.PayloadRequestedModel(opts, req.Model) + requestPath := helps.PayloadRequestPath(opts) + fromProtocol := opts.SourceFormat.String() + originalTranslated := geminiInteractionsPayloadConfigSource(targetName, req.Payload, opts, false) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + + baseURL := resolveGeminiBaseURL(auth) + url := fmt.Sprintf("%s/%s/interactions", baseURL, glAPIVersion) + httpReq, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if errRequest != nil { + return resp, errRequest + } + httpReq.Header.Set("Content-Type", "application/json") + if apiKey != "" { + httpReq.Header.Set("x-goog-api-key", apiKey) + } + applyGeminiHeaders(httpReq, auth) + applyGeminiInteractionsRequestHeaders(httpReq, opts.Headers) + applyGeminiInteractionsRevisionHeader(httpReq) + + authID, authLabel, authType, authValue := geminiAuthLogFields(auth) + helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{ + URL: url, + Method: http.MethodPost, + Headers: httpReq.Header.Clone(), + Body: body, + Provider: e.Identifier(), + AuthID: authID, + AuthLabel: authLabel, + AuthType: authType, + AuthValue: authValue, + }) + + httpClient := reporter.TrackHTTPClient(helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)) + httpResp, errDo := httpClient.Do(httpReq) + if errDo != nil { + helps.RecordAPIResponseError(ctx, e.cfg, errDo) + return resp, errDo + } + defer func() { + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("gemini executor: close interactions response body error: %v", errClose) + } + }() + helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) + data, errRead := io.ReadAll(httpResp.Body) + if errRead != nil { + helps.RecordAPIResponseError(ctx, e.cfg, errRead) + return resp, errRead + } + helps.AppendAPIResponseChunk(ctx, e.cfg, data) + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), data)) + err = statusErr{code: httpResp.StatusCode, msg: string(data)} + return resp, err + } + reporter.Publish(ctx, helps.ParseInteractionsUsage(data)) + var param any + out := sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatInteractions, cliproxyexecutor.ResponseFormatOrSource(opts), req.Model, opts.OriginalRequest, body, data, ¶m) + return cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}, nil +} + +func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) { + targetName := thinking.ParseSuffix(req.Model).ModelName + apiKey := geminiAPIKey(auth) + reporter := helps.NewExecutorUsageReporter(ctx, e, targetName, auth) + defer reporter.TrackFailure(ctx, &err) + + body := translateGeminiInteractionsRequestBody(targetName, req.Payload, opts, true) + if gjson.GetBytes(body, "model").Exists() && targetName != "" { + body, _ = sjson.SetBytes(body, "model", targetName) + } + body, err = applyGeminiInteractionsThinking(body, req.Model) + if err != nil { + return nil, err + } + requestedModel := helps.PayloadRequestedModel(opts, req.Model) + requestPath := helps.PayloadRequestPath(opts) + fromProtocol := opts.SourceFormat.String() + originalTranslated := geminiInteractionsPayloadConfigSource(targetName, req.Payload, opts, true) + body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers) + body, _ = sjson.SetBytes(body, "stream", true) + baseURL := resolveGeminiBaseURL(auth) + url := fmt.Sprintf("%s/%s/interactions", baseURL, glAPIVersion) + httpReq, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if errRequest != nil { + return nil, errRequest + } + httpReq.Header.Set("Content-Type", "application/json") + if apiKey != "" { + httpReq.Header.Set("x-goog-api-key", apiKey) + } + applyGeminiHeaders(httpReq, auth) + applyGeminiInteractionsRequestHeaders(httpReq, opts.Headers) + applyGeminiInteractionsRevisionHeader(httpReq) + + authID, authLabel, authType, authValue := geminiAuthLogFields(auth) + helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{ + URL: url, + Method: http.MethodPost, + Headers: httpReq.Header.Clone(), + Body: body, + Provider: e.Identifier(), + AuthID: authID, + AuthLabel: authLabel, + AuthType: authType, + AuthValue: authValue, + }) + + httpClient := reporter.TrackHTTPClient(helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)) + httpResp, errDo := httpClient.Do(httpReq) + if errDo != nil { + helps.RecordAPIResponseError(ctx, e.cfg, errDo) + return nil, errDo + } + helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + data, _ := io.ReadAll(httpResp.Body) + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("gemini executor: close interactions error response body error: %v", errClose) + } + helps.AppendAPIResponseChunk(ctx, e.cfg, data) + return nil, statusErr{code: httpResp.StatusCode, msg: string(data)} + } + + out := make(chan cliproxyexecutor.StreamChunk) + responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) + go func() { + defer close(out) + defer func() { + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("gemini executor: close interactions stream body error: %v", errClose) + } + }() + scanner := bufio.NewScanner(httpResp.Body) + scanner.Buffer(nil, streamScannerBuffer) + var param any + var frame []byte + emitFrame := func() bool { + rawFrame := bytes.Clone(frame) + trimmed := bytes.TrimSpace(rawFrame) + frame = frame[:0] + if len(trimmed) == 0 { + return true + } + payload := geminiInteractionsSSEPayload(rawFrame) + if len(payload) == 0 && geminiInteractionsSSEDone(rawFrame) { + payload = []byte("[DONE]") + } + if len(payload) == 0 && len(trimmed) > 0 && trimmed[0] == '{' { + payload = trimmed + } + if len(payload) > 0 { + if detail, ok := helps.ParseInteractionsStreamUsage(payload); ok { + reporter.Publish(ctx, detail) + } + } + if responseFormat == sdktranslator.FormatInteractions { + visibleFrame := append(bytes.TrimRight(rawFrame, "\r\n"), '\n', '\n') + select { + case out <- cliproxyexecutor.StreamChunk{Payload: visibleFrame}: + case <-ctx.Done(): + return false + } + return true + } + if len(payload) == 0 { + return true + } + var lines [][]byte + lines = sdktranslator.TranslateStream(ctx, sdktranslator.FormatInteractions, responseFormat, req.Model, opts.OriginalRequest, body, payload, ¶m) + for i := range lines { + select { + case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: + case <-ctx.Done(): + return false + } + } + return true + } + for scanner.Scan() { + line := bytes.Clone(scanner.Bytes()) + helps.AppendAPIResponseChunk(ctx, e.cfg, line) + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 { + if !emitFrame() { + return + } + continue + } + if len(frame) > 0 { + frame = append(frame, '\n') + } + frame = append(frame, line...) + } + if !emitFrame() { + return + } + if errScan := scanner.Err(); errScan != nil { + helps.RecordAPIResponseError(ctx, e.cfg, errScan) + reporter.PublishFailure(ctx, errScan) + select { + case out <- cliproxyexecutor.StreamChunk{Err: errScan}: + case <-ctx.Done(): + } + } + }() + return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil +} + // CountTokens counts tokens for the given request using the Gemini API. func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { baseModel := thinking.ParseSuffix(req.Model).ModelName @@ -508,6 +760,124 @@ func (e *GeminiExecutor) resolveGeminiConfig(auth *cliproxyauth.Auth) *config.Ge return nil } +func shouldExecuteNativeInteractions(auth *cliproxyauth.Auth, opts cliproxyexecutor.Options) bool { + return nativeInteractionsSourceFormat(opts.SourceFormat) && isNativeInteractionsAuth(auth) +} + +func nativeInteractionsSourceFormat(format sdktranslator.Format) bool { + switch format { + case sdktranslator.FormatInteractions, sdktranslator.FormatOpenAI, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatClaude, sdktranslator.FormatGemini: + return true + default: + return false + } +} + +func translateGeminiInteractionsRequestBody(model string, payload []byte, opts cliproxyexecutor.Options, stream bool) []byte { + if opts.SourceFormat == "" || opts.SourceFormat == sdktranslator.FormatInteractions { + return bytes.Clone(payload) + } + return sdktranslator.TranslateRequest(opts.SourceFormat, sdktranslator.FormatInteractions, model, payload, stream) +} + +func geminiInteractionsPayloadConfigSource(model string, payload []byte, opts cliproxyexecutor.Options, stream bool) []byte { + source := opts.OriginalRequest + if len(source) == 0 { + source = payload + } + return translateGeminiInteractionsRequestBody(model, source, opts, stream) +} + +func isNativeInteractionsAuth(auth *cliproxyauth.Auth) bool { + if auth == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(auth.Provider), "gemini-interactions") +} + +func applyGeminiInteractionsThinking(body []byte, model string) ([]byte, error) { + return thinking.ApplyThinking(body, model, sdktranslator.FormatInteractions.String(), sdktranslator.FormatInteractions.String(), "gemini") +} + +func applyGeminiInteractionsRevisionHeader(req *http.Request) { + if req == nil { + return + } + if req.Header.Get("Api-Revision") == "" { + req.Header.Set("Api-Revision", geminiInteractionsAPIRevision) + } +} + +func applyGeminiInteractionsRequestHeaders(req *http.Request, headers http.Header) { + if req == nil || headers == nil || req.Header.Get("Api-Revision") != "" { + return + } + if revision := headers.Get("Api-Revision"); revision != "" { + req.Header.Set("Api-Revision", revision) + } +} + +func geminiInteractionsSSEPayload(frame []byte) []byte { + trimmed := bytes.TrimSpace(frame) + if len(trimmed) == 0 { + return nil + } + if bytes.HasPrefix(trimmed, []byte("{")) { + return trimmed + } + lines := bytes.Split(frame, []byte{'\n'}) + var payload []byte + for _, line := range lines { + line = bytes.TrimRight(line, "\r") + if !bytes.HasPrefix(bytes.TrimSpace(line), []byte("data:")) { + continue + } + data := bytes.TrimSpace(line[bytes.Index(line, []byte("data:"))+len("data:"):]) + if len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) { + continue + } + if len(payload) > 0 { + payload = append(payload, '\n') + } + payload = append(payload, data...) + } + if len(payload) == 0 { + return nil + } + return payload +} + +func geminiInteractionsSSEDone(frame []byte) bool { + trimmed := bytes.TrimSpace(frame) + if bytes.Equal(trimmed, []byte("[DONE]")) { + return true + } + lines := bytes.Split(frame, []byte{'\n'}) + sawDoneEvent := false + for _, line := range lines { + line = bytes.TrimSpace(bytes.TrimRight(line, "\r")) + if bytes.EqualFold(line, []byte("event: done")) { + sawDoneEvent = true + continue + } + if bytes.HasPrefix(line, []byte("data:")) { + data := bytes.TrimSpace(line[len("data:"):]) + if bytes.Equal(data, []byte("[DONE]")) { + return true + } + } + } + return sawDoneEvent +} + +func geminiAuthLogFields(auth *cliproxyauth.Auth) (string, string, string, string) { + if auth == nil { + return "", "", "", "" + } + authType, authValue := auth.AccountInfo() + return auth.ID, auth.Label, authType, authValue +} + func applyGeminiHeaders(req *http.Request, auth *cliproxyauth.Auth) { var attrs map[string]string if auth != nil { diff --git a/internal/runtime/executor/gemini_executor_test.go b/internal/runtime/executor/gemini_executor_test.go index fbcd0d55d..6a22e4e74 100644 --- a/internal/runtime/executor/gemini_executor_test.go +++ b/internal/runtime/executor/gemini_executor_test.go @@ -1,6 +1,7 @@ package executor import ( + "bytes" "context" "io" "net/http" @@ -8,6 +9,7 @@ import ( "testing" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" @@ -88,3 +90,905 @@ func TestGeminiExecutorExecuteCapsMaxOutputTokensBeforeUpstream(t *testing.T) { t.Fatalf("upstream maxOutputTokens = %d, want 65536", upstreamMaxOutputTokens) } } + +func TestGeminiExecutorInteractionsWithGeminiAPIKeyUsesGeminiEndpoint(t *testing.T) { + var gotPath string + var gotRevision string + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotRevision = r.Header.Get("Api-Revision") + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + upstreamBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}`)) + })) + defer server.Close() + + exec := NewGeminiExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gemini", + Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }, + } + req := cliproxyexecutor.Request{ + Model: "gemini-3.5-flash", + Payload: []byte(`{"model":"gemini-3.5-flash","input":"hi"}`), + } + + _, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatInteractions, + ResponseFormat: sdktranslator.FormatInteractions, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if gotPath != "/v1beta/models/gemini-3.5-flash:generateContent" { + t.Fatalf("path = %q, want Gemini generateContent endpoint", gotPath) + } + if gotRevision != "" { + t.Fatalf("Api-Revision = %q, want empty for Gemini protocol request", gotRevision) + } + if !gjson.GetBytes(upstreamBody, "contents.0.parts.0.text").Exists() { + t.Fatalf("contents text missing from translated Gemini body: %s", string(upstreamBody)) + } + if gjson.GetBytes(upstreamBody, "input").Exists() { + t.Fatalf("raw interactions input exists in translated Gemini body: %s", string(upstreamBody)) + } +} + +func TestGeminiExecutorNativeInteractionsUsesInteractionsEndpoint(t *testing.T) { + var gotPath string + var gotRevision string + var gotModelExists bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotRevision = r.Header.Get("Api-Revision") + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + gotModelExists = gjson.GetBytes(body, "model").Exists() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`)) + })) + defer server.Close() + + exec := NewGeminiInteractionsExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gemini-interactions", + Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }, + } + req := cliproxyexecutor.Request{ + Model: "agents/test-agent", + Payload: []byte(`{"agent":"agents/test-agent","input":"hi"}`), + } + + resp, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatInteractions, + ResponseFormat: sdktranslator.FormatInteractions, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if gotPath != "/v1beta/interactions" { + t.Fatalf("path = %q, want /v1beta/interactions", gotPath) + } + if gotRevision != "2026-05-20" { + t.Fatalf("Api-Revision = %q, want 2026-05-20", gotRevision) + } + if gotModelExists { + t.Fatal("model field exists for agent-only request, want absent") + } + if got := gjson.GetBytes(resp.Payload, "id").String(); got != "interaction_1" { + t.Fatalf("response id = %q, want interaction_1", got) + } +} + +func TestGeminiExecutorNativeInteractionsTranslatesOpenAIResponsesRequest(t *testing.T) { + var gotPath string + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + upstreamBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`)) + })) + defer server.Close() + + exec := NewGeminiInteractionsExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gemini-interactions", + Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }, + } + req := cliproxyexecutor.Request{ + Model: "gemini-3.1-flash-lite", + Payload: []byte(`{ + "model":"gemini-3.1-flash-lite", + "instructions":"be brief", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}], + "reasoning":{"effort":"high","summary":"auto"} + }`), + } + + resp, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAIResponse, + ResponseFormat: sdktranslator.FormatOpenAIResponse, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if gotPath != "/v1beta/interactions" { + t.Fatalf("path = %q, want /v1beta/interactions", gotPath) + } + if got := gjson.GetBytes(upstreamBody, "input.0.type").String(); got != "user_input" { + t.Fatalf("input.0.type = %q, want user_input. Body: %s", got, string(upstreamBody)) + } + if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_level").String(); got != "high" { + t.Fatalf("thinking_level = %q, want high. Body: %s", got, string(upstreamBody)) + } + if got := gjson.GetBytes(resp.Payload, "output.0.content.0.text").String(); got != "ok" { + t.Fatalf("response text = %q, want ok. Payload: %s", got, string(resp.Payload)) + } +} + +func TestGeminiExecutorNativeInteractionsPayloadRulesUseResponsesFromProtocol(t *testing.T) { + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + upstreamBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"text":"ok"}]}]}`)) + })) + defer server.Close() + + exec := NewGeminiInteractionsExecutor(&config.Config{ + Payload: config.PayloadConfig{ + Override: []config.PayloadRule{ + { + Models: []config.PayloadModelRule{ + {Name: "gemini-3.1-flash-lite", Protocol: "interactions", FromProtocol: "openai"}, + }, + Params: map[string]any{ + "generation_config.thinking_summaries": "wrong", + }, + }, + { + Models: []config.PayloadModelRule{ + {Name: "gemini-3.1-flash-lite", Protocol: "interactions", FromProtocol: "responses"}, + }, + Params: map[string]any{ + "generation_config.thinking_summaries": "detailed", + }, + }, + }, + }, + }) + auth := &cliproxyauth.Auth{ + Provider: "gemini-interactions", + Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }, + } + req := cliproxyexecutor.Request{ + Model: "gemini-3.1-flash-lite", + Payload: []byte(`{ + "model":"gemini-3.1-flash-lite", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}] + }`), + } + + _, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAIResponse, + ResponseFormat: sdktranslator.FormatOpenAIResponse, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_summaries").String(); got != "detailed" { + t.Fatalf("thinking_summaries = %q, want detailed. Body: %s", got, string(upstreamBody)) + } +} + +func TestGeminiExecutorNativeInteractionsTranslatesOpenAIChatRequest(t *testing.T) { + var gotPath string + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + upstreamBody = body + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"i1\",\"model\":\"gemini-3.1-flash-lite\"}}\n\n")) + _, _ = w.Write([]byte("event: step.start\ndata: {\"event_type\":\"step.start\",\"index\":0,\"step\":{\"type\":\"function_call\",\"id\":\"call_1\",\"name\":\"get_weather\",\"arguments\":{}}}\n\n")) + _, _ = w.Write([]byte("event: step.delta\ndata: {\"event_type\":\"step.delta\",\"index\":0,\"delta\":{\"type\":\"arguments_delta\",\"arguments\":\"{\\\"location\\\":\\\"北京\\\"}\"}}\n\n")) + _, _ = w.Write([]byte("event: step.stop\ndata: {\"event_type\":\"step.stop\",\"index\":0}\n\n")) + _, _ = w.Write([]byte("event: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"i1\",\"status\":\"requires_action\",\"usage\":{\"total_input_tokens\":2,\"total_output_tokens\":3,\"total_tokens\":5}}}\n\n")) + })) + defer server.Close() + + exec := NewGeminiInteractionsExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gemini-interactions", + Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }, + } + req := cliproxyexecutor.Request{ + Model: "gemini-3.1-flash-lite", + Payload: []byte(`{ + "model":"gemini-3.1-flash-lite", + "stream":true, + "messages":[{"role":"user","content":"今天北京的天气怎么样?"}], + "tools":[{"type":"function","function":{"name":"get_weather","parameters":{"type":"object","properties":{"location":{"type":"string"}}}}}], + "tool_choice":"auto" + }`), + } + + result, errExecute := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAI, + ResponseFormat: sdktranslator.FormatOpenAI, + }) + if errExecute != nil { + t.Fatalf("ExecuteStream() error = %v", errExecute) + } + var toolStart []byte + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error: %v", chunk.Err) + } + if gjson.GetBytes(chunk.Payload, "choices.0.delta.tool_calls.0.function.name").String() == "get_weather" { + toolStart = chunk.Payload + } + } + if gotPath != "/v1beta/interactions" { + t.Fatalf("path = %q, want /v1beta/interactions", gotPath) + } + if got := gjson.GetBytes(upstreamBody, "input.0.content.0.text").String(); got != "今天北京的天气怎么样?" { + t.Fatalf("translated request text = %q. Body: %s", got, string(upstreamBody)) + } + if gjson.GetBytes(upstreamBody, "messages").Exists() { + t.Fatalf("raw OpenAI messages should not be sent upstream: %s", string(upstreamBody)) + } + if got := gjson.GetBytes(upstreamBody, "tools.0.type").String(); got != "function" { + t.Fatalf("translated tool type = %q, want function. Body: %s", got, string(upstreamBody)) + } + if got := gjson.GetBytes(upstreamBody, "generation_config.tool_choice").String(); got != "auto" { + t.Fatalf("translated tool choice = %q, want auto. Body: %s", got, string(upstreamBody)) + } + if toolStart == nil { + t.Fatal("OpenAI tool call chunk not found") + } + if got := gjson.GetBytes(toolStart, "choices.0.delta.tool_calls.0.id").String(); got != "call_1" { + t.Fatalf("tool call id = %q, want call_1. Payload: %s", got, string(toolStart)) + } +} + +func TestGeminiExecutorNativeInteractionsPayloadDefaultsUseTranslatedOpenAIChatSource(t *testing.T) { + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + upstreamBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"text":"ok"}]}]}`)) + })) + defer server.Close() + + exec := NewGeminiInteractionsExecutor(&config.Config{ + Payload: config.PayloadConfig{ + Default: []config.PayloadRule{ + { + Models: []config.PayloadModelRule{ + {Name: "gemini-3.1-flash-lite", Protocol: "interactions", FromProtocol: "openai"}, + }, + Params: map[string]any{ + "generation_config.temperature": 0.9, + "generation_config.top_p": 0.8, + }, + }, + }, + }, + }) + auth := &cliproxyauth.Auth{ + Provider: "gemini-interactions", + Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }, + } + req := cliproxyexecutor.Request{ + Model: "gemini-3.1-flash-lite", + Payload: []byte(`{ + "model":"gemini-3.1-flash-lite", + "messages":[{"role":"user","content":"hi"}], + "temperature":0.2 + }`), + } + + _, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAI, + ResponseFormat: sdktranslator.FormatOpenAI, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if got := gjson.GetBytes(upstreamBody, "generation_config.temperature").Float(); got != 0.2 { + t.Fatalf("temperature = %v, want 0.2. Body: %s", got, string(upstreamBody)) + } + if got := gjson.GetBytes(upstreamBody, "generation_config.top_p").Float(); got != 0.8 { + t.Fatalf("top_p = %v, want default 0.8. Body: %s", got, string(upstreamBody)) + } +} + +func TestGeminiExecutorNativeInteractionsTranslatesGeminiStreamResponse(t *testing.T) { + var gotPath string + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + upstreamBody = body + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"i1\",\"model\":\"gemini-3.1-flash-lite\"}}\n\n")) + _, _ = w.Write([]byte("event: step.start\ndata: {\"event_type\":\"step.start\",\"index\":0,\"step\":{\"type\":\"function_call\",\"id\":\"call_1\",\"signature\":\"sig_1\",\"name\":\"get_weather\",\"arguments\":{}}}\n\n")) + _, _ = w.Write([]byte("event: step.delta\ndata: {\"event_type\":\"step.delta\",\"index\":0,\"delta\":{\"type\":\"arguments_delta\",\"arguments\":\"{\\\"location\\\":\\\"北京\\\"}\"}}\n\n")) + _, _ = w.Write([]byte("event: step.stop\ndata: {\"event_type\":\"step.stop\",\"index\":0}\n\n")) + _, _ = w.Write([]byte("event: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"i1\",\"status\":\"requires_action\",\"usage\":{\"total_input_tokens\":2,\"total_output_tokens\":3,\"total_tokens\":5,\"total_cached_tokens\":1},\"service_tier\":\"standard\",\"model\":\"gemini-3.1-flash-lite\"}}\n\n")) + _, _ = w.Write([]byte("event: done\ndata: [DONE]\n\n")) + })) + defer server.Close() + + exec := NewGeminiInteractionsExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gemini-interactions", + Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }, + } + req := cliproxyexecutor.Request{ + Model: "gemini-3.1-flash-lite", + Payload: []byte(`{ + "contents":[{"role":"user","parts":[{"text":"今天北京的天气怎么样?"}]}], + "tools":[{"functionDeclarations":[{"name":"get_weather","parameters":{"type":"OBJECT","properties":{"location":{"type":"STRING"}},"required":["location"]}}]}] + }`), + } + + result, errExecute := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatGemini, + ResponseFormat: sdktranslator.FormatGemini, + }) + if errExecute != nil { + t.Fatalf("ExecuteStream() error = %v", errExecute) + } + var callChunk []byte + var finishChunk []byte + chunkCount := 0 + for chunk := range result.Chunks { + chunkCount++ + if chunk.Err != nil { + t.Fatalf("stream chunk error: %v", chunk.Err) + } + if gjson.GetBytes(chunk.Payload, "event_type").Exists() { + t.Fatalf("interactions payload leaked to Gemini response: %s", string(chunk.Payload)) + } + if gjson.GetBytes(chunk.Payload, "candidates.0.content.parts.0.functionCall").Exists() { + callChunk = chunk.Payload + } + if gjson.GetBytes(chunk.Payload, "candidates.0.finishReason").Exists() { + finishChunk = chunk.Payload + } + } + if gotPath != "/v1beta/interactions" { + t.Fatalf("path = %q, want /v1beta/interactions", gotPath) + } + if gjson.GetBytes(upstreamBody, "contents").Exists() { + t.Fatalf("raw Gemini contents should not be sent upstream: %s", string(upstreamBody)) + } + if got := gjson.GetBytes(upstreamBody, "input.0.content.0.text").String(); got != "今天北京的天气怎么样?" { + t.Fatalf("translated request text = %q. Body: %s", got, string(upstreamBody)) + } + if chunkCount != 2 { + t.Fatalf("stream chunk count = %d, want 2", chunkCount) + } + if callChunk == nil { + t.Fatal("Gemini functionCall chunk not found") + } + if got := gjson.GetBytes(callChunk, "candidates.0.content.parts.0.functionCall.name").String(); got != "get_weather" { + t.Fatalf("functionCall.name = %q, want get_weather. Payload: %s", got, string(callChunk)) + } + if got := gjson.GetBytes(callChunk, "candidates.0.content.parts.0.functionCall.args.location").String(); got != "北京" { + t.Fatalf("functionCall.args.location = %q, want 北京. Payload: %s", got, string(callChunk)) + } + if got := gjson.GetBytes(callChunk, "candidates.0.content.parts.0.thoughtSignature").String(); got != "sig_1" { + t.Fatalf("thoughtSignature = %q, want sig_1. Payload: %s", got, string(callChunk)) + } + if finishChunk == nil { + t.Fatal("Gemini finish chunk not found") + } + if got := gjson.GetBytes(finishChunk, "candidates.0.finishReason").String(); got != "STOP" { + t.Fatalf("finishReason = %q, want STOP. Payload: %s", got, string(finishChunk)) + } + if got := gjson.GetBytes(finishChunk, "usageMetadata.promptTokenCount").Int(); got != 2 { + t.Fatalf("promptTokenCount = %d, want 2. Payload: %s", got, string(finishChunk)) + } + if got := gjson.GetBytes(finishChunk, "usageMetadata.candidatesTokenCount").Int(); got != 3 { + t.Fatalf("candidatesTokenCount = %d, want 3. Payload: %s", got, string(finishChunk)) + } + if got := gjson.GetBytes(finishChunk, "usageMetadata.totalTokenCount").Int(); got != 5 { + t.Fatalf("totalTokenCount = %d, want 5. Payload: %s", got, string(finishChunk)) + } +} + +func TestNativeInteractionsSourceFormatAllowsSupportedEntryProtocols(t *testing.T) { + supported := []sdktranslator.Format{ + sdktranslator.FormatInteractions, + sdktranslator.FormatOpenAI, + sdktranslator.FormatOpenAIResponse, + sdktranslator.FormatClaude, + sdktranslator.FormatGemini, + } + for _, format := range supported { + if !nativeInteractionsSourceFormat(format) { + t.Fatalf("nativeInteractionsSourceFormat(%q) = false, want true", format) + } + } + for _, format := range []sdktranslator.Format{sdktranslator.FormatCodex, sdktranslator.FormatAntigravity} { + if nativeInteractionsSourceFormat(format) { + t.Fatalf("nativeInteractionsSourceFormat(%q) = true, want false", format) + } + } +} + +func TestGeminiExecutorNativeInteractionsTranslatesClaudeRequest(t *testing.T) { + var gotPath string + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + upstreamBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"interaction_1","object":"interaction","status":"completed","model":"gemini-3.1-flash-lite","steps":[{"type":"model_output","content":[{"type":"text","text":"ok"}]}],"usage":{"total_input_tokens":1,"total_output_tokens":1}}`)) + })) + defer server.Close() + + exec := NewGeminiInteractionsExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gemini-interactions", + Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }, + } + req := cliproxyexecutor.Request{ + Model: "gemini-3.1-flash-lite", + Payload: []byte(`{ + "model":"gemini-3.1-flash-lite", + "max_tokens":1024, + "tools":[{"name":"get_weather","description":"weather","input_schema":{"type":"object","properties":{"location":{"type":"string"}}}}], + "messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}] + }`), + } + + resp, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + ResponseFormat: sdktranslator.FormatClaude, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if gotPath != "/v1beta/interactions" { + t.Fatalf("path = %q, want /v1beta/interactions", gotPath) + } + if got := gjson.GetBytes(upstreamBody, "input.0.content.0.text").String(); got != "hi" { + t.Fatalf("translated request text = %q, want hi. Body: %s", got, string(upstreamBody)) + } + if gjson.GetBytes(upstreamBody, "messages").Exists() { + t.Fatalf("raw Claude messages should not be sent upstream: %s", string(upstreamBody)) + } + if got := gjson.GetBytes(upstreamBody, "tools.0.type").String(); got != "function" { + t.Fatalf("translated tool type = %q, want function. Body: %s", got, string(upstreamBody)) + } + if got := gjson.GetBytes(resp.Payload, "content.0.text").String(); got != "ok" { + t.Fatalf("response text = %q, want ok. Payload: %s", got, string(resp.Payload)) + } + if got := gjson.GetBytes(resp.Payload, "usage.output_tokens").Int(); got != 1 { + t.Fatalf("response output tokens = %d, want 1. Payload: %s", got, string(resp.Payload)) + } +} + +func TestGeminiExecutorNativeInteractionsAppliesThinkingSuffix(t *testing.T) { + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + upstreamBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"interaction_1","status":"completed","steps":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`)) + })) + defer server.Close() + + exec := NewGeminiInteractionsExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gemini-interactions", + Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }, + } + req := cliproxyexecutor.Request{ + Model: "gemini-3.1-flash-lite(high)", + Payload: []byte(`{"model":"gemini-3.1-flash-lite(high)","generation_config":{"max_output_tokens":32},"input":"hi"}`), + } + _, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatInteractions, + ResponseFormat: sdktranslator.FormatInteractions, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if got := gjson.GetBytes(upstreamBody, "model").String(); got != "gemini-3.1-flash-lite" { + t.Fatalf("model = %q, want gemini-3.1-flash-lite. Body: %s", got, string(upstreamBody)) + } + if gjson.GetBytes(upstreamBody, "generationConfig").Exists() { + t.Fatalf("generationConfig exists, want Interactions snake_case only. Body: %s", string(upstreamBody)) + } + if gjson.GetBytes(upstreamBody, "generation_config.thinking_config").Exists() { + t.Fatalf("thinking_config exists, want native Interactions fields. Body: %s", string(upstreamBody)) + } + if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_level").String(); got != "high" { + t.Fatalf("thinking_level = %q, want high. Body: %s", got, string(upstreamBody)) + } + if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_summaries").String(); got != "auto" { + t.Fatalf("thinking_summaries = %q, want auto. Body: %s", got, string(upstreamBody)) + } +} + +func TestGeminiExecutorNativeInteractionsPreservesThinkingProtocolFields(t *testing.T) { + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + upstreamBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"interaction_1","status":"completed","steps":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`)) + })) + defer server.Close() + + exec := NewGeminiInteractionsExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gemini-interactions", + Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }, + } + req := cliproxyexecutor.Request{ + Model: "gemini-3.1-flash-lite", + Payload: []byte(`{"model":"gemini-3.1-flash-lite","generation_config":{"tool_choice":"auto","thinking_level":"high","thinking_summaries":"auto"},"input":"hi"}`), + } + _, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatInteractions, + ResponseFormat: sdktranslator.FormatInteractions, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if gjson.GetBytes(upstreamBody, "generationConfig").Exists() { + t.Fatalf("generationConfig exists, want Interactions snake_case only. Body: %s", string(upstreamBody)) + } + if gjson.GetBytes(upstreamBody, "generation_config.thinking_config").Exists() { + t.Fatalf("thinking_config exists, want native Interactions fields. Body: %s", string(upstreamBody)) + } + if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_level").String(); got != "high" { + t.Fatalf("thinking_level = %q, want high. Body: %s", got, string(upstreamBody)) + } + if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_summaries").String(); got != "auto" { + t.Fatalf("thinking_summaries = %q, want auto. Body: %s", got, string(upstreamBody)) + } +} + +func TestGeminiExecutorNativeInteractionsPreservesApiRevision(t *testing.T) { + var gotRevision string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotRevision = r.Header.Get("Api-Revision") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"interaction_1","status":"completed","steps":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`)) + })) + defer server.Close() + + exec := NewGeminiInteractionsExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gemini-interactions", + Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }, + } + auth.Attributes["header:Api-Revision"] = "2026-06-01" + req := cliproxyexecutor.Request{ + Model: "agents/test-agent", + Payload: []byte(`{"agent":"agents/test-agent","input":"hi"}`), + } + _, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatInteractions, + ResponseFormat: sdktranslator.FormatInteractions, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if gotRevision != "2026-06-01" { + t.Fatalf("Api-Revision = %q, want 2026-06-01", gotRevision) + } +} + +func TestGeminiExecutorNativeInteractionsUsesRequestApiRevision(t *testing.T) { + var gotRevision string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotRevision = r.Header.Get("Api-Revision") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"interaction_1","status":"completed","steps":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`)) + })) + defer server.Close() + + exec := NewGeminiInteractionsExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gemini-interactions", + Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }, + } + req := cliproxyexecutor.Request{ + Model: "agents/test-agent", + Payload: []byte(`{"agent":"agents/test-agent","input":"hi"}`), + } + _, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatInteractions, + ResponseFormat: sdktranslator.FormatInteractions, + Headers: http.Header{"Api-Revision": []string{"2026-06-01"}}, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if gotRevision != "2026-06-01" { + t.Fatalf("Api-Revision = %q, want 2026-06-01", gotRevision) + } +} + +func TestGeminiExecutorNativeInteractionsRequestApiRevisionDoesNotOverrideAuthHeader(t *testing.T) { + var gotRevision string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotRevision = r.Header.Get("Api-Revision") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"interaction_1","status":"completed","steps":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`)) + })) + defer server.Close() + + exec := NewGeminiInteractionsExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + "header:Api-Revision": "2026-06-01", + }, Provider: "gemini-interactions"} + req := cliproxyexecutor.Request{ + Model: "agents/test-agent", + Payload: []byte(`{"agent":"agents/test-agent","input":"hi"}`), + } + _, errExecute := exec.Execute(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatInteractions, + ResponseFormat: sdktranslator.FormatInteractions, + Headers: http.Header{"Api-Revision": []string{"2026-07-01"}}, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + if gotRevision != "2026-06-01" { + t.Fatalf("Api-Revision = %q, want 2026-06-01", gotRevision) + } +} + +func TestGeminiExecutorNativeInteractionsStreamParsesUsage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"i1\"}}\n\n")) + _, _ = w.Write([]byte("event: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"i1\",\"status\":\"completed\",\"usage\":{\"total_input_tokens\":2,\"total_output_tokens\":3,\"total_tokens\":5}}}\n\n")) + })) + defer server.Close() + + exec := NewGeminiInteractionsExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gemini-interactions", + Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }, + } + req := cliproxyexecutor.Request{ + Model: "gemini-3.5-flash", + Payload: []byte(`{"model":"gemini-3.5-flash","input":"hi","stream":true}`), + } + result, errExecute := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatInteractions, + ResponseFormat: sdktranslator.FormatInteractions, + }) + if errExecute != nil { + t.Fatalf("ExecuteStream() error = %v", errExecute) + } + count := 0 + var completed []byte + for chunk := range result.Chunks { + count++ + if chunk.Err != nil { + t.Fatalf("stream chunk error: %v", chunk.Err) + } + if !bytes.Contains(chunk.Payload, []byte("event:")) || !bytes.Contains(chunk.Payload, []byte("data:")) { + t.Fatalf("chunk = %q, want complete SSE frame", string(chunk.Payload)) + } + payload := geminiInteractionsSSEPayload(chunk.Payload) + if gjson.GetBytes(payload, "event_type").String() == "interaction.completed" { + completed = payload + } + } + if count == 0 { + t.Fatal("no stream chunks received") + } + if completed == nil { + t.Fatal("interaction.completed chunk not found") + } + if got := gjson.GetBytes(completed, "interaction.usage.total_input_tokens").Int(); got != 2 { + t.Fatalf("total_input_tokens = %d, want 2", got) + } + if got := gjson.GetBytes(completed, "interaction.usage.total_output_tokens").Int(); got != 3 { + t.Fatalf("total_output_tokens = %d, want 3", got) + } + if got := gjson.GetBytes(completed, "interaction.usage.total_tokens").Int(); got != 5 { + t.Fatalf("total_tokens = %d, want 5", got) + } +} + +func TestGeminiExecutorNativeInteractionsClaudeStreamPreservesToolSignature(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"i1\",\"model\":\"gemini-3.1-flash-lite\"}}\n\n")) + _, _ = w.Write([]byte("event: step.start\ndata: {\"event_type\":\"step.start\",\"index\":0,\"step\":{\"type\":\"function_call\",\"id\":\"toolu_1\",\"signature\":\"sig_1\",\"name\":\"get_weather\",\"arguments\":{}}}\n\n")) + _, _ = w.Write([]byte("event: step.delta\ndata: {\"event_type\":\"step.delta\",\"index\":0,\"delta\":{\"type\":\"arguments_delta\",\"arguments\":\"{\\\"location\\\":\\\"北京\\\"}\"}}\n\n")) + _, _ = w.Write([]byte("event: step.stop\ndata: {\"event_type\":\"step.stop\",\"index\":0}\n\n")) + _, _ = w.Write([]byte("event: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"i1\",\"status\":\"requires_action\",\"usage\":{\"total_input_tokens\":1,\"total_output_tokens\":2}}}\n\n")) + _, _ = w.Write([]byte("event: done\ndata: [DONE]\n\n")) + })) + defer server.Close() + + exec := NewGeminiInteractionsExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gemini-interactions", + Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }, + } + req := cliproxyexecutor.Request{ + Model: "gemini-3.1-flash-lite", + Payload: []byte(`{"model":"gemini-3.1-flash-lite","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`), + } + + result, errExecute := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatClaude, + ResponseFormat: sdktranslator.FormatClaude, + }) + if errExecute != nil { + t.Fatalf("ExecuteStream() error = %v", errExecute) + } + + var toolStart []byte + var toolDelta []byte + var messageStop []byte + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error: %v", chunk.Err) + } + payload := geminiInteractionsSSEPayload(chunk.Payload) + switch gjson.GetBytes(payload, "type").String() { + case "content_block_start": + if gjson.GetBytes(payload, "content_block.type").String() == "tool_use" { + toolStart = payload + } + case "content_block_delta": + if gjson.GetBytes(payload, "delta.type").String() == "input_json_delta" { + toolDelta = payload + } + case "message_stop": + messageStop = payload + } + } + if toolStart == nil { + t.Fatal("tool content_block_start chunk not found") + } + if got := gjson.GetBytes(toolStart, "content_block.signature").String(); got != "sig_1" { + t.Fatalf("tool signature = %q, want sig_1. Payload: %s", got, string(toolStart)) + } + if got := gjson.GetBytes(toolDelta, "delta.partial_json").String(); got != `{"location":"北京"}` { + t.Fatalf("tool partial_json = %q, want location payload. Payload: %s", got, string(toolDelta)) + } + if messageStop == nil { + t.Fatal("message_stop chunk not found") + } +} + +func TestGeminiExecutorNativeInteractionsResponsesStreamEmitsDone(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"i1\",\"model\":\"gemini-3.1-flash-lite\"}}\n\n")) + _, _ = w.Write([]byte("event: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"i1\",\"status\":\"completed\",\"usage\":{\"total_input_tokens\":1,\"total_output_tokens\":2}}}\n\n")) + _, _ = w.Write([]byte("event: done\ndata: [DONE]\n\n")) + })) + defer server.Close() + + exec := NewGeminiInteractionsExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "gemini-interactions", + Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }, + } + req := cliproxyexecutor.Request{ + Model: "gemini-3.1-flash-lite", + Payload: []byte(`{"model":"gemini-3.1-flash-lite","stream":true,"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]}`), + } + + result, errExecute := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAIResponse, + ResponseFormat: sdktranslator.FormatOpenAIResponse, + }) + if errExecute != nil { + t.Fatalf("ExecuteStream() error = %v", errExecute) + } + + done := false + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error: %v", chunk.Err) + } + if bytes.Equal(bytes.TrimSpace(chunk.Payload), []byte("data: [DONE]")) { + done = true + } + } + if !done { + t.Fatal("Responses [DONE] chunk not found") + } +} diff --git a/internal/runtime/executor/helps/thinking_providers.go b/internal/runtime/executor/helps/thinking_providers.go index e879ff130..d8848cff4 100644 --- a/internal/runtime/executor/helps/thinking_providers.go +++ b/internal/runtime/executor/helps/thinking_providers.go @@ -5,6 +5,7 @@ import ( _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/claude" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/codex" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/interactions" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/kimi" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/openai" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/xai" diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go index 9327831f5..67d4205bb 100644 --- a/internal/runtime/executor/helps/usage_helpers.go +++ b/internal/runtime/executor/helps/usage_helpers.go @@ -580,6 +580,53 @@ func parseGeminiFamilyUsageDetail(node gjson.Result) usage.Detail { return detail } +func parseInteractionsUsageDetail(node gjson.Result) usage.Detail { + detail := usage.Detail{ + InputTokens: firstExistingUsageNode(node, "input_tokens", "prompt_tokens", "total_input_tokens").Int(), + OutputTokens: firstExistingUsageNode(node, "output_tokens", "completion_tokens", "total_output_tokens").Int(), + ReasoningTokens: firstExistingUsageNode(node, "reasoning_tokens", "thoughtsTokenCount", "total_thought_tokens").Int(), + TotalTokens: firstExistingUsageNode(node, "total_tokens", "totalTokenCount").Int(), + CachedTokens: firstExistingUsageNode(node, "cached_tokens", "cachedContentTokenCount", "total_cached_tokens").Int(), + CacheReadTokens: firstExistingUsageNode(node, "cache_read_tokens", "cacheReadTokens").Int(), + CacheCreationTokens: firstExistingUsageNode(node, "cache_creation_tokens", "cacheCreationTokens").Int(), + } + if detail.TotalTokens == 0 { + detail.TotalTokens = detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens + detail.CacheReadTokens + detail.CacheCreationTokens + } + return detail +} + +func hasUsageDetail(detail usage.Detail) bool { + return hasNonZeroTokenUsage(detail) +} + +func ParseInteractionsUsage(data []byte) usage.Detail { + root := gjson.ParseBytes(data) + node := firstExistingUsageNode(root, "usage", "total_usage", "metadata.total_usage", "metadata.usage", "usageMetadata", "usage_metadata", "interaction.usage", "interaction.total_usage", "interaction.metadata.total_usage") + if !node.Exists() { + return usage.Detail{} + } + if node.Get("promptTokenCount").Exists() || node.Get("candidatesTokenCount").Exists() { + return parseGeminiFamilyUsageDetail(node) + } + return parseInteractionsUsageDetail(node) +} + +func ParseInteractionsStreamUsage(line []byte) (usage.Detail, bool) { + payload := jsonPayload(line) + if len(payload) == 0 { + payload = line + } + if len(payload) == 0 || !gjson.ValidBytes(payload) { + return usage.Detail{}, false + } + detail := ParseInteractionsUsage(payload) + if !hasUsageDetail(detail) { + return usage.Detail{}, false + } + return detail, true +} + func ParseGeminiUsage(data []byte) usage.Detail { usageNode := gjson.ParseBytes(data) node := usageNode.Get("usageMetadata") diff --git a/internal/runtime/executor/helps/usage_helpers_test.go b/internal/runtime/executor/helps/usage_helpers_test.go index a7557aea5..03520744d 100644 --- a/internal/runtime/executor/helps/usage_helpers_test.go +++ b/internal/runtime/executor/helps/usage_helpers_test.go @@ -123,6 +123,57 @@ func TestParseClaudeUsageFallsBackCachedTokensToCacheCreation(t *testing.T) { } } +func TestParseInteractionsUsage(t *testing.T) { + detail := ParseInteractionsUsage([]byte(`{"usage":{"input_tokens":3,"output_tokens":4,"reasoning_tokens":5,"total_tokens":12,"cached_tokens":2}}`)) + if detail.InputTokens != 3 { + t.Fatalf("input tokens = %d, want 3", detail.InputTokens) + } + if detail.OutputTokens != 4 { + t.Fatalf("output tokens = %d, want 4", detail.OutputTokens) + } + if detail.ReasoningTokens != 5 { + t.Fatalf("reasoning tokens = %d, want 5", detail.ReasoningTokens) + } + if detail.TotalTokens != 12 { + t.Fatalf("total tokens = %d, want 12", detail.TotalTokens) + } + if detail.CachedTokens != 2 { + t.Fatalf("cached tokens = %d, want 2", detail.CachedTokens) + } +} + +func TestParseInteractionsStreamUsage(t *testing.T) { + detail, ok := ParseInteractionsStreamUsage([]byte(`{"type":"interaction.completed","interaction":{"usage":{"input_tokens":2,"output_tokens":6,"total_tokens":8}}}`)) + if !ok { + t.Fatal("ParseInteractionsStreamUsage() ok = false, want true") + } + if detail.TotalTokens != 8 { + t.Fatalf("total tokens = %d, want 8", detail.TotalTokens) + } +} + +func TestParseInteractionsStreamUsageOfficialMetadata(t *testing.T) { + detail, ok := ParseInteractionsStreamUsage([]byte(`data: {"event_type":"finish","metadata":{"total_usage":{"total_input_tokens":2,"total_output_tokens":6,"total_thought_tokens":3,"total_cached_tokens":1,"total_tokens":11}}}`)) + if !ok { + t.Fatal("ParseInteractionsStreamUsage() ok = false, want true") + } + if detail.InputTokens != 2 { + t.Fatalf("input tokens = %d, want 2", detail.InputTokens) + } + if detail.OutputTokens != 6 { + t.Fatalf("output tokens = %d, want 6", detail.OutputTokens) + } + if detail.ReasoningTokens != 3 { + t.Fatalf("reasoning tokens = %d, want 3", detail.ReasoningTokens) + } + if detail.CachedTokens != 1 { + t.Fatalf("cached tokens = %d, want 1", detail.CachedTokens) + } + if detail.TotalTokens != 11 { + t.Fatalf("total tokens = %d, want 11", detail.TotalTokens) + } +} + func TestUsageReporterBuildRecordIncludesLatency(t *testing.T) { reporter := &UsageReporter{ provider: "openai", diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go index 389196b0e..7194988cd 100644 --- a/internal/thinking/apply.go +++ b/internal/thinking/apply.go @@ -414,6 +414,8 @@ func extractThinkingConfig(body []byte, provider string) ThinkingConfig { return extractClaudeConfig(body) case "gemini", "antigravity": return extractGeminiConfig(body, provider) + case "interactions": + return extractInteractionsConfig(body) case "openai": return extractOpenAIConfig(body) case "codex", "xai": @@ -608,6 +610,56 @@ func extractGeminiConfig(body []byte, provider string) ThinkingConfig { return ThinkingConfig{} } +func extractInteractionsConfig(body []byte) ThinkingConfig { + for _, path := range []string{ + "generation_config.thinking_level", + "generation_config.thinkingLevel", + "generation_config.thinking_config.thinking_level", + "generation_config.thinking_config.thinkingLevel", + "generation_config.thinkingConfig.thinking_level", + "generation_config.thinkingConfig.thinkingLevel", + } { + level := gjson.GetBytes(body, path) + if !level.Exists() { + continue + } + value := strings.ToLower(strings.TrimSpace(level.String())) + switch value { + case "none": + return ThinkingConfig{Mode: ModeNone, Budget: 0} + case "auto": + return ThinkingConfig{Mode: ModeAuto, Budget: -1} + default: + return ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)} + } + } + + for _, path := range []string{ + "generation_config.thinking_budget", + "generation_config.thinkingBudget", + "generation_config.thinking_config.thinking_budget", + "generation_config.thinking_config.thinkingBudget", + "generation_config.thinkingConfig.thinking_budget", + "generation_config.thinkingConfig.thinkingBudget", + } { + budget := gjson.GetBytes(body, path) + if !budget.Exists() { + continue + } + value := int(budget.Int()) + switch value { + case 0: + return ThinkingConfig{Mode: ModeNone, Budget: 0} + case -1: + return ThinkingConfig{Mode: ModeAuto, Budget: -1} + default: + return ThinkingConfig{Mode: ModeBudget, Budget: value} + } + } + + return ThinkingConfig{} +} + // extractOpenAIConfig extracts thinking configuration from OpenAI format request body. // // OpenAI API format: diff --git a/internal/thinking/provider/interactions/apply.go b/internal/thinking/provider/interactions/apply.go new file mode 100644 index 000000000..2951b511b --- /dev/null +++ b/internal/thinking/provider/interactions/apply.go @@ -0,0 +1,176 @@ +// Package interactions applies native Interactions thinking configuration. +package interactions + +import ( + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// Applier implements thinking.ProviderApplier for the native Interactions API. +type Applier struct{} + +// NewApplier creates a new Interactions thinking applier. +func NewApplier() *Applier { + return &Applier{} +} + +func init() { + thinking.RegisterProvider("interactions", NewApplier()) +} + +// Apply writes thinking configuration using native Interactions generation_config fields. +func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) { + if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto { + return body, nil + } + if len(body) == 0 || !gjson.ValidBytes(body) { + body = []byte(`{}`) + } + + result := stripInteractionsThinkingFields(body) + switch config.Mode { + case thinking.ModeLevel: + return applyInteractionsLevel(result, body, string(config.Level), modelInfo, "auto"), nil + case thinking.ModeBudget: + return applyInteractionsBudget(result, body, config.Budget, modelInfo, "auto"), nil + case thinking.ModeAuto: + return setInteractionsThinkingSummaries(result, body, "auto"), nil + case thinking.ModeNone: + return applyInteractionsNone(result, body, config, modelInfo), nil + default: + return body, nil + } +} + +func applyInteractionsBudget(result, original []byte, budget int, modelInfo *registry.ModelInfo, summariesFallback string) []byte { + level, ok := thinking.ConvertBudgetToLevel(budget) + if !ok { + return result + } + switch level { + case string(thinking.LevelNone): + return setInteractionsThinkingSummaries(result, original, "none") + case string(thinking.LevelAuto): + return setInteractionsThinkingSummaries(result, original, "auto") + default: + return applyInteractionsLevel(result, original, level, modelInfo, summariesFallback) + } +} + +func applyInteractionsLevel(result, original []byte, level string, modelInfo *registry.ModelInfo, summariesFallback string) []byte { + level = normalizeInteractionsLevel(level, modelInfo) + if level == "" { + return result + } + result, _ = sjson.SetBytes(result, "generation_config.thinking_level", level) + return setInteractionsThinkingSummaries(result, original, summariesFallback) +} + +func applyInteractionsNone(result, original []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) []byte { + if config.Level != "" { + result = applyInteractionsLevel(result, original, string(config.Level), modelInfo, "none") + } else if config.Budget > 0 { + result = applyInteractionsBudget(result, original, config.Budget, modelInfo, "none") + } + result, _ = sjson.SetBytes(result, "generation_config.thinking_summaries", "none") + return result +} + +func stripInteractionsThinkingFields(body []byte) []byte { + result := body + for _, path := range []string{ + "generation_config.thinking_level", + "generation_config.thinkingLevel", + "generation_config.thinking_budget", + "generation_config.thinkingBudget", + "generation_config.thinking_summaries", + "generation_config.thinkingSummaries", + "generation_config.thinking_config", + "generation_config.thinkingConfig", + "generationConfig.thinkingLevel", + "generationConfig.thinking_level", + "generationConfig.thinkingBudget", + "generationConfig.thinking_budget", + "generationConfig.thinkingSummaries", + "generationConfig.thinking_summaries", + "generationConfig.thinkingConfig", + } { + result, _ = sjson.DeleteBytes(result, path) + } + return result +} + +func setInteractionsThinkingSummaries(result, original []byte, fallback string) []byte { + if value, okValue := originalInteractionsThinkingSummaries(original); okValue { + result, _ = sjson.SetBytes(result, "generation_config.thinking_summaries", value) + return result + } + if includeThoughts, okValue := originalInteractionsIncludeThoughts(original); okValue { + value := "none" + if includeThoughts { + value = fallback + if value == "" { + value = "auto" + } + } + result, _ = sjson.SetBytes(result, "generation_config.thinking_summaries", value) + return result + } + if fallback != "" { + result, _ = sjson.SetBytes(result, "generation_config.thinking_summaries", fallback) + } + return result +} + +func originalInteractionsThinkingSummaries(body []byte) (string, bool) { + for _, path := range []string{ + "generation_config.thinking_summaries", + "generation_config.thinkingSummaries", + } { + value := gjson.GetBytes(body, path) + if value.Exists() && value.Type == gjson.String { + return strings.ToLower(strings.TrimSpace(value.String())), true + } + } + return "", false +} + +func originalInteractionsIncludeThoughts(body []byte) (bool, bool) { + for _, path := range []string{ + "generation_config.thinking_config.include_thoughts", + "generation_config.thinking_config.includeThoughts", + "generation_config.thinkingConfig.include_thoughts", + "generation_config.thinkingConfig.includeThoughts", + } { + value := gjson.GetBytes(body, path) + if value.Exists() { + return value.Bool(), true + } + } + return false, false +} + +func normalizeInteractionsLevel(level string, modelInfo *registry.ModelInfo) string { + level = strings.ToLower(strings.TrimSpace(level)) + if level == "" || level == string(thinking.LevelNone) || level == string(thinking.LevelAuto) { + return "" + } + if modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) > 0 { + for _, candidate := range modelInfo.Thinking.Levels { + if strings.EqualFold(candidate, level) { + return strings.ToLower(candidate) + } + } + return strings.ToLower(modelInfo.Thinking.Levels[len(modelInfo.Thinking.Levels)-1]) + } + switch level { + case string(thinking.LevelMax), string(thinking.LevelXHigh): + return string(thinking.LevelHigh) + default: + return level + } +} diff --git a/internal/thinking/strip.go b/internal/thinking/strip.go index 9fac8ae9e..f514a7bdc 100644 --- a/internal/thinking/strip.go +++ b/internal/thinking/strip.go @@ -35,6 +35,17 @@ func StripThinkingConfig(body []byte, provider string) []byte { paths = []string{"generationConfig.thinkingConfig"} case "antigravity": paths = []string{"request.generationConfig.thinkingConfig"} + case "interactions": + paths = []string{ + "generation_config.thinking_level", + "generation_config.thinkingLevel", + "generation_config.thinking_budget", + "generation_config.thinkingBudget", + "generation_config.thinking_summaries", + "generation_config.thinkingSummaries", + "generation_config.thinking_config", + "generation_config.thinkingConfig", + } case "openai": paths = []string{"reasoning_effort"} case "kimi": diff --git a/internal/translator/antigravity/interactions/init.go b/internal/translator/antigravity/interactions/init.go new file mode 100644 index 000000000..af231b003 --- /dev/null +++ b/internal/translator/antigravity/interactions/init.go @@ -0,0 +1,19 @@ +package interactions + +import ( + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" +) + +func init() { + translator.Register( + Interactions, + Antigravity, + ConvertInteractionsRequestToAntigravity, + interfaces.TranslateResponse{ + Stream: ConvertAntigravityResponseToInteractions, + NonStream: ConvertAntigravityResponseToInteractionsNonStream, + }, + ) +} diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_request.go b/internal/translator/antigravity/interactions/interactions_antigravity_request.go new file mode 100644 index 000000000..d391fd146 --- /dev/null +++ b/internal/translator/antigravity/interactions/interactions_antigravity_request.go @@ -0,0 +1,719 @@ +package interactions + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func ConvertInteractionsRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) []byte { + root := gjson.ParseBytes(inputRawJSON) + out := []byte(`{"project":"","request":{"contents":[]},"model":""}`) + out, _ = sjson.SetBytes(out, "model", modelName) + if stream || root.Get("stream").Bool() { + out, _ = sjson.SetBytes(out, "request.stream", true) + } + out = copyInteractionsSystemToAntigravity(out, root) + out = copyInteractionsGenerationConfigToAntigravity(out, root) + out = appendInteractionsInputToAntigravity(out, root.Get("input")) + out = copyInteractionsToolsToAntigravity(out, root) + out = attachDefaultAntigravitySafetySettings(out) + return out +} + +func copyInteractionsSystemToAntigravity(out []byte, root gjson.Result) []byte { + sys := root.Get("system_instruction") + if !sys.Exists() { + return out + } + if sys.Type == gjson.String { + instr := []byte(`{"parts":[{"text":""}]}`) + instr, _ = sjson.SetBytes(instr, "parts.0.text", sys.String()) + out, _ = sjson.SetRawBytes(out, "request.systemInstruction", instr) + return out + } + if text := sys.Get("text"); text.Exists() && !sys.Get("parts").Exists() { + instr := []byte(`{"parts":[{"text":""}]}`) + instr, _ = sjson.SetBytes(instr, "parts.0.text", text.String()) + out, _ = sjson.SetRawBytes(out, "request.systemInstruction", instr) + return out + } + out, _ = sjson.SetRawBytes(out, "request.systemInstruction", []byte(sys.Raw)) + return out +} + +func copyInteractionsGenerationConfigToAntigravity(out []byte, root gjson.Result) []byte { + if cfg := root.Get("generation_config"); cfg.Exists() { + out, _ = sjson.SetRawBytes(out, "request.generationConfig", convertSnakeCaseKeysToCamelCaseForAntigravity([]byte(cfg.Raw))) + } else if cfg := root.Get("generationConfig"); cfg.Exists() { + out, _ = sjson.SetRawBytes(out, "request.generationConfig", []byte(cfg.Raw)) + } + out = normalizeInteractionsGenerationConfigForAntigravity(out) + out = copyInteractionsReasoningToAntigravity(out, root) + out = copyInteractionsResponseModalitiesToAntigravity(out, root) + out = copyInteractionsToolChoiceToAntigravity(out, root) + return out +} + +func normalizeInteractionsGenerationConfigForAntigravity(out []byte) []byte { + if thinkingLevel := gjson.GetBytes(out, "request.generationConfig.thinkingLevel"); thinkingLevel.Exists() { + out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", []byte(thinkingLevel.Raw)) + out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingLevel") + } + if thinkingBudget := gjson.GetBytes(out, "request.generationConfig.thinkingBudget"); thinkingBudget.Exists() { + out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", []byte(thinkingBudget.Raw)) + out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingBudget") + } + if includeThoughts := gjson.GetBytes(out, "request.generationConfig.includeThoughts"); includeThoughts.Exists() { + out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", []byte(includeThoughts.Raw)) + out, _ = sjson.DeleteBytes(out, "request.generationConfig.includeThoughts") + } + if summaries := gjson.GetBytes(out, "request.generationConfig.thinkingSummaries"); summaries.Exists() { + if includeThoughts, ok := antigravityThinkingSummariesIncludeThoughts(summaries); ok { + out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) + } + out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingSummaries") + } + if toolChoice := gjson.GetBytes(out, "request.generationConfig.toolChoice"); toolChoice.Exists() { + out, _ = sjson.DeleteBytes(out, "request.generationConfig.toolChoice") + } + return out +} + +func copyInteractionsReasoningToAntigravity(out []byte, root gjson.Result) []byte { + reasoning := root.Get("reasoning") + if !reasoning.Exists() { + return out + } + effort := strings.ToLower(strings.TrimSpace(reasoning.Get("effort").String())) + if effort == "" { + effort = strings.ToLower(strings.TrimSpace(reasoning.Get("thinking_level").String())) + } + if effort != "" { + if effort == "auto" { + out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", -1) + out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", true) + } else { + out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", effort) + out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", effort != "none") + } + } + if summary := reasoning.Get("summary"); summary.Exists() { + if includeThoughts, ok := antigravityThinkingSummariesIncludeThoughts(summary); ok { + out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) + } + } + return out +} + +func copyInteractionsResponseModalitiesToAntigravity(out []byte, root gjson.Result) []byte { + mods := root.Get("response_modalities") + if !mods.Exists() { + mods = root.Get("responseModalities") + } + if !mods.Exists() || !mods.IsArray() { + return out + } + var responseMods []string + mods.ForEach(func(_, mod gjson.Result) bool { + switch strings.ToLower(strings.TrimSpace(mod.String())) { + case "text": + responseMods = append(responseMods, "TEXT") + case "image": + responseMods = append(responseMods, "IMAGE") + case "audio": + responseMods = append(responseMods, "AUDIO") + } + return true + }) + if len(responseMods) > 0 { + out, _ = sjson.SetBytes(out, "request.generationConfig.responseModalities", responseMods) + } + return out +} + +func copyInteractionsToolChoiceToAntigravity(out []byte, root gjson.Result) []byte { + toolChoice := root.Get("tool_choice") + if !toolChoice.Exists() { + toolChoice = root.Get("generation_config.tool_choice") + } + if !toolChoice.Exists() { + toolChoice = root.Get("generationConfig.toolChoice") + } + if !toolChoice.Exists() { + return out + } + mode := "" + var allowedNames []string + if toolChoice.Type == gjson.String { + switch strings.ToLower(strings.TrimSpace(toolChoice.String())) { + case "none": + mode = "NONE" + case "auto": + mode = "AUTO" + case "required", "any": + mode = "ANY" + } + } else if toolChoice.IsObject() { + switch strings.ToLower(strings.TrimSpace(toolChoice.Get("type").String())) { + case "none": + mode = "NONE" + case "auto": + mode = "AUTO" + case "required", "any": + mode = "ANY" + case "function": + mode = "ANY" + if name := strings.TrimSpace(toolChoice.Get("function.name").String()); name != "" { + allowedNames = append(allowedNames, name) + } + case "tool": + mode = "ANY" + if name := strings.TrimSpace(toolChoice.Get("name").String()); name != "" { + allowedNames = append(allowedNames, name) + } + } + } + if mode == "" { + return out + } + out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", mode) + if len(allowedNames) > 0 { + out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", allowedNames) + } + return out +} + +func appendInteractionsInputToAntigravity(out []byte, input gjson.Result) []byte { + if !input.Exists() { + return out + } + if input.Type == gjson.String { + return appendAntigravityTextContent(out, "user", input.String()) + } + if input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + out = appendInteractionsStepToAntigravity(out, item, "user") + return true + }) + return out + } + if steps := input.Get("steps"); steps.Exists() && steps.IsArray() { + defaultRole := "user" + if role := input.Get("role").String(); role == "model" || role == "assistant" { + defaultRole = "model" + } + steps.ForEach(func(_, step gjson.Result) bool { + out = appendInteractionsStepToAntigravity(out, step, defaultRole) + return true + }) + return out + } + return appendInteractionsStepToAntigravity(out, input, "user") +} + +func appendInteractionsStepToAntigravity(out []byte, step gjson.Result, defaultRole string) []byte { + if step.Type == gjson.String { + return appendAntigravityTextContent(out, defaultRole, step.String()) + } + if steps := step.Get("steps"); steps.Exists() && steps.IsArray() { + role := defaultRole + if itemRole := step.Get("role").String(); itemRole == "model" || itemRole == "assistant" { + role = "model" + } else if itemRole == "user" { + role = "user" + } + steps.ForEach(func(_, child gjson.Result) bool { + out = appendInteractionsStepToAntigravity(out, child, role) + return true + }) + return out + } + switch step.Get("type").String() { + case "model_output": + return appendInteractionsStepContentToAntigravity(out, "model", step, false) + case "thought": + return appendInteractionsStepContentToAntigravity(out, "model", step, true) + case "function_call": + return appendInteractionsFunctionCallToAntigravity(out, step) + case "function_result": + return appendInteractionsFunctionResultToAntigravity(out, step) + case "user_input", "": + if step.Get("parts").Exists() { + return appendInteractionsNativeContentToAntigravity(out, step, defaultRole) + } + return appendInteractionsContentListToAntigravity(out, defaultRole, step.Get("content")) + default: + if step.Get("parts").Exists() { + return appendInteractionsNativeContentToAntigravity(out, step, defaultRole) + } + if step.Get("content").Exists() { + return appendInteractionsContentListToAntigravity(out, defaultRole, step.Get("content")) + } + if text := step.Get("text"); text.Exists() { + return appendAntigravityTextContent(out, defaultRole, text.String()) + } + } + return out +} + +func appendInteractionsNativeContentToAntigravity(out []byte, step gjson.Result, defaultRole string) []byte { + parts := step.Get("parts") + if !parts.Exists() || !parts.IsArray() { + return out + } + contentObj := []byte(`{"role":"","parts":[]}`) + contentObj, _ = sjson.SetBytes(contentObj, "role", antigravityContentRole(step.Get("role").String(), defaultRole)) + parts.ForEach(func(_, part gjson.Result) bool { + if partJSON := interactionsNativeAntigravityPart(part); len(partJSON) > 0 { + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", partJSON) + } + return true + }) + if gjson.GetBytes(contentObj, "parts.#").Int() == 0 { + return out + } + out, _ = sjson.SetRawBytes(out, "request.contents.-1", contentObj) + return out +} + +func appendInteractionsStepContentToAntigravity(out []byte, role string, step gjson.Result, thought bool) []byte { + content := step.Get("content") + if !content.Exists() { + return out + } + contentObj := []byte(`{"role":"","parts":[]}`) + contentObj, _ = sjson.SetBytes(contentObj, "role", role) + if content.IsArray() { + content.ForEach(func(_, part gjson.Result) bool { + if partJSON := appendInteractionsContentToAntigravityPart(nil, part, thought); len(partJSON) > 0 { + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", partJSON) + } + return true + }) + } else if content.IsObject() { + if partJSON := appendInteractionsContentToAntigravityPart(nil, content, thought); len(partJSON) > 0 { + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", partJSON) + } + } else if content.Type == gjson.String { + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", antigravityTextPartJSON(content.String(), thought)) + } + if gjson.GetBytes(contentObj, "parts.#").Int() == 0 { + return out + } + out, _ = sjson.SetRawBytes(out, "request.contents.-1", contentObj) + return out +} + +func appendInteractionsContentListToAntigravity(out []byte, role string, content gjson.Result) []byte { + if !content.Exists() { + return out + } + if content.IsArray() { + content.ForEach(func(_, part gjson.Result) bool { + out = appendInteractionsContentPartToAntigravity(out, role, part) + return true + }) + return out + } + if content.IsObject() { + return appendInteractionsContentPartToAntigravity(out, role, content) + } + if content.Type == gjson.String { + return appendAntigravityTextContent(out, role, content.String()) + } + return out +} + +func appendInteractionsContentPartToAntigravity(out []byte, role string, part gjson.Result) []byte { + partJSON := appendInteractionsContentToAntigravityPart(nil, part, false) + if len(partJSON) == 0 { + return out + } + contentObj := []byte(`{"role":"","parts":[]}`) + contentObj, _ = sjson.SetBytes(contentObj, "role", role) + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", partJSON) + out, _ = sjson.SetRawBytes(out, "request.contents.-1", contentObj) + return out +} + +func appendInteractionsContentToAntigravityPart(_ []byte, content gjson.Result, thought bool) []byte { + if text := content.Get("text"); text.Exists() { + return antigravityTextPartJSON(text.String(), thought) + } + if inline := content.Get("inline_data"); inline.Exists() { + return antigravityInlineDataPartJSON(inline) + } + if inline := content.Get("inlineData"); inline.Exists() { + return antigravityInlineDataPartJSON(inline) + } + switch strings.ToLower(strings.TrimSpace(content.Get("type").String())) { + case "text": + if text := content.Get("text"); text.Exists() { + return antigravityTextPartJSON(text.String(), thought) + } + case "image", "audio", "video", "document": + if mime := content.Get("mime_type"); mime.Exists() || content.Get("mimeType").Exists() { + mimeType := mime.String() + if mimeType == "" { + mimeType = content.Get("mimeType").String() + } + if data := content.Get("data").String(); data != "" { + return antigravityInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data))) + } + } + if uri := content.Get("file_uri"); uri.Exists() || content.Get("fileUri").Exists() { + fileURI := uri.String() + if fileURI == "" { + fileURI = content.Get("fileUri").String() + } + mimeType := content.Get("mime_type").String() + if mimeType == "" { + mimeType = content.Get("mimeType").String() + } + return antigravityFileDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mimeType":%q,"fileUri":%q}`, mimeType, fileURI))) + } + if url := content.Get("url"); url.Exists() { + return antigravityInlineDataPartFromDataURL(url.String()) + } + case "image_url": + return antigravityInlineDataPartFromDataURL(content.Get("image_url.url").String()) + case "input_audio": + mimeType := antigravityInputAudioMimeType(content.Get("input_audio.format").String()) + return antigravityInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, content.Get("input_audio.data").String()))) + case "file": + filename := content.Get("file.filename").String() + fileData := content.Get("file.file_data").String() + ext := "" + if sp := strings.Split(filename, "."); len(sp) > 1 { + ext = sp[len(sp)-1] + } + if mimeType, ok := misc.MimeTypes[ext]; ok && fileData != "" { + return antigravityInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, fileData))) + } + } + return nil +} + +func appendInteractionsFunctionCallToAntigravity(out []byte, step gjson.Result) []byte { + part := []byte(`{"functionCall":{"name":"","args":{}}}`) + part, _ = sjson.SetBytes(part, "functionCall.name", step.Get("name").String()) + if callID := step.Get("call_id"); callID.Exists() { + part, _ = sjson.SetBytes(part, "functionCall.id", callID.String()) + } else if id := step.Get("id"); id.Exists() { + part, _ = sjson.SetBytes(part, "functionCall.id", id.String()) + } + if args := step.Get("arguments"); args.Exists() { + part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(args.Raw)) + } + contentObj := []byte(`{"role":"model","parts":[]}`) + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", part) + out, _ = sjson.SetRawBytes(out, "request.contents.-1", contentObj) + return out +} + +func appendInteractionsFunctionResultToAntigravity(out []byte, step gjson.Result) []byte { + part := []byte(`{"functionResponse":{"name":"","response":{}}}`) + part, _ = sjson.SetBytes(part, "functionResponse.name", step.Get("name").String()) + if callID := step.Get("call_id"); callID.Exists() { + part, _ = sjson.SetBytes(part, "functionResponse.id", callID.String()) + } else if id := step.Get("id"); id.Exists() { + part, _ = sjson.SetBytes(part, "functionResponse.id", id.String()) + } + if result := step.Get("result"); result.Exists() { + part, _ = sjson.SetRawBytes(part, "functionResponse.response", []byte(result.Raw)) + } + contentObj := []byte(`{"role":"user","parts":[]}`) + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", part) + out, _ = sjson.SetRawBytes(out, "request.contents.-1", contentObj) + return out +} + +func copyInteractionsToolsToAntigravity(out []byte, root gjson.Result) []byte { + tools := root.Get("tools") + if !tools.Exists() { + return out + } + if !tools.IsArray() { + out, _ = sjson.SetRawBytes(out, "request.tools", []byte(tools.Raw)) + return out + } + functionToolNode := []byte(`{}`) + hasFunction := false + otherTools := make([][]byte, 0) + tools.ForEach(func(_, tool gjson.Result) bool { + if decls := tool.Get("functionDeclarations"); decls.Exists() && decls.IsArray() { + decls.ForEach(func(_, decl gjson.Result) bool { + functionToolNode, hasFunction = appendAntigravityFunctionDeclaration(functionToolNode, decl, hasFunction) + return true + }) + return true + } + if decls := tool.Get("function_declarations"); decls.Exists() && decls.IsArray() { + decls.ForEach(func(_, decl gjson.Result) bool { + functionToolNode, hasFunction = appendAntigravityFunctionDeclaration(functionToolNode, decl, hasFunction) + return true + }) + return true + } + if tool.Get("type").String() == "function" || tool.Get("name").Exists() { + functionToolNode, hasFunction = appendAntigravityFunctionDeclaration(functionToolNode, tool, hasFunction) + return true + } + otherTools = append(otherTools, []byte(tool.Raw)) + return true + }) + toolsNode := []byte(`[]`) + if hasFunction { + toolsNode, _ = sjson.SetRawBytes(toolsNode, "-1", functionToolNode) + } + for _, tool := range otherTools { + toolsNode, _ = sjson.SetRawBytes(toolsNode, "-1", tool) + } + if hasFunction || len(otherTools) > 0 { + out, _ = sjson.SetRawBytes(out, "request.tools", toolsNode) + } + return out +} + +func appendAntigravityFunctionDeclaration(functionToolNode []byte, decl gjson.Result, hasFunction bool) ([]byte, bool) { + fnRaw := antigravityFunctionDeclarationJSON(decl) + if len(fnRaw) == 0 { + return functionToolNode, hasFunction + } + if !hasFunction { + functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", []byte(`[]`)) + } + functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations.-1", fnRaw) + return functionToolNode, true +} + +func antigravityFunctionDeclarationJSON(decl gjson.Result) []byte { + fn := decl + if nested := decl.Get("function"); nested.Exists() && nested.IsObject() { + fn = nested + } + name := strings.TrimSpace(fn.Get("name").String()) + if name == "" { + return nil + } + out := []byte(`{"name":"","parametersJsonSchema":{"type":"object","properties":{}}}`) + out, _ = sjson.SetBytes(out, "name", util.SanitizeFunctionName(name)) + if desc := fn.Get("description"); desc.Exists() { + out, _ = sjson.SetBytes(out, "description", desc.String()) + } + if params := fn.Get("parametersJsonSchema"); params.Exists() { + out, _ = sjson.SetRawBytes(out, "parametersJsonSchema", []byte(params.Raw)) + } else if params := fn.Get("parameters"); params.Exists() { + out, _ = sjson.SetRawBytes(out, "parametersJsonSchema", []byte(params.Raw)) + } + if response := fn.Get("response"); response.Exists() { + out, _ = sjson.SetRawBytes(out, "response", []byte(response.Raw)) + } + if responseSchema := fn.Get("responseJsonSchema"); responseSchema.Exists() { + out, _ = sjson.SetRawBytes(out, "responseJsonSchema", []byte(responseSchema.Raw)) + } + out, _ = sjson.DeleteBytes(out, "strict") + return out +} + +func interactionsNativeAntigravityPart(part gjson.Result) []byte { + switch { + case part.Get("text").Exists(), part.Get("functionCall").Exists(), part.Get("functionResponse").Exists(): + return []byte(part.Raw) + case part.Get("inlineData").Exists(): + return antigravityInlineDataPartJSON(part.Get("inlineData")) + case part.Get("fileData").Exists(): + return antigravityFileDataPartJSON(part.Get("fileData")) + case part.Get("inline_data").Exists(): + return antigravityInlineDataPartJSON(part.Get("inline_data")) + case part.Get("file_data").Exists(): + return antigravityFileDataPartJSON(part.Get("file_data")) + } + return nil +} + +func antigravityTextPartJSON(text string, thought bool) []byte { + partJSON := []byte(`{"text":""}`) + partJSON, _ = sjson.SetBytes(partJSON, "text", text) + if thought { + partJSON, _ = sjson.SetBytes(partJSON, "thought", true) + } + return partJSON +} + +func antigravityInlineDataPartJSON(inline gjson.Result) []byte { + mimeType := inline.Get("mimeType").String() + if mimeType == "" { + mimeType = inline.Get("mime_type").String() + } + data := inline.Get("data").String() + if mimeType == "" || data == "" { + return nil + } + partJSON := []byte(`{"inlineData":{"mimeType":"","data":""}}`) + partJSON, _ = sjson.SetBytes(partJSON, "inlineData.mimeType", mimeType) + partJSON, _ = sjson.SetBytes(partJSON, "inlineData.data", data) + return partJSON +} + +func antigravityFileDataPartJSON(fileData gjson.Result) []byte { + mimeType := fileData.Get("mimeType").String() + if mimeType == "" { + mimeType = fileData.Get("mime_type").String() + } + fileURI := fileData.Get("fileUri").String() + if fileURI == "" { + fileURI = fileData.Get("file_uri").String() + } + if mimeType == "" || fileURI == "" { + return nil + } + partJSON := []byte(`{"fileData":{"mimeType":"","fileUri":""}}`) + partJSON, _ = sjson.SetBytes(partJSON, "fileData.mimeType", mimeType) + partJSON, _ = sjson.SetBytes(partJSON, "fileData.fileUri", fileURI) + return partJSON +} + +func antigravityInlineDataPartFromDataURL(dataURL string) []byte { + if !strings.HasPrefix(dataURL, "data:") { + return nil + } + payload := dataURL[5:] + pieces := strings.SplitN(payload, ";", 2) + if len(pieces) != 2 || !strings.HasPrefix(pieces[1], "base64,") { + return nil + } + return antigravityInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, pieces[0], pieces[1][7:]))) +} + +func appendAntigravityTextContent(out []byte, role, text string) []byte { + contentObj := []byte(`{"role":"","parts":[{"text":""}]}`) + contentObj, _ = sjson.SetBytes(contentObj, "role", antigravityContentRole(role, "user")) + contentObj, _ = sjson.SetBytes(contentObj, "parts.0.text", text) + out, _ = sjson.SetRawBytes(out, "request.contents.-1", contentObj) + return out +} + +func antigravityContentRole(role, defaultRole string) string { + switch strings.ToLower(strings.TrimSpace(role)) { + case "model", "assistant": + return "model" + case "user": + return "user" + } + if defaultRole == "model" { + return "model" + } + return "user" +} + +func antigravityInputAudioMimeType(format string) string { + switch strings.ToLower(strings.TrimSpace(format)) { + case "wav": + return "audio/wav" + case "mp3": + return "audio/mpeg" + case "flac": + return "audio/flac" + case "opus": + return "audio/opus" + case "pcm16": + return "audio/pcm" + default: + return "audio/mpeg" + } +} + +func antigravityThinkingSummariesIncludeThoughts(summary gjson.Result) (bool, bool) { + switch summary.Type { + case gjson.True: + return true, true + case gjson.False: + return false, true + case gjson.String: + switch strings.ToLower(strings.TrimSpace(summary.String())) { + case "", "none", "off", "false", "disabled": + return false, true + default: + return true, true + } + } + return false, false +} + +func convertSnakeCaseKeysToCamelCaseForAntigravity(raw []byte) []byte { + root := gjson.ParseBytes(raw) + if !root.Exists() { + return raw + } + out := []byte(`{}`) + out = copySnakeCaseValueToCamelCaseForAntigravity(out, "", root) + return out +} + +func copySnakeCaseValueToCamelCaseForAntigravity(out []byte, path string, node gjson.Result) []byte { + if node.IsObject() { + node.ForEach(func(key, value gjson.Result) bool { + childPath := joinAntigravityJSONPath(path, toAntigravityCamelCase(key.String())) + out = copySnakeCaseValueToCamelCaseForAntigravity(out, childPath, value) + return true + }) + return out + } + if node.IsArray() { + node.ForEach(func(_, value gjson.Result) bool { + out = copySnakeCaseValueToCamelCaseForAntigravity(out, path+".-1", value) + return true + }) + return out + } + out, _ = sjson.SetRawBytes(out, path, []byte(node.Raw)) + return out +} + +func joinAntigravityJSONPath(path, key string) string { + if path == "" { + return key + } + return path + "." + key +} + +func toAntigravityCamelCase(s string) string { + parts := strings.Split(s, "_") + if len(parts) == 0 { + return s + } + out := parts[0] + for _, part := range parts[1:] { + if part == "" { + continue + } + out += strings.ToUpper(part[:1]) + part[1:] + } + return out +} + +func attachDefaultAntigravitySafetySettings(out []byte) []byte { + if gjson.GetBytes(out, "request.safetySettings").Exists() { + return out + } + settings := []map[string]string{ + {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"}, + {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"}, + {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"}, + {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"}, + {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}, + } + raw, errMarshal := json.Marshal(settings) + if errMarshal != nil { + return out + } + out, _ = sjson.SetRawBytes(out, "request.safetySettings", raw) + return out +} diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_response.go b/internal/translator/antigravity/interactions/interactions_antigravity_response.go new file mode 100644 index 000000000..2792eb175 --- /dev/null +++ b/internal/translator/antigravity/interactions/interactions_antigravity_response.go @@ -0,0 +1,457 @@ +package interactions + +import ( + "bytes" + "context" + "fmt" + "strings" + "time" + + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type antigravityToInteractionsStreamState struct { + Started bool + Finished bool + Completed bool + Done bool + ActiveStepOpen bool + ID string + StepID string + ActiveStepType string + ActiveStepIndex int + StepIndex int +} + +func ConvertAntigravityResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { + _ = ctx + _ = originalRequestRawJSON + _ = requestRawJSON + if param == nil { + var local any + param = &local + } + if *param == nil { + *param = &antigravityToInteractionsStreamState{ID: fmt.Sprintf("interaction_%d", time.Now().UnixNano())} + } + st := (*param).(*antigravityToInteractionsStreamState) + payloads := antigravityStreamPayloads(rawJSON) + out := make([][]byte, 0) + for _, payload := range payloads { + if bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) { + if !st.Completed { + out = appendAntigravityInteractionsStepStop(out, st) + out = appendAntigravityInteractionsCompleted(out, st, modelName, gjson.Result{}) + } + out = appendAntigravityInteractionsDone(out, st) + continue + } + root := unwrapAntigravityResponse(gjson.ParseBytes(payload)) + if !root.Exists() { + continue + } + if !st.Started { + out = appendAntigravityInteractionsCreated(out, st, modelName) + out = appendAntigravityInteractionsStatusUpdate(out, st) + st.Started = true + } + root.Get("candidates.0.content.parts").ForEach(func(_, part gjson.Result) bool { + out = appendAntigravityPartToInteractionsStream(out, st, part) + return true + }) + hasFinish := root.Get("candidates.0.finishReason").Exists() + hasUsage := hasAntigravityStreamUsage(root) + if hasFinish && !st.Finished { + out = appendAntigravityInteractionsStepStop(out, st) + st.Finished = true + } + if hasUsage && st.Finished && !st.Completed { + out = appendAntigravityInteractionsCompleted(out, st, modelName, root) + } + } + return out +} + +func ConvertAntigravityResponseToInteractionsNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { + _ = ctx + _ = originalRequestRawJSON + _ = requestRawJSON + root := unwrapAntigravityResponse(gjson.ParseBytes(rawJSON)) + out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`) + id := root.Get("responseId").String() + if id == "" { + id = fmt.Sprintf("interaction_%d", time.Now().UnixNano()) + } + out, _ = sjson.SetBytes(out, "id", id) + out, _ = sjson.SetBytes(out, "model", modelName) + root.Get("candidates.0.content.parts").ForEach(func(_, part gjson.Result) bool { + if step := antigravityPartToInteractionsStep(part); len(step) > 0 { + out, _ = sjson.SetRawBytes(out, "steps.-1", step) + } + return true + }) + out = setInteractionsUsageFromAntigravity(out, "usage", root) + return out +} + +func antigravityStreamPayloads(rawJSON []byte) [][]byte { + trimmed := bytes.TrimSpace(rawJSON) + if bytes.HasPrefix(trimmed, []byte("data:")) { + return [][]byte{bytes.TrimSpace(trimmed[5:])} + } + root := gjson.ParseBytes(trimmed) + if root.IsArray() { + payloads := make([][]byte, 0) + root.ForEach(func(_, item gjson.Result) bool { + if response := item.Get("response"); response.Exists() { + payloads = append(payloads, []byte(response.Raw)) + } else if item.Exists() { + payloads = append(payloads, []byte(item.Raw)) + } + return true + }) + if len(payloads) > 0 { + return payloads + } + } + return [][]byte{trimmed} +} + +func unwrapAntigravityResponse(root gjson.Result) gjson.Result { + if response := root.Get("response"); response.Exists() { + response = restoreAntigravityUsageMetadata(response) + return response + } + return restoreAntigravityUsageMetadata(root) +} + +func restoreAntigravityUsageMetadata(root gjson.Result) gjson.Result { + if !root.Get("usageMetadata").Exists() { + if cpaUsage := root.Get("cpaUsageMetadata"); cpaUsage.Exists() { + raw, _ := sjson.SetRawBytes([]byte(root.Raw), "usageMetadata", []byte(cpaUsage.Raw)) + raw, _ = sjson.DeleteBytes(raw, "cpaUsageMetadata") + return gjson.ParseBytes(raw) + } + } + return root +} + +func appendAntigravityInteractionsCreated(out [][]byte, st *antigravityToInteractionsStreamState, modelName string) [][]byte { + created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`) + created, _ = sjson.SetBytes(created, "interaction.id", st.ID) + created, _ = sjson.SetBytes(created, "interaction.model", modelName) + return append(out, translatorcommon.SSEEventData("interaction.created", created)) +} + +func appendAntigravityInteractionsStatusUpdate(out [][]byte, st *antigravityToInteractionsStreamState) [][]byte { + statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`) + statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID) + return append(out, translatorcommon.SSEEventData("interaction.status_update", statusUpdate)) +} + +func appendAntigravityInteractionsCompleted(out [][]byte, st *antigravityToInteractionsStreamState, modelName string, root gjson.Result) [][]byte { + now := time.Now().UTC().Format(time.RFC3339) + completed := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`) + completed, _ = sjson.SetBytes(completed, "interaction.id", st.ID) + completed, _ = sjson.SetBytes(completed, "interaction.created", now) + completed, _ = sjson.SetBytes(completed, "interaction.updated", now) + completed, _ = sjson.SetBytes(completed, "interaction.model", modelName) + if root.Exists() { + completed = setInteractionsStreamUsageFromAntigravity(completed, "interaction.usage", root) + } + out = append(out, translatorcommon.SSEEventData("interaction.completed", completed)) + st.Completed = true + return out +} + +func appendAntigravityInteractionsDone(out [][]byte, st *antigravityToInteractionsStreamState) [][]byte { + if st.Done { + return out + } + out = append(out, translatorcommon.SSEEventData("done", []byte("[DONE]"))) + st.Done = true + return out +} + +func appendAntigravityInteractionsStepStart(out [][]byte, st *antigravityToInteractionsStreamState, stepType string, part gjson.Result) [][]byte { + st.StepID = fmt.Sprintf("step_%d", time.Now().UnixNano()) + st.ActiveStepIndex = st.StepIndex + st.StepIndex++ + st.ActiveStepType = stepType + st.ActiveStepOpen = true + stepStart := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`) + stepStart, _ = sjson.SetBytes(stepStart, "index", st.ActiveStepIndex) + stepStart, _ = sjson.SetBytes(stepStart, "step.type", stepType) + if stepType == "function_call" { + id := antigravityFunctionPartID(part) + if id == "" { + id = st.StepID + } + stepStart, _ = sjson.SetBytes(stepStart, "step.id", id) + stepStart, _ = sjson.SetBytes(stepStart, "step.call_id", id) + stepStart, _ = sjson.SetBytes(stepStart, "step.name", part.Get("name").String()) + stepStart, _ = sjson.SetRawBytes(stepStart, "step.arguments", []byte(`{}`)) + } + return append(out, translatorcommon.SSEEventData("step.start", stepStart)) +} + +func appendAntigravityInteractionsStepStop(out [][]byte, st *antigravityToInteractionsStreamState) [][]byte { + if !st.ActiveStepOpen { + return out + } + stepStop := []byte(`{"index":0,"event_type":"step.stop"}`) + stepStop, _ = sjson.SetBytes(stepStop, "index", st.ActiveStepIndex) + out = append(out, translatorcommon.SSEEventData("step.stop", stepStop)) + st.ActiveStepOpen = false + st.ActiveStepType = "" + return out +} + +func ensureAntigravityInteractionsStep(out [][]byte, st *antigravityToInteractionsStreamState, stepType string, part gjson.Result) [][]byte { + if st.ActiveStepOpen && st.ActiveStepType == stepType { + return out + } + out = appendAntigravityInteractionsStepStop(out, st) + return appendAntigravityInteractionsStepStart(out, st, stepType, part) +} + +func appendAntigravityPartToInteractionsStream(out [][]byte, st *antigravityToInteractionsStreamState, part gjson.Result) [][]byte { + if text := part.Get("text"); text.Exists() && text.String() != "" { + if part.Get("thought").Bool() { + out = ensureAntigravityInteractionsStep(out, st, "thought", gjson.Result{}) + delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`) + delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) + delta, _ = sjson.SetBytes(delta, "delta.content.text", text.String()) + out = append(out, translatorcommon.SSEEventData("step.delta", delta)) + return appendAntigravityThoughtSignature(out, st, part) + } + out = ensureAntigravityInteractionsStep(out, st, "model_output", gjson.Result{}) + delta := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`) + delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) + delta, _ = sjson.SetBytes(delta, "delta.text", text.String()) + return append(out, translatorcommon.SSEEventData("step.delta", delta)) + } + if fc := part.Get("functionCall"); fc.Exists() { + out = appendAntigravityThoughtSignature(out, st, part) + out = ensureAntigravityInteractionsStep(out, st, "function_call", fc) + delta := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`) + delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) + arguments := `{}` + if args := fc.Get("args"); args.Exists() { + arguments = args.Raw + } + delta, _ = sjson.SetBytes(delta, "delta.arguments", arguments) + out = append(out, translatorcommon.SSEEventData("step.delta", delta)) + return appendAntigravityInteractionsStepStop(out, st) + } + if fr := part.Get("functionResponse"); fr.Exists() { + out = ensureAntigravityInteractionsStep(out, st, "function_result", fr) + delta := []byte(`{"index":0,"delta":{"type":"function_result","name":"","result":{}},"event_type":"step.delta"}`) + delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) + delta, _ = sjson.SetBytes(delta, "delta.name", fr.Get("name").String()) + if response := fr.Get("response"); response.Exists() { + delta, _ = sjson.SetRawBytes(delta, "delta.result", []byte(response.Raw)) + } + out = append(out, translatorcommon.SSEEventData("step.delta", delta)) + return appendAntigravityInteractionsStepStop(out, st) + } + return out +} + +func appendAntigravityThoughtSignature(out [][]byte, st *antigravityToInteractionsStreamState, part gjson.Result) [][]byte { + if signature := antigravityThoughtSignature(part); signature != "" { + out = ensureAntigravityInteractionsStep(out, st, "thought", gjson.Result{}) + signatureDelta := []byte(`{"index":0,"delta":{"signature":"","type":"thought_signature"},"event_type":"step.delta"}`) + signatureDelta, _ = sjson.SetBytes(signatureDelta, "index", st.ActiveStepIndex) + signatureDelta, _ = sjson.SetBytes(signatureDelta, "delta.signature", signature) + return append(out, translatorcommon.SSEEventData("step.delta", signatureDelta)) + } + return out +} + +func antigravityPartToInteractionsStep(part gjson.Result) []byte { + if fc := part.Get("functionCall"); fc.Exists() { + step := []byte(`{"type":"function_call","name":"","arguments":{}}`) + step, _ = sjson.SetBytes(step, "name", fc.Get("name").String()) + if id := fc.Get("id"); id.Exists() { + step, _ = sjson.SetBytes(step, "call_id", id.String()) + } else if callID := fc.Get("call_id"); callID.Exists() { + step, _ = sjson.SetBytes(step, "call_id", callID.String()) + } + if args := fc.Get("args"); args.Exists() { + step, _ = sjson.SetRawBytes(step, "arguments", []byte(args.Raw)) + } + return step + } + if fr := part.Get("functionResponse"); fr.Exists() { + step := []byte(`{"type":"function_result","name":"","result":{}}`) + step, _ = sjson.SetBytes(step, "name", fr.Get("name").String()) + if id := fr.Get("id"); id.Exists() { + step, _ = sjson.SetBytes(step, "call_id", id.String()) + } else if callID := fr.Get("call_id"); callID.Exists() { + step, _ = sjson.SetBytes(step, "call_id", callID.String()) + } + if response := fr.Get("response"); response.Exists() { + step, _ = sjson.SetRawBytes(step, "result", []byte(response.Raw)) + } + return step + } + if text := part.Get("text"); text.Exists() { + step := []byte(`{"type":"model_output","content":[]}`) + if part.Get("thought").Bool() { + step, _ = sjson.SetBytes(step, "type", "thought") + } + item := []byte(`{"type":"text","text":""}`) + item, _ = sjson.SetBytes(item, "text", text.String()) + step, _ = sjson.SetRawBytes(step, "content.-1", item) + return step + } + if inline := part.Get("inlineData"); inline.Exists() { + return antigravityInlineDataToInteractionsStep(inline) + } + if inline := part.Get("inline_data"); inline.Exists() { + return antigravityInlineDataToInteractionsStep(inline) + } + return nil +} + +func antigravityInlineDataToInteractionsStep(inline gjson.Result) []byte { + mimeType := inline.Get("mimeType").String() + if mimeType == "" { + mimeType = inline.Get("mime_type").String() + } + data := inline.Get("data").String() + if mimeType == "" || data == "" { + return nil + } + contentType := "document" + lower := strings.ToLower(mimeType) + switch { + case strings.HasPrefix(lower, "image/"): + contentType = "image" + case strings.HasPrefix(lower, "audio/"): + contentType = "audio" + case strings.HasPrefix(lower, "video/"): + contentType = "video" + } + item := []byte(`{"type":"","mime_type":"","data":""}`) + item, _ = sjson.SetBytes(item, "type", contentType) + item, _ = sjson.SetBytes(item, "mime_type", mimeType) + item, _ = sjson.SetBytes(item, "data", data) + step := []byte(`{"type":"model_output","content":[]}`) + step, _ = sjson.SetRawBytes(step, "content.-1", item) + return step +} + +func hasAntigravityStreamUsage(root gjson.Result) bool { + usage := antigravityUsageNode(root) + if !usage.Exists() { + return false + } + for _, path := range []string{ + "promptTokenCount", + "candidatesTokenCount", + "totalTokenCount", + "thoughtsTokenCount", + "cachedContentTokenCount", + "prompt_token_count", + "candidates_token_count", + "total_token_count", + "thoughts_token_count", + "cached_content_token_count", + } { + if usage.Get(path).Exists() { + return true + } + } + return false +} + +func setInteractionsUsageFromAntigravity(out []byte, path string, root gjson.Result) []byte { + usage := antigravityUsageNode(root) + if !usage.Exists() { + return out + } + out, _ = sjson.SetBytes(out, path+".input_tokens", firstAntigravityUsageInt(usage, "promptTokenCount", "prompt_token_count")) + out, _ = sjson.SetBytes(out, path+".output_tokens", firstAntigravityUsageInt(usage, "candidatesTokenCount", "candidates_token_count")) + if antigravityUsagePathExists(usage, "thoughtsTokenCount", "thoughts_token_count") { + out, _ = sjson.SetBytes(out, path+".reasoning_tokens", firstAntigravityUsageInt(usage, "thoughtsTokenCount", "thoughts_token_count")) + } + out, _ = sjson.SetBytes(out, path+".total_tokens", firstAntigravityUsageInt(usage, "totalTokenCount", "total_token_count")) + if antigravityUsagePathExists(usage, "cachedContentTokenCount", "cached_content_token_count") { + out, _ = sjson.SetBytes(out, path+".cached_tokens", firstAntigravityUsageInt(usage, "cachedContentTokenCount", "cached_content_token_count")) + } + return out +} + +func setInteractionsStreamUsageFromAntigravity(out []byte, path string, root gjson.Result) []byte { + usage := antigravityUsageNode(root) + if !usage.Exists() { + return out + } + inputTokens := firstAntigravityUsageInt(usage, "promptTokenCount", "prompt_token_count") + outputTokens := firstAntigravityUsageInt(usage, "candidatesTokenCount", "candidates_token_count") + totalTokens := firstAntigravityUsageInt(usage, "totalTokenCount", "total_token_count") + thoughtTokens := firstAntigravityUsageInt(usage, "thoughtsTokenCount", "thoughts_token_count") + cachedTokens := firstAntigravityUsageInt(usage, "cachedContentTokenCount", "cached_content_token_count") + out, _ = sjson.SetBytes(out, path+".total_tokens", totalTokens) + out, _ = sjson.SetBytes(out, path+".total_input_tokens", inputTokens) + out, _ = sjson.SetRawBytes(out, path+".input_tokens_by_modality", []byte(fmt.Sprintf(`[{"modality":"text","tokens":%d}]`, inputTokens))) + out, _ = sjson.SetBytes(out, path+".total_cached_tokens", cachedTokens) + out, _ = sjson.SetBytes(out, path+".total_output_tokens", outputTokens) + out, _ = sjson.SetBytes(out, path+".total_tool_use_tokens", 0) + out, _ = sjson.SetBytes(out, path+".total_thought_tokens", thoughtTokens) + return out +} + +func antigravityUsageNode(root gjson.Result) gjson.Result { + if usage := root.Get("usageMetadata"); usage.Exists() { + return usage + } + if usage := root.Get("usage_metadata"); usage.Exists() { + return usage + } + if usage := root.Get("cpaUsageMetadata"); usage.Exists() { + return usage + } + return gjson.Result{} +} + +func firstAntigravityUsageInt(usage gjson.Result, paths ...string) int64 { + for _, path := range paths { + if value := usage.Get(path); value.Exists() { + return value.Int() + } + } + return 0 +} + +func antigravityUsagePathExists(usage gjson.Result, paths ...string) bool { + for _, path := range paths { + if usage.Get(path).Exists() { + return true + } + } + return false +} + +func antigravityFunctionPartID(part gjson.Result) string { + if id := part.Get("id"); id.Exists() { + return id.String() + } + if callID := part.Get("call_id"); callID.Exists() { + return callID.String() + } + return "" +} + +func antigravityThoughtSignature(part gjson.Result) string { + for _, path := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} { + if signature := strings.TrimSpace(part.Get(path).String()); signature != "" { + return signature + } + } + return "" +} diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_test.go b/internal/translator/antigravity/interactions/interactions_antigravity_test.go new file mode 100644 index 000000000..7e39a755e --- /dev/null +++ b/internal/translator/antigravity/interactions/interactions_antigravity_test.go @@ -0,0 +1,121 @@ +package interactions + +import ( + "bytes" + "context" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertInteractionsRequestToAntigravityWithToolMessagesDirect(t *testing.T) { + out := ConvertInteractionsRequestToAntigravity("antigravity-test", []byte(`{"model":"antigravity-test","system_instruction":"be brief","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}],"tools":[{"type":"function","name":"lookup","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}]}`), false) + if got := gjson.GetBytes(out, "request.systemInstruction.parts.0.text").String(); got != "be brief" { + t.Fatalf("request.systemInstruction.parts.0.text = %q, want be brief. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "hi" { + t.Fatalf("request.contents.0.parts.0.text = %q, want hi. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.name").String(); got != "lookup" { + t.Fatalf("functionCall.name = %q, want lookup. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.name").String(); got != "lookup" { + t.Fatalf("functionResponse.name = %q, want lookup. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "request.tools.0.functionDeclarations.0.name").String(); got != "lookup" { + t.Fatalf("request.tools.0.functionDeclarations.0.name = %q, want lookup. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "request.tools.0.functionDeclarations.0.parametersJsonSchema.properties.q.type").String(); got != "string" { + t.Fatalf("tool parameters schema was not preserved. Output: %s", string(out)) + } +} + +func TestConvertInteractionsRequestToAntigravityPreservesGenerationConfig(t *testing.T) { + out := ConvertInteractionsRequestToAntigravity("antigravity-test", []byte(`{"model":"antigravity-test","input":"hi","generation_config":{"max_output_tokens":16,"top_p":0.8,"tool_choice":"auto","thinking_level":"high","thinking_summaries":"auto"},"reasoning":{"summary":"auto"},"stream":true}`), true) + if gjson.GetBytes(out, "input").Exists() { + t.Fatalf("raw interactions input exists in translated request. Output: %s", string(out)) + } + for _, path := range []string{ + "request.generationConfig.toolChoice", + "request.generationConfig.thinkingLevel", + "request.generationConfig.thinkingSummaries", + } { + if gjson.GetBytes(out, path).Exists() { + t.Fatalf("%s exists, want omitted. Output: %s", path, string(out)) + } + } + if got := gjson.GetBytes(out, "request.stream").Bool(); !got { + t.Fatalf("request.stream = false, want true. Output: %s", string(out)) + } + if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "hi" { + t.Fatalf("request.contents.0.parts.0.text = %q, want hi. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "request.generationConfig.maxOutputTokens").Int(); got != 16 { + t.Fatalf("request.generationConfig.maxOutputTokens = %d, want 16. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "request.generationConfig.topP").Float(); got != 0.8 { + t.Fatalf("request.generationConfig.topP = %v, want 0.8. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" { + t.Fatalf("request.generationConfig.thinkingConfig.thinkingLevel = %q, want high. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts").Bool(); !got { + t.Fatalf("request.generationConfig.thinkingConfig.includeThoughts = false, want true. Output: %s", string(out)) + } + if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String(); got != "AUTO" { + t.Fatalf("request.toolConfig.functionCallingConfig.mode = %q, want AUTO. Output: %s", got, string(out)) + } +} + +func TestConvertAntigravityResponseToInteractionsNonStream(t *testing.T) { + raw := []byte(`{"response":{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"text":"ok"},{"functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":2,"totalTokenCount":5}}}`) + out := ConvertAntigravityResponseToInteractionsNonStream(context.Background(), "antigravity-test", nil, nil, raw, nil) + if got := gjson.GetBytes(out, "steps.0.content.0.text").String(); got != "ok" { + t.Fatalf("steps.0.content.0.text = %q, want ok. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "steps.1.type").String(); got != "function_call" { + t.Fatalf("steps.1.type = %q, want function_call. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 5 { + t.Fatalf("usage.total_tokens = %d, want 5. Output: %s", got, string(out)) + } +} + +func TestConvertAntigravityResponseToInteractionsStream(t *testing.T) { + ctx := context.WithValue(context.Background(), "alt", "") + var param any + events := ConvertAntigravityResponseToInteractions(ctx, "antigravity-test", nil, nil, []byte(`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]}}]}}`), ¶m) + payload := findAntigravityInteractionsEventPayload(events, "step.delta") + if len(payload) == 0 { + t.Fatalf("step.delta event not found: %q", events) + } + if got := gjson.GetBytes(payload, "delta.text").String(); got != "ok" { + t.Fatalf("delta.text = %q, want ok. Payload: %s", got, string(payload)) + } +} + +func TestConvertAntigravityResponseToInteractionsStreamFunctionCallStartHasCallID(t *testing.T) { + var param any + events := ConvertAntigravityResponseToInteractions(context.Background(), "antigravity-test", nil, nil, []byte(`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]}}]}}`), ¶m) + payload := findAntigravityInteractionsEventPayload(events, "step.start") + if got := gjson.GetBytes(payload, "step.call_id").String(); got != "call_1" { + t.Fatalf("step.call_id = %q, want call_1. Payload: %s", got, string(payload)) + } +} + +func findAntigravityInteractionsEventPayload(events [][]byte, eventType string) []byte { + prefix := []byte("data:") + for _, event := range events { + for _, line := range bytes.Split(event, []byte("\n")) { + line = bytes.TrimSpace(line) + if !bytes.HasPrefix(line, prefix) { + continue + } + payload := bytes.TrimSpace(line[len(prefix):]) + if gjson.GetBytes(payload, "type").String() == eventType || gjson.GetBytes(payload, "event_type").String() == eventType { + return payload + } + } + } + return nil +} diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go index bd9a34479..9a0a31e43 100644 --- a/internal/translator/claude/gemini/claude_gemini_request.go +++ b/internal/translator/claude/gemini/claude_gemini_request.go @@ -107,6 +107,9 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream // Model mapping to specify which Claude Code model to use out, _ = sjson.SetBytes(out, "model", modelName) + if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String { + out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String()) + } // Generation config extraction from Gemini format if genConfig := root.Get("generationConfig"); genConfig.Exists() { @@ -326,29 +329,19 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream return true } - // Image content (inline_data) conversion to Claude Code format - if inlineData := part.Get("inline_data"); inlineData.Exists() { - imageContent := []byte(`{"type":"image","source":{"type":"base64","media_type":"","data":""}}`) - if mimeType := inlineData.Get("mime_type"); mimeType.Exists() { - imageContent, _ = sjson.SetBytes(imageContent, "source.media_type", mimeType.String()) + // Inline data conversion to Claude Code content format + if inlineData := geminiClaudeInlineData(part); inlineData.Exists() { + if contentPart, ok := claudeContentPartFromGeminiInlineData(inlineData); ok { + msg, _ = sjson.SetRawBytes(msg, "content.-1", contentPart) } - if data := inlineData.Get("data"); data.Exists() { - imageContent, _ = sjson.SetBytes(imageContent, "source.data", data.String()) - } - msg, _ = sjson.SetRawBytes(msg, "content.-1", imageContent) return true } - // File data conversion to text content with file info - if fileData := part.Get("file_data"); fileData.Exists() { - // For file data, we'll convert to text content with file info - textContent := []byte(`{"type":"text","text":""}`) - fileInfo := "File: " + fileData.Get("file_uri").String() - if mimeType := fileData.Get("mime_type"); mimeType.Exists() { - fileInfo += " (Type: " + mimeType.String() + ")" + // File data conversion to Claude Code content format + if fileData := geminiClaudeFileData(part); fileData.Exists() { + if contentPart, ok := claudeContentPartFromGeminiFileData(fileData); ok { + msg, _ = sjson.SetRawBytes(msg, "content.-1", contentPart) } - textContent, _ = sjson.SetBytes(textContent, "text", fileInfo) - msg, _ = sjson.SetRawBytes(msg, "content.-1", textContent) return true } @@ -408,18 +401,9 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream // Tool config mapping from Gemini format to Claude Code format if toolConfig := root.Get("tool_config"); toolConfig.Exists() { - if funcCalling := toolConfig.Get("function_calling_config"); funcCalling.Exists() { - if mode := funcCalling.Get("mode"); mode.Exists() { - switch mode.String() { - case "AUTO": - out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`)) - case "NONE": - out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"none"}`)) - case "ANY": - out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`)) - } - } - } + out = setClaudeToolChoiceFromGeminiToolConfig(out, toolConfig.Get("function_calling_config")) + } else if toolConfig := root.Get("toolConfig"); toolConfig.Exists() { + out = setClaudeToolChoiceFromGeminiToolConfig(out, toolConfig.Get("functionCallingConfig")) } // Stream setting configuration @@ -436,3 +420,114 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream return out } + +func setClaudeToolChoiceFromGeminiToolConfig(out []byte, funcCalling gjson.Result) []byte { + if !funcCalling.Exists() { + return out + } + mode := funcCalling.Get("mode") + if !mode.Exists() { + return out + } + switch mode.String() { + case "AUTO": + out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`)) + case "NONE": + out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"none"}`)) + case "ANY": + allowedNames := funcCalling.Get("allowedFunctionNames") + if !allowedNames.Exists() { + allowedNames = funcCalling.Get("allowed_function_names") + } + if allowedNames.IsArray() && len(allowedNames.Array()) == 1 { + choice := []byte(`{"type":"tool","name":""}`) + choice, _ = sjson.SetBytes(choice, "name", allowedNames.Array()[0].String()) + out, _ = sjson.SetRawBytes(out, "tool_choice", choice) + } else { + out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`)) + } + } + return out +} + +func geminiClaudeInlineData(part gjson.Result) gjson.Result { + inlineData := part.Get("inlineData") + if inlineData.Exists() { + return inlineData + } + return part.Get("inline_data") +} + +func geminiClaudeFileData(part gjson.Result) gjson.Result { + fileData := part.Get("fileData") + if fileData.Exists() { + return fileData + } + return part.Get("file_data") +} + +func claudeContentPartFromGeminiInlineData(inlineData gjson.Result) ([]byte, bool) { + mimeType := inlineData.Get("mimeType").String() + if mimeType == "" { + mimeType = inlineData.Get("mime_type").String() + } + data := inlineData.Get("data").String() + if mimeType == "" || data == "" { + return nil, false + } + lowerMimeType := strings.ToLower(mimeType) + switch { + case strings.HasPrefix(lowerMimeType, "image/"): + imageContent := []byte(`{"type":"image","source":{"type":"base64","media_type":"","data":""}}`) + imageContent, _ = sjson.SetBytes(imageContent, "source.media_type", mimeType) + imageContent, _ = sjson.SetBytes(imageContent, "source.data", data) + return imageContent, true + case strings.HasPrefix(lowerMimeType, "application/"), strings.HasPrefix(lowerMimeType, "text/"): + documentContent := []byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`) + documentContent, _ = sjson.SetBytes(documentContent, "source.media_type", mimeType) + documentContent, _ = sjson.SetBytes(documentContent, "source.data", data) + return documentContent, true + default: + return claudeTextContentPart(fmt.Sprintf("Media content: inline data (Type: %s)", mimeType)), true + } +} + +func claudeContentPartFromGeminiFileData(fileData gjson.Result) ([]byte, bool) { + fileURI := fileData.Get("fileUri").String() + if fileURI == "" { + fileURI = fileData.Get("file_uri").String() + } + if fileURI == "" { + return nil, false + } + mimeType := fileData.Get("mimeType").String() + if mimeType == "" { + mimeType = fileData.Get("mime_type").String() + } + lowerMimeType := strings.ToLower(mimeType) + switch { + case strings.HasPrefix(lowerMimeType, "image/"): + imageContent := []byte(`{"type":"image","source":{"type":"url","url":""}}`) + imageContent, _ = sjson.SetBytes(imageContent, "source.url", fileURI) + return imageContent, true + case strings.HasPrefix(lowerMimeType, "application/"), strings.HasPrefix(lowerMimeType, "text/"): + documentContent := []byte(`{"type":"document","source":{"type":"url","url":""}}`) + documentContent, _ = sjson.SetBytes(documentContent, "source.url", fileURI) + if mimeType != "" { + documentContent, _ = sjson.SetBytes(documentContent, "source.media_type", mimeType) + } + return documentContent, true + default: + fileInfo := "File: " + fileURI + if mimeType != "" { + fileInfo += " (Type: " + mimeType + ")" + } + return claudeTextContentPart(fileInfo), true + } +} + +func claudeTextContentPart(text string) []byte { + textContent := []byte(`{"type":"text","text":""}`) + textContent, _ = sjson.SetBytes(textContent, "text", text) + return textContent +} diff --git a/internal/translator/claude/gemini/claude_gemini_request_test.go b/internal/translator/claude/gemini/claude_gemini_request_test.go index e599bb0ce..0a8834ba4 100644 --- a/internal/translator/claude/gemini/claude_gemini_request_test.go +++ b/internal/translator/claude/gemini/claude_gemini_request_test.go @@ -85,3 +85,30 @@ func TestConvertGeminiRequestToClaude_DropsTemperature(t *testing.T) { t.Fatalf("top_p = %v, want 0.8", got) } } + +func TestConvertGeminiRequestToClaude_AcceptsCamelInlineData(t *testing.T) { + out := ConvertGeminiRequestToClaude("claude-sonnet-4", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}]}`), false) + if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "image" { + t.Fatalf("content type = %q, want image. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.0.content.0.source.media_type").String(); got != "image/png" { + t.Fatalf("media_type = %q, want image/png. Output: %s", got, string(out)) + } +} + +func TestConvertGeminiRequestToClaude_SplitsNonImageInlineDataByMIME(t *testing.T) { + out := ConvertGeminiRequestToClaude("claude-sonnet-4", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"audio/wav","data":"UklGRg=="}},{"inlineData":{"mimeType":"video/mp4","data":"AAAAIGZ0eXA="}},{"inlineData":{"mimeType":"application/pdf","data":"JVBERi0="}}]}]}`), false) + + if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "text" { + t.Fatalf("audio fallback type = %q, want text. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.0.content.1.type").String(); got != "text" { + t.Fatalf("video fallback type = %q, want text. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.0.content.2.type").String(); got != "document" { + t.Fatalf("document content type = %q, want document. Output: %s", got, string(out)) + } + if gjson.GetBytes(out, "messages.0.content.#(type==\"image\")").Exists() { + t.Fatalf("non-image inlineData must not be converted to image. Output: %s", string(out)) + } +} diff --git a/internal/translator/claude/interactions/init.go b/internal/translator/claude/interactions/init.go new file mode 100644 index 000000000..e1aa15047 --- /dev/null +++ b/internal/translator/claude/interactions/init.go @@ -0,0 +1,19 @@ +package interactions + +import ( + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" +) + +func init() { + translator.Register( + Interactions, + Claude, + ConvertInteractionsRequestToClaude, + interfaces.TranslateResponse{ + Stream: ConvertClaudeResponseToInteractions, + NonStream: ConvertClaudeResponseToInteractionsNonStream, + }, + ) +} diff --git a/internal/translator/claude/interactions/interactions_claude_request.go b/internal/translator/claude/interactions/interactions_claude_request.go new file mode 100644 index 000000000..604dfaf15 --- /dev/null +++ b/internal/translator/claude/interactions/interactions_claude_request.go @@ -0,0 +1,451 @@ +package interactions + +import ( + "fmt" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func ConvertInteractionsRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte { + root := gjson.ParseBytes(inputRawJSON) + out := []byte(`{"model":"","max_tokens":32000,"messages":[]}`) + out, _ = sjson.SetBytes(out, "model", modelName) + if stream || root.Get("stream").Bool() { + out, _ = sjson.SetBytes(out, "stream", true) + } + out = copyInteractionsSystemToClaude(out, root) + out = copyInteractionsGenerationConfigToClaude(out, root) + out = appendInteractionsInputToClaudeMessages(out, root.Get("input")) + out = copyInteractionsToolsToClaude(out, root) + return out +} + +func copyInteractionsSystemToClaude(out []byte, root gjson.Result) []byte { + sys := root.Get("system_instruction") + if !sys.Exists() { + sys = root.Get("systemInstruction") + } + text := interactionsClaudeText(sys) + if text == "" { + return out + } + out, _ = sjson.SetBytes(out, "system", text) + return out +} + +func copyInteractionsGenerationConfigToClaude(out []byte, root gjson.Result) []byte { + cfg := root.Get("generation_config") + if !cfg.Exists() { + cfg = root.Get("generationConfig") + } + if cfg.Exists() { + out = copyJSONField(out, cfg, "max_output_tokens", "max_tokens") + out = copyJSONField(out, cfg, "maxOutputTokens", "max_tokens") + out = copyJSONField(out, cfg, "top_p", "top_p") + out = copyJSONField(out, cfg, "topP", "top_p") + out = copyJSONField(out, cfg, "temperature", "temperature") + out = copyJSONField(out, cfg, "stop_sequences", "stop_sequences") + out = copyJSONField(out, cfg, "stopSequences", "stop_sequences") + out = copyInteractionsThinkingConfigToClaude(out, cfg) + out = copyInteractionsToolChoiceToClaude(out, cfg.Get("tool_choice")) + out = copyInteractionsToolChoiceToClaude(out, cfg.Get("toolChoice")) + } + out = copyInteractionsReasoningToClaude(out, root.Get("reasoning")) + out = copyInteractionsToolChoiceToClaude(out, root.Get("tool_choice")) + out = copyInteractionsToolChoiceToClaude(out, root.Get("toolChoice")) + return out +} + +func copyJSONField(out []byte, root gjson.Result, from, to string) []byte { + value := root.Get(from) + if !value.Exists() { + return out + } + out, _ = sjson.SetRawBytes(out, to, []byte(value.Raw)) + return out +} + +func copyInteractionsThinkingConfigToClaude(out []byte, cfg gjson.Result) []byte { + level := firstClaudeInteractionsExisting(cfg, "thinking_level", "thinkingLevel", "reasoning.effort") + if !level.Exists() { + return out + } + return setClaudeThinkingFromLevel(out, level.String()) +} + +func copyInteractionsReasoningToClaude(out []byte, reasoning gjson.Result) []byte { + if !reasoning.Exists() { + return out + } + if effort := reasoning.Get("effort"); effort.Exists() { + return setClaudeThinkingFromLevel(out, effort.String()) + } + if level := reasoning.Get("thinking_level"); level.Exists() { + return setClaudeThinkingFromLevel(out, level.String()) + } + return out +} + +func setClaudeThinkingFromLevel(out []byte, level string) []byte { + normalized := strings.ToLower(strings.TrimSpace(level)) + if normalized == "" { + return out + } + switch normalized { + case "none", "disabled", "off", "false": + out, _ = sjson.SetBytes(out, "thinking.type", "disabled") + out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens") + return out + case "auto", "adaptive": + out, _ = sjson.SetBytes(out, "thinking.type", "adaptive") + out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens") + return out + } + if budget, ok := thinking.ConvertLevelToBudget(normalized); ok { + switch { + case budget == 0: + out, _ = sjson.SetBytes(out, "thinking.type", "disabled") + case budget < 0: + out, _ = sjson.SetBytes(out, "thinking.type", "enabled") + default: + out, _ = sjson.SetBytes(out, "thinking.type", "enabled") + out, _ = sjson.SetBytes(out, "thinking.budget_tokens", budget) + } + return out + } + out, _ = sjson.SetBytes(out, "thinking.type", "adaptive") + out, _ = sjson.SetBytes(out, "output_config.effort", normalized) + return out +} + +func appendInteractionsInputToClaudeMessages(out []byte, input gjson.Result) []byte { + if !input.Exists() { + return out + } + if input.Type == gjson.String { + step := []byte(`{"type":"user_input","content":[{"type":"text","text":""}]}`) + step, _ = sjson.SetBytes(step, "content.0.text", input.String()) + return appendInteractionsStepToClaude(out, gjson.ParseBytes(step), "user") + } + if input.IsObject() { + return appendInteractionsInputItemToClaude(out, input) + } + input.ForEach(func(_, step gjson.Result) bool { + out = appendInteractionsInputItemToClaude(out, step) + return true + }) + return out +} + +func appendInteractionsInputItemToClaude(out []byte, step gjson.Result) []byte { + if step.Get("steps").IsArray() { + defaultRole := "user" + if role := step.Get("role").String(); role == "model" || role == "assistant" { + defaultRole = "assistant" + } + step.Get("steps").ForEach(func(_, nestedStep gjson.Result) bool { + out = appendInteractionsStepToClaude(out, nestedStep, defaultRole) + return true + }) + return out + } + if step.Get("parts").Exists() { + wrapped := []byte(`{"type":"user_input","content":[]}`) + if role := step.Get("role").String(); role == "model" || role == "assistant" { + wrapped, _ = sjson.SetBytes(wrapped, "type", "model_output") + } + wrapped, _ = sjson.SetRawBytes(wrapped, "content", []byte(step.Get("parts").Raw)) + return appendInteractionsStepToClaude(out, gjson.ParseBytes(wrapped), "user") + } + stepType := step.Get("type").String() + switch stepType { + case "function_call": + return appendInteractionsFunctionCallToClaude(out, step) + case "function_result": + return appendInteractionsFunctionResultToClaude(out, step) + case "model_output", "thought": + return appendInteractionsStepToClaude(out, step, "assistant") + default: + return appendInteractionsStepToClaude(out, step, "user") + } +} + +func appendInteractionsStepToClaude(out []byte, step gjson.Result, defaultRole string) []byte { + role := defaultRole + if stepRole := step.Get("role").String(); stepRole == "user" || stepRole == "assistant" { + role = stepRole + } + content := []byte(`[]`) + stepContent := step.Get("content") + if stepContent.Type == gjson.String { + part := []byte(`{"type":"text","text":""}`) + part, _ = sjson.SetBytes(part, "text", stepContent.String()) + content, _ = sjson.SetRawBytes(content, "-1", part) + } else if stepContent.IsArray() { + stepContent.ForEach(func(_, part gjson.Result) bool { + content = appendInteractionsContentToClaude(content, part, role) + return true + }) + } else if text := step.Get("text"); text.Exists() { + part := []byte(`{"type":"text","text":""}`) + part, _ = sjson.SetBytes(part, "text", text.String()) + content, _ = sjson.SetRawBytes(content, "-1", part) + } + if len(gjson.ParseBytes(content).Array()) == 0 { + return out + } + msg := []byte(`{"role":"","content":[]}`) + msg, _ = sjson.SetBytes(msg, "role", role) + msg, _ = sjson.SetRawBytes(msg, "content", content) + out, _ = sjson.SetRawBytes(out, "messages.-1", msg) + return out +} + +func appendInteractionsContentToClaude(content []byte, part gjson.Result, role string) []byte { + partType := part.Get("type").String() + if partType == "" && part.Get("text").Exists() { + partType = "text" + } + switch partType { + case "text": + textPart := []byte(`{"type":"text","text":""}`) + textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String()) + content, _ = sjson.SetRawBytes(content, "-1", textPart) + case "thinking", "reasoning": + if role != "assistant" { + return content + } + thinkingPart := []byte(`{"type":"thinking","thinking":""}`) + thinkingPart, _ = sjson.SetBytes(thinkingPart, "thinking", interactionsClaudeText(part)) + content, _ = sjson.SetRawBytes(content, "-1", thinkingPart) + case "image": + if imagePart, ok := interactionsClaudeMediaPart(part, "image"); ok { + content, _ = sjson.SetRawBytes(content, "-1", imagePart) + } + case "document", "file": + if documentPart, ok := interactionsClaudeMediaPart(part, "document"); ok { + content, _ = sjson.SetRawBytes(content, "-1", documentPart) + } + default: + if text := interactionsClaudeText(part); text != "" { + textPart := []byte(`{"type":"text","text":""}`) + textPart, _ = sjson.SetBytes(textPart, "text", text) + content, _ = sjson.SetRawBytes(content, "-1", textPart) + } else if part.Get("data").String() != "" || part.Get("file_data").String() != "" { + textPart := []byte(`{"type":"text","text":""}`) + textPart, _ = sjson.SetBytes(textPart, "text", fmt.Sprintf("[%s content omitted]", partType)) + content, _ = sjson.SetRawBytes(content, "-1", textPart) + } + } + return content +} + +func appendInteractionsFunctionCallToClaude(out []byte, step gjson.Result) []byte { + toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`) + toolUse, _ = sjson.SetBytes(toolUse, "id", interactionsClaudeToolID(step)) + toolUse, _ = sjson.SetBytes(toolUse, "name", step.Get("name").String()) + args := step.Get("arguments") + if !args.Exists() { + args = step.Get("args") + } + if args.Exists() && args.IsObject() { + toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(args.Raw)) + } + msg := []byte(`{"role":"assistant","content":[]}`) + msg, _ = sjson.SetRawBytes(msg, "content.-1", toolUse) + out, _ = sjson.SetRawBytes(out, "messages.-1", msg) + return out +} + +func appendInteractionsFunctionResultToClaude(out []byte, step gjson.Result) []byte { + toolResult := []byte(`{"type":"tool_result","tool_use_id":"","content":""}`) + toolResult, _ = sjson.SetBytes(toolResult, "tool_use_id", interactionsClaudeToolID(step)) + result := step.Get("result") + if !result.Exists() { + result = step.Get("output") + } + switch { + case result.IsArray(): + content := []byte(`[]`) + result.ForEach(func(_, part gjson.Result) bool { + content = appendInteractionsContentToClaude(content, part, "user") + return true + }) + toolResult, _ = sjson.SetRawBytes(toolResult, "content", content) + case result.Exists() && result.Raw != "": + toolResult, _ = sjson.SetBytes(toolResult, "content", result.Raw) + default: + toolResult, _ = sjson.SetBytes(toolResult, "content", "") + } + msg := []byte(`{"role":"user","content":[]}`) + msg, _ = sjson.SetRawBytes(msg, "content.-1", toolResult) + out, _ = sjson.SetRawBytes(out, "messages.-1", msg) + return out +} + +func copyInteractionsToolsToClaude(out []byte, root gjson.Result) []byte { + tools := root.Get("tools") + if !tools.Exists() || !tools.IsArray() { + return out + } + claudeTools := []byte(`[]`) + tools.ForEach(func(_, tool gjson.Result) bool { + if tool.Get("function_declarations").IsArray() { + tool.Get("function_declarations").ForEach(func(_, decl gjson.Result) bool { + claudeTools = appendInteractionsClaudeTool(claudeTools, decl) + return true + }) + return true + } + if tool.Get("functionDeclarations").IsArray() { + tool.Get("functionDeclarations").ForEach(func(_, decl gjson.Result) bool { + claudeTools = appendInteractionsClaudeTool(claudeTools, decl) + return true + }) + return true + } + claudeTools = appendInteractionsClaudeTool(claudeTools, tool) + return true + }) + if len(gjson.ParseBytes(claudeTools).Array()) > 0 { + out, _ = sjson.SetRawBytes(out, "tools", claudeTools) + } + return out +} + +func appendInteractionsClaudeTool(tools []byte, tool gjson.Result) []byte { + name := tool.Get("name").String() + if name == "" { + name = tool.Get("function.name").String() + } + if name == "" { + return tools + } + converted := []byte(`{"name":"","input_schema":{}}`) + converted, _ = sjson.SetBytes(converted, "name", name) + if desc := tool.Get("description"); desc.Exists() { + converted, _ = sjson.SetBytes(converted, "description", desc.String()) + } else if desc := tool.Get("function.description"); desc.Exists() { + converted, _ = sjson.SetBytes(converted, "description", desc.String()) + } + params := firstClaudeInteractionsExisting(tool, "parameters", "parametersJsonSchema", "parameters_json_schema", "input_schema") + if params.Exists() && params.IsObject() { + converted, _ = sjson.SetRawBytes(converted, "input_schema", []byte(params.Raw)) + } + tools, _ = sjson.SetRawBytes(tools, "-1", converted) + return tools +} + +func copyInteractionsToolChoiceToClaude(out []byte, toolChoice gjson.Result) []byte { + if !toolChoice.Exists() { + return out + } + switch toolChoice.Type { + case gjson.String: + switch strings.ToLower(strings.TrimSpace(toolChoice.String())) { + case "auto": + out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`)) + case "required", "any": + out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`)) + } + case gjson.JSON: + toolType := strings.ToLower(strings.TrimSpace(toolChoice.Get("type").String())) + switch toolType { + case "auto": + out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`)) + case "required", "any": + out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`)) + case "function", "tool": + name := toolChoice.Get("name").String() + if name == "" { + name = toolChoice.Get("function.name").String() + } + if name != "" { + choice := []byte(`{"type":"tool","name":""}`) + choice, _ = sjson.SetBytes(choice, "name", name) + out, _ = sjson.SetRawBytes(out, "tool_choice", choice) + } + } + } + return out +} + +func interactionsClaudeToolID(step gjson.Result) string { + for _, path := range []string{"call_id", "id", "tool_use_id"} { + if value := step.Get(path).String(); value != "" { + return util.SanitizeClaudeToolID(value) + } + } + if name := step.Get("name").String(); name != "" { + return util.SanitizeClaudeToolID("toolu_" + name) + } + return "toolu_interactions" +} + +func interactionsClaudeText(value gjson.Result) string { + if !value.Exists() { + return "" + } + if value.Type == gjson.String { + return value.String() + } + if text := value.Get("text"); text.Exists() { + return text.String() + } + if thinking := value.Get("thinking"); thinking.Exists() { + return thinking.String() + } + if content := value.Get("content"); content.Exists() { + return interactionsClaudeText(content) + } + if parts := value.Get("parts"); parts.Exists() && parts.IsArray() { + var builder strings.Builder + parts.ForEach(func(_, part gjson.Result) bool { + text := interactionsClaudeText(part) + if text == "" { + return true + } + if builder.Len() > 0 { + builder.WriteByte('\n') + } + builder.WriteString(text) + return true + }) + return builder.String() + } + return "" +} + +func interactionsClaudeMediaPart(part gjson.Result, claudeType string) ([]byte, bool) { + mimeType := firstClaudeInteractionsExisting(part, "mime_type", "mimeType", "media_type", "mediaType").String() + data := firstClaudeInteractionsExisting(part, "data", "file_data", "fileData").String() + if source := part.Get("source"); source.Exists() { + if mimeType == "" { + mimeType = source.Get("media_type").String() + } + if data == "" { + data = source.Get("data").String() + } + } + if mimeType == "" || data == "" { + return nil, false + } + out := []byte(`{"type":"","source":{"type":"base64","media_type":"","data":""}}`) + out, _ = sjson.SetBytes(out, "type", claudeType) + out, _ = sjson.SetBytes(out, "source.media_type", mimeType) + out, _ = sjson.SetBytes(out, "source.data", data) + return out, true +} + +func firstClaudeInteractionsExisting(root gjson.Result, paths ...string) gjson.Result { + for _, path := range paths { + if value := root.Get(path); value.Exists() { + return value + } + } + return gjson.Result{} +} diff --git a/internal/translator/claude/interactions/interactions_claude_response.go b/internal/translator/claude/interactions/interactions_claude_response.go new file mode 100644 index 000000000..4a6e06cc8 --- /dev/null +++ b/internal/translator/claude/interactions/interactions_claude_response.go @@ -0,0 +1,583 @@ +package interactions + +import ( + "bufio" + "bytes" + "context" + "fmt" + "strings" + "time" + + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +var claudeInteractionsDataTag = []byte("data:") + +type claudeToInteractionsStreamState struct { + ID string + Model string + Created bool + StatusUpdated bool + Completed bool + Done bool + UsageRaw []byte + StepIndex int + ActiveStepIndex int + ActiveStepType string + ActiveStepOpen bool + CurrentStepByIndex map[int]string + ToolNames map[int]string + ToolIDs map[int]string + ToolArgs map[int]*strings.Builder +} + +func ConvertClaudeResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { + _ = ctx + _ = originalRequestRawJSON + _ = requestRawJSON + if param == nil { + var local any + param = &local + } + if *param == nil { + *param = &claudeToInteractionsStreamState{Model: modelName} + } + st := (*param).(*claudeToInteractionsStreamState) + st.Model = firstNonEmptyString(st.Model, modelName) + st.ensureMaps() + return convertClaudeEventToInteractions(modelName, rawJSON, st) +} + +func ConvertClaudeResponseToInteractionsNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { + _ = ctx + _ = originalRequestRawJSON + _ = requestRawJSON + root := gjson.ParseBytes(rawJSON) + if root.Exists() && root.Get("content").Exists() { + return convertClaudeMessageToInteractions(modelName, root) + } + return convertClaudeSSEToInteractionsNonStream(modelName, rawJSON) +} + +func convertClaudeMessageToInteractions(modelName string, root gjson.Result) []byte { + out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`) + out, _ = sjson.SetBytes(out, "id", firstNonEmptyString(root.Get("id").String(), fmt.Sprintf("interaction_%d", time.Now().UnixNano()))) + out, _ = sjson.SetBytes(out, "model", firstNonEmptyString(root.Get("model").String(), modelName)) + root.Get("content").ForEach(func(_, part gjson.Result) bool { + if step := claudeContentBlockToInteractionsStep(part); len(step) > 0 { + out, _ = sjson.SetRawBytes(out, "steps.-1", step) + } + return true + }) + out = setInteractionsUsageFromClaude(out, "usage", root.Get("usage")) + return out +} + +func convertClaudeSSEToInteractionsNonStream(modelName string, rawJSON []byte) []byte { + out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`) + out, _ = sjson.SetBytes(out, "id", fmt.Sprintf("interaction_%d", time.Now().UnixNano())) + out, _ = sjson.SetBytes(out, "model", modelName) + st := &claudeToInteractionsStreamState{Model: modelName} + st.ensureMaps() + scanner := bufio.NewScanner(bytes.NewReader(rawJSON)) + buffer := make([]byte, 1024*1024) + scanner.Buffer(buffer, 52_428_800) + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if !bytes.HasPrefix(line, claudeInteractionsDataTag) { + continue + } + payload := bytes.TrimSpace(line[len(claudeInteractionsDataTag):]) + if bytes.Equal(payload, []byte("[DONE]")) { + continue + } + root := gjson.ParseBytes(payload) + switch root.Get("type").String() { + case "message_start": + msg := root.Get("message") + if id := msg.Get("id").String(); id != "" { + out, _ = sjson.SetBytes(out, "id", id) + } + if model := msg.Get("model").String(); model != "" { + out, _ = sjson.SetBytes(out, "model", model) + } + mergeClaudeUsage(st, msg.Get("usage")) + case "content_block_start": + claudeNonStreamContentBlockStart(root, st) + case "content_block_delta": + claudeNonStreamContentBlockDelta(root, st) + case "content_block_stop": + if step := claudeNonStreamContentBlockStop(root, st); len(step) > 0 { + out, _ = sjson.SetRawBytes(out, "steps.-1", step) + } + case "message_delta": + mergeClaudeUsage(st, root.Get("usage")) + } + } + out = setInteractionsUsageFromClaude(out, "usage", claudeMergedUsage(st)) + return out +} + +func convertClaudeEventToInteractions(modelName string, rawJSON []byte, st *claudeToInteractionsStreamState) [][]byte { + payload := claudeInteractionsSSEPayload(rawJSON) + if len(payload) == 0 { + return nil + } + if bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) { + return appendClaudeInteractionsDone(nil, st) + } + root := gjson.ParseBytes(payload) + switch root.Get("type").String() { + case "message_start": + msg := root.Get("message") + st.ID = firstNonEmptyString(msg.Get("id").String(), st.ID, fmt.Sprintf("interaction_%d", time.Now().UnixNano())) + st.Model = firstNonEmptyString(msg.Get("model").String(), st.Model, modelName) + mergeClaudeUsage(st, msg.Get("usage")) + return appendClaudeInteractionsCreated(nil, st, st.Model) + case "content_block_start": + return claudeContentBlockStartToInteractions(modelName, root, st) + case "content_block_delta": + return claudeContentBlockDeltaToInteractions(modelName, root, st) + case "content_block_stop": + return claudeContentBlockStopToInteractions(root, st) + case "message_delta": + mergeClaudeUsage(st, root.Get("usage")) + out := appendClaudeInteractionsStepStop(nil, st) + out = appendClaudeInteractionsCompleted(out, st, modelName, root) + return out + case "message_stop": + if st.Completed { + return nil + } + return appendClaudeInteractionsCompleted(nil, st, modelName, root) + case "error": + out := appendClaudeInteractionsCreated(nil, st, modelName) + return appendClaudeInteractionsCompleted(out, st, modelName, root) + } + return nil +} + +func claudeContentBlockStartToInteractions(modelName string, root gjson.Result, st *claudeToInteractionsStreamState) [][]byte { + out := appendClaudeInteractionsCreated(nil, st, modelName) + out = appendClaudeInteractionsStepStop(out, st) + index := int(root.Get("index").Int()) + block := root.Get("content_block") + stepType := claudeBlockInteractionsStepType(block.Get("type").String()) + st.CurrentStepByIndex[index] = stepType + if stepType == "function_call" { + if name := block.Get("name").String(); name != "" { + st.ToolNames[index] = name + } + if id := block.Get("id").String(); id != "" { + st.ToolIDs[index] = id + } + if input := block.Get("input"); input.Exists() && input.IsObject() && input.Raw != "{}" { + builder := &strings.Builder{} + builder.WriteString(input.Raw) + st.ToolArgs[index] = builder + } + } + step := claudeBlockToInteractionsStep(block, stepType) + return appendClaudeInteractionsStepStart(out, st, stepType, step) +} + +func claudeContentBlockDeltaToInteractions(modelName string, root gjson.Result, st *claudeToInteractionsStreamState) [][]byte { + index := int(root.Get("index").Int()) + stepType := st.CurrentStepByIndex[index] + if stepType == "" { + stepType = claudeDeltaInteractionsStepType(root.Get("delta.type").String()) + out := appendClaudeInteractionsCreated(nil, st, modelName) + out = appendClaudeInteractionsStepStop(out, st) + out = appendClaudeInteractionsStepStart(out, st, stepType, []byte(`{"type":"`+stepType+`"}`)) + st.CurrentStepByIndex[index] = stepType + return appendClaudeDeltaToInteractions(out, st, root.Get("delta"), index) + } + if !st.ActiveStepOpen || st.ActiveStepIndex != index { + out := appendClaudeInteractionsCreated(nil, st, modelName) + out = appendClaudeInteractionsStepStop(out, st) + step := claudeStepForKnownIndex(stepType, index, st) + out = appendClaudeInteractionsStepStart(out, st, stepType, step) + return appendClaudeDeltaToInteractions(out, st, root.Get("delta"), index) + } + return appendClaudeDeltaToInteractions(nil, st, root.Get("delta"), index) +} + +func claudeContentBlockStopToInteractions(root gjson.Result, st *claudeToInteractionsStreamState) [][]byte { + index := int(root.Get("index").Int()) + out := appendClaudeInteractionsStepStop(nil, st) + delete(st.CurrentStepByIndex, index) + delete(st.ToolNames, index) + delete(st.ToolIDs, index) + delete(st.ToolArgs, index) + return out +} + +func appendClaudeDeltaToInteractions(out [][]byte, st *claudeToInteractionsStreamState, delta gjson.Result, index int) [][]byte { + switch delta.Get("type").String() { + case "text_delta": + return appendClaudeInteractionsTextDelta(out, st, delta.Get("text").String(), false) + case "thinking_delta": + return appendClaudeInteractionsTextDelta(out, st, delta.Get("thinking").String(), true) + case "input_json_delta": + if st.ToolArgs[index] == nil { + st.ToolArgs[index] = &strings.Builder{} + } + partial := delta.Get("partial_json").String() + st.ToolArgs[index].WriteString(partial) + return appendClaudeInteractionsArgumentsDelta(out, st, partial) + } + return out +} + +func claudeContentBlockToInteractionsStep(part gjson.Result) []byte { + switch part.Get("type").String() { + case "text": + step := []byte(`{"type":"model_output","content":[]}`) + content := []byte(`{"type":"text","text":""}`) + content, _ = sjson.SetBytes(content, "text", part.Get("text").String()) + step, _ = sjson.SetRawBytes(step, "content.-1", content) + return step + case "thinking": + step := []byte(`{"type":"thought","content":[]}`) + content := []byte(`{"type":"text","text":""}`) + content, _ = sjson.SetBytes(content, "text", part.Get("thinking").String()) + step, _ = sjson.SetRawBytes(step, "content.-1", content) + return step + case "tool_use": + return claudeToolUseToInteractionsStep(part, strings.TrimSpace(part.Get("input").Raw)) + } + return nil +} + +func claudeToolUseToInteractionsStep(part gjson.Result, argsRaw string) []byte { + step := []byte(`{"type":"function_call","name":"","arguments":{}}`) + step, _ = sjson.SetBytes(step, "name", part.Get("name").String()) + if id := part.Get("id").String(); id != "" { + step, _ = sjson.SetBytes(step, "id", id) + step, _ = sjson.SetBytes(step, "call_id", id) + } + if argsRaw != "" && gjson.Valid(argsRaw) { + step, _ = sjson.SetRawBytes(step, "arguments", []byte(argsRaw)) + } + return step +} + +func claudeBlockToInteractionsStep(block gjson.Result, stepType string) []byte { + step := []byte(`{"type":""}`) + step, _ = sjson.SetBytes(step, "type", stepType) + if stepType == "function_call" { + step, _ = sjson.SetBytes(step, "name", block.Get("name").String()) + if id := block.Get("id").String(); id != "" { + step, _ = sjson.SetBytes(step, "id", id) + step, _ = sjson.SetBytes(step, "call_id", id) + } + step, _ = sjson.SetRawBytes(step, "arguments", []byte(`{}`)) + } + return step +} + +func claudeStepForKnownIndex(stepType string, index int, st *claudeToInteractionsStreamState) []byte { + step := []byte(`{"type":""}`) + step, _ = sjson.SetBytes(step, "type", stepType) + if stepType == "function_call" { + step, _ = sjson.SetBytes(step, "name", st.ToolNames[index]) + if id := st.ToolIDs[index]; id != "" { + step, _ = sjson.SetBytes(step, "id", id) + step, _ = sjson.SetBytes(step, "call_id", id) + } + step, _ = sjson.SetRawBytes(step, "arguments", []byte(`{}`)) + } + return step +} + +func claudeNonStreamContentBlockStart(root gjson.Result, st *claudeToInteractionsStreamState) { + index := int(root.Get("index").Int()) + block := root.Get("content_block") + st.CurrentStepByIndex[index] = claudeBlockInteractionsStepType(block.Get("type").String()) + if block.Get("type").String() != "tool_use" { + return + } + st.ToolNames[index] = block.Get("name").String() + st.ToolIDs[index] = block.Get("id").String() + if input := block.Get("input"); input.Exists() && input.IsObject() && input.Raw != "{}" { + builder := &strings.Builder{} + builder.WriteString(input.Raw) + st.ToolArgs[index] = builder + } +} + +func claudeNonStreamContentBlockDelta(root gjson.Result, st *claudeToInteractionsStreamState) { + index := int(root.Get("index").Int()) + delta := root.Get("delta") + switch delta.Get("type").String() { + case "text_delta", "thinking_delta": + if st.ToolArgs[index] == nil { + st.ToolArgs[index] = &strings.Builder{} + } + if delta.Get("type").String() == "text_delta" { + st.ToolArgs[index].WriteString(delta.Get("text").String()) + } else { + st.ToolArgs[index].WriteString(delta.Get("thinking").String()) + } + case "input_json_delta": + if st.ToolArgs[index] == nil { + st.ToolArgs[index] = &strings.Builder{} + } + st.ToolArgs[index].WriteString(delta.Get("partial_json").String()) + } +} + +func claudeNonStreamContentBlockStop(root gjson.Result, st *claudeToInteractionsStreamState) []byte { + index := int(root.Get("index").Int()) + stepType := st.CurrentStepByIndex[index] + builder := st.ToolArgs[index] + text := "" + if builder != nil { + text = builder.String() + } + var step []byte + switch stepType { + case "thought": + step = []byte(`{"type":"thought","content":[]}`) + content := []byte(`{"type":"text","text":""}`) + content, _ = sjson.SetBytes(content, "text", text) + step, _ = sjson.SetRawBytes(step, "content.-1", content) + case "function_call": + part := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`) + part, _ = sjson.SetBytes(part, "id", st.ToolIDs[index]) + part, _ = sjson.SetBytes(part, "name", st.ToolNames[index]) + step = claudeToolUseToInteractionsStep(gjson.ParseBytes(part), strings.TrimSpace(text)) + default: + step = []byte(`{"type":"model_output","content":[]}`) + content := []byte(`{"type":"text","text":""}`) + content, _ = sjson.SetBytes(content, "text", text) + step, _ = sjson.SetRawBytes(step, "content.-1", content) + } + delete(st.CurrentStepByIndex, index) + delete(st.ToolNames, index) + delete(st.ToolIDs, index) + delete(st.ToolArgs, index) + return step +} + +func mergeClaudeUsage(st *claudeToInteractionsStreamState, usage gjson.Result) { + if !usage.Exists() { + return + } + if len(st.UsageRaw) == 0 { + st.UsageRaw = []byte(`{}`) + } + for _, key := range []string{ + "input_tokens", + "output_tokens", + "cache_read_input_tokens", + "cache_creation_input_tokens", + "thinking_tokens", + } { + value := usage.Get(key) + if !value.Exists() { + continue + } + st.UsageRaw, _ = sjson.SetRawBytes(st.UsageRaw, key, []byte(value.Raw)) + } +} + +func claudeMergedUsage(st *claudeToInteractionsStreamState) gjson.Result { + if len(st.UsageRaw) == 0 { + return gjson.Result{} + } + return gjson.ParseBytes(st.UsageRaw) +} + +func setInteractionsUsageFromClaude(out []byte, path string, usage gjson.Result) []byte { + if !usage.Exists() { + return out + } + inputTokens := usage.Get("input_tokens").Int() + outputTokens := usage.Get("output_tokens").Int() + cacheRead := usage.Get("cache_read_input_tokens").Int() + cacheCreation := usage.Get("cache_creation_input_tokens").Int() + thinkingTokens := usage.Get("thinking_tokens").Int() + if usage.Get("input_tokens").Exists() { + out, _ = sjson.SetBytes(out, path+".input_tokens", inputTokens) + out, _ = sjson.SetBytes(out, path+".total_input_tokens", inputTokens) + } + if usage.Get("output_tokens").Exists() { + out, _ = sjson.SetBytes(out, path+".output_tokens", outputTokens) + out, _ = sjson.SetBytes(out, path+".total_output_tokens", outputTokens) + } + total := inputTokens + outputTokens + if usage.Get("input_tokens").Exists() || usage.Get("output_tokens").Exists() { + out, _ = sjson.SetBytes(out, path+".total_tokens", total) + } + if cacheRead != 0 || cacheCreation != 0 { + out, _ = sjson.SetBytes(out, path+".cached_tokens", cacheRead+cacheCreation) + out, _ = sjson.SetBytes(out, path+".total_cached_tokens", cacheRead+cacheCreation) + } + if thinkingTokens != 0 { + out, _ = sjson.SetBytes(out, path+".reasoning_tokens", thinkingTokens) + out, _ = sjson.SetBytes(out, path+".total_thought_tokens", thinkingTokens) + } + return out +} + +func appendClaudeInteractionsCreated(out [][]byte, st *claudeToInteractionsStreamState, modelName string) [][]byte { + if st.Created { + return out + } + st.ID = firstNonEmptyString(st.ID, fmt.Sprintf("interaction_%d", time.Now().UnixNano())) + created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`) + created, _ = sjson.SetBytes(created, "interaction.id", st.ID) + created, _ = sjson.SetBytes(created, "interaction.model", firstNonEmptyString(st.Model, modelName)) + out = append(out, translatorcommon.SSEEventData("interaction.created", created)) + st.Created = true + return appendClaudeInteractionsStatusUpdate(out, st) +} + +func appendClaudeInteractionsStatusUpdate(out [][]byte, st *claudeToInteractionsStreamState) [][]byte { + if st.StatusUpdated { + return out + } + statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`) + statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID) + out = append(out, translatorcommon.SSEEventData("interaction.status_update", statusUpdate)) + st.StatusUpdated = true + return out +} + +func appendClaudeInteractionsStepStart(out [][]byte, st *claudeToInteractionsStreamState, stepType string, step []byte) [][]byte { + st.ActiveStepIndex = st.StepIndex + st.ActiveStepType = stepType + st.ActiveStepOpen = true + payload := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`) + payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex) + if len(step) > 0 && gjson.ValidBytes(step) { + payload, _ = sjson.SetRawBytes(payload, "step", step) + } else { + payload, _ = sjson.SetBytes(payload, "step.type", stepType) + } + return append(out, translatorcommon.SSEEventData("step.start", payload)) +} + +func appendClaudeInteractionsTextDelta(out [][]byte, st *claudeToInteractionsStreamState, text string, thought bool) [][]byte { + payload := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`) + payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex) + if thought { + payload, _ = sjson.SetBytes(payload, "delta.type", "thought_summary") + payload, _ = sjson.SetBytes(payload, "delta.content.type", "text") + payload, _ = sjson.SetBytes(payload, "delta.content.text", text) + payload, _ = sjson.DeleteBytes(payload, "delta.text") + } else { + payload, _ = sjson.SetBytes(payload, "delta.text", text) + } + return append(out, translatorcommon.SSEEventData("step.delta", payload)) +} + +func appendClaudeInteractionsArgumentsDelta(out [][]byte, st *claudeToInteractionsStreamState, arguments string) [][]byte { + payload := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`) + payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex) + payload, _ = sjson.SetBytes(payload, "delta.arguments", arguments) + return append(out, translatorcommon.SSEEventData("step.delta", payload)) +} + +func appendClaudeInteractionsStepStop(out [][]byte, st *claudeToInteractionsStreamState) [][]byte { + if !st.ActiveStepOpen { + return out + } + payload := []byte(`{"index":0,"event_type":"step.stop"}`) + payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex) + out = append(out, translatorcommon.SSEEventData("step.stop", payload)) + st.ActiveStepOpen = false + st.ActiveStepType = "" + st.StepIndex++ + return out +} + +func appendClaudeInteractionsCompleted(out [][]byte, st *claudeToInteractionsStreamState, modelName string, root gjson.Result) [][]byte { + if st.Completed { + return out + } + out = appendClaudeInteractionsCreated(out, st, modelName) + now := time.Now().UTC().Format(time.RFC3339) + completed := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`) + completed, _ = sjson.SetBytes(completed, "interaction.id", st.ID) + completed, _ = sjson.SetBytes(completed, "interaction.created", now) + completed, _ = sjson.SetBytes(completed, "interaction.updated", now) + completed, _ = sjson.SetBytes(completed, "interaction.model", firstNonEmptyString(st.Model, modelName)) + usage := claudeMergedUsage(st) + if !usage.Exists() { + usage = root.Get("usage") + } + completed = setInteractionsUsageFromClaude(completed, "interaction.usage", usage) + out = append(out, translatorcommon.SSEEventData("interaction.completed", completed)) + st.Completed = true + return out +} + +func appendClaudeInteractionsDone(out [][]byte, st *claudeToInteractionsStreamState) [][]byte { + if st.Done { + return out + } + out = append(out, translatorcommon.SSEEventData("done", []byte("[DONE]"))) + st.Done = true + return out +} + +func claudeInteractionsSSEPayload(rawJSON []byte) []byte { + rawJSON = bytes.TrimSpace(rawJSON) + if bytes.Equal(rawJSON, []byte("[DONE]")) { + return rawJSON + } + if !bytes.HasPrefix(rawJSON, claudeInteractionsDataTag) { + return nil + } + return bytes.TrimSpace(rawJSON[len(claudeInteractionsDataTag):]) +} + +func claudeBlockInteractionsStepType(blockType string) string { + switch blockType { + case "thinking": + return "thought" + case "tool_use": + return "function_call" + default: + return "model_output" + } +} + +func claudeDeltaInteractionsStepType(deltaType string) string { + switch deltaType { + case "thinking_delta": + return "thought" + case "input_json_delta": + return "function_call" + default: + return "model_output" + } +} + +func (st *claudeToInteractionsStreamState) ensureMaps() { + if st.CurrentStepByIndex == nil { + st.CurrentStepByIndex = make(map[int]string) + } + if st.ToolNames == nil { + st.ToolNames = make(map[int]string) + } + if st.ToolIDs == nil { + st.ToolIDs = make(map[int]string) + } + if st.ToolArgs == nil { + st.ToolArgs = make(map[int]*strings.Builder) + } +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/internal/translator/claude/interactions/interactions_claude_test.go b/internal/translator/claude/interactions/interactions_claude_test.go new file mode 100644 index 000000000..f1eef5e94 --- /dev/null +++ b/internal/translator/claude/interactions/interactions_claude_test.go @@ -0,0 +1,181 @@ +package interactions + +import ( + "bytes" + "context" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertInteractionsRequestToClaudeWithToolMessagesDirect(t *testing.T) { + out := ConvertInteractionsRequestToClaude("claude-test", []byte(`{"model":"claude-test","system_instruction":"be brief","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"function_call","name":"lookup","call_id":"toolu_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"toolu_1","result":{"ok":true}}]}`), false) + if got := gjson.GetBytes(out, "system").String(); got != "be brief" { + t.Fatalf("system = %q, want be brief. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.0.content.0.text").String(); got != "hi" { + t.Fatalf("messages.0.content.0.text = %q, want hi. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.1.content.0.type").String(); got != "tool_use" { + t.Fatalf("messages.1.content.0.type = %q, want tool_use. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.2.content.0.type").String(); got != "tool_result" { + t.Fatalf("messages.2.content.0.type = %q, want tool_result. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.2.content.0.tool_use_id").String(); got != "toolu_1" { + t.Fatalf("tool_use_id = %q, want toolu_1. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToClaudeStringInputDirect(t *testing.T) { + out := ConvertInteractionsRequestToClaude("claude-test", []byte(`{"model":"claude-test","input":"hello"}`), false) + if got := gjson.GetBytes(out, "messages.0.role").String(); got != "user" { + t.Fatalf("messages.0.role = %q, want user. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.0.content.0.text").String(); got != "hello" { + t.Fatalf("messages.0.content.0.text = %q, want hello. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToClaudeMapsGenerationConfigToolsAndStreamDirect(t *testing.T) { + out := ConvertInteractionsRequestToClaude("claude-test", []byte(`{"model":"claude-test","stream":true,"input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]}],"tools":[{"type":"function","name":"lookup","description":"Lookup data","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}],"generation_config":{"max_output_tokens":99,"top_p":0.7,"stop_sequences":["END"],"tool_choice":{"type":"function","name":"lookup"},"thinking_level":"high"}}`), false) + if !gjson.GetBytes(out, "stream").Bool() { + t.Fatalf("stream should be true when request body asks for stream. Output: %s", string(out)) + } + if got := gjson.GetBytes(out, "max_tokens").Int(); got != 99 { + t.Fatalf("max_tokens = %d, want 99. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.input_schema.properties.q.type").String(); got != "string" { + t.Fatalf("tool schema type = %q, want string. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tool_choice.name").String(); got != "lookup" { + t.Fatalf("tool_choice.name = %q, want lookup. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "thinking.type").String(); got == "" { + t.Fatalf("thinking config was not mapped. Output: %s", string(out)) + } +} + +func TestConvertInteractionsRequestToClaudeAcceptsImageContent(t *testing.T) { + out := ConvertInteractionsRequestToClaude("claude-test", []byte(`{"model":"claude-test","input":[{"type":"user_input","content":[{"type":"image","mime_type":"image/png","data":"aGVsbG8="}]}]}`), false) + if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "image" { + t.Fatalf("content type = %q, want image. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.0.content.0.source.media_type").String(); got != "image/png" { + t.Fatalf("media_type = %q, want image/png. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.0.content.0.source.data").String(); got != "aGVsbG8=" { + t.Fatalf("data = %q, want aGVsbG8=. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToClaudePreservesNonImageMediaContent(t *testing.T) { + out := ConvertInteractionsRequestToClaude("claude-test", []byte(`{"model":"claude-test","input":[{"type":"thought","content":[{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="},{"type":"video","mime_type":"video/mp4","data":"AAAAIGZ0eXA="},{"type":"document","mime_type":"application/pdf","data":"JVBERi0="}]}]}`), false) + + if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" { + t.Fatalf("messages.0.role = %q, want assistant. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "text" { + t.Fatalf("audio fallback type = %q, want text. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.0.content.1.type").String(); got != "text" { + t.Fatalf("video fallback type = %q, want text. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.0.content.2.type").String(); got != "document" { + t.Fatalf("document content type = %q, want document. Output: %s", got, string(out)) + } + if gjson.GetBytes(out, "messages.0.content.#(type==\"image\")").Exists() { + t.Fatalf("non-image media must not be converted to image. Output: %s", string(out)) + } +} + +func TestConvertClaudeResponseToInteractionsNonStream(t *testing.T) { + raw := []byte(`{"id":"msg_1","model":"claude-test","content":[{"type":"thinking","thinking":"reasoning"},{"type":"text","text":"ok"},{"type":"tool_use","id":"toolu_1","name":"lookup","input":{"q":"x"}}],"usage":{"input_tokens":3,"output_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":4,"thinking_tokens":5}}`) + out := ConvertClaudeResponseToInteractionsNonStream(context.Background(), "claude-test", nil, nil, raw, nil) + if got := gjson.GetBytes(out, "steps.0.type").String(); got != "thought" { + t.Fatalf("steps.0.type = %q, want thought. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "steps.1.content.0.text").String(); got != "ok" { + t.Fatalf("steps.1.content.0.text = %q, want ok. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "steps.2.call_id").String(); got != "toolu_1" { + t.Fatalf("steps.2.call_id = %q, want toolu_1. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 5 { + t.Fatalf("usage.total_tokens = %d, want 5. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "usage.total_cached_tokens").Int(); got != 5 { + t.Fatalf("usage.total_cached_tokens = %d, want 5. Output: %s", got, string(out)) + } +} + +func TestConvertClaudeSSEToInteractionsNonStream(t *testing.T) { + raw := []byte(`data: {"type":"message_start","message":{"id":"msg_1","model":"claude-test","usage":{"input_tokens":3,"output_tokens":0}}} +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}} +data: {"type":"content_block_stop","index":0} +data: {"type":"message_delta","usage":{"output_tokens":2}}`) + out := ConvertClaudeResponseToInteractionsNonStream(context.Background(), "claude-test", nil, nil, raw, nil) + if got := gjson.GetBytes(out, "steps.0.content.0.text").String(); got != "ok" { + t.Fatalf("steps.0.content.0.text = %q, want ok. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 5 { + t.Fatalf("usage.total_tokens = %d, want 5. Output: %s", got, string(out)) + } +} + +func TestConvertClaudeResponseToInteractionsStreamMergesUsageAndStatus(t *testing.T) { + var param any + var events [][]byte + for _, raw := range [][]byte{ + []byte(`data: {"type":"message_start","message":{"id":"msg_1","model":"claude-test","usage":{"input_tokens":3,"output_tokens":0}}}`), + []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`), + []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + []byte(`data: {"type":"message_delta","usage":{"output_tokens":2}}`), + } { + events = append(events, ConvertClaudeResponseToInteractions(context.Background(), "claude-test", nil, nil, raw, ¶m)...) + } + if payload := findClaudeInteractionsEventPayload(events, "interaction.status_update"); len(payload) == 0 { + t.Fatalf("interaction.status_update event not found: %q", events) + } + payload := findClaudeInteractionsEventPayload(events, "interaction.completed") + if got := gjson.GetBytes(payload, "interaction.usage.total_input_tokens").Int(); got != 3 { + t.Fatalf("total_input_tokens = %d, want 3. Payload: %s", got, string(payload)) + } + if got := gjson.GetBytes(payload, "interaction.usage.total_output_tokens").Int(); got != 2 { + t.Fatalf("total_output_tokens = %d, want 2. Payload: %s", got, string(payload)) + } + if got := gjson.GetBytes(payload, "interaction.usage.total_tokens").Int(); got != 5 { + t.Fatalf("total_tokens = %d, want 5. Payload: %s", got, string(payload)) + } +} + +func TestConvertClaudeResponseToInteractionsStream(t *testing.T) { + var param any + events := ConvertClaudeResponseToInteractions(context.Background(), "claude-test", nil, nil, []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`), ¶m) + payload := findClaudeInteractionsEventPayload(events, "step.delta") + if len(payload) == 0 { + t.Fatalf("step.delta event not found: %q", events) + } + if got := gjson.GetBytes(payload, "delta.text").String(); got != "ok" { + t.Fatalf("delta.text = %q, want ok. Payload: %s", got, string(payload)) + } +} + +func findClaudeInteractionsEventPayload(events [][]byte, eventType string) []byte { + prefix := []byte("data:") + for _, event := range events { + for _, line := range bytes.Split(event, []byte("\n")) { + line = bytes.TrimSpace(line) + if !bytes.HasPrefix(line, prefix) { + continue + } + payload := bytes.TrimSpace(line[len(prefix):]) + if gjson.GetBytes(payload, "event_type").String() == eventType || gjson.GetBytes(payload, "type").String() == eventType { + return payload + } + } + } + return nil +} diff --git a/internal/translator/codex/gemini/codex_gemini_request.go b/internal/translator/codex/gemini/codex_gemini_request.go index 03a862ba0..d72a5f6fa 100644 --- a/internal/translator/codex/gemini/codex_gemini_request.go +++ b/internal/translator/codex/gemini/codex_gemini_request.go @@ -102,6 +102,9 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // Model out, _ = sjson.SetBytes(out, "model", modelName) + if serviceTier := normalizeGeminiCodexServiceTier(root.Get("service_tier")); serviceTier != "" { + out, _ = sjson.SetBytes(out, "service_tier", serviceTier) + } // System instruction -> as a user message with input_text parts sysParts := root.Get("system_instruction.parts") @@ -159,6 +162,22 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) continue } + if contentPart, ok := codexContentPartFromGeminiInlineData(p); ok { + msg := []byte(`{"type":"message","role":"","content":[]}`) + msg, _ = sjson.SetBytes(msg, "role", role) + msg, _ = sjson.SetRawBytes(msg, "content.-1", contentPart) + out, _ = sjson.SetRawBytes(out, "input.-1", msg) + continue + } + + if contentPart, ok := codexContentPartFromGeminiFileData(p); ok { + msg := []byte(`{"type":"message","role":"","content":[]}`) + msg, _ = sjson.SetBytes(msg, "role", role) + msg, _ = sjson.SetRawBytes(msg, "content.-1", contentPart) + out, _ = sjson.SetRawBytes(out, "input.-1", msg) + continue + } + // function call from model if fc := p.Get("functionCall"); fc.Exists() { fn := []byte(`{"type":"function_call"}`) @@ -266,12 +285,23 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) // Fixed flags aligning with Codex expectations out, _ = sjson.SetBytes(out, "parallel_tool_calls", true) + out = setCodexToolChoiceFromGeminiToolConfig(out, root.Get("toolConfig.functionCallingConfig")) // Convert Gemini thinkingConfig to Codex reasoning.effort. // Note: Google official Python SDK sends snake_case fields (thinking_level/thinking_budget). effortSet := false if genConfig := root.Get("generationConfig"); genConfig.Exists() { - if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() { + thinkingLevel := genConfig.Get("thinkingLevel") + if !thinkingLevel.Exists() { + thinkingLevel = genConfig.Get("thinking_level") + } + if thinkingLevel.Exists() { + effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String())) + if effort != "" { + out, _ = sjson.SetBytes(out, "reasoning.effort", effort) + effortSet = true + } + } else if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() { thinkingLevel := thinkingConfig.Get("thinkingLevel") if !thinkingLevel.Exists() { thinkingLevel = thinkingConfig.Get("thinking_level") @@ -320,6 +350,150 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) return out } +func setCodexToolChoiceFromGeminiToolConfig(out []byte, functionCallingConfig gjson.Result) []byte { + if !functionCallingConfig.Exists() { + return out + } + mode := functionCallingConfig.Get("mode").String() + switch mode { + case "NONE": + out, _ = sjson.SetBytes(out, "tool_choice", "none") + case "AUTO": + out, _ = sjson.SetBytes(out, "tool_choice", "auto") + case "ANY": + allowedNames := functionCallingConfig.Get("allowedFunctionNames") + if allowedNames.IsArray() && len(allowedNames.Array()) == 1 { + choice := []byte(`{"type":"function","name":""}`) + choice, _ = sjson.SetBytes(choice, "name", shortenNameIfNeeded(allowedNames.Array()[0].String())) + out, _ = sjson.SetRawBytes(out, "tool_choice", choice) + } else { + out, _ = sjson.SetBytes(out, "tool_choice", "required") + } + } + return out +} + +func normalizeGeminiCodexServiceTier(serviceTier gjson.Result) string { + if !serviceTier.Exists() || serviceTier.Type != gjson.String { + return "" + } + switch strings.ToLower(strings.TrimSpace(serviceTier.String())) { + case "priority", "fast": + return "priority" + } + return "" +} + +func codexContentPartFromGeminiInlineData(part gjson.Result) ([]byte, bool) { + inlineData := part.Get("inlineData") + if !inlineData.Exists() { + inlineData = part.Get("inline_data") + } + if !inlineData.Exists() { + return nil, false + } + mimeType := inlineData.Get("mimeType").String() + if mimeType == "" { + mimeType = inlineData.Get("mime_type").String() + } + data := inlineData.Get("data").String() + if mimeType == "" || data == "" { + return nil, false + } + lowerMimeType := strings.ToLower(mimeType) + switch { + case strings.HasPrefix(lowerMimeType, "image/"): + contentPart := []byte(`{"type":"input_image","image_url":""}`) + contentPart, _ = sjson.SetBytes(contentPart, "image_url", fmt.Sprintf("data:%s;base64,%s", mimeType, data)) + return contentPart, true + case strings.HasPrefix(lowerMimeType, "audio/"): + contentPart := []byte(`{"type":"input_audio","input_audio":{"data":"","format":""}}`) + contentPart, _ = sjson.SetBytes(contentPart, "input_audio.data", data) + contentPart, _ = sjson.SetBytes(contentPart, "input_audio.format", codexInputAudioFormatFromMIME(mimeType)) + return contentPart, true + default: + contentPart := []byte(`{"type":"input_file","file_data":"","filename":""}`) + contentPart, _ = sjson.SetBytes(contentPart, "file_data", data) + contentPart, _ = sjson.SetBytes(contentPart, "filename", codexFileNameFromMIME(mimeType)) + return contentPart, true + } +} + +func codexContentPartFromGeminiFileData(part gjson.Result) ([]byte, bool) { + fileData := part.Get("fileData") + if !fileData.Exists() { + fileData = part.Get("file_data") + } + if !fileData.Exists() { + return nil, false + } + fileURI := fileData.Get("fileUri").String() + if fileURI == "" { + fileURI = fileData.Get("file_uri").String() + } + if fileURI == "" { + return nil, false + } + mimeType := fileData.Get("mimeType").String() + if mimeType == "" { + mimeType = fileData.Get("mime_type").String() + } + lowerMimeType := strings.ToLower(mimeType) + if strings.HasPrefix(lowerMimeType, "image/") { + contentPart := []byte(`{"type":"input_image","image_url":""}`) + contentPart, _ = sjson.SetBytes(contentPart, "image_url", fileURI) + return contentPart, true + } + if strings.HasPrefix(lowerMimeType, "video/") || strings.HasPrefix(lowerMimeType, "application/") || strings.HasPrefix(lowerMimeType, "text/") { + contentPart := []byte(`{"type":"input_file","file_url":"","filename":""}`) + contentPart, _ = sjson.SetBytes(contentPart, "file_url", fileURI) + contentPart, _ = sjson.SetBytes(contentPart, "filename", codexFileNameFromMIME(mimeType)) + return contentPart, true + } + fileInfo := "File: " + fileURI + if mimeType != "" { + fileInfo += " (Type: " + mimeType + ")" + } + contentPart := []byte(`{"type":"input_text","text":""}`) + contentPart, _ = sjson.SetBytes(contentPart, "text", fileInfo) + return contentPart, true +} + +func codexInputAudioFormatFromMIME(mimeType string) string { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "audio/wav", "audio/wave", "audio/x-wav": + return "wav" + case "audio/flac": + return "flac" + case "audio/opus", "audio/ogg": + return "opus" + case "audio/pcm", "audio/l16": + return "pcm16" + default: + return "mp3" + } +} + +func codexFileNameFromMIME(mimeType string) string { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "application/pdf": + return "document.pdf" + case "text/plain": + return "document.txt" + case "text/csv": + return "document.csv" + case "application/json": + return "document.json" + case "application/xml", "text/xml": + return "document.xml" + default: + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(mimeType)), "video/") { + return "video" + } + return "document" + } +} + // shortenNameIfNeeded applies the simple shortening rule for a single name. func shortenNameIfNeeded(name string) string { const limit = 64 diff --git a/internal/translator/codex/gemini/codex_gemini_request_test.go b/internal/translator/codex/gemini/codex_gemini_request_test.go index a98cdba4d..3dc0db4da 100644 --- a/internal/translator/codex/gemini/codex_gemini_request_test.go +++ b/internal/translator/codex/gemini/codex_gemini_request_test.go @@ -61,3 +61,27 @@ func TestConvertGeminiRequestToCodex_PreservesCustomCallIDs(t *testing.T) { }) } } + +func TestConvertGeminiRequestToCodex_AcceptsInlineData(t *testing.T) { + out := ConvertGeminiRequestToCodex("gpt-5.1-codex", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}]}`), false) + if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "input_image" { + t.Fatalf("content type = %q, want input_image. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.0.content.0.image_url").String(); got != "data:image/png;base64,aGVsbG8=" { + t.Fatalf("image_url = %q, want data:image/png;base64,aGVsbG8=. Output: %s", got, string(out)) + } +} + +func TestConvertGeminiRequestToCodex_SplitsNonImageInlineDataByMIME(t *testing.T) { + out := ConvertGeminiRequestToCodex("gpt-5.1-codex", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"audio/wav","data":"UklGRg=="}},{"inlineData":{"mimeType":"video/mp4","data":"AAAAIGZ0eXA="}},{"inlineData":{"mimeType":"application/pdf","data":"JVBERi0="}}]}]}`), false) + + if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "input_audio" { + t.Fatalf("audio content type = %q, want input_audio. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.1.content.0.type").String(); got != "input_file" { + t.Fatalf("video content type = %q, want input_file. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.2.content.0.type").String(); got != "input_file" { + t.Fatalf("document content type = %q, want input_file. Output: %s", got, string(out)) + } +} diff --git a/internal/translator/codex/interactions/init.go b/internal/translator/codex/interactions/init.go new file mode 100644 index 000000000..af9bc0ef4 --- /dev/null +++ b/internal/translator/codex/interactions/init.go @@ -0,0 +1,19 @@ +package interactions + +import ( + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" +) + +func init() { + translator.Register( + Interactions, + Codex, + ConvertInteractionsRequestToCodex, + interfaces.TranslateResponse{ + Stream: ConvertCodexResponseToInteractions, + NonStream: ConvertCodexResponseToInteractionsNonStream, + }, + ) +} diff --git a/internal/translator/codex/interactions/interactions_codex_request.go b/internal/translator/codex/interactions/interactions_codex_request.go new file mode 100644 index 000000000..fee429e93 --- /dev/null +++ b/internal/translator/codex/interactions/interactions_codex_request.go @@ -0,0 +1,717 @@ +package interactions + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func ConvertInteractionsRequestToCodex(modelName string, inputRawJSON []byte, stream bool) []byte { + root := gjson.ParseBytes(inputRawJSON) + out := []byte(`{"model":"","instructions":"","input":[]}`) + out, _ = sjson.SetBytes(out, "model", modelName) + if stream || root.Get("stream").Bool() { + out, _ = sjson.SetBytes(out, "stream", true) + } + out = copyInteractionsSystemToCodex(out, root) + out = copyInteractionsGenerationConfigToCodex(out, root) + out = appendInteractionsInputToCodex(out, root.Get("input")) + out = copyInteractionsToolsToCodex(out, root) + out = copyInteractionsCodexTopLevel(out, root) + return out +} + +func copyInteractionsSystemToCodex(out []byte, root gjson.Result) []byte { + systemInstruction := root.Get("system_instruction") + if !systemInstruction.Exists() { + systemInstruction = root.Get("systemInstruction") + } + if !systemInstruction.Exists() { + return out + } + if systemInstruction.Type == gjson.String { + out, _ = sjson.SetBytes(out, "instructions", systemInstruction.String()) + return out + } + if text := systemInstruction.Get("text"); text.Exists() && text.Type == gjson.String { + out, _ = sjson.SetBytes(out, "instructions", text.String()) + return out + } + if parts := systemInstruction.Get("parts"); parts.Exists() && parts.IsArray() { + var builder strings.Builder + parts.ForEach(func(_, part gjson.Result) bool { + text := part.Get("text").String() + if text == "" { + return true + } + if builder.Len() > 0 { + builder.WriteByte('\n') + } + builder.WriteString(text) + return true + }) + if builder.Len() > 0 { + out, _ = sjson.SetBytes(out, "instructions", builder.String()) + } + } + return out +} + +func copyInteractionsGenerationConfigToCodex(out []byte, root gjson.Result) []byte { + cfg := root.Get("generation_config") + if !cfg.Exists() { + cfg = root.Get("generationConfig") + } + if !cfg.Exists() { + if reasoning := root.Get("reasoning"); reasoning.Exists() { + out, _ = sjson.SetRawBytes(out, "reasoning", []byte(reasoning.Raw)) + } + return out + } + if reasoning := cfg.Get("reasoning"); reasoning.Exists() { + out, _ = sjson.SetRawBytes(out, "reasoning", []byte(reasoning.Raw)) + } + if effort := interactionsCodexReasoningEffort(cfg); effort != "" { + out, _ = sjson.SetBytes(out, "reasoning.effort", effort) + } + if summary := interactionsCodexReasoningSummary(cfg); summary != "" { + out, _ = sjson.SetBytes(out, "reasoning.summary", summary) + } + copyRawPaths := map[string]string{ + "max_output_tokens": "max_output_tokens", + "maxOutputTokens": "max_output_tokens", + "max_tokens": "max_output_tokens", + "temperature": "temperature", + "top_p": "top_p", + "topP": "top_p", + "presence_penalty": "presence_penalty", + "presencePenalty": "presence_penalty", + "frequency_penalty": "frequency_penalty", + "frequencyPenalty": "frequency_penalty", + "parallel_tool_calls": "parallel_tool_calls", + "parallelToolCalls": "parallel_tool_calls", + "response_format": "response_format", + "responseFormat": "response_format", + "text": "text", + "verbosity": "text.verbosity", + "truncation": "truncation", + "tool_choice": "tool_choice", + "toolChoice": "tool_choice", + "service_tier": "service_tier", + "serviceTier": "service_tier", + } + for sourcePath, targetPath := range copyRawPaths { + if value := cfg.Get(sourcePath); value.Exists() { + out, _ = sjson.SetRawBytes(out, targetPath, []byte(value.Raw)) + } + } + return out +} + +func interactionsCodexReasoningEffort(cfg gjson.Result) string { + for _, path := range []string{ + "thinking_level", + "thinkingLevel", + "thinking_config.thinking_level", + "thinking_config.thinkingLevel", + "thinkingConfig.thinking_level", + "thinkingConfig.thinkingLevel", + "reasoning.effort", + } { + if value := cfg.Get(path); value.Exists() { + effort := strings.ToLower(strings.TrimSpace(value.String())) + if effort != "" { + return effort + } + } + } + for _, path := range []string{ + "thinking_budget", + "thinkingBudget", + "thinking_config.thinking_budget", + "thinking_config.thinkingBudget", + "thinkingConfig.thinking_budget", + "thinkingConfig.thinkingBudget", + } { + if value := cfg.Get(path); value.Exists() { + if effort, ok := thinking.ConvertBudgetToLevel(int(value.Int())); ok { + return effort + } + } + } + return "" +} + +func interactionsCodexReasoningSummary(cfg gjson.Result) string { + for _, path := range []string{ + "thinking_summaries", + "thinkingSummaries", + "reasoning.summary", + } { + if value := cfg.Get(path); value.Exists() { + switch value.Type { + case gjson.True: + return "auto" + case gjson.False: + return "none" + case gjson.String: + summary := strings.ToLower(strings.TrimSpace(value.String())) + if summary != "" { + return summary + } + } + } + } + for _, path := range []string{ + "include_thoughts", + "includeThoughts", + "thinking_config.include_thoughts", + "thinking_config.includeThoughts", + "thinkingConfig.include_thoughts", + "thinkingConfig.includeThoughts", + } { + if value := cfg.Get(path); value.Exists() { + if value.Bool() { + return "auto" + } + return "none" + } + } + return "" +} + +func appendInteractionsInputToCodex(out []byte, input gjson.Result) []byte { + if !input.Exists() { + return out + } + if input.Type == gjson.String { + return appendInteractionsTextToCodex(out, "user", input.String()) + } + if input.IsArray() { + input.ForEach(func(_, step gjson.Result) bool { + out = appendInteractionsStepToCodex(out, step, "user") + return true + }) + return out + } + if steps := input.Get("steps"); steps.Exists() && steps.IsArray() { + defaultRole := interactionsCodexDefaultRole(input.Get("role").String(), "user") + steps.ForEach(func(_, step gjson.Result) bool { + out = appendInteractionsStepToCodex(out, step, defaultRole) + return true + }) + return out + } + return appendInteractionsStepToCodex(out, input, "user") +} + +func appendInteractionsStepToCodex(out []byte, step gjson.Result, defaultRole string) []byte { + if step.Type == gjson.String { + return appendInteractionsTextToCodex(out, defaultRole, step.String()) + } + if steps := step.Get("steps"); steps.Exists() && steps.IsArray() { + role := interactionsCodexDefaultRole(step.Get("role").String(), defaultRole) + steps.ForEach(func(_, nested gjson.Result) bool { + out = appendInteractionsStepToCodex(out, nested, role) + return true + }) + return out + } + stepType := strings.ToLower(strings.TrimSpace(step.Get("type").String())) + switch stepType { + case "function_call": + return appendInteractionsFunctionCallToCodex(out, step) + case "function_result", "function_call_output": + return appendInteractionsFunctionResultToCodex(out, step) + case "model_output", "assistant": + return appendInteractionsContentToCodexItem(out, step.Get("content"), "assistant") + case "thought", "reasoning": + return appendInteractionsThoughtToCodex(out, step) + case "user_input", "message", "": + role := interactionsCodexDefaultRole(step.Get("role").String(), defaultRole) + if content := step.Get("content"); content.Exists() { + return appendInteractionsContentToCodexItem(out, content, role) + } + if text := step.Get("text"); text.Exists() { + return appendInteractionsTextToCodex(out, role, text.String()) + } + default: + role := interactionsCodexDefaultRole(step.Get("role").String(), defaultRole) + if content := step.Get("content"); content.Exists() { + return appendInteractionsContentToCodexItem(out, content, role) + } + if text := step.Get("text"); text.Exists() { + return appendInteractionsTextToCodex(out, role, text.String()) + } + } + return out +} + +func appendInteractionsContentToCodexItem(out []byte, content gjson.Result, role string) []byte { + if !content.Exists() { + return out + } + if content.Type == gjson.String { + return appendInteractionsTextToCodex(out, role, content.String()) + } + if content.IsArray() { + content.ForEach(func(_, part gjson.Result) bool { + item := interactionsCodexMessagePart(part, role) + if len(item) > 0 { + out = appendInteractionsMessagePartToCodex(out, role, item) + } + return true + }) + return out + } + if content.IsObject() { + if item := interactionsCodexMessagePart(content, role); len(item) > 0 { + return appendInteractionsMessagePartToCodex(out, role, item) + } + } + return out +} + +func appendInteractionsFunctionCallToCodex(out []byte, step gjson.Result) []byte { + item := []byte(`{"type":"function_call"}`) + if name := step.Get("name"); name.Exists() { + item, _ = sjson.SetBytes(item, "name", shortenCodexToolNameIfNeeded(name.String())) + } + if callID := interactionsCodexCallID(step); callID != "" { + item, _ = sjson.SetBytes(item, "call_id", callID) + } + if args := step.Get("arguments"); args.Exists() { + item, _ = sjson.SetBytes(item, "arguments", interactionsCodexJSONString(args)) + } else if args := step.Get("args"); args.Exists() { + item, _ = sjson.SetBytes(item, "arguments", interactionsCodexJSONString(args)) + } + out, _ = sjson.SetRawBytes(out, "input.-1", item) + return out +} + +func appendInteractionsFunctionResultToCodex(out []byte, step gjson.Result) []byte { + item := []byte(`{"type":"function_call_output"}`) + if callID := interactionsCodexCallID(step); callID != "" { + item, _ = sjson.SetBytes(item, "call_id", callID) + } + if result := step.Get("result"); result.Exists() { + item, _ = sjson.SetBytes(item, "output", interactionsCodexOutputString(result)) + } else if output := step.Get("output"); output.Exists() { + item, _ = sjson.SetBytes(item, "output", interactionsCodexOutputString(output)) + } + out, _ = sjson.SetRawBytes(out, "input.-1", item) + return out +} + +func copyInteractionsToolsToCodex(out []byte, root gjson.Result) []byte { + tools := root.Get("tools") + if !tools.Exists() { + return out + } + if !tools.IsArray() { + out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw)) + return out + } + normalized := make([]map[string]any, 0) + tools.ForEach(func(_, tool gjson.Result) bool { + if decls := tool.Get("function_declarations"); decls.Exists() { + appendCodexToolDeclarations(&normalized, decls) + return true + } + if decls := tool.Get("functionDeclarations"); decls.Exists() { + appendCodexToolDeclarations(&normalized, decls) + return true + } + if name := tool.Get("name"); name.Exists() { + normalized = append(normalized, codexToolFromDeclaration(tool)) + } + return true + }) + if len(normalized) == 0 { + out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw)) + return out + } + raw, errMarshal := json.Marshal(normalized) + if errMarshal != nil { + out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw)) + return out + } + out, _ = sjson.SetRawBytes(out, "tools", raw) + if !gjson.GetBytes(out, "tool_choice").Exists() { + out, _ = sjson.SetBytes(out, "tool_choice", "auto") + } + return out +} + +func copyInteractionsCodexTopLevel(out []byte, root gjson.Result) []byte { + if serviceTier := normalizeInteractionsCodexServiceTier(root.Get("service_tier")); serviceTier != "" { + out, _ = sjson.SetBytes(out, "service_tier", serviceTier) + } + if toolChoice := root.Get("tool_choice"); toolChoice.Exists() { + out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(toolChoice.Raw)) + } + for _, path := range []string{"parallel_tool_calls", "store", "metadata", "include", "truncation"} { + if value := root.Get(path); value.Exists() { + out, _ = sjson.SetRawBytes(out, path, []byte(value.Raw)) + } + } + return out +} + +func appendInteractionsThoughtToCodex(out []byte, step gjson.Result) []byte { + text := interactionsCodexContentText(step.Get("content")) + if text == "" { + text = step.Get("text").String() + } + item := []byte(`{"type":"reasoning"}`) + if text != "" { + item, _ = sjson.SetBytes(item, "content", text) + } + if id := step.Get("id"); id.Exists() { + item, _ = sjson.SetBytes(item, "id", id.String()) + } + out, _ = sjson.SetRawBytes(out, "input.-1", item) + return out +} + +func appendInteractionsTextToCodex(out []byte, role, text string) []byte { + part := []byte(`{"type":"","text":""}`) + if role == "assistant" { + part, _ = sjson.SetBytes(part, "type", "output_text") + } else { + part, _ = sjson.SetBytes(part, "type", "input_text") + } + part, _ = sjson.SetBytes(part, "text", text) + return appendInteractionsMessagePartToCodex(out, role, part) +} + +func appendInteractionsMessagePartToCodex(out []byte, role string, part []byte) []byte { + message := []byte(`{"type":"message","role":"","content":[]}`) + message, _ = sjson.SetBytes(message, "role", role) + message, _ = sjson.SetRawBytes(message, "content.-1", part) + out, _ = sjson.SetRawBytes(out, "input.-1", message) + return out +} + +func interactionsCodexMessagePart(part gjson.Result, role string) []byte { + if text := part.Get("text"); text.Exists() { + item := []byte(`{"type":"","text":""}`) + if role == "assistant" { + item, _ = sjson.SetBytes(item, "type", "output_text") + } else { + item, _ = sjson.SetBytes(item, "type", "input_text") + } + item, _ = sjson.SetBytes(item, "text", text.String()) + return item + } + partType := strings.ToLower(strings.TrimSpace(part.Get("type").String())) + switch partType { + case "text", "": + return nil + case "image": + return interactionsCodexImagePart(part) + case "image_url": + item := []byte(`{"type":"input_image","image_url":""}`) + item, _ = sjson.SetBytes(item, "image_url", part.Get("image_url.url").String()) + return item + case "audio": + return interactionsCodexAudioPart(part) + case "input_audio": + item := []byte(`{"type":"input_audio","input_audio":{}}`) + if audio := part.Get("input_audio"); audio.Exists() { + item, _ = sjson.SetRawBytes(item, "input_audio", []byte(audio.Raw)) + } + return item + case "video", "document", "file": + return interactionsCodexFilePart(part) + default: + if inline := part.Get("inline_data"); inline.Exists() { + return interactionsCodexInlinePart(inline) + } + if inline := part.Get("inlineData"); inline.Exists() { + return interactionsCodexInlinePart(inline) + } + if file := part.Get("file_data"); file.Exists() { + return interactionsCodexFileDataPart(file) + } + if file := part.Get("fileData"); file.Exists() { + return interactionsCodexFileDataPart(file) + } + } + return nil +} + +func interactionsCodexImagePart(part gjson.Result) []byte { + if url := part.Get("url"); url.Exists() { + item := []byte(`{"type":"input_image","image_url":""}`) + item, _ = sjson.SetBytes(item, "image_url", url.String()) + return item + } + if fileURI := firstString(part, "file_uri", "fileUri"); fileURI != "" { + item := []byte(`{"type":"input_image","image_url":""}`) + item, _ = sjson.SetBytes(item, "image_url", fileURI) + return item + } + mimeType := firstString(part, "mime_type", "mimeType") + data := part.Get("data").String() + if mimeType == "" || data == "" { + return nil + } + item := []byte(`{"type":"input_image","image_url":""}`) + item, _ = sjson.SetBytes(item, "image_url", fmt.Sprintf("data:%s;base64,%s", mimeType, data)) + return item +} + +func interactionsCodexAudioPart(part gjson.Result) []byte { + mimeType := firstString(part, "mime_type", "mimeType") + data := part.Get("data").String() + if mimeType == "" || data == "" { + return nil + } + item := []byte(`{"type":"input_audio","input_audio":{"data":"","format":""}}`) + item, _ = sjson.SetBytes(item, "input_audio.data", data) + item, _ = sjson.SetBytes(item, "input_audio.format", codexInputAudioFormatFromMIME(mimeType)) + return item +} + +func interactionsCodexFilePart(part gjson.Result) []byte { + if fileData := part.Get("file.file_data").String(); fileData != "" { + item := []byte(`{"type":"input_file","file_data":"","filename":""}`) + item, _ = sjson.SetBytes(item, "file_data", fileData) + item, _ = sjson.SetBytes(item, "filename", part.Get("file.filename").String()) + return item + } + mimeType := firstString(part, "mime_type", "mimeType") + if fileURI := firstString(part, "file_uri", "fileUri", "url"); fileURI != "" { + item := []byte(`{"type":"input_file","file_url":"","filename":""}`) + item, _ = sjson.SetBytes(item, "file_url", fileURI) + item, _ = sjson.SetBytes(item, "filename", codexFileNameFromMIME(mimeType)) + return item + } + data := part.Get("data").String() + if mimeType == "" || data == "" { + return nil + } + item := []byte(`{"type":"input_file","file_data":"","filename":""}`) + item, _ = sjson.SetBytes(item, "file_data", data) + item, _ = sjson.SetBytes(item, "filename", codexFileNameFromMIME(mimeType)) + return item +} + +func interactionsCodexInlinePart(inline gjson.Result) []byte { + mimeType := firstString(inline, "mime_type", "mimeType") + data := inline.Get("data").String() + if mimeType == "" || data == "" { + return nil + } + switch { + case strings.HasPrefix(strings.ToLower(mimeType), "image/"): + return interactionsCodexImagePart(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data))) + case strings.HasPrefix(strings.ToLower(mimeType), "audio/"): + return interactionsCodexAudioPart(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data))) + default: + return interactionsCodexFilePart(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data))) + } +} + +func interactionsCodexFileDataPart(fileData gjson.Result) []byte { + mimeType := firstString(fileData, "mime_type", "mimeType") + fileURI := firstString(fileData, "file_uri", "fileUri") + if fileURI == "" { + return nil + } + if strings.HasPrefix(strings.ToLower(mimeType), "image/") { + item := []byte(`{"type":"input_image","image_url":""}`) + item, _ = sjson.SetBytes(item, "image_url", fileURI) + return item + } + item := []byte(`{"type":"input_file","file_url":"","filename":""}`) + item, _ = sjson.SetBytes(item, "file_url", fileURI) + item, _ = sjson.SetBytes(item, "filename", codexFileNameFromMIME(mimeType)) + return item +} + +func appendCodexToolDeclarations(normalized *[]map[string]any, declarations gjson.Result) { + if !declarations.IsArray() { + return + } + declarations.ForEach(func(_, declaration gjson.Result) bool { + if declaration.Get("name").Exists() { + *normalized = append(*normalized, codexToolFromDeclaration(declaration)) + } + return true + }) +} + +func codexToolFromDeclaration(declaration gjson.Result) map[string]any { + tool := map[string]any{ + "type": "function", + "name": shortenCodexToolNameIfNeeded(declaration.Get("name").String()), + "strict": false, + } + if desc := declaration.Get("description"); desc.Exists() { + tool["description"] = desc.String() + } + if params := declaration.Get("parameters"); params.Exists() { + tool["parameters"] = cleanedCodexToolParameters(params) + } else if params := declaration.Get("parametersJsonSchema"); params.Exists() { + tool["parameters"] = cleanedCodexToolParameters(params) + } else if params := declaration.Get("parameters_json_schema"); params.Exists() { + tool["parameters"] = cleanedCodexToolParameters(params) + } + return tool +} + +func cleanedCodexToolParameters(params gjson.Result) json.RawMessage { + cleaned := []byte(params.Raw) + cleaned, _ = sjson.DeleteBytes(cleaned, "$schema") + cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false) + return json.RawMessage(cleaned) +} + +func interactionsCodexContentText(content gjson.Result) string { + if !content.Exists() { + return "" + } + if content.Type == gjson.String { + return content.String() + } + if content.IsObject() { + return content.Get("text").String() + } + if content.IsArray() { + var builder strings.Builder + content.ForEach(func(_, part gjson.Result) bool { + text := part.Get("text").String() + if text == "" { + return true + } + if builder.Len() > 0 { + builder.WriteByte('\n') + } + builder.WriteString(text) + return true + }) + return builder.String() + } + return "" +} + +func interactionsCodexCallID(step gjson.Result) string { + if callID := strings.TrimSpace(step.Get("call_id").String()); callID != "" { + return callID + } + return strings.TrimSpace(step.Get("id").String()) +} + +func interactionsCodexJSONString(value gjson.Result) string { + if value.Type == gjson.String { + return value.String() + } + if value.Exists() { + return value.Raw + } + return "{}" +} + +func interactionsCodexOutputString(value gjson.Result) string { + if value.Type == gjson.String { + return value.String() + } + if value.Exists() { + return value.Raw + } + return "" +} + +func interactionsCodexDefaultRole(role, fallback string) string { + switch strings.ToLower(strings.TrimSpace(role)) { + case "model", "assistant": + return "assistant" + case "developer", "system": + return "developer" + case "user": + return "user" + } + if fallback == "assistant" || fallback == "developer" { + return fallback + } + return "user" +} + +func normalizeInteractionsCodexServiceTier(serviceTier gjson.Result) string { + if !serviceTier.Exists() || serviceTier.Type != gjson.String { + return "" + } + switch strings.ToLower(strings.TrimSpace(serviceTier.String())) { + case "priority", "fast": + return "priority" + } + return "" +} + +func codexInputAudioFormatFromMIME(mimeType string) string { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "audio/wav", "audio/wave", "audio/x-wav": + return "wav" + case "audio/flac": + return "flac" + case "audio/opus", "audio/ogg": + return "opus" + case "audio/pcm", "audio/l16": + return "pcm16" + default: + return "mp3" + } +} + +func codexFileNameFromMIME(mimeType string) string { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "application/pdf": + return "document.pdf" + case "text/plain": + return "document.txt" + case "text/csv": + return "document.csv" + case "application/json": + return "document.json" + case "application/xml", "text/xml": + return "document.xml" + default: + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(mimeType)), "video/") { + return "video" + } + return "document" + } +} + +func shortenCodexToolNameIfNeeded(name string) string { + const limit = 64 + if len(name) <= limit { + return name + } + if strings.HasPrefix(name, "mcp__") { + idx := strings.LastIndex(name, "__") + if idx > 0 { + candidate := "mcp__" + name[idx+2:] + if len(candidate) > limit { + return candidate[:limit] + } + return candidate + } + } + return name[:limit] +} + +func firstString(root gjson.Result, paths ...string) string { + for _, path := range paths { + if value := root.Get(path); value.Exists() { + return value.String() + } + } + return "" +} diff --git a/internal/translator/codex/interactions/interactions_codex_response.go b/internal/translator/codex/interactions/interactions_codex_response.go new file mode 100644 index 000000000..dec2b28aa --- /dev/null +++ b/internal/translator/codex/interactions/interactions_codex_response.go @@ -0,0 +1,552 @@ +package interactions + +import ( + "bytes" + "context" + "fmt" + "strings" + "time" + + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type codexToInteractionsStreamState struct { + Started bool + Completed bool + Done bool + ActiveStepOpen bool + ActiveStepType string + ActiveStepIndex int + StepIndex int + ID string + Model string + CreatedAt int64 + HasOutputText bool + FunctionCallName string + FunctionCallID string +} + +func ConvertCodexResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { + _ = ctx + _ = originalRequestRawJSON + _ = requestRawJSON + if param == nil { + var local any + param = &local + } + if *param == nil { + *param = &codexToInteractionsStreamState{ + ID: fmt.Sprintf("interaction_%d", time.Now().UnixNano()), + Model: modelName, + } + } + st := (*param).(*codexToInteractionsStreamState) + payload := codexStreamPayload(rawJSON) + if bytes.Equal(payload, []byte("[DONE]")) { + out := appendCodexInteractionsStepStop(nil, st) + if !st.Completed { + out = appendCodexInteractionsCompleted(out, st, gjson.Result{}) + } + return appendCodexInteractionsDone(out, st) + } + if len(payload) == 0 { + return nil + } + root := gjson.ParseBytes(payload) + switch root.Get("type").String() { + case "response.created": + return appendCodexInteractionsCreated(nil, st, root.Get("response")) + case "response.output_item.added": + return codexOutputItemAddedToInteractions(st, root) + case "response.output_text.delta": + return codexOutputTextDeltaToInteractions(st, root) + case "response.reasoning_summary_text.delta", "response.reasoning_text.delta": + return codexReasoningDeltaToInteractions(st, root) + case "response.function_call_arguments.delta": + return codexFunctionArgumentsDeltaToInteractions(st, root) + case "response.output_item.done": + return codexOutputItemDoneToInteractions(st, root.Get("item")) + case "response.completed": + out := appendCodexInteractionsCreated(nil, st, root.Get("response")) + out = appendCodexInteractionsStepStop(out, st) + out = appendCodexInteractionsCompleted(out, st, root.Get("response")) + return appendCodexInteractionsDone(out, st) + default: + return nil + } +} + +func ConvertCodexResponseToInteractionsNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { + _ = ctx + _ = originalRequestRawJSON + _ = requestRawJSON + root := gjson.ParseBytes(rawJSON) + response := root.Get("response") + if !response.Exists() { + response = root + } + out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`) + id := response.Get("id").String() + if id == "" { + id = fmt.Sprintf("interaction_%d", time.Now().UnixNano()) + } + out, _ = sjson.SetBytes(out, "id", id) + if model := response.Get("model").String(); model != "" { + out, _ = sjson.SetBytes(out, "model", model) + } else { + out, _ = sjson.SetBytes(out, "model", modelName) + } + response.Get("output").ForEach(func(_, item gjson.Result) bool { + switch item.Get("type").String() { + case "message": + out = appendCodexMessageItemToInteractions(out, item) + case "reasoning": + out = appendCodexReasoningItemToInteractions(out, item) + case "function_call", "tool_call": + out = appendCodexFunctionCallItemToInteractions(out, item) + case "image_generation_call": + out = appendCodexImageItemToInteractions(out, item) + } + return true + }) + out = setCodexInteractionsUsage(out, "usage", response.Get("usage"), false) + return out +} + +func codexStreamPayload(rawJSON []byte) []byte { + rawJSON = bytes.TrimSpace(rawJSON) + if bytes.HasPrefix(rawJSON, []byte("data:")) { + rawJSON = bytes.TrimSpace(rawJSON[len("data:"):]) + } + return rawJSON +} + +func codexStreamEventType(rawJSON []byte) string { + payload := codexStreamPayload(rawJSON) + if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) { + return "" + } + return gjson.GetBytes(payload, "type").String() +} + +func appendCodexInteractionsCreated(out [][]byte, st *codexToInteractionsStreamState, response gjson.Result) [][]byte { + if st.Started { + return out + } + if id := response.Get("id").String(); id != "" { + st.ID = id + } + if model := response.Get("model").String(); model != "" { + st.Model = model + } + if createdAt := response.Get("created_at"); createdAt.Exists() { + st.CreatedAt = createdAt.Int() + } + created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`) + created, _ = sjson.SetBytes(created, "interaction.id", st.ID) + created, _ = sjson.SetBytes(created, "interaction.model", st.Model) + out = append(out, translatorcommon.SSEEventData("interaction.created", created)) + statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`) + statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID) + out = append(out, translatorcommon.SSEEventData("interaction.status_update", statusUpdate)) + st.Started = true + return out +} + +func appendCodexInteractionsCompleted(out [][]byte, st *codexToInteractionsStreamState, response gjson.Result) [][]byte { + if st.Completed { + return out + } + created := time.Now().UTC() + if st.CreatedAt > 0 { + created = time.Unix(st.CreatedAt, 0).UTC() + } + completed := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`) + completed, _ = sjson.SetBytes(completed, "interaction.id", st.ID) + completed, _ = sjson.SetBytes(completed, "interaction.created", created.Format(time.RFC3339)) + completed, _ = sjson.SetBytes(completed, "interaction.updated", time.Now().UTC().Format(time.RFC3339)) + completed, _ = sjson.SetBytes(completed, "interaction.model", st.Model) + completed = setCodexInteractionsUsage(completed, "interaction.usage", response.Get("usage"), true) + out = append(out, translatorcommon.SSEEventData("interaction.completed", completed)) + st.Completed = true + return out +} + +func appendCodexInteractionsDone(out [][]byte, st *codexToInteractionsStreamState) [][]byte { + if st.Done { + return out + } + out = append(out, translatorcommon.SSEEventData("done", []byte("[DONE]"))) + st.Done = true + return out +} + +func codexOutputItemAddedToInteractions(st *codexToInteractionsStreamState, root gjson.Result) [][]byte { + out := appendCodexInteractionsCreated(nil, st, root.Get("response")) + item := root.Get("item") + switch item.Get("type").String() { + case "message": + return ensureCodexInteractionsStep(out, st, "model_output", item) + case "reasoning": + return ensureCodexInteractionsStep(out, st, "thought", item) + case "function_call", "tool_call": + st.FunctionCallName = item.Get("name").String() + st.FunctionCallID = codexItemCallID(item) + return ensureCodexInteractionsStep(out, st, "function_call", item) + } + return out +} + +func codexOutputTextDeltaToInteractions(st *codexToInteractionsStreamState, root gjson.Result) [][]byte { + out := appendCodexInteractionsCreated(nil, st, root.Get("response")) + out = ensureCodexInteractionsStep(out, st, "model_output", gjson.Result{}) + delta := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`) + delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) + delta, _ = sjson.SetBytes(delta, "delta.text", root.Get("delta").String()) + st.HasOutputText = true + return append(out, translatorcommon.SSEEventData("step.delta", delta)) +} + +func codexReasoningDeltaToInteractions(st *codexToInteractionsStreamState, root gjson.Result) [][]byte { + out := appendCodexInteractionsCreated(nil, st, root.Get("response")) + out = ensureCodexInteractionsStep(out, st, "thought", gjson.Result{}) + delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`) + delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) + delta, _ = sjson.SetBytes(delta, "delta.content.text", root.Get("delta").String()) + return append(out, translatorcommon.SSEEventData("step.delta", delta)) +} + +func codexFunctionArgumentsDeltaToInteractions(st *codexToInteractionsStreamState, root gjson.Result) [][]byte { + out := appendCodexInteractionsCreated(nil, st, root.Get("response")) + out = ensureCodexInteractionsStep(out, st, "function_call", root.Get("item")) + delta := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`) + delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) + delta, _ = sjson.SetBytes(delta, "delta.arguments", root.Get("delta").String()) + return append(out, translatorcommon.SSEEventData("step.delta", delta)) +} + +func codexOutputItemDoneToInteractions(st *codexToInteractionsStreamState, item gjson.Result) [][]byte { + out := appendCodexInteractionsCreated(nil, st, gjson.Result{}) + switch item.Get("type").String() { + case "message": + if st.HasOutputText { + return appendCodexInteractionsStepStop(out, st) + } + out = appendCodexMessageItemToInteractionsStream(out, st, item) + return appendCodexInteractionsStepStop(out, st) + case "reasoning": + out = appendCodexReasoningItemToInteractionsStream(out, st, item) + return appendCodexInteractionsStepStop(out, st) + case "function_call", "tool_call": + out = appendCodexFunctionCallItemToInteractionsStream(out, st, item) + return appendCodexInteractionsStepStop(out, st) + case "image_generation_call": + out = appendCodexImageItemToInteractionsStream(out, st, item) + return appendCodexInteractionsStepStop(out, st) + } + return out +} + +func ensureCodexInteractionsStep(out [][]byte, st *codexToInteractionsStreamState, stepType string, item gjson.Result) [][]byte { + if st.ActiveStepOpen && st.ActiveStepType == stepType { + return out + } + out = appendCodexInteractionsStepStop(out, st) + return appendCodexInteractionsStepStart(out, st, stepType, item) +} + +func appendCodexInteractionsStepStart(out [][]byte, st *codexToInteractionsStreamState, stepType string, item gjson.Result) [][]byte { + st.ActiveStepIndex = st.StepIndex + st.StepIndex++ + st.ActiveStepOpen = true + st.ActiveStepType = stepType + stepStart := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`) + stepStart, _ = sjson.SetBytes(stepStart, "index", st.ActiveStepIndex) + stepStart, _ = sjson.SetBytes(stepStart, "step.type", stepType) + if stepType == "function_call" { + name := item.Get("name").String() + if name == "" { + name = st.FunctionCallName + } + callID := codexItemCallID(item) + if callID == "" { + callID = st.FunctionCallID + } + if callID == "" { + callID = fmt.Sprintf("step_%d", time.Now().UnixNano()) + } + stepStart, _ = sjson.SetBytes(stepStart, "step.id", callID) + stepStart, _ = sjson.SetBytes(stepStart, "step.call_id", callID) + stepStart, _ = sjson.SetBytes(stepStart, "step.name", name) + stepStart, _ = sjson.SetRawBytes(stepStart, "step.arguments", []byte(`{}`)) + } + return append(out, translatorcommon.SSEEventData("step.start", stepStart)) +} + +func appendCodexInteractionsStepStop(out [][]byte, st *codexToInteractionsStreamState) [][]byte { + if !st.ActiveStepOpen { + return out + } + stepStop := []byte(`{"index":0,"event_type":"step.stop"}`) + stepStop, _ = sjson.SetBytes(stepStop, "index", st.ActiveStepIndex) + out = append(out, translatorcommon.SSEEventData("step.stop", stepStop)) + st.ActiveStepOpen = false + st.ActiveStepType = "" + return out +} + +func appendCodexMessageItemToInteractions(out []byte, item gjson.Result) []byte { + step := []byte(`{"type":"model_output","content":[]}`) + item.Get("content").ForEach(func(_, content gjson.Result) bool { + if contentItem := codexContentToInteractionsContent(content); len(contentItem) > 0 { + step, _ = sjson.SetRawBytes(step, "content.-1", contentItem) + } + return true + }) + if gjson.GetBytes(step, "content.#").Int() == 0 { + return out + } + out, _ = sjson.SetRawBytes(out, "steps.-1", step) + return out +} + +func appendCodexReasoningItemToInteractions(out []byte, item gjson.Result) []byte { + text := codexReasoningText(item) + if text == "" { + return out + } + step := []byte(`{"type":"thought","content":[{"type":"text","text":""}]}`) + step, _ = sjson.SetBytes(step, "content.0.text", text) + out, _ = sjson.SetRawBytes(out, "steps.-1", step) + return out +} + +func appendCodexFunctionCallItemToInteractions(out []byte, item gjson.Result) []byte { + step := []byte(`{"type":"function_call","name":"","arguments":{}}`) + step, _ = sjson.SetBytes(step, "name", item.Get("name").String()) + if callID := codexItemCallID(item); callID != "" { + step, _ = sjson.SetBytes(step, "call_id", callID) + } + if args := codexArgumentsJSON(item.Get("arguments")); len(args) > 0 { + step, _ = sjson.SetRawBytes(step, "arguments", args) + } + out, _ = sjson.SetRawBytes(out, "steps.-1", step) + return out +} + +func appendCodexImageItemToInteractions(out []byte, item gjson.Result) []byte { + result := item.Get("result").String() + if result == "" { + return out + } + step := []byte(`{"type":"model_output","content":[{"type":"image","mime_type":"","data":""}]}`) + step, _ = sjson.SetBytes(step, "content.0.mime_type", mimeTypeFromCodexOutputFormat(item.Get("output_format").String())) + step, _ = sjson.SetBytes(step, "content.0.data", result) + out, _ = sjson.SetRawBytes(out, "steps.-1", step) + return out +} + +func appendCodexMessageItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item gjson.Result) [][]byte { + item.Get("content").ForEach(func(_, content gjson.Result) bool { + if text := codexContentText(content); text != "" { + out = ensureCodexInteractionsStep(out, st, "model_output", item) + delta := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`) + delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) + delta, _ = sjson.SetBytes(delta, "delta.text", text) + out = append(out, translatorcommon.SSEEventData("step.delta", delta)) + } + return true + }) + return out +} + +func appendCodexReasoningItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item gjson.Result) [][]byte { + text := codexReasoningText(item) + if text == "" { + return out + } + out = ensureCodexInteractionsStep(out, st, "thought", item) + delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`) + delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) + delta, _ = sjson.SetBytes(delta, "delta.content.text", text) + return append(out, translatorcommon.SSEEventData("step.delta", delta)) +} + +func appendCodexFunctionCallItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item gjson.Result) [][]byte { + out = ensureCodexInteractionsStep(out, st, "function_call", item) + delta := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`) + delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) + delta, _ = sjson.SetBytes(delta, "delta.arguments", item.Get("arguments").String()) + return append(out, translatorcommon.SSEEventData("step.delta", delta)) +} + +func appendCodexImageItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item gjson.Result) [][]byte { + result := item.Get("result").String() + if result == "" { + return out + } + out = ensureCodexInteractionsStep(out, st, "model_output", item) + delta := []byte(`{"index":0,"delta":{"content":{"type":"image","mime_type":"","data":""},"type":"content"},"event_type":"step.delta"}`) + delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) + delta, _ = sjson.SetBytes(delta, "delta.content.mime_type", mimeTypeFromCodexOutputFormat(item.Get("output_format").String())) + delta, _ = sjson.SetBytes(delta, "delta.content.data", result) + return append(out, translatorcommon.SSEEventData("step.delta", delta)) +} + +func codexContentToInteractionsContent(content gjson.Result) []byte { + if text := codexContentText(content); text != "" { + item := []byte(`{"type":"text","text":""}`) + item, _ = sjson.SetBytes(item, "text", text) + return item + } + return nil +} + +func codexContentText(content gjson.Result) string { + for _, path := range []string{"text", "content"} { + if value := content.Get(path); value.Exists() && value.Type == gjson.String { + return value.String() + } + } + return "" +} + +func codexReasoningText(item gjson.Result) string { + if content := item.Get("content"); content.Exists() { + if content.Type == gjson.String { + return content.String() + } + if content.IsArray() { + var builder strings.Builder + content.ForEach(func(_, part gjson.Result) bool { + text := codexContentText(part) + if text == "" { + text = part.Get("summary_text").String() + } + if text == "" { + return true + } + if builder.Len() > 0 { + builder.WriteByte('\n') + } + builder.WriteString(text) + return true + }) + return builder.String() + } + } + if summary := item.Get("summary"); summary.Exists() { + if summary.Type == gjson.String { + return summary.String() + } + if summary.IsArray() { + var builder strings.Builder + summary.ForEach(func(_, part gjson.Result) bool { + text := codexContentText(part) + if text == "" { + return true + } + if builder.Len() > 0 { + builder.WriteByte('\n') + } + builder.WriteString(text) + return true + }) + return builder.String() + } + } + return "" +} + +func codexItemCallID(item gjson.Result) string { + if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" { + return callID + } + return strings.TrimSpace(item.Get("id").String()) +} + +func codexArgumentsJSON(arguments gjson.Result) []byte { + if !arguments.Exists() { + return nil + } + if arguments.Type == gjson.String { + parsed := gjson.Parse(arguments.String()) + if parsed.Exists() && parsed.IsObject() { + return []byte(arguments.String()) + } + return []byte(`{}`) + } + if arguments.IsObject() { + return []byte(arguments.Raw) + } + return nil +} + +func setCodexInteractionsUsage(out []byte, path string, usage gjson.Result, stream bool) []byte { + if !usage.Exists() { + return out + } + inputTokens := usage.Get("input_tokens").Int() + outputTokens := usage.Get("output_tokens").Int() + if inputTokens == 0 { + inputTokens = usage.Get("prompt_tokens").Int() + } + if outputTokens == 0 { + outputTokens = usage.Get("completion_tokens").Int() + } + totalTokens := usage.Get("total_tokens").Int() + if totalTokens == 0 { + totalTokens = inputTokens + outputTokens + } + reasoningTokens := usage.Get("output_tokens_details.reasoning_tokens").Int() + if reasoningTokens == 0 { + reasoningTokens = usage.Get("reasoning_tokens").Int() + } + cachedTokens := usage.Get("input_tokens_details.cached_tokens").Int() + if cachedTokens == 0 { + cachedTokens = usage.Get("cached_tokens").Int() + } + if stream { + out, _ = sjson.SetBytes(out, path+".total_tokens", totalTokens) + out, _ = sjson.SetBytes(out, path+".total_input_tokens", inputTokens) + out, _ = sjson.SetRawBytes(out, path+".input_tokens_by_modality", []byte(fmt.Sprintf(`[{"modality":"text","tokens":%d}]`, inputTokens))) + out, _ = sjson.SetBytes(out, path+".total_cached_tokens", cachedTokens) + out, _ = sjson.SetBytes(out, path+".total_output_tokens", outputTokens) + out, _ = sjson.SetBytes(out, path+".total_tool_use_tokens", 0) + out, _ = sjson.SetBytes(out, path+".total_thought_tokens", reasoningTokens) + return out + } + out, _ = sjson.SetBytes(out, path+".input_tokens", inputTokens) + out, _ = sjson.SetBytes(out, path+".output_tokens", outputTokens) + out, _ = sjson.SetBytes(out, path+".total_tokens", totalTokens) + if reasoningTokens > 0 { + out, _ = sjson.SetBytes(out, path+".reasoning_tokens", reasoningTokens) + } + if cachedTokens > 0 { + out, _ = sjson.SetBytes(out, path+".cached_tokens", cachedTokens) + } + return out +} + +func mimeTypeFromCodexOutputFormat(outputFormat string) string { + if outputFormat == "" { + return "image/png" + } + if strings.Contains(outputFormat, "/") { + return outputFormat + } + switch strings.ToLower(outputFormat) { + case "png": + return "image/png" + case "jpg", "jpeg": + return "image/jpeg" + case "webp": + return "image/webp" + case "gif": + return "image/gif" + default: + return "image/png" + } +} diff --git a/internal/translator/codex/interactions/interactions_codex_test.go b/internal/translator/codex/interactions/interactions_codex_test.go new file mode 100644 index 000000000..34a3fecda --- /dev/null +++ b/internal/translator/codex/interactions/interactions_codex_test.go @@ -0,0 +1,202 @@ +package interactions + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertInteractionsRequestToCodexWithToolMessagesDirect(t *testing.T) { + out := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"model":"codex-test","system_instruction":"be brief","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"thought","content":[{"type":"text","text":"thinking"}]},{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}],"tools":[{"type":"function","name":"lookup","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}]}`), false) + if got := gjson.GetBytes(out, "instructions").String(); got != "be brief" { + t.Fatalf("instructions = %q, want be brief. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hi" { + t.Fatalf("input.0.content.0.text = %q, want hi. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.1.type").String(); got != "reasoning" { + t.Fatalf("input.1.type = %q, want reasoning. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.2.type").String(); got != "function_call" { + t.Fatalf("input.2.type = %q, want function_call. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.2.call_id").String(); got != "call_1" { + t.Fatalf("function_call call_id = %q, want call_1. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.3.type").String(); got != "function_call_output" { + t.Fatalf("input.3.type = %q, want function_call_output. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.name").String(); got != "lookup" { + t.Fatalf("tools.0.name = %q, want lookup. Output: %s", got, string(out)) + } + if gjson.GetBytes(out, "contents").Exists() || gjson.GetBytes(out, "systemInstruction").Exists() { + t.Fatalf("Codex request must not use foreign request shape. Output: %s", string(out)) + } +} + +func TestConvertInteractionsRequestToCodexPreservesNonImageMediaContent(t *testing.T) { + out := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"model":"codex-test","input":[{"type":"model_output","content":[{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="},{"type":"video","mime_type":"video/mp4","data":"AAAAIGZ0eXA="},{"type":"document","mime_type":"application/pdf","data":"JVBERi0="}]}]}`), false) + + if got := gjson.GetBytes(out, "input.0.role").String(); got != "assistant" { + t.Fatalf("input.0.role = %q, want assistant. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "input_audio" { + t.Fatalf("audio content type = %q, want input_audio. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.1.content.0.type").String(); got != "input_file" { + t.Fatalf("video content type = %q, want input_file. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.2.content.0.type").String(); got != "input_file" { + t.Fatalf("document content type = %q, want input_file. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToCodexPreservesTopLevelThinkingLevel(t *testing.T) { + out := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"model":"codex-test","generation_config":{"thinking_level":"high"},"input":"hi"}`), true) + if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "high" { + t.Fatalf("reasoning.effort = %q, want high. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "stream").Bool(); !got { + t.Fatalf("stream = %v, want true. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToCodexUsesBodyStream(t *testing.T) { + out := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"model":"codex-test","stream":true,"input":"hi"}`), false) + if got := gjson.GetBytes(out, "stream").Bool(); !got { + t.Fatalf("stream = %v, want true. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToCodexFunctionDeclarations(t *testing.T) { + out := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"model":"codex-test","input":"hi","tools":[{"function_declarations":[{"name":"lookup","description":"Lookup data","parameters":{"type":"object","$schema":"http://json-schema.org/draft-07/schema#","properties":{"q":{"type":"string"}}}}]}]}`), false) + if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" { + t.Fatalf("tools.0.type = %q, want function. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.name").String(); got != "lookup" { + t.Fatalf("tools.0.name = %q, want lookup. Output: %s", got, string(out)) + } + if gjson.GetBytes(out, "tools.0.parameters.$schema").Exists() { + t.Fatalf("tool parameters should not keep $schema. Output: %s", string(out)) + } +} + +func TestConvertCodexResponseToInteractionsNonStream(t *testing.T) { + raw := []byte(`{"type":"response.completed","response":{"id":"resp_1","created_at":1700000000,"usage":{"input_tokens":3,"output_tokens":2},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]},{"type":"reasoning","content":"thinking"},{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"x\"}"}]}}`) + out := ConvertCodexResponseToInteractionsNonStream(context.Background(), "codex-test", nil, nil, raw, nil) + if got := gjson.GetBytes(out, "steps.0.content.0.text").String(); got != "ok" { + t.Fatalf("steps.0.content.0.text = %q, want ok. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "steps.1.type").String(); got != "thought" { + t.Fatalf("steps.1.type = %q, want thought. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "steps.2.type").String(); got != "function_call" { + t.Fatalf("steps.2.type = %q, want function_call. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 5 { + t.Fatalf("usage.total_tokens = %d, want 5. Output: %s", got, string(out)) + } +} + +func TestConvertCodexResponseToInteractionsStream(t *testing.T) { + var param any + events := ConvertCodexResponseToInteractions(context.Background(), "codex-test", nil, nil, []byte(`data: {"type":"response.output_text.delta","delta":"ok"}`), ¶m) + payload := findCodexInteractionsEventPayload(events, "step.delta") + if len(payload) == 0 { + t.Fatalf("step.delta event not found: %q", events) + } + if got := gjson.GetBytes(payload, "delta.text").String(); got != "ok" { + t.Fatalf("delta.text = %q, want ok. Payload: %s", got, string(payload)) + } +} + +func TestConvertCodexResponseToInteractionsStreamFunctionCallStartHasCallID(t *testing.T) { + var param any + events := ConvertCodexResponseToInteractions(context.Background(), "codex-test", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"x\"}"}}`), ¶m) + payload := findCodexInteractionsEventPayload(events, "step.start") + if got := gjson.GetBytes(payload, "step.call_id").String(); got != "call_1" { + t.Fatalf("step.call_id = %q, want call_1. Payload: %s", got, string(payload)) + } +} + +func TestConvertCodexResponseToInteractionsStreamCompletesAfterSteps(t *testing.T) { + var param any + var events [][]byte + for _, chunk := range [][]byte{ + []byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"codex-test"}}`), + []byte(`data: {"type":"response.output_text.delta","delta":"我将调用工具。"}`), + []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"weather\"}"},"output_index":1}`), + []byte(`data: {"type":"response.completed","response":{"id":"resp_1","output":[],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`), + } { + events = append(events, ConvertCodexResponseToInteractions(context.Background(), "codex-test", nil, nil, chunk, ¶m)...) + } + + got := strings.Join(codexInteractionsEventNames(events), ",") + want := "interaction.created,interaction.status_update,step.start,step.delta,step.stop,step.start,step.delta,step.stop,interaction.completed,done" + if got != want { + t.Fatalf("events = %s, want %s", got, want) + } + completed := findCodexInteractionsEventPayload(events, "interaction.completed") + if gotTokens := gjson.GetBytes(completed, "interaction.usage.total_tokens").Int(); gotTokens != 3 { + t.Fatalf("total_tokens = %d, want 3. Payload: %s", gotTokens, string(completed)) + } +} + +func findCodexInteractionsEventPayload(events [][]byte, eventType string) []byte { + prefix := []byte("data:") + for _, event := range events { + eventName := codexInteractionsFrameEventName(event) + for _, line := range bytes.Split(event, []byte("\n")) { + line = bytes.TrimSpace(line) + if !bytes.HasPrefix(line, prefix) { + continue + } + payload := bytes.TrimSpace(line[len(prefix):]) + if codexInteractionsEventName(eventName, payload) == eventType { + return payload + } + } + } + return nil +} + +func codexInteractionsEventNames(events [][]byte) []string { + names := make([]string, 0, len(events)) + for _, event := range events { + eventName := codexInteractionsFrameEventName(event) + for _, line := range bytes.Split(event, []byte("\n")) { + line = bytes.TrimSpace(line) + if !bytes.HasPrefix(line, []byte("data:")) { + continue + } + payload := bytes.TrimSpace(line[len("data:"):]) + if name := codexInteractionsEventName(eventName, payload); name != "" { + names = append(names, name) + } + } + } + return names +} + +func codexInteractionsEventName(eventName string, payload []byte) string { + if eventType := gjson.GetBytes(payload, "event_type").String(); eventType != "" { + return eventType + } + if eventType := gjson.GetBytes(payload, "type").String(); eventType != "" { + return eventType + } + return eventName +} + +func codexInteractionsFrameEventName(event []byte) string { + for _, line := range bytes.Split(event, []byte("\n")) { + line = bytes.TrimSpace(line) + if bytes.HasPrefix(line, []byte("event:")) { + return strings.TrimSpace(string(line[len("event:"):])) + } + } + return "" +} diff --git a/internal/translator/common/interactions_usage.go b/internal/translator/common/interactions_usage.go new file mode 100644 index 000000000..eabe4273a --- /dev/null +++ b/internal/translator/common/interactions_usage.go @@ -0,0 +1,19 @@ +package common + +import "github.com/tidwall/gjson" + +func InteractionsUsage(root gjson.Result) gjson.Result { + for _, path := range []string{ + "interaction.usage", + "usage", + "metadata.total_usage", + "metadata.usage", + "interaction.metadata.total_usage", + "interaction.metadata.usage", + } { + if value := root.Get(path); value.Exists() { + return value + } + } + return gjson.Result{} +} diff --git a/internal/translator/gemini/interactions/init.go b/internal/translator/gemini/interactions/init.go new file mode 100644 index 000000000..b888f03e8 --- /dev/null +++ b/internal/translator/gemini/interactions/init.go @@ -0,0 +1,37 @@ +package interactions + +import ( + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" +) + +func init() { + translator.Register( + Interactions, + Interactions, + ConvertInteractionsRequestToInteractions, + interfaces.TranslateResponse{ + Stream: ConvertInteractionsResponsePassthrough, + NonStream: ConvertInteractionsResponsePassthroughNonStream, + }, + ) + translator.Register( + Interactions, + Gemini, + ConvertInteractionsRequestToGemini, + interfaces.TranslateResponse{ + Stream: ConvertGeminiResponseToInteractions, + NonStream: ConvertGeminiResponseToInteractionsNonStream, + }, + ) + translator.Register( + Gemini, + Interactions, + ConvertGeminiRequestToInteractions, + interfaces.TranslateResponse{ + Stream: ConvertInteractionsResponseToGemini, + NonStream: ConvertInteractionsResponseToGeminiNonStream, + }, + ) +} diff --git a/internal/translator/gemini/interactions/interactions_gemini_common.go b/internal/translator/gemini/interactions/interactions_gemini_common.go new file mode 100644 index 000000000..3b53d4743 --- /dev/null +++ b/internal/translator/gemini/interactions/interactions_gemini_common.go @@ -0,0 +1,1334 @@ +package interactions + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type StreamState struct { + Started bool + Finished bool + Completed bool + Done bool + ActiveStepOpen bool + ID string + StepID string + ActiveStepType string + ActiveStepIndex int + StepIndex int +} + +func ConvertInteractionsRequestToGemini(modelName string, inputRawJSON []byte, stream bool) []byte { + root := gjson.ParseBytes(inputRawJSON) + out := []byte(`{"model":"","contents":[]}`) + if modelName != "" && root.Get("model").Exists() { + out, _ = sjson.SetBytes(out, "model", modelName) + } + out = copyInteractionsSystemInstruction(out, root) + out = copyInteractionsGenerationConfig(out, root) + out = copyInteractionsResponseModalities(out, root) + out = copyInteractionsTools(out, root) + out = copyInteractionsToolChoice(out, root) + out = copyInteractionsServiceTier(out, root) + input := root.Get("input") + out = appendInteractionsInput(out, input) + return out +} + +func ConvertGeminiRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) []byte { + root := gjson.ParseBytes(inputRawJSON) + out := []byte(`{"model":"","input":[]}`) + out, _ = sjson.SetBytes(out, "model", modelName) + out = copyGeminiSystemInstructionToInteractions(out, root) + if root.Get("generationConfig").Exists() { + converted := convertCamelCaseKeysToSnakeCase([]byte(root.Get("generationConfig").Raw)) + out, _ = sjson.SetRawBytes(out, "generation_config", converted) + out = normalizeGeminiThinkingConfigForInteractions(out) + } + out = copyGeminiToolsToInteractions(out, root) + root.Get("contents").ForEach(func(_, content gjson.Result) bool { + role := content.Get("role").String() + stepType := "user_input" + if role == "model" { + stepType = "model_output" + } + content.Get("parts").ForEach(func(_, part gjson.Result) bool { + if fc := part.Get("functionCall"); fc.Exists() { + step := geminiPartToInteractionsStep(part) + if len(step) > 0 { + out, _ = sjson.SetRawBytes(out, "input.-1", step) + } + return true + } + if fr := part.Get("functionResponse"); fr.Exists() { + step := geminiPartToInteractionsStep(part) + if len(step) > 0 { + out, _ = sjson.SetRawBytes(out, "input.-1", step) + } + return true + } + item := geminiPartToInteractionsContent(part) + if len(item) == 0 { + return true + } + currentStepType := stepType + if part.Get("thought").Bool() && role == "model" { + currentStepType = "thought" + } + step := []byte(`{"type":"","content":[]}`) + step, _ = sjson.SetBytes(step, "type", currentStepType) + step, _ = sjson.SetRawBytes(step, "content.-1", item) + out, _ = sjson.SetRawBytes(out, "input.-1", step) + return true + }) + return true + }) + out, _ = sjson.SetBytes(out, "stream", stream) + return out +} + +func copyGeminiSystemInstructionToInteractions(out []byte, root gjson.Result) []byte { + sys := root.Get("systemInstruction") + if !sys.Exists() { + sys = root.Get("system_instruction") + } + text := geminiSystemInstructionText(sys) + if text == "" { + return out + } + out, _ = sjson.SetBytes(out, "system_instruction", text) + return out +} + +func geminiSystemInstructionText(sys gjson.Result) string { + if !sys.Exists() { + return "" + } + if sys.Type == gjson.String { + return sys.String() + } + if text := sys.Get("text"); text.Exists() && text.Type == gjson.String { + return text.String() + } + parts := sys.Get("parts") + if !parts.Exists() || !parts.IsArray() { + return "" + } + var builder strings.Builder + parts.ForEach(func(_, part gjson.Result) bool { + text := part.Get("text").String() + if text == "" { + return true + } + if builder.Len() > 0 { + builder.WriteByte('\n') + } + builder.WriteString(text) + return true + }) + return builder.String() +} + +func normalizeGeminiThinkingConfigForInteractions(out []byte) []byte { + if level := firstExistingPath(gjson.ParseBytes(out), []string{ + "generation_config.thinking_config.thinking_level", + "generation_config.thinkingConfig.thinkingLevel", + "generation_config.thinkingConfig.thinking_level", + }); level.Exists() { + out, _ = sjson.SetBytes(out, "generation_config.thinking_level", strings.ToLower(strings.TrimSpace(level.String()))) + } + if budget := firstExistingPath(gjson.ParseBytes(out), []string{ + "generation_config.thinking_config.thinking_budget", + "generation_config.thinkingConfig.thinkingBudget", + "generation_config.thinkingConfig.thinking_budget", + }); budget.Exists() { + out, _ = sjson.SetRawBytes(out, "generation_config.thinking_budget", []byte(budget.Raw)) + } + if !gjson.GetBytes(out, "generation_config.thinking_summaries").Exists() { + if include := firstExistingPath(gjson.ParseBytes(out), []string{ + "generation_config.thinking_config.include_thoughts", + "generation_config.thinking_config.includeThoughts", + "generation_config.thinkingConfig.include_thoughts", + "generation_config.thinkingConfig.includeThoughts", + }); include.Exists() { + summary := "none" + if include.Bool() { + summary = "auto" + } + out, _ = sjson.SetBytes(out, "generation_config.thinking_summaries", summary) + } + } + return out +} + +func firstExistingPath(root gjson.Result, paths []string) gjson.Result { + for _, path := range paths { + if value := root.Get(path); value.Exists() { + return value + } + } + return gjson.Result{} +} + +func copyGeminiToolsToInteractions(out []byte, root gjson.Result) []byte { + tools := root.Get("tools") + if !tools.Exists() { + return out + } + if !tools.IsArray() { + out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw)) + return out + } + normalized := make([]map[string]any, 0) + tools.ForEach(func(_, tool gjson.Result) bool { + if name := tool.Get("name"); name.Exists() { + entry := map[string]any{ + "type": "function", + "name": name.String(), + } + if desc := tool.Get("description"); desc.Exists() { + entry["description"] = desc.String() + } + if params := tool.Get("parameters"); params.Exists() { + entry["parameters"] = json.RawMessage(params.Raw) + } else if params := tool.Get("parametersJsonSchema"); params.Exists() { + entry["parameters"] = json.RawMessage(params.Raw) + } + normalized = append(normalized, entry) + return true + } + decls := tool.Get("functionDeclarations") + if !decls.Exists() { + decls = tool.Get("function_declarations") + } + decls.ForEach(func(_, decl gjson.Result) bool { + if name := decl.Get("name"); name.Exists() { + entry := map[string]any{ + "type": "function", + "name": name.String(), + } + if desc := decl.Get("description"); desc.Exists() { + entry["description"] = desc.String() + } + if params := decl.Get("parameters"); params.Exists() { + entry["parameters"] = json.RawMessage(params.Raw) + } else if params := decl.Get("parametersJsonSchema"); params.Exists() { + entry["parameters"] = json.RawMessage(params.Raw) + } + normalized = append(normalized, entry) + } + return true + }) + return true + }) + if len(normalized) == 0 { + out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw)) + return out + } + raw, errMarshal := json.Marshal(normalized) + if errMarshal != nil { + out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw)) + return out + } + out, _ = sjson.SetRawBytes(out, "tools", raw) + return out +} + +func geminiPartToInteractionsContent(part gjson.Result) []byte { + if text := part.Get("text"); text.Exists() { + item := []byte(`{"type":"text","text":""}`) + item, _ = sjson.SetBytes(item, "text", text.String()) + return item + } + if inline := part.Get("inlineData"); inline.Exists() { + mimeType := inline.Get("mimeType").String() + if mimeType == "" { + mimeType = inline.Get("mime_type").String() + } + return geminiInlineDataToInteractionsContent(mimeType, inline.Get("data").String()) + } + if inline := part.Get("inline_data"); inline.Exists() { + return geminiInlineDataToInteractionsContent(inline.Get("mime_type").String(), inline.Get("data").String()) + } + return nil +} + +func ConvertGeminiResponseToInteractionsStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { + _ = ctx + if *param == nil { + *param = &StreamState{ID: fmt.Sprintf("interaction_%d", time.Now().UnixNano())} + } + st := (*param).(*StreamState) + if bytes.Equal(bytes.TrimSpace(rawJSON), []byte("[DONE]")) { + var out [][]byte + if !st.Completed { + out = appendInteractionsStepStop(out, st) + out = appendInteractionsCompleted(out, st, modelName, gjson.Result{}) + } + return appendInteractionsDone(out, st) + } + root := gjson.ParseBytes(rawJSON) + var out [][]byte + if !st.Started { + out = appendInteractionsCreated(out, st, modelName) + out = appendInteractionsStatusUpdate(out, st) + st.Started = true + } + root.Get("candidates.0.content.parts").ForEach(func(_, part gjson.Result) bool { + out = appendGeminiPartToInteractionsStream(out, st, part) + return true + }) + hasFinish := root.Get("candidates.0.finishReason").Exists() + hasUsage := hasInteractionsGeminiStreamUsage(root) + if hasFinish && !st.Finished { + out = appendInteractionsStepStop(out, st) + st.Finished = true + } + if hasUsage && st.Finished && !st.Completed { + out = appendInteractionsCompleted(out, st, modelName, root) + } + return out +} + +func hasInteractionsGeminiStreamUsage(root gjson.Result) bool { + usage := root.Get("usageMetadata") + if !usage.Exists() { + usage = root.Get("usage_metadata") + } + if !usage.Exists() { + return false + } + for _, path := range []string{ + "promptTokenCount", + "candidatesTokenCount", + "totalTokenCount", + "thoughtsTokenCount", + "cachedContentTokenCount", + "prompt_token_count", + "candidates_token_count", + "total_token_count", + "thoughts_token_count", + "cached_content_token_count", + } { + if usage.Get(path).Exists() { + return true + } + } + return false +} + +func appendInteractionsCreated(out [][]byte, st *StreamState, modelName string) [][]byte { + created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`) + created, _ = sjson.SetBytes(created, "interaction.id", st.ID) + created, _ = sjson.SetBytes(created, "interaction.model", modelName) + return append(out, translatorcommon.SSEEventData("interaction.created", created)) +} + +func appendInteractionsStatusUpdate(out [][]byte, st *StreamState) [][]byte { + statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`) + statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID) + return append(out, translatorcommon.SSEEventData("interaction.status_update", statusUpdate)) +} + +func appendInteractionsCompleted(out [][]byte, st *StreamState, modelName string, root gjson.Result) [][]byte { + now := time.Now().UTC().Format(time.RFC3339) + completed := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`) + completed, _ = sjson.SetBytes(completed, "interaction.id", st.ID) + completed, _ = sjson.SetBytes(completed, "interaction.created", now) + completed, _ = sjson.SetBytes(completed, "interaction.updated", now) + completed, _ = sjson.SetBytes(completed, "interaction.model", modelName) + if root.Exists() { + completed = setInteractionsStreamUsageFromGemini(completed, "interaction.usage", root) + } + out = append(out, translatorcommon.SSEEventData("interaction.completed", completed)) + st.Completed = true + return out +} + +func appendInteractionsDone(out [][]byte, st *StreamState) [][]byte { + if st.Done { + return out + } + out = append(out, translatorcommon.SSEEventData("done", []byte("[DONE]"))) + st.Done = true + return out +} + +func convertGeminiResponseToInteractionsNonStreamDirect(modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte) []byte { + _ = originalRequestRawJSON + _ = requestRawJSON + root := gjson.ParseBytes(rawJSON) + out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`) + id := root.Get("responseId").String() + if id == "" { + id = fmt.Sprintf("interaction_%d", time.Now().UnixNano()) + } + out, _ = sjson.SetBytes(out, "id", id) + out, _ = sjson.SetBytes(out, "model", modelName) + root.Get("candidates.0.content.parts").ForEach(func(_, part gjson.Result) bool { + if step := geminiPartToInteractionsStep(part); len(step) > 0 { + out, _ = sjson.SetRawBytes(out, "steps.-1", step) + } + return true + }) + out = setInteractionsUsageFromGemini(out, "usage", root) + return out +} + +func copyInteractionsSystemInstruction(out []byte, root gjson.Result) []byte { + sys := root.Get("system_instruction") + if !sys.Exists() { + return out + } + if sys.Type == gjson.String { + instr := []byte(`{"parts":[{"text":""}]}`) + instr, _ = sjson.SetBytes(instr, "parts.0.text", sys.String()) + out, _ = sjson.SetRawBytes(out, "systemInstruction", instr) + return out + } + if text := sys.Get("text"); text.Exists() && !sys.Get("parts").Exists() { + instr := []byte(`{"parts":[{"text":""}]}`) + instr, _ = sjson.SetBytes(instr, "parts.0.text", text.String()) + out, _ = sjson.SetRawBytes(out, "systemInstruction", instr) + return out + } + out, _ = sjson.SetRawBytes(out, "systemInstruction", []byte(sys.Raw)) + return out +} + +func copyInteractionsGenerationConfig(out []byte, root gjson.Result) []byte { + cfg := root.Get("generation_config") + if !cfg.Exists() { + cfg = root.Get("generationConfig") + if !cfg.Exists() { + return out + } + out, _ = sjson.SetRawBytes(out, "generationConfig", []byte(cfg.Raw)) + return normalizeInteractionsGenerationConfig(out) + } + converted := convertSnakeCaseKeysToCamelCase([]byte(cfg.Raw)) + out, _ = sjson.SetRawBytes(out, "generationConfig", converted) + out = normalizeInteractionsGenerationConfig(out) + return out +} + +func normalizeInteractionsGenerationConfig(out []byte) []byte { + if toolChoice := gjson.GetBytes(out, "generationConfig.toolChoice"); toolChoice.Exists() { + out, _ = sjson.DeleteBytes(out, "generationConfig.toolChoice") + } + if thinkingLevel := gjson.GetBytes(out, "generationConfig.thinkingLevel"); thinkingLevel.Exists() { + out, _ = sjson.SetRawBytes(out, "generationConfig.thinkingConfig.thinkingLevel", []byte(thinkingLevel.Raw)) + out, _ = sjson.DeleteBytes(out, "generationConfig.thinkingLevel") + } + if thinkingBudget := gjson.GetBytes(out, "generationConfig.thinkingBudget"); thinkingBudget.Exists() { + out, _ = sjson.SetRawBytes(out, "generationConfig.thinkingConfig.thinkingBudget", []byte(thinkingBudget.Raw)) + out, _ = sjson.DeleteBytes(out, "generationConfig.thinkingBudget") + } + if includeThoughts := gjson.GetBytes(out, "generationConfig.includeThoughts"); includeThoughts.Exists() { + out, _ = sjson.SetRawBytes(out, "generationConfig.thinkingConfig.includeThoughts", []byte(includeThoughts.Raw)) + out, _ = sjson.DeleteBytes(out, "generationConfig.includeThoughts") + } + if summaries := gjson.GetBytes(out, "generationConfig.thinkingSummaries"); summaries.Exists() { + if includeThoughts, ok := interactionsThinkingSummariesIncludeThoughts(summaries); ok { + out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.includeThoughts", includeThoughts) + } + out, _ = sjson.DeleteBytes(out, "generationConfig.thinkingSummaries") + } + return out +} + +func interactionsThinkingSummariesIncludeThoughts(summary gjson.Result) (bool, bool) { + switch summary.Type { + case gjson.True: + return true, true + case gjson.False: + return false, true + case gjson.String: + switch strings.ToLower(strings.TrimSpace(summary.String())) { + case "", "none", "off", "false", "disabled": + return false, true + default: + return true, true + } + } + return false, false +} + +func copyInteractionsResponseModalities(out []byte, root gjson.Result) []byte { + mods := root.Get("response_modalities") + if !mods.Exists() { + mods = root.Get("responseModalities") + } + if !mods.Exists() || !mods.IsArray() { + return out + } + var responseMods []string + mods.ForEach(func(_, mod gjson.Result) bool { + switch strings.ToLower(strings.TrimSpace(mod.String())) { + case "text": + responseMods = append(responseMods, "TEXT") + case "image": + responseMods = append(responseMods, "IMAGE") + case "audio": + responseMods = append(responseMods, "AUDIO") + } + return true + }) + if len(responseMods) > 0 { + out, _ = sjson.SetBytes(out, "generationConfig.responseModalities", responseMods) + } + return out +} + +func copyInteractionsToolChoice(out []byte, root gjson.Result) []byte { + toolChoice := root.Get("tool_choice") + if !toolChoice.Exists() { + toolChoice = root.Get("generation_config.tool_choice") + } + if !toolChoice.Exists() { + toolChoice = root.Get("generationConfig.toolChoice") + } + if !toolChoice.Exists() { + return out + } + mode := "" + var allowedNames []string + if toolChoice.Type == gjson.String { + switch strings.ToLower(strings.TrimSpace(toolChoice.String())) { + case "none": + mode = "NONE" + case "auto": + mode = "AUTO" + case "required", "any": + mode = "ANY" + } + } else if toolChoice.IsObject() { + toolType := strings.ToLower(strings.TrimSpace(toolChoice.Get("type").String())) + switch toolType { + case "none": + mode = "NONE" + case "auto": + mode = "AUTO" + case "required", "any": + mode = "ANY" + case "function": + mode = "ANY" + if name := strings.TrimSpace(toolChoice.Get("function.name").String()); name != "" { + allowedNames = append(allowedNames, name) + } + case "tool": + mode = "ANY" + if name := strings.TrimSpace(toolChoice.Get("name").String()); name != "" { + allowedNames = append(allowedNames, name) + } + } + } + if mode == "" { + return out + } + out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", mode) + if len(allowedNames) > 0 { + out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames", allowedNames) + } + return out +} + +func copyInteractionsServiceTier(out []byte, root gjson.Result) []byte { + serviceTier := root.Get("service_tier") + if !serviceTier.Exists() || serviceTier.Type != gjson.String { + return out + } + out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String()) + return out +} + +func convertSnakeCaseKeysToCamelCase(raw []byte) []byte { + root := gjson.ParseBytes(raw) + if !root.Exists() { + return raw + } + out := []byte(`{}`) + out = copySnakeCaseValueToCamelCase(out, "", root) + return out +} + +func copySnakeCaseValueToCamelCase(out []byte, path string, node gjson.Result) []byte { + if node.IsObject() { + node.ForEach(func(key, value gjson.Result) bool { + childPath := joinJSONPath(path, toCamelCase(key.String())) + out = copySnakeCaseValueToCamelCase(out, childPath, value) + return true + }) + return out + } + if node.IsArray() { + node.ForEach(func(_, value gjson.Result) bool { + childPath := path + ".-1" + out = copySnakeCaseValueToCamelCase(out, childPath, value) + return true + }) + return out + } + out, _ = sjson.SetRawBytes(out, path, []byte(node.Raw)) + return out +} + +func joinJSONPath(path, key string) string { + if path == "" { + return key + } + return path + "." + key +} + +func toCamelCase(s string) string { + parts := strings.Split(s, "_") + if len(parts) == 0 { + return s + } + out := parts[0] + for _, p := range parts[1:] { + if p == "" { + continue + } + out += strings.ToUpper(p[:1]) + p[1:] + } + return out +} + +func convertCamelCaseKeysToSnakeCase(raw []byte) []byte { + root := gjson.ParseBytes(raw) + if !root.Exists() { + return raw + } + out := []byte(`{}`) + out = copyCamelCaseValueToSnakeCase(out, "", root) + return out +} + +func copyCamelCaseValueToSnakeCase(out []byte, path string, node gjson.Result) []byte { + if node.IsObject() { + node.ForEach(func(key, value gjson.Result) bool { + childPath := joinJSONPath(path, toSnakeCase(key.String())) + out = copyCamelCaseValueToSnakeCase(out, childPath, value) + return true + }) + return out + } + if node.IsArray() { + node.ForEach(func(_, value gjson.Result) bool { + childPath := path + ".-1" + out = copyCamelCaseValueToSnakeCase(out, childPath, value) + return true + }) + return out + } + out, _ = sjson.SetRawBytes(out, path, []byte(node.Raw)) + return out +} + +func toSnakeCase(s string) string { + var out strings.Builder + for i, r := range s { + if i > 0 && r >= 'A' && r <= 'Z' { + out.WriteByte('_') + } + out.WriteRune(r) + } + return strings.ToLower(out.String()) +} + +func copyInteractionsTools(out []byte, root gjson.Result) []byte { + tools := root.Get("tools") + if !tools.Exists() { + return out + } + if !tools.IsArray() { + out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw)) + return out + } + normalized := make([]map[string]any, 0) + tools.ForEach(func(_, tool gjson.Result) bool { + if tool.Get("functionDeclarations").Exists() { + out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw)) + normalized = nil + return false + } + entry := map[string]any{} + if decls := tool.Get("function_declarations"); decls.Exists() && decls.IsArray() { + entry["functionDeclarations"] = json.RawMessage(decls.Raw) + } else if name := tool.Get("name"); name.Exists() { + decl := map[string]any{"name": name.String()} + if desc := tool.Get("description"); desc.Exists() { + decl["description"] = desc.String() + } + if params := tool.Get("parameters"); params.Exists() { + decl["parameters"] = json.RawMessage(params.Raw) + } + entry["functionDeclarations"] = []map[string]any{decl} + } else { + entry = nil + } + if entry != nil { + normalized = append(normalized, entry) + } + return true + }) + if normalized == nil { + return out + } + if len(normalized) == 0 { + out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw)) + return out + } + raw, errMarshal := json.Marshal(normalized) + if errMarshal != nil { + out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw)) + return out + } + out, _ = sjson.SetRawBytes(out, "tools", raw) + return out +} + +func appendInteractionsInput(out []byte, input gjson.Result) []byte { + if !input.Exists() { + return out + } + if input.Type == gjson.String { + return appendGeminiTextContent(out, "user", input.String()) + } + if input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + out = appendInteractionsInputItem(out, item, "user") + return true + }) + return out + } + if steps := input.Get("steps"); steps.Exists() && steps.IsArray() { + defaultRole := "user" + if role := input.Get("role").String(); role == "model" || role == "assistant" { + defaultRole = "model" + } + steps.ForEach(func(_, step gjson.Result) bool { + out = appendInteractionsInputItem(out, step, defaultRole) + return true + }) + return out + } + return appendInteractionsInputItem(out, input, "user") +} + +func appendInteractionsInputItem(out []byte, item gjson.Result, defaultRole string) []byte { + if item.Type == gjson.String { + return appendGeminiTextContent(out, defaultRole, item.String()) + } + if steps := item.Get("steps"); steps.Exists() && steps.IsArray() { + role := defaultRole + if itemRole := item.Get("role").String(); itemRole == "model" || itemRole == "assistant" { + role = "model" + } else if itemRole == "user" { + role = "user" + } + steps.ForEach(func(_, step gjson.Result) bool { + out = appendInteractionsInputItem(out, step, role) + return true + }) + return out + } + stepType := item.Get("type").String() + switch stepType { + case "model_output", "thought": + return appendInteractionsStepContent(out, "model", item, stepType == "thought") + case "function_call": + return appendInteractionsFunctionCall(out, item) + case "function_result": + return appendInteractionsFunctionResult(out, item) + case "user_input", "": + if item.Get("parts").Exists() { + return appendInteractionsNativeContent(out, item, defaultRole) + } + return appendInteractionsContentList(out, defaultRole, item.Get("content")) + default: + if item.Get("parts").Exists() { + return appendInteractionsNativeContent(out, item, defaultRole) + } + if item.Get("content").Exists() { + return appendInteractionsContentList(out, defaultRole, item.Get("content")) + } + if text := item.Get("text"); text.Exists() { + return appendGeminiTextContent(out, defaultRole, text.String()) + } + } + return out +} + +func appendInteractionsNativeContent(out []byte, item gjson.Result, defaultRole string) []byte { + parts := item.Get("parts") + if !parts.Exists() || !parts.IsArray() { + return out + } + role := interactionsGeminiContentRole(item.Get("role").String(), defaultRole) + contentObj := []byte(`{"role":"","parts":[]}`) + contentObj, _ = sjson.SetBytes(contentObj, "role", role) + parts.ForEach(func(_, part gjson.Result) bool { + partJSON := interactionsNativeGeminiPart(part) + if len(partJSON) > 0 { + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", partJSON) + } + return true + }) + if gjson.GetBytes(contentObj, "parts.#").Int() == 0 { + return out + } + out, _ = sjson.SetRawBytes(out, "contents.-1", contentObj) + return out +} + +func interactionsGeminiContentRole(role, defaultRole string) string { + switch strings.ToLower(strings.TrimSpace(role)) { + case "model", "assistant": + return "model" + case "user": + return "user" + } + if defaultRole == "model" { + return "model" + } + return "user" +} + +func interactionsNativeGeminiPart(part gjson.Result) []byte { + switch { + case part.Get("text").Exists(), part.Get("functionCall").Exists(), part.Get("functionResponse").Exists(): + return []byte(part.Raw) + case part.Get("inlineData").Exists(): + return geminiInlineDataPartJSON(part.Get("inlineData")) + case part.Get("fileData").Exists(): + return geminiFileDataPartJSON(part.Get("fileData")) + case part.Get("inline_data").Exists(): + return geminiInlineDataPartJSON(part.Get("inline_data")) + case part.Get("file_data").Exists(): + return geminiFileDataPartJSON(part.Get("file_data")) + } + return nil +} + +func appendInteractionsContentPart(out []byte, role string, part gjson.Result) []byte { + partJSON := interactionsContentPartToGeminiPart(part, false) + if len(partJSON) == 0 { + return out + } + contentObj := []byte(`{"role":"","parts":[]}`) + contentObj, _ = sjson.SetBytes(contentObj, "role", role) + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", partJSON) + out, _ = sjson.SetRawBytes(out, "contents.-1", contentObj) + return out +} + +func interactionsContentPartToGeminiPart(part gjson.Result, thought bool) []byte { + if text := part.Get("text"); text.Exists() { + return geminiTextPartJSON(text.String(), thought) + } + if inline := part.Get("inline_data"); inline.Exists() { + return geminiInlineDataPartJSON(inline) + } + if inline := part.Get("inlineData"); inline.Exists() { + return geminiInlineDataPartJSON(inline) + } + partType := strings.ToLower(strings.TrimSpace(part.Get("type").String())) + switch partType { + case "text": + if text := part.Get("text"); text.Exists() { + return geminiTextPartJSON(text.String(), thought) + } + case "image", "audio", "video", "document": + if mime := part.Get("mime_type"); mime.Exists() || part.Get("mimeType").Exists() { + mimeType := mime.String() + if mimeType == "" { + mimeType = part.Get("mimeType").String() + } + data := part.Get("data").String() + if data != "" { + return geminiInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data))) + } + } + if uri := part.Get("file_uri"); uri.Exists() || part.Get("fileUri").Exists() { + fileURI := uri.String() + if fileURI == "" { + fileURI = part.Get("fileUri").String() + } + mimeType := part.Get("mime_type").String() + if mimeType == "" { + mimeType = part.Get("mimeType").String() + } + return geminiFileDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mimeType":%q,"fileUri":%q}`, mimeType, fileURI))) + } + if url := part.Get("url"); url.Exists() { + return geminiInlineDataPartFromDataURL(url.String()) + } + case "image_url": + return geminiInlineDataPartFromDataURL(part.Get("image_url.url").String()) + case "input_audio": + mimeType := interactionsInputAudioMimeType(part.Get("input_audio.format").String()) + return geminiInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, part.Get("input_audio.data").String()))) + case "file": + filename := part.Get("file.filename").String() + fileData := part.Get("file.file_data").String() + ext := "" + if sp := strings.Split(filename, "."); len(sp) > 1 { + ext = sp[len(sp)-1] + } + if mimeType, ok := misc.MimeTypes[ext]; ok && fileData != "" { + return geminiInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, fileData))) + } + } + return nil +} + +func geminiTextPartJSON(text string, thought bool) []byte { + partJSON := []byte(`{"text":""}`) + partJSON, _ = sjson.SetBytes(partJSON, "text", text) + if thought { + partJSON, _ = sjson.SetBytes(partJSON, "thought", true) + } + return partJSON +} + +func appendGeminiInlineDataPart(out []byte, role string, inline gjson.Result) []byte { + mimeType := inline.Get("mime_type").String() + if mimeType == "" { + mimeType = inline.Get("mimeType").String() + } + data := inline.Get("data").String() + if mimeType == "" || data == "" { + return out + } + partJSON := geminiInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mimeType":%q,"data":%q}`, mimeType, data))) + contentObj := []byte(`{"role":"","parts":[]}`) + contentObj, _ = sjson.SetBytes(contentObj, "role", role) + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", partJSON) + out, _ = sjson.SetRawBytes(out, "contents.-1", contentObj) + return out +} + +func appendGeminiFileDataPart(out []byte, role, mimeType, fileURI string) []byte { + if mimeType == "" || fileURI == "" { + return out + } + partJSON := geminiFileDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mimeType":%q,"fileUri":%q}`, mimeType, fileURI))) + contentObj := []byte(`{"role":"","parts":[]}`) + contentObj, _ = sjson.SetBytes(contentObj, "role", role) + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", partJSON) + out, _ = sjson.SetRawBytes(out, "contents.-1", contentObj) + return out +} + +func geminiInlineDataPartJSON(inline gjson.Result) []byte { + mimeType := inline.Get("mimeType").String() + if mimeType == "" { + mimeType = inline.Get("mime_type").String() + } + data := inline.Get("data").String() + if mimeType == "" || data == "" { + return nil + } + partJSON := []byte(`{"inlineData":{"mimeType":"","data":""}}`) + partJSON, _ = sjson.SetBytes(partJSON, "inlineData.mimeType", mimeType) + partJSON, _ = sjson.SetBytes(partJSON, "inlineData.data", data) + return partJSON +} + +func geminiFileDataPartJSON(fileData gjson.Result) []byte { + mimeType := fileData.Get("mimeType").String() + if mimeType == "" { + mimeType = fileData.Get("mime_type").String() + } + fileURI := fileData.Get("fileUri").String() + if fileURI == "" { + fileURI = fileData.Get("file_uri").String() + } + if mimeType == "" || fileURI == "" { + return nil + } + partJSON := []byte(`{"fileData":{"mimeType":"","fileUri":""}}`) + partJSON, _ = sjson.SetBytes(partJSON, "fileData.mimeType", mimeType) + partJSON, _ = sjson.SetBytes(partJSON, "fileData.fileUri", fileURI) + return partJSON +} + +func appendGeminiInlineDataFromDataURL(out []byte, role, dataURL string) []byte { + partJSON := geminiInlineDataPartFromDataURL(dataURL) + if len(partJSON) == 0 { + return out + } + contentObj := []byte(`{"role":"","parts":[]}`) + contentObj, _ = sjson.SetBytes(contentObj, "role", role) + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", partJSON) + out, _ = sjson.SetRawBytes(out, "contents.-1", contentObj) + return out +} + +func geminiInlineDataPartFromDataURL(dataURL string) []byte { + if !strings.HasPrefix(dataURL, "data:") { + return nil + } + payload := dataURL[5:] + pieces := strings.SplitN(payload, ";", 2) + if len(pieces) != 2 || !strings.HasPrefix(pieces[1], "base64,") { + return nil + } + mimeType := pieces[0] + data := pieces[1][7:] + return geminiInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data))) +} + +func interactionsInputAudioMimeType(format string) string { + switch strings.ToLower(strings.TrimSpace(format)) { + case "wav": + return "audio/wav" + case "mp3": + return "audio/mpeg" + case "flac": + return "audio/flac" + case "opus": + return "audio/opus" + case "pcm16": + return "audio/pcm" + default: + return "audio/mpeg" + } +} + +func geminiInlineDataToInteractionsContent(mimeType, data string) []byte { + contentType := "document" + lower := strings.ToLower(mimeType) + switch { + case strings.HasPrefix(lower, "image/"): + contentType = "image" + case strings.HasPrefix(lower, "audio/"): + contentType = "audio" + case strings.HasPrefix(lower, "video/"): + contentType = "video" + } + item := []byte(`{"type":"","mime_type":"","data":""}`) + item, _ = sjson.SetBytes(item, "type", contentType) + item, _ = sjson.SetBytes(item, "mime_type", mimeType) + item, _ = sjson.SetBytes(item, "data", data) + return item +} + +func appendInteractionsContentList(out []byte, role string, content gjson.Result) []byte { + if !content.Exists() { + return out + } + if content.IsArray() { + content.ForEach(func(_, part gjson.Result) bool { + out = appendInteractionsContentPart(out, role, part) + return true + }) + return out + } + if content.IsObject() { + return appendInteractionsContentPart(out, role, content) + } + if content.Type == gjson.String { + return appendGeminiTextContent(out, role, content.String()) + } + return out +} + +func appendInteractionsStepContent(out []byte, role string, item gjson.Result, thought bool) []byte { + content := item.Get("content") + if !content.Exists() { + return out + } + contentObj := []byte(`{"role":"","parts":[]}`) + contentObj, _ = sjson.SetBytes(contentObj, "role", role) + if content.IsArray() { + content.ForEach(func(_, part gjson.Result) bool { + if partJSON := interactionsContentPartToGeminiPart(part, thought); len(partJSON) > 0 { + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", partJSON) + } + return true + }) + } else if content.IsObject() { + if partJSON := interactionsContentPartToGeminiPart(content, thought); len(partJSON) > 0 { + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", partJSON) + } + } else if content.Type == gjson.String { + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", geminiTextPartJSON(content.String(), thought)) + } + if gjson.GetBytes(contentObj, "parts.#").Int() == 0 { + return out + } + out, _ = sjson.SetRawBytes(out, "contents.-1", contentObj) + return out +} + +func appendInteractionsFunctionCall(out []byte, item gjson.Result) []byte { + part := []byte(`{"functionCall":{"name":"","args":{}}}`) + part, _ = sjson.SetBytes(part, "functionCall.name", item.Get("name").String()) + if callID := item.Get("call_id"); callID.Exists() { + part, _ = sjson.SetBytes(part, "functionCall.id", callID.String()) + } else if id := item.Get("id"); id.Exists() { + part, _ = sjson.SetBytes(part, "functionCall.id", id.String()) + } + if args := item.Get("arguments"); args.Exists() { + part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(args.Raw)) + } + contentObj := []byte(`{"role":"model","parts":[]}`) + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", part) + out, _ = sjson.SetRawBytes(out, "contents.-1", contentObj) + return out +} + +func appendInteractionsFunctionResult(out []byte, item gjson.Result) []byte { + part := []byte(`{"functionResponse":{"name":"","response":{}}}`) + part, _ = sjson.SetBytes(part, "functionResponse.name", item.Get("name").String()) + if callID := item.Get("call_id"); callID.Exists() { + part, _ = sjson.SetBytes(part, "functionResponse.id", callID.String()) + } else if id := item.Get("id"); id.Exists() { + part, _ = sjson.SetBytes(part, "functionResponse.id", id.String()) + } + if result := item.Get("result"); result.Exists() { + part, _ = sjson.SetRawBytes(part, "functionResponse.response", []byte(result.Raw)) + } + contentObj := []byte(`{"role":"user","parts":[]}`) + contentObj, _ = sjson.SetRawBytes(contentObj, "parts.-1", part) + out, _ = sjson.SetRawBytes(out, "contents.-1", contentObj) + return out +} + +func appendGeminiTextContent(out []byte, role, text string) []byte { + contentObj := []byte(`{"role":"","parts":[{"text":""}]}`) + contentObj, _ = sjson.SetBytes(contentObj, "role", role) + contentObj, _ = sjson.SetBytes(contentObj, "parts.0.text", text) + out, _ = sjson.SetRawBytes(out, "contents.-1", contentObj) + return out +} + +func setInteractionsUsageFromGemini(out []byte, path string, root gjson.Result) []byte { + usage := root.Get("usageMetadata") + if !usage.Exists() { + usage = root.Get("usage_metadata") + } + if !usage.Exists() { + return out + } + out, _ = sjson.SetBytes(out, path+".input_tokens", usage.Get("promptTokenCount").Int()) + out, _ = sjson.SetBytes(out, path+".output_tokens", usage.Get("candidatesTokenCount").Int()) + if reasoning := usage.Get("thoughtsTokenCount"); reasoning.Exists() { + out, _ = sjson.SetBytes(out, path+".reasoning_tokens", reasoning.Int()) + } + out, _ = sjson.SetBytes(out, path+".total_tokens", usage.Get("totalTokenCount").Int()) + if cached := usage.Get("cachedContentTokenCount"); cached.Exists() { + out, _ = sjson.SetBytes(out, path+".cached_tokens", cached.Int()) + } else if cached := usage.Get("cached_content_token_count"); cached.Exists() { + out, _ = sjson.SetBytes(out, path+".cached_tokens", cached.Int()) + } + return out +} + +func setInteractionsStreamUsageFromGemini(out []byte, path string, root gjson.Result) []byte { + usage := root.Get("usageMetadata") + if !usage.Exists() { + usage = root.Get("usage_metadata") + } + if !usage.Exists() { + return out + } + inputTokens := usage.Get("promptTokenCount").Int() + outputTokens := usage.Get("candidatesTokenCount").Int() + totalTokens := usage.Get("totalTokenCount").Int() + thoughtTokens := usage.Get("thoughtsTokenCount").Int() + cachedTokens := usage.Get("cachedContentTokenCount").Int() + if cachedTokens == 0 { + cachedTokens = usage.Get("cached_content_token_count").Int() + } + out, _ = sjson.SetBytes(out, path+".total_tokens", totalTokens) + out, _ = sjson.SetBytes(out, path+".total_input_tokens", inputTokens) + out, _ = sjson.SetRawBytes(out, path+".input_tokens_by_modality", []byte(fmt.Sprintf(`[{"modality":"text","tokens":%d}]`, inputTokens))) + out, _ = sjson.SetBytes(out, path+".total_cached_tokens", cachedTokens) + out, _ = sjson.SetBytes(out, path+".total_output_tokens", outputTokens) + out, _ = sjson.SetBytes(out, path+".total_tool_use_tokens", 0) + out, _ = sjson.SetBytes(out, path+".total_thought_tokens", thoughtTokens) + return out +} + +func appendInteractionsStepStart(out [][]byte, st *StreamState, stepType string, part gjson.Result) [][]byte { + st.StepID = fmt.Sprintf("step_%d", time.Now().UnixNano()) + st.ActiveStepIndex = st.StepIndex + st.StepIndex++ + st.ActiveStepType = stepType + st.ActiveStepOpen = true + stepStart := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`) + stepStart, _ = sjson.SetBytes(stepStart, "index", st.ActiveStepIndex) + stepStart, _ = sjson.SetBytes(stepStart, "step.type", stepType) + if stepType == "function_call" { + id := interactionsFunctionPartID(part) + if id == "" { + id = st.StepID + } + stepStart, _ = sjson.SetBytes(stepStart, "step.id", id) + stepStart, _ = sjson.SetBytes(stepStart, "step.name", part.Get("name").String()) + stepStart, _ = sjson.SetRawBytes(stepStart, "step.arguments", []byte(`{}`)) + } + return append(out, translatorcommon.SSEEventData("step.start", stepStart)) +} + +func appendInteractionsStepStop(out [][]byte, st *StreamState) [][]byte { + if !st.ActiveStepOpen { + return out + } + stepStop := []byte(`{"index":0,"event_type":"step.stop"}`) + stepStop, _ = sjson.SetBytes(stepStop, "index", st.ActiveStepIndex) + out = append(out, translatorcommon.SSEEventData("step.stop", stepStop)) + st.ActiveStepOpen = false + st.ActiveStepType = "" + return out +} + +func ensureInteractionsStep(out [][]byte, st *StreamState, stepType string, part gjson.Result) [][]byte { + if st.ActiveStepOpen && st.ActiveStepType == stepType { + return out + } + out = appendInteractionsStepStop(out, st) + return appendInteractionsStepStart(out, st, stepType, part) +} + +func appendGeminiPartToInteractionsStream(out [][]byte, st *StreamState, part gjson.Result) [][]byte { + if text := part.Get("text"); text.Exists() && text.String() != "" { + if part.Get("thought").Bool() { + out = ensureInteractionsStep(out, st, "thought", gjson.Result{}) + delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`) + delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) + delta, _ = sjson.SetBytes(delta, "delta.content.text", text.String()) + out = append(out, translatorcommon.SSEEventData("step.delta", delta)) + return appendInteractionsThoughtSignature(out, st, part) + } + out = ensureInteractionsStep(out, st, "model_output", gjson.Result{}) + delta := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`) + delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) + delta, _ = sjson.SetBytes(delta, "delta.text", text.String()) + return append(out, translatorcommon.SSEEventData("step.delta", delta)) + } + if fc := part.Get("functionCall"); fc.Exists() { + out = appendInteractionsThoughtSignature(out, st, part) + out = ensureInteractionsStep(out, st, "function_call", fc) + delta := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`) + delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) + arguments := `{}` + if args := fc.Get("args"); args.Exists() { + arguments = args.Raw + } + delta, _ = sjson.SetBytes(delta, "delta.arguments", arguments) + out = append(out, translatorcommon.SSEEventData("step.delta", delta)) + return appendInteractionsStepStop(out, st) + } + if fr := part.Get("functionResponse"); fr.Exists() { + out = ensureInteractionsStep(out, st, "function_result", fr) + delta := []byte(`{"index":0,"delta":{"type":"function_result","name":"","result":{}},"event_type":"step.delta"}`) + delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex) + delta, _ = sjson.SetBytes(delta, "delta.name", fr.Get("name").String()) + if response := fr.Get("response"); response.Exists() { + delta, _ = sjson.SetRawBytes(delta, "delta.result", []byte(response.Raw)) + } + out = append(out, translatorcommon.SSEEventData("step.delta", delta)) + return appendInteractionsStepStop(out, st) + } + return out +} + +func appendInteractionsThoughtSignature(out [][]byte, st *StreamState, part gjson.Result) [][]byte { + if signature := interactionsThoughtSignature(part); signature != "" { + out = ensureInteractionsStep(out, st, "thought", gjson.Result{}) + signatureDelta := []byte(`{"index":0,"delta":{"signature":"","type":"thought_signature"},"event_type":"step.delta"}`) + signatureDelta, _ = sjson.SetBytes(signatureDelta, "index", st.ActiveStepIndex) + signatureDelta, _ = sjson.SetBytes(signatureDelta, "delta.signature", signature) + return append(out, translatorcommon.SSEEventData("step.delta", signatureDelta)) + } + return out +} + +func interactionsFunctionPartID(part gjson.Result) string { + if id := part.Get("id"); id.Exists() { + return id.String() + } + if callID := part.Get("call_id"); callID.Exists() { + return callID.String() + } + return "" +} + +func interactionsThoughtSignature(part gjson.Result) string { + for _, path := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} { + if signature := strings.TrimSpace(part.Get(path).String()); signature != "" { + return signature + } + } + return "" +} + +func geminiPartToInteractionsStep(part gjson.Result) []byte { + if fc := part.Get("functionCall"); fc.Exists() { + step := []byte(`{"type":"function_call","name":"","arguments":{}}`) + step, _ = sjson.SetBytes(step, "name", fc.Get("name").String()) + if id := fc.Get("id"); id.Exists() { + step, _ = sjson.SetBytes(step, "call_id", id.String()) + } else if callID := fc.Get("call_id"); callID.Exists() { + step, _ = sjson.SetBytes(step, "call_id", callID.String()) + } + if args := fc.Get("args"); args.Exists() { + step, _ = sjson.SetRawBytes(step, "arguments", []byte(args.Raw)) + } + return step + } + if fr := part.Get("functionResponse"); fr.Exists() { + step := []byte(`{"type":"function_result","name":"","result":{}}`) + step, _ = sjson.SetBytes(step, "name", fr.Get("name").String()) + if id := fr.Get("id"); id.Exists() { + step, _ = sjson.SetBytes(step, "call_id", id.String()) + } else if callID := fr.Get("call_id"); callID.Exists() { + step, _ = sjson.SetBytes(step, "call_id", callID.String()) + } + if response := fr.Get("response"); response.Exists() { + step, _ = sjson.SetRawBytes(step, "result", []byte(response.Raw)) + } + return step + } + if text := part.Get("text"); text.Exists() { + step := []byte(`{"type":"model_output","content":[]}`) + if part.Get("thought").Bool() { + step, _ = sjson.SetBytes(step, "type", "thought") + } + item := []byte(`{"text":""}`) + item, _ = sjson.SetBytes(item, "text", text.String()) + step, _ = sjson.SetRawBytes(step, "content.-1", item) + return step + } + if inline := part.Get("inlineData"); inline.Exists() { + mimeType := inline.Get("mimeType").String() + if mimeType == "" { + mimeType = inline.Get("mime_type").String() + } + item := geminiInlineDataToInteractionsContent(mimeType, inline.Get("data").String()) + step := []byte(`{"type":"model_output","content":[]}`) + step, _ = sjson.SetRawBytes(step, "content.-1", item) + return step + } + if inline := part.Get("inline_data"); inline.Exists() { + item := geminiInlineDataToInteractionsContent(inline.Get("mime_type").String(), inline.Get("data").String()) + step := []byte(`{"type":"model_output","content":[]}`) + step, _ = sjson.SetRawBytes(step, "content.-1", item) + return step + } + return nil +} diff --git a/internal/translator/gemini/interactions/interactions_gemini_common_test.go b/internal/translator/gemini/interactions/interactions_gemini_common_test.go new file mode 100644 index 000000000..71d762f85 --- /dev/null +++ b/internal/translator/gemini/interactions/interactions_gemini_common_test.go @@ -0,0 +1,715 @@ +package interactions + +import ( + "bytes" + "context" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertInteractionsRequestToGeminiStringInput(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":"hello"}`), false) + if got := gjson.GetBytes(out, "contents.0.role").String(); got != "user" { + t.Fatalf("role = %q, want user", got) + } + if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hello" { + t.Fatalf("text = %q, want hello", got) + } +} + +func TestConvertInteractionsRequestToGeminiSystemAndGenerationConfig(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","system_instruction":{"text":"be brief"},"generation_config":{"max_output_tokens":32,"top_p":0.8},"input":"hi"}`), false) + if got := gjson.GetBytes(out, "systemInstruction.parts.0.text").String(); got != "be brief" { + t.Fatalf("systemInstruction = %q, want be brief", got) + } + if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != 32 { + t.Fatalf("maxOutputTokens = %d, want 32", got) + } + if got := gjson.GetBytes(out, "generationConfig.topP").Float(); got != 0.8 { + t.Fatalf("topP = %v, want 0.8", got) + } +} + +func TestConvertInteractionsRequestToGeminiStringSystemInstruction(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","system_instruction":"be brief","input":"hi"}`), false) + if got := gjson.GetBytes(out, "systemInstruction.parts.0.text").String(); got != "be brief" { + t.Fatalf("systemInstruction.parts.0.text = %q, want be brief. Output: %s", got, string(out)) + } +} + +func TestConvertGeminiRequestToInteractionsStringSystemInstruction(t *testing.T) { + out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","systemInstruction":{"parts":[{"text":"be brief"},{"text":"answer directly"}]},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`), false) + sys := gjson.GetBytes(out, "system_instruction") + if sys.Type != gjson.String { + t.Fatalf("system_instruction type = %v, want string. Output: %s", sys.Type, string(out)) + } + if got := sys.String(); got != "be brief\nanswer directly" { + t.Fatalf("system_instruction = %q, want merged text. Output: %s", got, string(out)) + } + if gjson.GetBytes(out, "system_instruction.parts").Exists() { + t.Fatalf("system_instruction.parts should not be forwarded. Output: %s", string(out)) + } +} + +func TestConvertGeminiResponseToInteractionsNonStream(t *testing.T) { + out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2,"totalTokenCount":3}}`)) + if got := gjson.GetBytes(out, "steps.0.type").String(); got != "model_output" { + t.Fatalf("step type = %q, want model_output", got) + } + if got := gjson.GetBytes(out, "steps.0.content.0.text").String(); got != "ok" { + t.Fatalf("text = %q, want ok", got) + } + if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 3 { + t.Fatalf("total tokens = %d, want 3", got) + } +} + +func TestConvertInteractionsResponseToGeminiStreamFunctionCall(t *testing.T) { + var param any + created := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`data: {"interaction":{"id":"i1","model":"gemini-3.1-flash-lite"},"event_type":"interaction.created"}`), ¶m) + if len(created) != 0 { + t.Fatalf("created output count = %d, want 0", len(created)) + } + start := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`data: {"index":0,"step":{"type":"function_call","id":"call_1","signature":"sig_1","name":"get_weather","arguments":{}},"event_type":"step.start"}`), ¶m) + if len(start) != 0 { + t.Fatalf("start output count = %d, want 0", len(start)) + } + delta := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`data: {"index":0,"delta":{"type":"arguments_delta","arguments":"{\"location\":\"北京\"}"},"event_type":"step.delta"}`), ¶m) + if len(delta) != 1 { + t.Fatalf("delta output count = %d, want 1", len(delta)) + } + if got := gjson.GetBytes(delta[0], "candidates.0.content.parts.0.functionCall.name").String(); got != "get_weather" { + t.Fatalf("functionCall.name = %q, want get_weather. Payload: %s", got, string(delta[0])) + } + if got := gjson.GetBytes(delta[0], "candidates.0.content.parts.0.functionCall.args.location").String(); got != "北京" { + t.Fatalf("functionCall.args.location = %q, want 北京. Payload: %s", got, string(delta[0])) + } + if got := gjson.GetBytes(delta[0], "candidates.0.content.parts.0.functionCall.id").String(); got != "call_1" { + t.Fatalf("functionCall.id = %q, want call_1. Payload: %s", got, string(delta[0])) + } + if got := gjson.GetBytes(delta[0], "candidates.0.content.parts.0.thoughtSignature").String(); got != "sig_1" { + t.Fatalf("thoughtSignature = %q, want sig_1. Payload: %s", got, string(delta[0])) + } + completed := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`data: {"interaction":{"id":"i1","status":"requires_action","usage":{"total_input_tokens":2,"total_output_tokens":3,"total_tokens":5,"total_thought_tokens":1,"total_cached_tokens":4},"service_tier":"standard","model":"gemini-3.1-flash-lite"},"event_type":"interaction.completed"}`), ¶m) + if len(completed) != 1 { + t.Fatalf("completed output count = %d, want 1", len(completed)) + } + if got := gjson.GetBytes(completed[0], "candidates.0.finishReason").String(); got != "STOP" { + t.Fatalf("finishReason = %q, want STOP. Payload: %s", got, string(completed[0])) + } + if got := gjson.GetBytes(completed[0], "usageMetadata.promptTokenCount").Int(); got != 2 { + t.Fatalf("promptTokenCount = %d, want 2. Payload: %s", got, string(completed[0])) + } + if got := gjson.GetBytes(completed[0], "usageMetadata.candidatesTokenCount").Int(); got != 3 { + t.Fatalf("candidatesTokenCount = %d, want 3. Payload: %s", got, string(completed[0])) + } + if got := gjson.GetBytes(completed[0], "usageMetadata.totalTokenCount").Int(); got != 5 { + t.Fatalf("totalTokenCount = %d, want 5. Payload: %s", got, string(completed[0])) + } + if got := gjson.GetBytes(completed[0], "usageMetadata.promptTokensDetails.0.tokenCount").Int(); got != 2 { + t.Fatalf("promptTokensDetails.0.tokenCount = %d, want 2. Payload: %s", got, string(completed[0])) + } + done := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`event: done +data: [DONE]`), ¶m) + if len(done) != 0 { + t.Fatalf("done output count = %d, want 0", len(done)) + } +} + +func TestConvertInteractionsResponseToGeminiStreamFinishMetadataUsage(t *testing.T) { + var param any + out := ConvertInteractionsResponseToGemini(context.Background(), "gemini-test", nil, nil, []byte(`data: {"event_type":"finish","metadata":{"total_usage":{"total_input_tokens":2,"total_output_tokens":6,"total_thought_tokens":3,"total_cached_tokens":1,"total_tokens":11}}}`), ¶m) + if len(out) != 1 { + t.Fatalf("output count = %d, want 1", len(out)) + } + if got := gjson.GetBytes(out[0], "candidates.0.finishReason").String(); got != "STOP" { + t.Fatalf("finishReason = %q, want STOP. Payload: %s", got, string(out[0])) + } + if got := gjson.GetBytes(out[0], "usageMetadata.promptTokenCount").Int(); got != 2 { + t.Fatalf("promptTokenCount = %d, want 2. Payload: %s", got, string(out[0])) + } + if got := gjson.GetBytes(out[0], "usageMetadata.candidatesTokenCount").Int(); got != 6 { + t.Fatalf("candidatesTokenCount = %d, want 6. Payload: %s", got, string(out[0])) + } + if got := gjson.GetBytes(out[0], "usageMetadata.thoughtsTokenCount").Int(); got != 3 { + t.Fatalf("thoughtsTokenCount = %d, want 3. Payload: %s", got, string(out[0])) + } + if got := gjson.GetBytes(out[0], "usageMetadata.cachedContentTokenCount").Int(); got != 1 { + t.Fatalf("cachedContentTokenCount = %d, want 1. Payload: %s", got, string(out[0])) + } + if got := gjson.GetBytes(out[0], "usageMetadata.totalTokenCount").Int(); got != 11 { + t.Fatalf("totalTokenCount = %d, want 11. Payload: %s", got, string(out[0])) + } +} + +func TestConvertInteractionsResponseToGeminiNonStreamFunctionCall(t *testing.T) { + raw := []byte(`{"id":"i1","model":"gemini-3.1-flash-lite","steps":[{"type":"function_call","call_id":"call_1","signature":"sig_1","name":"get_weather","arguments":{"location":"北京"}}],"usage":{"total_input_tokens":2,"total_output_tokens":3,"total_tokens":5}}`) + out := ConvertInteractionsResponseToGeminiNonStream(context.Background(), "gemini-3.1-flash-lite", nil, nil, raw, nil) + if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.name").String(); got != "get_weather" { + t.Fatalf("functionCall.name = %q, want get_weather. Payload: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.args.location").String(); got != "北京" { + t.Fatalf("functionCall.args.location = %q, want 北京. Payload: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "candidates.0.content.parts.0.thoughtSignature").String(); got != "sig_1" { + t.Fatalf("thoughtSignature = %q, want sig_1. Payload: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "usageMetadata.totalTokenCount").Int(); got != 5 { + t.Fatalf("totalTokenCount = %d, want 5. Payload: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToGeminiTurnInput(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":{"role":"user","steps":[{"type":"user_input","content":[{"text":"hi"}]}]}}`), false) + if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hi" { + t.Fatalf("text = %q, want hi", got) + } +} + +func TestConvertInteractionsRequestToGeminiTurnArrayInput(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"role":"user","steps":[{"type":"user_input","content":[{"text":"hi"}]}]},{"role":"assistant","steps":[{"type":"model_output","content":[{"text":"ok"}]}]}]}`), false) + if got := gjson.GetBytes(out, "contents.0.role").String(); got != "user" { + t.Fatalf("contents.0.role = %q, want user. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hi" { + t.Fatalf("contents.0.parts.0.text = %q, want hi. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "contents.1.role").String(); got != "model" { + t.Fatalf("contents.1.role = %q, want model. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "contents.1.parts.0.text").String(); got != "ok" { + t.Fatalf("contents.1.parts.0.text = %q, want ok. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToGeminiPreservesExpressibleTopLevelFields(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","tool_choice":{"type":"function","function":{"name":"lookup"}},"response_modalities":["text","image"],"service_tier":"priority","input":"hi"}`), false) + if got := gjson.GetBytes(out, "toolConfig.functionCallingConfig.mode").String(); got != "ANY" { + t.Fatalf("toolConfig.functionCallingConfig.mode = %q, want ANY. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != "lookup" { + t.Fatalf("allowedFunctionNames.0 = %q, want lookup. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "generationConfig.responseModalities.0").String(); got != "TEXT" { + t.Fatalf("responseModalities.0 = %q, want TEXT. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "generationConfig.responseModalities.1").String(); got != "IMAGE" { + t.Fatalf("responseModalities.1 = %q, want IMAGE. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "service_tier").String(); got != "priority" { + t.Fatalf("service_tier = %q, want priority. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToGeminiContentInput(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":{"role":"user","parts":[{"text":"hi"}]}}`), false) + if got := gjson.GetBytes(out, "contents.0.role").String(); got != "user" { + t.Fatalf("contents.0.role = %q, want user", got) + } + if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hi" { + t.Fatalf("contents.0.parts.0.text = %q, want hi", got) + } +} + +func TestConvertInteractionsRequestToGeminiContentArrayInput(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"role":"user","parts":[{"text":"hi"}]},{"role":"assistant","parts":[{"text":"ok"}]}]}`), false) + if got := gjson.GetBytes(out, "contents.0.role").String(); got != "user" { + t.Fatalf("contents.0.role = %q, want user", got) + } + if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hi" { + t.Fatalf("contents.0.parts.0.text = %q, want hi", got) + } + if got := gjson.GetBytes(out, "contents.1.role").String(); got != "model" { + t.Fatalf("contents.1.role = %q, want model", got) + } + if got := gjson.GetBytes(out, "contents.1.parts.0.text").String(); got != "ok" { + t.Fatalf("contents.1.parts.0.text = %q, want ok", got) + } +} + +func TestConvertGeminiResponseToInteractionsNonStreamFunctionCall(t *testing.T) { + out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","args":{"q":"x"}}}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2,"totalTokenCount":3,"cachedContentTokenCount":4}}`)) + if got := gjson.GetBytes(out, "steps.0.type").String(); got != "function_call" { + t.Fatalf("step type = %q, want function_call", got) + } + if got := gjson.GetBytes(out, "steps.0.name").String(); got != "lookup" { + t.Fatalf("name = %q, want lookup", got) + } + if got := gjson.GetBytes(out, "usage.cached_tokens").Int(); got != 4 { + t.Fatalf("cached tokens = %d, want 4", got) + } +} + +func TestConvertGeminiResponseToInteractionsNonStreamFunctionCallPreservesCallID(t *testing.T) { + out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","call_id":"call_response_1","args":{"q":"x"}}}]}}]}`)) + if got := gjson.GetBytes(out, "steps.0.call_id").String(); got != "call_response_1" { + t.Fatalf("steps.0.call_id = %q, want call_response_1", got) + } +} + +func TestConvertGeminiResponseToInteractionsStreamFunctionCallCallID(t *testing.T) { + var param any + out := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","call_id":"call_stream_1","args":{"q":"x"}}}]}}]}`), ¶m) + payload := findStepDeltaPayload(out) + if len(payload) == 0 { + t.Fatalf("step.delta payload not found") + } + startPayload := findEventPayload(out, "step.start") + if got := gjson.GetBytes(startPayload, "step.id").String(); got != "call_stream_1" { + t.Fatalf("step.id = %q, want call_stream_1", got) + } + if got := gjson.GetBytes(payload, "delta.arguments").String(); got != `{"q":"x"}` { + t.Fatalf("delta.arguments = %q, want JSON string", got) + } +} + +func TestConvertGeminiResponseToInteractionsStreamFunctionCallThoughtSignature(t *testing.T) { + var param any + thoughtOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"thinking","thought":true}]}}]}`), ¶m) + textOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"I will call the tool."}]}}]}`), ¶m) + callOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig-call","functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]}}]}`), ¶m) + + out := append(append(thoughtOut, textOut...), callOut...) + signaturePayload := findStepDeltaPayloadByType(out, "thought_signature") + if len(signaturePayload) == 0 { + t.Fatalf("thought_signature step.delta payload not found. Events: %s", eventTypes(out)) + } + if got := gjson.GetBytes(signaturePayload, "delta.signature").String(); got != "sig-call" { + t.Fatalf("delta.signature = %q, want sig-call. Payload: %s", got, string(signaturePayload)) + } + if got := gjson.GetBytes(signaturePayload, "index").Int(); got != 2 { + t.Fatalf("signature index = %d, want 2. Events: %s", got, eventTypes(out)) + } + functionStartPayload := findNthEventPayload(out, "step.start", 3) + if got := gjson.GetBytes(functionStartPayload, "step.type").String(); got != "function_call" { + t.Fatalf("fourth step type = %q, want function_call. Events: %s", got, eventTypes(out)) + } + if got := gjson.GetBytes(functionStartPayload, "step.id").String(); got != "call_1" { + t.Fatalf("function call id = %q, want call_1. Payload: %s", got, string(functionStartPayload)) + } + argumentsPayload := findStepDeltaPayloadByType(out, "arguments_delta") + if got := gjson.GetBytes(argumentsPayload, "delta.arguments").String(); got != `{"q":"x"}` { + t.Fatalf("delta.arguments = %q, want JSON string. Payload: %s", got, string(argumentsPayload)) + } +} + +func TestConvertGeminiResponseToInteractionsStreamStepLifecycle(t *testing.T) { + var param any + thoughtOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"thinking","thought":true}]}}]}`), ¶m) + textOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"answer"}]}}]}`), ¶m) + callOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":4,"totalTokenCount":7,"thoughtsTokenCount":2}}`), ¶m) + + out := append(append(thoughtOut, textOut...), callOut...) + if got := eventTypes(out); !bytes.Equal(got, []byte("interaction.created,interaction.status_update,step.start,step.delta,step.stop,step.start,step.delta,step.stop,step.start,step.delta,step.stop,interaction.completed")) { + t.Fatalf("event sequence = %s", got) + } + if got := gjson.GetBytes(findNthEventPayload(out, "step.start", 0), "step.type").String(); got != "thought" { + t.Fatalf("first step type = %q, want thought", got) + } + if got := gjson.GetBytes(findNthEventPayload(out, "step.start", 1), "step.type").String(); got != "model_output" { + t.Fatalf("second step type = %q, want model_output", got) + } + if got := gjson.GetBytes(findNthEventPayload(out, "step.start", 2), "step.type").String(); got != "function_call" { + t.Fatalf("third step type = %q, want function_call", got) + } + if got := gjson.GetBytes(findNthEventPayload(out, "step.delta", 0), "delta.type").String(); got != "thought_summary" { + t.Fatalf("thought delta type = %q, want thought_summary", got) + } + if got := gjson.GetBytes(findNthEventPayload(out, "step.delta", 2), "delta.type").String(); got != "arguments_delta" { + t.Fatalf("function delta type = %q, want arguments_delta", got) + } + completed := findCompletedPayload(out) + if got := gjson.GetBytes(completed, "interaction.usage.total_input_tokens").Int(); got != 3 { + t.Fatalf("total_input_tokens = %d, want 3. Payload: %s", got, string(completed)) + } + if got := gjson.GetBytes(completed, "interaction.usage.total_output_tokens").Int(); got != 4 { + t.Fatalf("total_output_tokens = %d, want 4. Payload: %s", got, string(completed)) + } + if got := gjson.GetBytes(completed, "interaction.usage.total_thought_tokens").Int(); got != 2 { + t.Fatalf("total_thought_tokens = %d, want 2. Payload: %s", got, string(completed)) + } +} + +func TestConvertGeminiResponseToInteractionsStreamEmitsTerminalOnce(t *testing.T) { + var param any + finishOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"finishReason":"STOP"}]}`), ¶m) + usageOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2,"totalTokenCount":3}}`), ¶m) + doneOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`[DONE]`), ¶m) + + if got := countEventType(finishOut, "step.stop"); got != 0 { + t.Fatalf("finish step.stop count = %d, want 0", got) + } + if got := countEventType(finishOut, "interaction.completed"); got != 0 { + t.Fatalf("finish interaction.completed count = %d, want 0", got) + } + if got := countEventType(usageOut, "step.stop"); got != 0 { + t.Fatalf("usage step.stop count = %d, want 0", got) + } + if got := countEventType(usageOut, "interaction.completed"); got != 1 { + t.Fatalf("usage interaction.completed count = %d, want 1", got) + } + if got := countEventType(doneOut, "interaction.completed"); got != 0 { + t.Fatalf("done interaction.completed count = %d, want 0", got) + } + if got := countEventType(doneOut, "done"); got != 1 { + t.Fatalf("done event count = %d, want 1", got) + } + if payload := findEventPayload(doneOut, "done"); string(payload) != "[DONE]" { + t.Fatalf("done payload = %q, want [DONE]", string(payload)) + } + payload := findCompletedPayload(usageOut) + if got := gjson.GetBytes(payload, "interaction.usage.total_tokens").Int(); got != 3 { + t.Fatalf("completed total_tokens = %d, want 3. Payload: %s", got, string(payload)) + } +} + +func TestConvertGeminiResponseToInteractionsStreamDoesNotCompleteOnNonTerminalUsage(t *testing.T) { + var param any + thoughtOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash-low", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"thought":true,"text":"thinking"}]}}],"usageMetadata":{"promptTokenCount":124,"totalTokenCount":124}}`), ¶m) + if got := countEventType(thoughtOut, "interaction.completed"); got != 0 { + t.Fatalf("thought interaction.completed count = %d, want 0. Events: %s", got, eventTypes(thoughtOut)) + } + if got := countEventType(thoughtOut, "step.stop"); got != 0 { + t.Fatalf("thought step.stop count = %d, want 0. Events: %s", got, eventTypes(thoughtOut)) + } + + textOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash-low", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"好的,我将为您调用天气查询工具。"}]}}],"usageMetadata":{"promptTokenCount":124,"candidatesTokenCount":17,"totalTokenCount":452,"thoughtsTokenCount":311}}`), ¶m) + callOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash-low", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"get_weather","args":{"location":"北京"},"id":"nriii75p"}}]}}],"usageMetadata":{"promptTokenCount":124,"candidatesTokenCount":33,"totalTokenCount":468,"thoughtsTokenCount":311}}`), ¶m) + finishOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash-low", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":124,"candidatesTokenCount":33,"totalTokenCount":468,"thoughtsTokenCount":311}}`), ¶m) + + out := append(append(append(thoughtOut, textOut...), callOut...), finishOut...) + if got := countEventType(out, "interaction.completed"); got != 1 { + t.Fatalf("interaction.completed count = %d, want 1. Events: %s", got, eventTypes(out)) + } + if got := eventTypes(out); !bytes.Equal(got, []byte("interaction.created,interaction.status_update,step.start,step.delta,step.stop,step.start,step.delta,step.stop,step.start,step.delta,step.stop,interaction.completed")) { + t.Fatalf("event sequence = %s", got) + } + payload := findCompletedPayload(out) + if got := gjson.GetBytes(payload, "interaction.usage.total_tokens").Int(); got != 468 { + t.Fatalf("completed total_tokens = %d, want 468. Payload: %s", got, string(payload)) + } +} + +func TestConvertGeminiResponseToInteractionsStreamIgnoresTrafficOnlyUsageMetadata(t *testing.T) { + var param any + out := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"}}`), ¶m) + if got := countEventType(out, "interaction.completed"); got != 0 { + t.Fatalf("interaction.completed count = %d, want 0. Events: %q", got, out) + } + if got := countEventType(out, "done"); got != 0 { + t.Fatalf("done count = %d, want 0. Events: %q", got, out) + } +} + +func TestConvertGeminiResponseToInteractionsStreamCompletesOnDoneWithoutUsage(t *testing.T) { + var param any + finishOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"finishReason":"STOP"}]}`), ¶m) + doneOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`[DONE]`), ¶m) + + if got := countEventType(finishOut, "interaction.completed"); got != 0 { + t.Fatalf("finish interaction.completed count = %d, want 0", got) + } + if got := countEventType(doneOut, "interaction.completed"); got != 1 { + t.Fatalf("done interaction.completed count = %d, want 1", got) + } + if got := countEventType(doneOut, "done"); got != 1 { + t.Fatalf("done event count = %d, want 1", got) + } +} + +func TestConvertInteractionsRequestToGeminiImageContent(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"user_input","content":[{"type":"image","mime_type":"image/png","data":"aGVsbG8="}]}]}`), false) + if got := gjson.GetBytes(out, "contents.0.parts.0.inlineData.mimeType").String(); got != "image/png" { + t.Fatalf("mimeType = %q, want image/png", got) + } + if got := gjson.GetBytes(out, "contents.0.parts.0.inlineData.data").String(); got != "aGVsbG8=" { + t.Fatalf("data = %q, want aGVsbG8=", got) + } +} + +func TestConvertInteractionsRequestToGeminiModelOutputTypedContent(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"model_output","content":[{"type":"image","mime_type":"image/png","data":"aGVsbG8="},{"type":"document","mime_type":"application/pdf","file_uri":"gs://bucket/doc.pdf"}]}]}`), false) + if got := gjson.GetBytes(out, "contents.0.role").String(); got != "model" { + t.Fatalf("contents.0.role = %q, want model. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "contents.0.parts.0.inlineData.mimeType").String(); got != "image/png" { + t.Fatalf("image mimeType = %q, want image/png. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "contents.0.parts.0.inlineData.data").String(); got != "aGVsbG8=" { + t.Fatalf("image data = %q, want aGVsbG8=. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "contents.0.parts.1.fileData.mimeType").String(); got != "application/pdf" { + t.Fatalf("document mimeType = %q, want application/pdf. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "contents.0.parts.1.fileData.fileUri").String(); got != "gs://bucket/doc.pdf" { + t.Fatalf("document fileUri = %q, want gs://bucket/doc.pdf. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToGeminiThoughtTypedContent(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"thought","content":[{"type":"text","text":"thinking"},{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="}]}]}`), false) + if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "thinking" { + t.Fatalf("thought text = %q, want thinking. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "contents.0.parts.0.thought").Bool(); !got { + t.Fatalf("thought flag = false, want true. Output: %s", string(out)) + } + if got := gjson.GetBytes(out, "contents.0.parts.1.inlineData.mimeType").String(); got != "audio/wav" { + t.Fatalf("audio mimeType = %q, want audio/wav. Output: %s", got, string(out)) + } +} + +func TestConvertGeminiResponseToInteractionsNonStreamImage(t *testing.T) { + out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}}]}`)) + if got := gjson.GetBytes(out, "steps.0.content.0.type").String(); got != "image" { + t.Fatalf("content type = %q, want image", got) + } +} + +func TestConvertInteractionsRequestToGeminiGenerationConfigAllFields(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","generation_config":{"max_output_tokens":32,"response_schema":{"type":"object"},"seed":42,"thinking_config":{"thinking_budget":1024,"include_thoughts":true},"context_window_compression":{"trigger_tokens":1000}},"input":"hi"}`), false) + if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != 32 { + t.Fatalf("maxOutputTokens = %d, want 32", got) + } + if got := gjson.GetBytes(out, "generationConfig.responseSchema.type").String(); got != "object" { + t.Fatalf("responseSchema.type = %q, want object", got) + } + if got := gjson.GetBytes(out, "generationConfig.seed").Int(); got != 42 { + t.Fatalf("seed = %d, want 42", got) + } + if got := gjson.GetBytes(out, "generationConfig.thinkingConfig.thinkingBudget").Int(); got != 1024 { + t.Fatalf("thinkingBudget = %d, want 1024", got) + } + if got := gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts").Bool(); !got { + t.Fatalf("includeThoughts = false, want true") + } + if got := gjson.GetBytes(out, "generationConfig.contextWindowCompression.triggerTokens").Int(); got != 1000 { + t.Fatalf("triggerTokens = %d, want 1000", got) + } +} + +func TestConvertInteractionsRequestToGeminiGenerationConfigProtocolFields(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","generation_config":{"tool_choice":"auto","thinking_level":"high","thinking_summaries":"auto"},"stream":true,"input":"hi"}`), true) + for _, path := range []string{ + "stream", + "generationConfig.toolChoice", + "generationConfig.thinkingLevel", + "generationConfig.thinkingSummaries", + } { + if gjson.GetBytes(out, path).Exists() { + t.Fatalf("%s exists, want omitted. Output: %s", path, string(out)) + } + } + if got := gjson.GetBytes(out, "toolConfig.functionCallingConfig.mode").String(); got != "AUTO" { + t.Fatalf("toolConfig.functionCallingConfig.mode = %q, want AUTO. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" { + t.Fatalf("thinkingLevel = %q, want high. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts").Bool(); !got { + t.Fatalf("includeThoughts = false, want true. Output: %s", string(out)) + } +} + +func TestConvertGeminiRequestToInteractionsFunctionCall(t *testing.T) { + out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","args":{"q":"x"}}}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","response":{"ok":true}}}]}]}`), false) + if got := gjson.GetBytes(out, "input.0.type").String(); got != "function_call" { + t.Fatalf("input.0.type = %q, want function_call", got) + } + if got := gjson.GetBytes(out, "input.0.name").String(); got != "lookup" { + t.Fatalf("input.0.name = %q, want lookup", got) + } + if got := gjson.GetBytes(out, "input.1.type").String(); got != "function_result" { + t.Fatalf("input.1.type = %q, want function_result", got) + } +} + +func TestConvertGeminiRequestToInteractionsTextContentType(t *testing.T) { + out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`), false) + if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "text" { + t.Fatalf("content.0.type = %q, want text. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hi" { + t.Fatalf("content.0.text = %q, want hi. Output: %s", got, string(out)) + } +} + +func TestConvertGeminiRequestToInteractionsMultimodal(t *testing.T) { + out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"audio/wav","data":"aGVsbG8="}}]}]}`), false) + if got := gjson.GetBytes(out, "input.0.type").String(); got != "user_input" { + t.Fatalf("input.0.type = %q, want user_input", got) + } + if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "audio" { + t.Fatalf("content.0.type = %q, want audio", got) + } + if got := gjson.GetBytes(out, "input.0.content.0.mime_type").String(); got != "audio/wav" { + t.Fatalf("mime_type = %q, want audio/wav", got) + } +} + +func TestConvertGeminiRequestToInteractionsThought(t *testing.T) { + out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"model","parts":[{"text":"thinking","thought":true}]}]}`), false) + if got := gjson.GetBytes(out, "input.0.type").String(); got != "thought" { + t.Fatalf("input.0.type = %q, want thought", got) + } +} + +func TestConvertInteractionsRequestToGeminiTurnWithModelRole(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":{"role":"model","steps":[{"type":"user_input","content":[{"text":"hi"}]},{"type":"model_output","content":[{"text":"ok"}]}]}}`), false) + if got := gjson.GetBytes(out, "contents.0.role").String(); got != "model" { + t.Fatalf("contents.0.role = %q, want model", got) + } + if got := gjson.GetBytes(out, "contents.1.role").String(); got != "model" { + t.Fatalf("contents.1.role = %q, want model", got) + } +} + +func TestConvertInteractionsRequestToGeminiGenerationConfigPreservesLargeIntegers(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","generation_config":{"max_output_tokens":32,"large_identity":9223372036854775807},"input":"hi"}`), false) + if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != 32 { + t.Fatalf("maxOutputTokens = %d, want 32", got) + } + if got := gjson.GetBytes(out, "generationConfig.largeIdentity").String(); got != "9223372036854775807" { + t.Fatalf("largeIdentity = %q, want 9223372036854775807", got) + } +} + +func TestConvertInteractionsRequestToGeminiFunctionCallPreservesCallID(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}}]}`), false) + if got := gjson.GetBytes(out, "contents.0.parts.0.functionCall.id").String(); got != "call_1" { + t.Fatalf("functionCall.id = %q, want call_1", got) + } + if got := gjson.GetBytes(out, "contents.0.parts.0.functionCall.name").String(); got != "lookup" { + t.Fatalf("functionCall.name = %q, want lookup", got) + } + if got := gjson.GetBytes(out, "contents.0.parts.0.functionCall.args.q").String(); got != "x" { + t.Fatalf("functionCall.args.q = %q, want x", got) + } +} + +func TestConvertInteractionsRequestToGeminiFunctionResultPreservesCallID(t *testing.T) { + out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}]}`), false) + if got := gjson.GetBytes(out, "contents.0.parts.0.functionResponse.id").String(); got != "call_1" { + t.Fatalf("functionResponse.id = %q, want call_1", got) + } + if got := gjson.GetBytes(out, "contents.0.parts.0.functionResponse.name").String(); got != "lookup" { + t.Fatalf("functionResponse.name = %q, want lookup", got) + } +} + +func TestConvertGeminiRequestToInteractionsFunctionCallPreservesID(t *testing.T) { + out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","id":"call_1","response":{"ok":true}}}]}]}`), false) + if got := gjson.GetBytes(out, "input.0.call_id").String(); got != "call_1" { + t.Fatalf("input.0.call_id = %q, want call_1", got) + } + if got := gjson.GetBytes(out, "input.1.call_id").String(); got != "call_1" { + t.Fatalf("input.1.call_id = %q, want call_1", got) + } +} + +func TestConvertGeminiRequestToInteractionsFunctionCallPreservesCallID(t *testing.T) { + out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","call_id":"call_request_1","args":{"q":"x"}}}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","call_id":"call_request_1","response":{"ok":true}}}]}]}`), false) + if got := gjson.GetBytes(out, "input.0.call_id").String(); got != "call_request_1" { + t.Fatalf("input.0.call_id = %q, want call_request_1", got) + } + if got := gjson.GetBytes(out, "input.1.call_id").String(); got != "call_request_1" { + t.Fatalf("input.1.call_id = %q, want call_request_1", got) + } +} + +func TestConvertGeminiRequestToInteractionsGenerationConfig(t *testing.T) { + out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","generationConfig":{"maxOutputTokens":32,"topP":0.8,"thinkingConfig":{"thinkingBudget":1024}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`), false) + if got := gjson.GetBytes(out, "generation_config.max_output_tokens").Int(); got != 32 { + t.Fatalf("max_output_tokens = %d, want 32", got) + } + if got := gjson.GetBytes(out, "generation_config.top_p").Float(); got != 0.8 { + t.Fatalf("top_p = %v, want 0.8", got) + } + if got := gjson.GetBytes(out, "generation_config.thinking_config.thinking_budget").Int(); got != 1024 { + t.Fatalf("thinking_budget = %d, want 1024", got) + } +} + +func findStepDeltaPayload(events [][]byte) []byte { + return findEventPayload(events, "step.delta") +} + +func findStepDeltaPayloadByType(events [][]byte, deltaType string) []byte { + for _, event := range events { + payload := ssePayload(event) + if eventName(event, payload) == "step.delta" && gjson.GetBytes(payload, "delta.type").String() == deltaType { + return payload + } + } + return nil +} + +func findCompletedPayload(events [][]byte) []byte { + return findEventPayload(events, "interaction.completed") +} + +func findEventPayload(events [][]byte, eventType string) []byte { + return findNthEventPayload(events, eventType, 0) +} + +func findNthEventPayload(events [][]byte, eventType string, n int) []byte { + for _, event := range events { + payload := ssePayload(event) + if eventName(event, payload) == eventType { + if n == 0 { + return payload + } + n-- + } + } + return nil +} + +func eventTypes(events [][]byte) []byte { + var out []byte + for _, event := range events { + payload := ssePayload(event) + eventType := eventName(event, payload) + if eventType == "" { + continue + } + if len(out) > 0 { + out = append(out, ',') + } + out = append(out, eventType...) + } + return out +} + +func countEventType(events [][]byte, eventType string) int { + count := 0 + for _, event := range events { + payload := ssePayload(event) + if eventName(event, payload) == eventType { + count++ + } + } + return count +} + +func eventName(event, payload []byte) string { + if eventType := gjson.GetBytes(payload, "event_type").String(); eventType != "" { + return eventType + } + const prefix = "event: " + lineEnd := bytes.IndexByte(event, '\n') + if lineEnd < 0 || !bytes.HasPrefix(event, []byte(prefix)) { + return "" + } + return string(event[len(prefix):lineEnd]) +} + +func ssePayload(event []byte) []byte { + const prefix = "\ndata: " + idx := bytes.Index(event, []byte(prefix)) + if idx < 0 { + return nil + } + return event[idx+len(prefix):] +} diff --git a/internal/translator/gemini/interactions/interactions_gemini_response.go b/internal/translator/gemini/interactions/interactions_gemini_response.go new file mode 100644 index 000000000..c89b3052a --- /dev/null +++ b/internal/translator/gemini/interactions/interactions_gemini_response.go @@ -0,0 +1,363 @@ +package interactions + +import ( + "bytes" + "context" + "fmt" + "strings" + "time" + + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type interactionsToGeminiStreamState struct { + ID string + Model string + ServiceTier string + StepNames map[int]string + StepIDs map[int]string + StepSignatures map[int]string +} + +func ConvertGeminiResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { + return ConvertGeminiResponseToInteractionsStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param) +} + +func ConvertGeminiResponseToInteractionsNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { + return convertGeminiResponseToInteractionsNonStreamDirect(modelName, originalRequestRawJSON, requestRawJSON, rawJSON) +} + +func ConvertInteractionsResponseToGemini(_ context.Context, modelName string, _, _, rawJSON []byte, param *any) [][]byte { + if param == nil { + var local any + param = &local + } + if *param == nil { + *param = &interactionsToGeminiStreamState{Model: modelName} + } + st := (*param).(*interactionsToGeminiStreamState) + st.ensureMaps() + return convertInteractionsEventToGemini(modelName, rawJSON, st) +} + +func ConvertInteractionsResponseToGeminiNonStream(_ context.Context, modelName string, _, _, rawJSON []byte, _ *any) []byte { + root := gjson.ParseBytes(rawJSON) + interaction := root + if nested := root.Get("interaction"); nested.Exists() { + interaction = nested + } + st := &interactionsToGeminiStreamState{ + ID: firstNonEmptyInteractionString(interaction.Get("id").String(), root.Get("id").String(), fmt.Sprintf("response_%d", time.Now().UnixNano())), + Model: firstNonEmptyInteractionString(interaction.Get("model").String(), root.Get("model").String(), modelName), + ServiceTier: firstNonEmptyInteractionString(interaction.Get("service_tier").String(), root.Get("service_tier").String()), + } + var parts [][]byte + steps := interaction.Get("steps") + if !steps.Exists() { + steps = root.Get("steps") + } + steps.ForEach(func(_, step gjson.Result) bool { + parts = append(parts, interactionsStepToGeminiParts(step)...) + return true + }) + out := buildInteractionsGeminiChunk(st, modelName, parts, "STOP", translatorcommon.InteractionsUsage(root), true) + return out +} + +func ConvertInteractionsRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) []byte { + _ = modelName + _ = stream + return inputRawJSON +} + +func ConvertInteractionsResponsePassthrough(_ context.Context, _ string, _, _, rawJSON []byte, _ *any) [][]byte { + if len(rawJSON) == 0 { + return nil + } + return [][]byte{rawJSON} +} + +func ConvertInteractionsResponsePassthroughNonStream(_ context.Context, _ string, _, _, rawJSON []byte, _ *any) []byte { + return rawJSON +} + +func convertInteractionsEventToGemini(modelName string, rawJSON []byte, st *interactionsToGeminiStreamState) [][]byte { + payload := interactionsGeminiSSEPayload(rawJSON) + if len(payload) == 0 { + return nil + } + root := gjson.ParseBytes(payload) + if !root.Exists() { + return nil + } + switch root.Get("event_type").String() { + case "interaction.created": + interaction := root.Get("interaction") + st.ID = firstNonEmptyInteractionString(st.ID, interaction.Get("id").String()) + st.Model = firstNonEmptyInteractionString(st.Model, interaction.Get("model").String(), modelName) + case "step.start": + rememberInteractionsGeminiStep(root, st) + case "step.delta": + if chunk := interactionsStepDeltaToGeminiChunk(modelName, root, st); len(chunk) > 0 { + return [][]byte{chunk} + } + case "interaction.completed", "finish": + interaction := root.Get("interaction") + st.ID = firstNonEmptyInteractionString(st.ID, interaction.Get("id").String()) + st.Model = firstNonEmptyInteractionString(st.Model, interaction.Get("model").String(), modelName) + st.ServiceTier = firstNonEmptyInteractionString(st.ServiceTier, interaction.Get("service_tier").String()) + chunk := buildInteractionsGeminiChunk(st, modelName, nil, "STOP", translatorcommon.InteractionsUsage(root), true) + return [][]byte{chunk} + } + return nil +} + +func rememberInteractionsGeminiStep(root gjson.Result, st *interactionsToGeminiStreamState) { + index := int(root.Get("index").Int()) + step := root.Get("step") + st.StepNames[index] = step.Get("name").String() + st.StepIDs[index] = firstNonEmptyInteractionString(step.Get("call_id").String(), step.Get("id").String()) + st.StepSignatures[index] = firstNonEmptyInteractionString(step.Get("signature").String(), step.Get("thoughtSignature").String(), step.Get("thought_signature").String()) +} + +func interactionsStepDeltaToGeminiChunk(modelName string, root gjson.Result, st *interactionsToGeminiStreamState) []byte { + index := int(root.Get("index").Int()) + delta := root.Get("delta") + switch delta.Get("type").String() { + case "arguments_delta": + part := []byte(`{"functionCall":{"name":"","args":{}}}`) + part, _ = sjson.SetBytes(part, "functionCall.name", firstNonEmptyInteractionString(st.StepNames[index], root.Get("step.name").String())) + if id := st.StepIDs[index]; id != "" { + part, _ = sjson.SetBytes(part, "functionCall.id", id) + } + if signature := st.StepSignatures[index]; signature != "" { + part, _ = sjson.SetBytes(part, "thoughtSignature", signature) + } + arguments := strings.TrimSpace(delta.Get("arguments").String()) + if arguments != "" && gjson.Valid(arguments) { + part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(arguments)) + } + return buildInteractionsGeminiChunk(st, modelName, [][]byte{part}, "", gjson.Result{}, false) + case "text": + text := firstNonEmptyInteractionString(delta.Get("text").String(), delta.Get("content.text").String()) + if text == "" { + return nil + } + return buildInteractionsGeminiChunk(st, modelName, [][]byte{geminiTextPartJSON(text, false)}, "", gjson.Result{}, false) + case "thought_summary": + text := firstNonEmptyInteractionString(delta.Get("content.text").String(), delta.Get("text").String()) + if text == "" { + return nil + } + return buildInteractionsGeminiChunk(st, modelName, [][]byte{geminiTextPartJSON(text, true)}, "", gjson.Result{}, false) + case "thought_signature": + signature := firstNonEmptyInteractionString(delta.Get("signature").String(), delta.Get("thought_signature").String(), delta.Get("thoughtSignature").String()) + if signature == "" { + return nil + } + st.StepSignatures[index] = signature + part := geminiTextPartJSON("", true) + part, _ = sjson.SetBytes(part, "thoughtSignature", signature) + return buildInteractionsGeminiChunk(st, modelName, [][]byte{part}, "", gjson.Result{}, false) + } + return nil +} + +func interactionsStepToGeminiParts(step gjson.Result) [][]byte { + switch step.Get("type").String() { + case "function_call": + return [][]byte{interactionsFunctionCallStepToGeminiPart(step)} + case "function_result": + return [][]byte{interactionsFunctionResponseStepToGeminiPart(step)} + case "thought": + return interactionsContentToGeminiParts(step.Get("content"), true) + default: + return interactionsContentToGeminiParts(step.Get("content"), false) + } +} + +func interactionsContentToGeminiParts(content gjson.Result, thought bool) [][]byte { + var parts [][]byte + if !content.Exists() { + return parts + } + if content.Type == gjson.String { + return [][]byte{geminiTextPartJSON(content.String(), thought)} + } + if content.IsObject() { + if part := interactionsContentPartToGeminiPart(content, thought); len(part) > 0 { + parts = append(parts, part) + } + return parts + } + if content.IsArray() { + content.ForEach(func(_, item gjson.Result) bool { + if part := interactionsContentPartToGeminiPart(item, thought); len(part) > 0 { + parts = append(parts, part) + } + return true + }) + } + return parts +} + +func interactionsFunctionCallStepToGeminiPart(step gjson.Result) []byte { + part := []byte(`{"functionCall":{"name":"","args":{}}}`) + part, _ = sjson.SetBytes(part, "functionCall.name", step.Get("name").String()) + if id := firstNonEmptyInteractionString(step.Get("call_id").String(), step.Get("id").String()); id != "" { + part, _ = sjson.SetBytes(part, "functionCall.id", id) + } + if signature := firstNonEmptyInteractionString(step.Get("signature").String(), step.Get("thoughtSignature").String(), step.Get("thought_signature").String()); signature != "" { + part, _ = sjson.SetBytes(part, "thoughtSignature", signature) + } + part = setInteractionsGeminiRawObject(part, "functionCall.args", firstExistingInteractionResult(step, "arguments", "args")) + return part +} + +func interactionsFunctionResponseStepToGeminiPart(step gjson.Result) []byte { + part := []byte(`{"functionResponse":{"name":"","response":{}}}`) + part, _ = sjson.SetBytes(part, "functionResponse.name", step.Get("name").String()) + if id := firstNonEmptyInteractionString(step.Get("call_id").String(), step.Get("id").String()); id != "" { + part, _ = sjson.SetBytes(part, "functionResponse.id", id) + } + part = setInteractionsGeminiRawObject(part, "functionResponse.response", firstExistingInteractionResult(step, "result", "response")) + return part +} + +func buildInteractionsGeminiChunk(st *interactionsToGeminiStreamState, modelName string, parts [][]byte, finishReason string, usage gjson.Result, includeEmptyPart bool) []byte { + out := []byte(`{"candidates":[{"content":{"parts":[],"role":"model"},"index":0}]}`) + if len(parts) == 0 && includeEmptyPart { + parts = append(parts, geminiTextPartJSON("", false)) + } + for _, part := range parts { + if len(part) > 0 { + out, _ = sjson.SetRawBytes(out, "candidates.0.content.parts.-1", part) + } + } + if finishReason != "" { + out, _ = sjson.SetBytes(out, "candidates.0.finishReason", finishReason) + } + if model := firstNonEmptyInteractionString(st.Model, modelName); model != "" { + out, _ = sjson.SetBytes(out, "modelVersion", model) + } + if id := st.ID; id != "" { + out, _ = sjson.SetBytes(out, "responseId", id) + } + if st.ServiceTier != "" { + out, _ = sjson.SetBytes(out, "usageMetadata.serviceTier", st.ServiceTier) + } + return setGeminiUsageMetadataFromInteractionsUsage(out, usage) +} + +func setGeminiUsageMetadataFromInteractionsUsage(out []byte, usage gjson.Result) []byte { + if !usage.Exists() { + return out + } + inputTokens, hasInputTokens := interactionsUsageInt(usage, "input_tokens", "total_input_tokens") + outputTokens, hasOutputTokens := interactionsUsageInt(usage, "output_tokens", "total_output_tokens") + totalTokens, hasTotalTokens := interactionsUsageInt(usage, "total_tokens") + if hasInputTokens { + out, _ = sjson.SetBytes(out, "usageMetadata.promptTokenCount", inputTokens) + out, _ = sjson.SetRawBytes(out, "usageMetadata.promptTokensDetails", []byte(fmt.Sprintf(`[{"modality":"TEXT","tokenCount":%d}]`, inputTokens))) + } + if hasOutputTokens { + out, _ = sjson.SetBytes(out, "usageMetadata.candidatesTokenCount", outputTokens) + } + if hasTotalTokens { + out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", totalTokens) + } else if hasInputTokens || hasOutputTokens { + out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", inputTokens+outputTokens) + } + if thoughtTokens, ok := interactionsUsageInt(usage, "reasoning_tokens", "total_thought_tokens"); ok { + out, _ = sjson.SetBytes(out, "usageMetadata.thoughtsTokenCount", thoughtTokens) + } + if cachedTokens, ok := interactionsUsageInt(usage, "cached_tokens", "total_cached_tokens"); ok { + out, _ = sjson.SetBytes(out, "usageMetadata.cachedContentTokenCount", cachedTokens) + } + return out +} + +func interactionsGeminiSSEPayload(rawJSON []byte) []byte { + trimmed := bytes.TrimSpace(rawJSON) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("[DONE]")) { + return nil + } + if bytes.HasPrefix(trimmed, []byte("{")) { + return trimmed + } + var payload []byte + for _, line := range bytes.Split(trimmed, []byte{'\n'}) { + line = bytes.TrimSpace(bytes.TrimRight(line, "\r")) + if !bytes.HasPrefix(line, []byte("data:")) { + continue + } + data := bytes.TrimSpace(line[len("data:"):]) + if len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) { + continue + } + if len(payload) > 0 { + payload = append(payload, '\n') + } + payload = append(payload, data...) + } + return payload +} + +func interactionsUsageInt(usage gjson.Result, paths ...string) (int64, bool) { + for _, path := range paths { + if value := usage.Get(path); value.Exists() { + return value.Int(), true + } + } + return 0, false +} + +func firstExistingInteractionResult(root gjson.Result, paths ...string) gjson.Result { + for _, path := range paths { + if value := root.Get(path); value.Exists() { + return value + } + } + return gjson.Result{} +} + +func setInteractionsGeminiRawObject(out []byte, path string, value gjson.Result) []byte { + if !value.Exists() { + out, _ = sjson.SetRawBytes(out, path, []byte(`{}`)) + return out + } + if value.Type == gjson.String { + raw := strings.TrimSpace(value.String()) + if raw != "" && gjson.Valid(raw) { + out, _ = sjson.SetRawBytes(out, path, []byte(raw)) + return out + } + } + if value.Raw != "" { + out, _ = sjson.SetRawBytes(out, path, []byte(value.Raw)) + } + return out +} + +func firstNonEmptyInteractionString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func (st *interactionsToGeminiStreamState) ensureMaps() { + if st.StepNames == nil { + st.StepNames = make(map[int]string) + } + if st.StepIDs == nil { + st.StepIDs = make(map[int]string) + } + if st.StepSignatures == nil { + st.StepSignatures = make(map[int]string) + } +} diff --git a/internal/translator/init.go b/internal/translator/init.go index c0cccc9cd..65428dd0b 100644 --- a/internal/translator/init.go +++ b/internal/translator/init.go @@ -2,26 +2,34 @@ package translator import ( _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/interactions" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/openai/chat-completions" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/claude/openai/responses" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/claude" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/interactions" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/openai/chat-completions" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/codex/openai/responses" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/claude" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/interactions" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/chat-completions" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/interactions/claude" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/claude" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/interactions/chat-completions" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/interactions/responses" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/openai/chat-completions" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/openai/responses" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/claude" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/interactions" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/openai/chat-completions" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/openai/responses" ) diff --git a/internal/translator/interactions/claude/init.go b/internal/translator/interactions/claude/init.go new file mode 100644 index 000000000..5a1b0228e --- /dev/null +++ b/internal/translator/interactions/claude/init.go @@ -0,0 +1,19 @@ +package claude + +import ( + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" +) + +func init() { + translator.Register( + Claude, + Interactions, + ConvertClaudeRequestToInteractions, + interfaces.TranslateResponse{ + Stream: ConvertInteractionsResponseToClaude, + NonStream: ConvertInteractionsResponseToClaudeNonStream, + }, + ) +} diff --git a/internal/translator/interactions/claude/interactions_claude_request.go b/internal/translator/interactions/claude/interactions_claude_request.go new file mode 100644 index 000000000..86c71e65f --- /dev/null +++ b/internal/translator/interactions/claude/interactions_claude_request.go @@ -0,0 +1,299 @@ +package claude + +import ( + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func ConvertClaudeRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) []byte { + root := gjson.ParseBytes(inputRawJSON) + out := []byte(`{"model":"","input":[]}`) + out, _ = sjson.SetBytes(out, "model", firstNonEmpty(modelName, root.Get("model").String())) + if streamValue, ok := claudeRequestStreamValue(root, stream); ok { + out, _ = sjson.SetBytes(out, "stream", streamValue) + } + out = copyClaudeSystemToInteractions(out, root) + out = copyClaudeGenerationConfigToInteractions(out, root) + out = appendClaudeMessagesToInteractions(out, root.Get("messages")) + out = copyClaudeToolsToInteractions(out, root) + return out +} + +func claudeRequestStreamValue(root gjson.Result, stream bool) (bool, bool) { + if value := root.Get("stream"); value.Exists() { + return value.Bool(), true + } + if stream { + return true, true + } + return false, false +} + +func copyClaudeSystemToInteractions(out []byte, root gjson.Result) []byte { + text := claudeText(root.Get("system")) + if text == "" { + return out + } + out, _ = sjson.SetBytes(out, "system_instruction", text) + return out +} + +func copyClaudeGenerationConfigToInteractions(out []byte, root gjson.Result) []byte { + out = copyClaudeJSONField(out, root, "max_tokens", "generation_config.max_output_tokens") + out = copyClaudeJSONField(out, root, "temperature", "generation_config.temperature") + out = copyClaudeJSONField(out, root, "top_p", "generation_config.top_p") + out = copyClaudeJSONField(out, root, "stop_sequences", "generation_config.stop_sequences") + out = copyClaudeThinkingToInteractions(out, root) + return copyClaudeToolChoiceToInteractions(out, root.Get("tool_choice")) +} + +func copyClaudeJSONField(out []byte, root gjson.Result, from, to string) []byte { + value := root.Get(from) + if !value.Exists() { + return out + } + out, _ = sjson.SetRawBytes(out, to, []byte(value.Raw)) + return out +} + +func copyClaudeThinkingToInteractions(out []byte, root gjson.Result) []byte { + thinking := root.Get("thinking") + if thinking.Exists() { + switch strings.ToLower(strings.TrimSpace(thinking.Get("type").String())) { + case "disabled": + out, _ = sjson.SetBytes(out, "generation_config.thinking_level", "none") + case "enabled": + if budget := thinking.Get("budget_tokens"); budget.Exists() { + out, _ = sjson.SetRawBytes(out, "generation_config.thinking_config.thinking_budget", []byte(budget.Raw)) + } else { + out, _ = sjson.SetBytes(out, "generation_config.thinking_level", "high") + } + case "adaptive": + out, _ = sjson.SetBytes(out, "generation_config.thinking_level", "auto") + } + } + if effort := root.Get("output_config.effort"); effort.Exists() && effort.Type == gjson.String { + out, _ = sjson.SetBytes(out, "generation_config.thinking_level", strings.ToLower(strings.TrimSpace(effort.String()))) + } + return out +} + +func copyClaudeToolChoiceToInteractions(out []byte, toolChoice gjson.Result) []byte { + if !toolChoice.Exists() { + return out + } + switch toolChoice.Type { + case gjson.String: + switch strings.ToLower(strings.TrimSpace(toolChoice.String())) { + case "auto": + out, _ = sjson.SetBytes(out, "generation_config.tool_choice", "auto") + case "any", "required": + out, _ = sjson.SetBytes(out, "generation_config.tool_choice", "required") + } + case gjson.JSON: + toolType := strings.ToLower(strings.TrimSpace(toolChoice.Get("type").String())) + switch toolType { + case "auto": + out, _ = sjson.SetBytes(out, "generation_config.tool_choice", "auto") + case "any", "required": + out, _ = sjson.SetBytes(out, "generation_config.tool_choice", "required") + case "tool": + name := strings.TrimSpace(toolChoice.Get("name").String()) + if name != "" { + choice := []byte(`{"type":"function","name":""}`) + choice, _ = sjson.SetBytes(choice, "name", name) + out, _ = sjson.SetRawBytes(out, "generation_config.tool_choice", choice) + } + } + } + return out +} + +func appendClaudeMessagesToInteractions(out []byte, messages gjson.Result) []byte { + if !messages.Exists() || !messages.IsArray() { + return out + } + messages.ForEach(func(_, message gjson.Result) bool { + out = appendClaudeMessageToInteractions(out, message) + return true + }) + return out +} + +func appendClaudeMessageToInteractions(out []byte, message gjson.Result) []byte { + role := strings.ToLower(strings.TrimSpace(message.Get("role").String())) + defaultStepType := "user_input" + if role == "assistant" { + defaultStepType = "model_output" + } + content := message.Get("content") + if content.Type == gjson.String { + step := []byte(`{"type":"","content":[{"type":"text","text":""}]}`) + step, _ = sjson.SetBytes(step, "type", defaultStepType) + step, _ = sjson.SetBytes(step, "content.0.text", content.String()) + out, _ = sjson.SetRawBytes(out, "input.-1", step) + return out + } + if !content.IsArray() { + return out + } + stepContent := []byte(`[]`) + flushContent := func() { + if len(gjson.ParseBytes(stepContent).Array()) == 0 { + return + } + step := []byte(`{"type":"","content":[]}`) + step, _ = sjson.SetBytes(step, "type", defaultStepType) + step, _ = sjson.SetRawBytes(step, "content", stepContent) + out, _ = sjson.SetRawBytes(out, "input.-1", step) + stepContent = []byte(`[]`) + } + content.ForEach(func(_, part gjson.Result) bool { + partType := strings.ToLower(strings.TrimSpace(part.Get("type").String())) + switch partType { + case "text": + if text := part.Get("text").String(); text != "" { + contentPart := []byte(`{"type":"text","text":""}`) + contentPart, _ = sjson.SetBytes(contentPart, "text", text) + stepContent, _ = sjson.SetRawBytes(stepContent, "-1", contentPart) + } + case "thinking": + flushContent() + if text := part.Get("thinking").String(); text != "" { + step := []byte(`{"type":"thought","content":[{"type":"text","text":""}]}`) + step, _ = sjson.SetBytes(step, "content.0.text", text) + out, _ = sjson.SetRawBytes(out, "input.-1", step) + } + case "image", "document": + if mediaPart, ok := claudeMediaPartToInteractions(part, partType); ok { + stepContent, _ = sjson.SetRawBytes(stepContent, "-1", mediaPart) + } + case "tool_use": + flushContent() + out = appendClaudeToolUseToInteractions(out, part) + case "tool_result": + flushContent() + out = appendClaudeToolResultToInteractions(out, part) + } + return true + }) + flushContent() + return out +} + +func claudeMediaPartToInteractions(part gjson.Result, partType string) ([]byte, bool) { + source := part.Get("source") + mimeType := source.Get("media_type").String() + data := source.Get("data").String() + if mimeType == "" || data == "" { + return nil, false + } + out := []byte(`{"type":"","mime_type":"","data":""}`) + out, _ = sjson.SetBytes(out, "type", partType) + out, _ = sjson.SetBytes(out, "mime_type", mimeType) + out, _ = sjson.SetBytes(out, "data", data) + return out, true +} + +func appendClaudeToolUseToInteractions(out []byte, part gjson.Result) []byte { + step := []byte(`{"type":"function_call","name":"","arguments":{}}`) + step, _ = sjson.SetBytes(step, "name", part.Get("name").String()) + if id := part.Get("id").String(); id != "" { + step, _ = sjson.SetBytes(step, "id", id) + step, _ = sjson.SetBytes(step, "call_id", id) + } + input := part.Get("input") + if input.Exists() && input.IsObject() { + step, _ = sjson.SetRawBytes(step, "arguments", []byte(input.Raw)) + } + out, _ = sjson.SetRawBytes(out, "input.-1", step) + return out +} + +func appendClaudeToolResultToInteractions(out []byte, part gjson.Result) []byte { + step := []byte(`{"type":"function_result","call_id":"","result":""}`) + if id := part.Get("tool_use_id").String(); id != "" { + step, _ = sjson.SetBytes(step, "id", id) + step, _ = sjson.SetBytes(step, "call_id", id) + } + result := part.Get("content") + if result.Exists() { + switch { + case result.Type == gjson.String: + step, _ = sjson.SetBytes(step, "result", result.String()) + case result.IsArray(): + converted := []byte(`[]`) + result.ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "text" { + contentPart := []byte(`{"type":"text","text":""}`) + contentPart, _ = sjson.SetBytes(contentPart, "text", item.Get("text").String()) + converted, _ = sjson.SetRawBytes(converted, "-1", contentPart) + } + return true + }) + step, _ = sjson.SetRawBytes(step, "result", converted) + default: + step, _ = sjson.SetRawBytes(step, "result", []byte(result.Raw)) + } + } + out, _ = sjson.SetRawBytes(out, "input.-1", step) + return out +} + +func copyClaudeToolsToInteractions(out []byte, root gjson.Result) []byte { + tools := root.Get("tools") + if !tools.Exists() || !tools.IsArray() { + return out + } + converted := []byte(`[]`) + tools.ForEach(func(_, tool gjson.Result) bool { + name := strings.TrimSpace(tool.Get("name").String()) + if name == "" { + return true + } + item := []byte(`{"type":"function","name":"","parameters":{}}`) + item, _ = sjson.SetBytes(item, "name", name) + if desc := tool.Get("description"); desc.Exists() { + item, _ = sjson.SetBytes(item, "description", desc.String()) + } + if schema := tool.Get("input_schema"); schema.Exists() && schema.IsObject() { + item, _ = sjson.SetRawBytes(item, "parameters", []byte(schema.Raw)) + } + converted, _ = sjson.SetRawBytes(converted, "-1", item) + return true + }) + if len(gjson.ParseBytes(converted).Array()) > 0 { + out, _ = sjson.SetRawBytes(out, "tools", converted) + } + return out +} + +func claudeText(value gjson.Result) string { + if !value.Exists() { + return "" + } + if value.Type == gjson.String { + return value.String() + } + if text := value.Get("text"); text.Exists() { + return text.String() + } + if value.IsArray() { + var builder strings.Builder + value.ForEach(func(_, item gjson.Result) bool { + text := claudeText(item) + if text == "" { + return true + } + if builder.Len() > 0 { + builder.WriteByte('\n') + } + builder.WriteString(text) + return true + }) + return builder.String() + } + return "" +} diff --git a/internal/translator/interactions/claude/interactions_claude_response.go b/internal/translator/interactions/claude/interactions_claude_response.go new file mode 100644 index 000000000..42a279906 --- /dev/null +++ b/internal/translator/interactions/claude/interactions_claude_response.go @@ -0,0 +1,399 @@ +package claude + +import ( + "bytes" + "context" + "fmt" + "strings" + "time" + + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type interactionsToClaudeStreamState struct { + ID string + Model string + Started bool + ActiveBlock bool + ActiveBlockType string + BlockIndex int + SawToolCall bool + Completed bool + Stopped bool + Done bool + StepTypes map[int]string + ToolNames map[int]string + ToolIDs map[int]string + ToolSignatures map[int]string +} + +func ConvertInteractionsResponseToClaude(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { + _ = originalRequestRawJSON + _ = requestRawJSON + if param == nil { + var local any + param = &local + } + if *param == nil { + *param = &interactionsToClaudeStreamState{Model: modelName} + } + st := (*param).(*interactionsToClaudeStreamState) + st.Model = firstNonEmpty(st.Model, modelName) + st.ensureMaps() + return convertInteractionsEventToClaude(modelName, rawJSON, st) +} + +func ConvertInteractionsResponseToClaudeNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { + _ = originalRequestRawJSON + _ = requestRawJSON + root := gjson.ParseBytes(rawJSON) + interaction := root + if nested := root.Get("interaction"); nested.Exists() { + interaction = nested + } + out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`) + out, _ = sjson.SetBytes(out, "id", firstNonEmpty(interaction.Get("id").String(), root.Get("id").String(), fmt.Sprintf("msg_%d", time.Now().UnixNano()))) + out, _ = sjson.SetBytes(out, "model", firstNonEmpty(interaction.Get("model").String(), modelName)) + steps := interaction.Get("steps") + if !steps.Exists() { + steps = root.Get("steps") + } + sawToolCall := false + steps.ForEach(func(_, step gjson.Result) bool { + switch step.Get("type").String() { + case "thought": + for _, text := range interactionsContentTexts(step.Get("content")) { + block := []byte(`{"type":"thinking","thinking":""}`) + block, _ = sjson.SetBytes(block, "thinking", text) + out, _ = sjson.SetRawBytes(out, "content.-1", block) + } + case "function_call": + sawToolCall = true + block := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`) + block, _ = sjson.SetBytes(block, "id", interactionsToolID(step)) + block, _ = sjson.SetBytes(block, "name", step.Get("name").String()) + if signature := interactionsSignature(step); signature != "" { + block, _ = sjson.SetBytes(block, "signature", signature) + } + args := firstExisting(step, "arguments", "args") + if args.Exists() && args.IsObject() { + block, _ = sjson.SetRawBytes(block, "input", []byte(args.Raw)) + } + out, _ = sjson.SetRawBytes(out, "content.-1", block) + default: + for _, text := range interactionsContentTexts(step.Get("content")) { + block := []byte(`{"type":"text","text":""}`) + block, _ = sjson.SetBytes(block, "text", text) + out, _ = sjson.SetRawBytes(out, "content.-1", block) + } + } + return true + }) + if sawToolCall { + out, _ = sjson.SetBytes(out, "stop_reason", "tool_use") + } + out = setClaudeUsageFromInteractions(out, "usage", translatorcommon.InteractionsUsage(root)) + return out +} + +func convertInteractionsEventToClaude(modelName string, rawJSON []byte, st *interactionsToClaudeStreamState) [][]byte { + payload := interactionsSSEPayload(rawJSON) + if len(payload) == 0 { + return nil + } + if bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) { + return appendClaudeMessageStop(nil, st) + } + root := gjson.ParseBytes(payload) + if !root.Exists() { + return nil + } + switch root.Get("event_type").String() { + case "interaction.created": + interaction := root.Get("interaction") + st.ID = firstNonEmpty(interaction.Get("id").String(), st.ID) + st.Model = firstNonEmpty(interaction.Get("model").String(), st.Model, modelName) + return appendClaudeMessageStart(nil, st) + case "step.start": + return interactionsStepStartToClaude(modelName, root, st) + case "step.delta": + return interactionsStepDeltaToClaude(modelName, root, st) + case "step.stop": + return appendClaudeContentBlockStop(nil, st) + case "interaction.completed", "finish": + return appendClaudeMessageDelta(nil, root, st) + case "done": + return appendClaudeMessageStop(nil, st) + } + return nil +} + +func interactionsStepStartToClaude(modelName string, root gjson.Result, st *interactionsToClaudeStreamState) [][]byte { + out := appendClaudeMessageStart(nil, st) + out = appendClaudeContentBlockStop(out, st) + index := int(root.Get("index").Int()) + step := root.Get("step") + stepType := step.Get("type").String() + st.StepTypes[index] = stepType + switch stepType { + case "function_call": + st.SawToolCall = true + st.ToolNames[index] = step.Get("name").String() + st.ToolIDs[index] = interactionsToolID(step) + st.ToolSignatures[index] = interactionsSignature(step) + return appendClaudeToolBlockStart(out, index, st) + case "thought": + return appendClaudeContentBlockStart(out, "thinking", st) + default: + _ = modelName + return appendClaudeContentBlockStart(out, "text", st) + } +} + +func interactionsStepDeltaToClaude(modelName string, root gjson.Result, st *interactionsToClaudeStreamState) [][]byte { + index := int(root.Get("index").Int()) + delta := root.Get("delta") + switch delta.Get("type").String() { + case "thought_summary": + out := appendClaudeMessageStart(nil, st) + out = ensureClaudeContentBlock(out, "thinking", st) + text := firstNonEmpty(delta.Get("content.text").String(), delta.Get("text").String()) + return appendClaudeContentDelta(out, "thinking_delta", "thinking", text, st) + case "thought_signature": + if st.ActiveBlock && st.ActiveBlockType == "thinking" { + return appendClaudeContentDelta(nil, "signature_delta", "signature", delta.Get("signature").String(), st) + } + case "arguments_delta": + out := appendClaudeMessageStart(nil, st) + if !st.ActiveBlock || st.ActiveBlockType != "tool_use" { + out = appendClaudeContentBlockStop(out, st) + if st.ToolNames[index] == "" { + st.ToolNames[index] = root.Get("step.name").String() + } + if st.ToolIDs[index] == "" { + st.ToolIDs[index] = fmt.Sprintf("toolu_%d", index) + } + out = appendClaudeToolBlockStart(out, index, st) + } + return appendClaudeContentDelta(out, "input_json_delta", "partial_json", delta.Get("arguments").String(), st) + default: + _ = modelName + out := appendClaudeMessageStart(nil, st) + out = ensureClaudeContentBlock(out, "text", st) + return appendClaudeContentDelta(out, "text_delta", "text", delta.Get("text").String(), st) + } + return nil +} + +func appendClaudeMessageStart(out [][]byte, st *interactionsToClaudeStreamState) [][]byte { + if st.Started { + return out + } + msg := []byte(`{"type":"message_start","message":{"id":"","type":"message","role":"assistant","content":[],"model":"","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`) + msg, _ = sjson.SetBytes(msg, "message.id", firstNonEmpty(st.ID, fmt.Sprintf("msg_%d", time.Now().UnixNano()))) + msg, _ = sjson.SetBytes(msg, "message.model", st.Model) + st.Started = true + return append(out, translatorcommon.AppendSSEEventBytes(nil, "message_start", msg, 3)) +} + +func appendClaudeContentBlockStart(out [][]byte, blockType string, st *interactionsToClaudeStreamState) [][]byte { + if st.ActiveBlock && st.ActiveBlockType == blockType { + return out + } + out = appendClaudeContentBlockStop(out, st) + var block []byte + if blockType == "thinking" { + block = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`) + } else { + block = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`) + } + block, _ = sjson.SetBytes(block, "index", st.BlockIndex) + st.ActiveBlock = true + st.ActiveBlockType = blockType + return append(out, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", block, 3)) +} + +func appendClaudeToolBlockStart(out [][]byte, stepIndex int, st *interactionsToClaudeStreamState) [][]byte { + out = appendClaudeContentBlockStop(out, st) + block := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`) + block, _ = sjson.SetBytes(block, "index", st.BlockIndex) + block, _ = sjson.SetBytes(block, "content_block.id", firstNonEmpty(st.ToolIDs[stepIndex], fmt.Sprintf("toolu_%d", stepIndex))) + block, _ = sjson.SetBytes(block, "content_block.name", st.ToolNames[stepIndex]) + if signature := st.ToolSignatures[stepIndex]; signature != "" { + block, _ = sjson.SetBytes(block, "content_block.signature", signature) + } + st.ActiveBlock = true + st.ActiveBlockType = "tool_use" + return append(out, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", block, 3)) +} + +func ensureClaudeContentBlock(out [][]byte, blockType string, st *interactionsToClaudeStreamState) [][]byte { + if st.ActiveBlock && st.ActiveBlockType == blockType { + return out + } + return appendClaudeContentBlockStart(out, blockType, st) +} + +func appendClaudeContentDelta(out [][]byte, deltaType, field, value string, st *interactionsToClaudeStreamState) [][]byte { + if value == "" && deltaType != "input_json_delta" { + return out + } + delta := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":""}}`) + delta, _ = sjson.SetBytes(delta, "index", st.BlockIndex) + delta, _ = sjson.SetBytes(delta, "delta.type", deltaType) + delta, _ = sjson.SetBytes(delta, "delta."+field, value) + return append(out, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", delta, 3)) +} + +func appendClaudeContentBlockStop(out [][]byte, st *interactionsToClaudeStreamState) [][]byte { + if !st.ActiveBlock { + return out + } + stop := []byte(`{"type":"content_block_stop","index":0}`) + stop, _ = sjson.SetBytes(stop, "index", st.BlockIndex) + out = append(out, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", stop, 3)) + st.ActiveBlock = false + st.ActiveBlockType = "" + st.BlockIndex++ + return out +} + +func appendClaudeMessageDelta(out [][]byte, root gjson.Result, st *interactionsToClaudeStreamState) [][]byte { + if st.Completed { + return out + } + out = appendClaudeMessageStart(out, st) + out = appendClaudeContentBlockStop(out, st) + payload := []byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`) + if st.SawToolCall { + payload, _ = sjson.SetBytes(payload, "delta.stop_reason", "tool_use") + } + payload = setClaudeUsageFromInteractions(payload, "usage", translatorcommon.InteractionsUsage(root)) + out = append(out, translatorcommon.AppendSSEEventBytes(nil, "message_delta", payload, 3)) + st.Completed = true + return out +} + +func appendClaudeMessageStop(out [][]byte, st *interactionsToClaudeStreamState) [][]byte { + if st.Done { + return out + } + out = appendClaudeContentBlockStop(out, st) + if !st.Completed { + out = appendClaudeMessageDelta(out, gjson.Result{}, st) + } + if !st.Stopped { + out = append(out, translatorcommon.AppendSSEEventString(nil, "message_stop", `{"type":"message_stop"}`, 3)) + st.Stopped = true + } + st.Done = true + return out +} + +func setClaudeUsageFromInteractions(out []byte, path string, usage gjson.Result) []byte { + if !usage.Exists() { + return out + } + if v, ok := firstUsageInt(usage, "input_tokens", "total_input_tokens"); ok { + out, _ = sjson.SetBytes(out, path+".input_tokens", v) + } + if v, ok := firstUsageInt(usage, "output_tokens", "total_output_tokens"); ok { + out, _ = sjson.SetBytes(out, path+".output_tokens", v) + } + return out +} + +func interactionsSSEPayload(rawJSON []byte) []byte { + trimmed := bytes.TrimSpace(rawJSON) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("[DONE]")) { + return trimmed + } + if bytes.HasPrefix(trimmed, []byte("data:")) { + return bytes.TrimSpace(trimmed[len("data:"):]) + } + var dataLines [][]byte + for _, line := range bytes.Split(trimmed, []byte("\n")) { + line = bytes.TrimSpace(line) + if bytes.HasPrefix(line, []byte("data:")) { + dataLines = append(dataLines, bytes.TrimSpace(line[len("data:"):])) + } + } + if len(dataLines) > 0 { + return bytes.Join(dataLines, []byte("\n")) + } + return trimmed +} + +func interactionsContentTexts(content gjson.Result) []string { + if !content.Exists() { + return nil + } + if content.Type == gjson.String { + return []string{content.String()} + } + var out []string + content.ForEach(func(_, part gjson.Result) bool { + if text := firstNonEmpty(part.Get("text").String(), part.Get("content.text").String()); text != "" { + out = append(out, text) + } + return true + }) + return out +} + +func interactionsToolID(root gjson.Result) string { + return firstNonEmpty(root.Get("call_id").String(), root.Get("id").String(), root.Get("tool_use_id").String(), "toolu_interactions") +} + +func interactionsSignature(root gjson.Result) string { + return firstNonEmpty( + root.Get("signature").String(), + root.Get("thought_signature").String(), + root.Get("thoughtSignature").String(), + root.Get("extra_content.google.thought_signature").String(), + ) +} + +func firstExisting(root gjson.Result, paths ...string) gjson.Result { + for _, path := range paths { + if value := root.Get(path); value.Exists() { + return value + } + } + return gjson.Result{} +} + +func firstUsageInt(root gjson.Result, paths ...string) (int64, bool) { + for _, path := range paths { + if value := root.Get(path); value.Exists() { + return value.Int(), true + } + } + return 0, false +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func (st *interactionsToClaudeStreamState) ensureMaps() { + if st.StepTypes == nil { + st.StepTypes = make(map[int]string) + } + if st.ToolNames == nil { + st.ToolNames = make(map[int]string) + } + if st.ToolIDs == nil { + st.ToolIDs = make(map[int]string) + } + if st.ToolSignatures == nil { + st.ToolSignatures = make(map[int]string) + } +} diff --git a/internal/translator/interactions/claude/interactions_claude_test.go b/internal/translator/interactions/claude/interactions_claude_test.go new file mode 100644 index 000000000..f6de147d3 --- /dev/null +++ b/internal/translator/interactions/claude/interactions_claude_test.go @@ -0,0 +1,164 @@ +package claude + +import ( + "bytes" + "context" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertClaudeRequestToInteractionsMapsMessagesToolsAndStream(t *testing.T) { + raw := []byte(`{"model":"gemini-3.1-flash-lite","stream":true,"max_tokens":1024,"tools":[{"name":"get_weather","description":"Weather","input_schema":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}}],"messages":[{"role":"user","content":[{"type":"text","text":"今天北京的天气怎么样?"}]}]}`) + out := ConvertClaudeRequestToInteractions("gemini-3.1-flash-lite", raw, true) + if got := gjson.GetBytes(out, "model").String(); got != "gemini-3.1-flash-lite" { + t.Fatalf("model = %q, want gemini-3.1-flash-lite. Output: %s", got, string(out)) + } + if !gjson.GetBytes(out, "stream").Bool() { + t.Fatalf("stream should be true. Output: %s", string(out)) + } + if got := gjson.GetBytes(out, "generation_config.max_output_tokens").Int(); got != 1024 { + t.Fatalf("max_output_tokens = %d, want 1024. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.0.type").String(); got != "user_input" { + t.Fatalf("input.0.type = %q, want user_input. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "今天北京的天气怎么样?" { + t.Fatalf("input text = %q. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.parameters.properties.location.type").String(); got != "string" { + t.Fatalf("tool schema was not mapped. Output: %s", string(out)) + } + if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" { + t.Fatalf("tools.0.type = %q, want function. Output: %s", got, string(out)) + } +} + +func TestConvertClaudeRequestToInteractionsMapsToolUseAndResult(t *testing.T) { + raw := []byte(`{"model":"gemini-3.1-flash-lite","messages":[{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"location":"北京"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"晴"}]}]}`) + out := ConvertClaudeRequestToInteractions("gemini-3.1-flash-lite", raw, false) + if got := gjson.GetBytes(out, "input.0.type").String(); got != "function_call" { + t.Fatalf("input.0.type = %q, want function_call. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.0.call_id").String(); got != "toolu_1" { + t.Fatalf("call_id = %q, want toolu_1. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.1.type").String(); got != "function_result" { + t.Fatalf("input.1.type = %q, want function_result. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.1.result").String(); got != "晴" { + t.Fatalf("result = %q, want 晴. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsResponseToClaudeStream(t *testing.T) { + var param any + var out [][]byte + chunks := [][]byte{ + []byte(`event: interaction.created +data: {"interaction":{"id":"interaction_1","model":"gemini-3.1-flash-lite"},"event_type":"interaction.created"}`), + []byte(`event: step.start +data: {"index":0,"step":{"type":"model_output"},"event_type":"step.start"}`), + []byte(`event: step.delta +data: {"index":0,"delta":{"type":"text","text":"北京今天晴"},"event_type":"step.delta"}`), + []byte(`event: step.stop +data: {"index":0,"event_type":"step.stop"}`), + []byte(`event: interaction.completed +data: {"interaction":{"id":"interaction_1","model":"gemini-3.1-flash-lite","usage":{"total_input_tokens":3,"total_output_tokens":4}},"event_type":"interaction.completed"}`), + []byte(`event: done +data: [DONE]`), + } + for _, chunk := range chunks { + out = append(out, ConvertInteractionsResponseToClaude(context.Background(), "gemini-3.1-flash-lite", nil, nil, chunk, ¶m)...) + } + if payload := findClaudeEventPayload(out, "message_start"); gjson.GetBytes(payload, "message.model").String() != "gemini-3.1-flash-lite" { + t.Fatalf("message_start payload = %s", payload) + } + if payload := findClaudeEventPayload(out, "content_block_delta"); gjson.GetBytes(payload, "delta.text").String() != "北京今天晴" { + t.Fatalf("content_block_delta payload = %s", payload) + } + if payload := findClaudeEventPayload(out, "message_delta"); gjson.GetBytes(payload, "usage.output_tokens").Int() != 4 { + t.Fatalf("message_delta payload = %s", payload) + } + if payload := findClaudeEventPayload(out, "message_stop"); gjson.GetBytes(payload, "type").String() != "message_stop" { + t.Fatalf("message_stop payload = %s", payload) + } +} + +func TestConvertInteractionsResponseToClaudeStreamToolCall(t *testing.T) { + var param any + var out [][]byte + chunks := [][]byte{ + []byte(`data: {"interaction":{"id":"interaction_1","model":"gemini-3.1-flash-lite"},"event_type":"interaction.created"}`), + []byte(`data: {"index":0,"step":{"type":"function_call","id":"toolu_1","signature":"sig_1","name":"get_weather","arguments":{}},"event_type":"step.start"}`), + []byte(`data: {"index":0,"delta":{"type":"arguments_delta","arguments":"{\"location\":\"北京\"}"},"event_type":"step.delta"}`), + []byte(`data: {"index":0,"event_type":"step.stop"}`), + []byte(`data: {"interaction":{"usage":{"total_input_tokens":1,"total_output_tokens":2}},"event_type":"interaction.completed"}`), + } + for _, chunk := range chunks { + out = append(out, ConvertInteractionsResponseToClaude(context.Background(), "gemini-3.1-flash-lite", nil, nil, chunk, ¶m)...) + } + if payload := findClaudeEventPayload(out, "content_block_start"); gjson.GetBytes(payload, "content_block.type").String() != "tool_use" { + t.Fatalf("content_block_start payload = %s", payload) + } + if payload := findClaudeEventPayload(out, "content_block_start"); gjson.GetBytes(payload, "content_block.signature").String() != "sig_1" { + t.Fatalf("content_block_start signature payload = %s", payload) + } + if payload := findClaudeEventPayload(out, "content_block_delta"); gjson.GetBytes(payload, "delta.partial_json").String() != `{"location":"北京"}` { + t.Fatalf("content_block_delta payload = %s", payload) + } + if payload := findClaudeEventPayload(out, "message_delta"); gjson.GetBytes(payload, "delta.stop_reason").String() != "tool_use" { + t.Fatalf("message_delta payload = %s", payload) + } +} + +func TestConvertInteractionsResponseToClaudeStreamFinishMetadataUsage(t *testing.T) { + var param any + out := ConvertInteractionsResponseToClaude(context.Background(), "claude-test", nil, nil, []byte(`data: {"event_type":"finish","metadata":{"total_usage":{"total_input_tokens":2,"total_output_tokens":6,"total_tokens":8}}}`), ¶m) + payload := findClaudeEventPayload(out, "message_delta") + if len(payload) == 0 { + t.Fatalf("message_delta payload not found") + } + if got := gjson.GetBytes(payload, "usage.input_tokens").Int(); got != 2 { + t.Fatalf("input_tokens = %d, want 2. Payload: %s", got, string(payload)) + } + if got := gjson.GetBytes(payload, "usage.output_tokens").Int(); got != 6 { + t.Fatalf("output_tokens = %d, want 6. Payload: %s", got, string(payload)) + } +} + +func TestConvertInteractionsResponseToClaudeNonStream(t *testing.T) { + raw := []byte(`{"id":"interaction_1","model":"gemini-3.1-flash-lite","steps":[{"type":"model_output","content":[{"type":"text","text":"ok"}]},{"type":"function_call","call_id":"toolu_1","signature":"sig_1","name":"lookup","arguments":{"q":"x"}}],"usage":{"total_input_tokens":3,"total_output_tokens":4}}`) + out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "gemini-3.1-flash-lite", nil, nil, raw, nil) + if got := gjson.GetBytes(out, "content.0.text").String(); got != "ok" { + t.Fatalf("text = %q, want ok. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "content.1.type").String(); got != "tool_use" { + t.Fatalf("tool block type = %q, want tool_use. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "content.1.signature").String(); got != "sig_1" { + t.Fatalf("tool signature = %q, want sig_1. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "stop_reason").String(); got != "tool_use" { + t.Fatalf("stop_reason = %q, want tool_use. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "usage.input_tokens").Int(); got != 3 { + t.Fatalf("input_tokens = %d, want 3. Output: %s", got, string(out)) + } +} + +func findClaudeEventPayload(events [][]byte, eventName string) []byte { + prefix := []byte("data:") + for _, event := range events { + if !bytes.Contains(event, []byte("event: "+eventName)) { + continue + } + for _, line := range bytes.Split(event, []byte("\n")) { + line = bytes.TrimSpace(line) + if bytes.HasPrefix(line, prefix) { + return bytes.TrimSpace(line[len(prefix):]) + } + } + } + return nil +} diff --git a/internal/translator/interactions/import_boundary_test.go b/internal/translator/interactions/import_boundary_test.go new file mode 100644 index 000000000..4ccb0db97 --- /dev/null +++ b/internal/translator/interactions/import_boundary_test.go @@ -0,0 +1,50 @@ +package interactions_test + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestInteractionsTranslatorsDoNotImportGeminiTranslators(t *testing.T) { + repoRoot := filepath.Clean(filepath.Join("..", "..", "..")) + scanDirs := []string{ + "internal/translator/openai/interactions", + "internal/translator/claude/interactions", + "internal/translator/codex/interactions", + "internal/translator/antigravity/interactions", + } + forbidden := regexp.MustCompile(`"github\.com/router-for-me/CLIProxyAPI/v7/internal/translator/[^"]*/gemini[^"]*"`) + var violations []string + for _, scanDir := range scanDirs { + root := filepath.Join(repoRoot, scanDir) + errWalk := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + data, errRead := os.ReadFile(path) + if errRead != nil { + return errRead + } + if forbidden.Match(data) { + rel, errRel := filepath.Rel(repoRoot, path) + if errRel != nil { + rel = path + } + violations = append(violations, rel) + } + return nil + }) + if errWalk != nil { + t.Fatalf("scan %s: %v", scanDir, errWalk) + } + } + if len(violations) > 0 { + t.Fatalf("non-Gemini Interactions translators import Gemini translators: %s", strings.Join(violations, ", ")) + } +} diff --git a/internal/translator/openai/gemini/openai_gemini_request.go b/internal/translator/openai/gemini/openai_gemini_request.go index 53773806d..fed2fe0d5 100644 --- a/internal/translator/openai/gemini/openai_gemini_request.go +++ b/internal/translator/openai/gemini/openai_gemini_request.go @@ -81,6 +81,24 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream out, _ = sjson.SetBytes(out, "n", candidateCount.Int()) } + if responseModalities := genConfig.Get("responseModalities"); responseModalities.Exists() && responseModalities.IsArray() { + var modalities []string + responseModalities.ForEach(func(_, value gjson.Result) bool { + switch strings.ToLower(strings.TrimSpace(value.String())) { + case "text": + modalities = append(modalities, "text") + case "image": + modalities = append(modalities, "image") + case "audio": + modalities = append(modalities, "audio") + } + return true + }) + if len(modalities) > 0 { + out, _ = sjson.SetBytes(out, "modalities", modalities) + } + } + // Map Gemini thinkingConfig to OpenAI reasoning_effort. // Always perform conversion to support allowCompat models that may not be in registry. // Note: Google official Python SDK sends snake_case fields (thinking_level/thinking_budget). @@ -110,6 +128,9 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream // Stream parameter out, _ = sjson.SetBytes(out, "stream", stream) + if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String { + out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String()) + } // Process contents (Gemini messages) -> OpenAI messages var toolCallIDs []string // Track tool call IDs for matching with tool results @@ -137,16 +158,11 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream } // Handle inline data (e.g., images) - if inlineData := part.Get("inlineData"); inlineData.Exists() { - mimeType := inlineData.Get("mimeType").String() - if mimeType == "" { - mimeType = "application/octet-stream" - } - data := inlineData.Get("data").String() - imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data) - - contentPart := []byte(`{"type":"image_url","image_url":{"url":""}}`) - contentPart, _ = sjson.SetBytes(contentPart, "image_url.url", imageURL) + if contentPart, ok := openAIContentPartFromGeminiInlineData(part); ok { + msg, _ = sjson.SetRawBytes(msg, "content.-1", contentPart) + hasContent = true + } + if contentPart, ok := openAIContentPartFromGeminiFileData(part); ok { msg, _ = sjson.SetRawBytes(msg, "content.-1", contentPart) hasContent = true } @@ -192,25 +208,23 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream } // Handle inline data (e.g., images) - if inlineData := part.Get("inlineData"); inlineData.Exists() { + if contentPart, ok := openAIContentPartFromGeminiInlineData(part); ok { + onlyTextContent = false + contentWrapper, _ = sjson.SetRawBytes(contentWrapper, "arr.-1", contentPart) + contentPartsCount++ + } + if contentPart, ok := openAIContentPartFromGeminiFileData(part); ok { onlyTextContent = false - - mimeType := inlineData.Get("mimeType").String() - if mimeType == "" { - mimeType = "application/octet-stream" - } - data := inlineData.Get("data").String() - imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data) - - contentPart := []byte(`{"type":"image_url","image_url":{"url":""}}`) - contentPart, _ = sjson.SetBytes(contentPart, "image_url.url", imageURL) contentWrapper, _ = sjson.SetRawBytes(contentWrapper, "arr.-1", contentPart) contentPartsCount++ } // Handle function calls (Gemini) -> tool calls (OpenAI) if functionCall := part.Get("functionCall"); functionCall.Exists() { - toolCallID := genToolCallID() + toolCallID := explicitGeminiToolID(functionCall) + if toolCallID == "" { + toolCallID = genToolCallID() + } toolCallIDs = append(toolCallIDs, toolCallID) toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`) @@ -242,7 +256,12 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream } } - if toolCallConsumeIdx < len(toolCallIDs) { + if toolCallID := explicitGeminiToolID(functionResponse); toolCallID != "" { + toolMsg, _ = sjson.SetBytes(toolMsg, "tool_call_id", toolCallID) + if toolCallConsumeIdx < len(toolCallIDs) && toolCallIDs[toolCallConsumeIdx] == toolCallID { + toolCallConsumeIdx++ + } + } else if toolCallConsumeIdx < len(toolCallIDs) { toolMsg, _ = sjson.SetBytes(toolMsg, "tool_call_id", toolCallIDs[toolCallConsumeIdx]) toolCallConsumeIdx++ } else { @@ -304,16 +323,153 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream if toolConfig := root.Get("toolConfig"); toolConfig.Exists() { if functionCallingConfig := toolConfig.Get("functionCallingConfig"); functionCallingConfig.Exists() { mode := functionCallingConfig.Get("mode").String() + allowedNames := functionCallingConfig.Get("allowedFunctionNames") switch mode { case "NONE": out, _ = sjson.SetBytes(out, "tool_choice", "none") case "AUTO": out, _ = sjson.SetBytes(out, "tool_choice", "auto") case "ANY": - out, _ = sjson.SetBytes(out, "tool_choice", "required") + if allowedNames.IsArray() && len(allowedNames.Array()) == 1 { + choice := []byte(`{"type":"function","function":{"name":""}}`) + choice, _ = sjson.SetBytes(choice, "function.name", allowedNames.Array()[0].String()) + out, _ = sjson.SetRawBytes(out, "tool_choice", choice) + } else { + out, _ = sjson.SetBytes(out, "tool_choice", "required") + } } } } return out } + +func explicitGeminiToolID(node gjson.Result) string { + if id := strings.TrimSpace(node.Get("id").String()); id != "" { + return id + } + return strings.TrimSpace(node.Get("call_id").String()) +} + +func openAIContentPartFromGeminiInlineData(part gjson.Result) ([]byte, bool) { + inlineData := part.Get("inlineData") + if !inlineData.Exists() { + inlineData = part.Get("inline_data") + } + if !inlineData.Exists() { + return nil, false + } + mimeType := inlineData.Get("mimeType").String() + if mimeType == "" { + mimeType = inlineData.Get("mime_type").String() + } + if mimeType == "" { + mimeType = "application/octet-stream" + } + data := inlineData.Get("data").String() + if data == "" { + return nil, false + } + dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data) + lowerMimeType := strings.ToLower(mimeType) + switch { + case strings.HasPrefix(lowerMimeType, "image/"): + contentPart := []byte(`{"type":"image_url","image_url":{"url":""}}`) + contentPart, _ = sjson.SetBytes(contentPart, "image_url.url", dataURL) + return contentPart, true + case strings.HasPrefix(lowerMimeType, "audio/"): + contentPart := []byte(`{"type":"input_audio","input_audio":{"data":"","format":""}}`) + contentPart, _ = sjson.SetBytes(contentPart, "input_audio.data", data) + contentPart, _ = sjson.SetBytes(contentPart, "input_audio.format", openAIInputAudioFormatFromMIME(mimeType)) + return contentPart, true + case strings.HasPrefix(lowerMimeType, "video/"): + contentPart := []byte(`{"type":"video_url","video_url":{"url":""}}`) + contentPart, _ = sjson.SetBytes(contentPart, "video_url.url", dataURL) + return contentPart, true + default: + contentPart := []byte(`{"type":"file","file":{"filename":"","file_data":""}}`) + contentPart, _ = sjson.SetBytes(contentPart, "file.filename", openAIFileNameFromMIME(mimeType)) + contentPart, _ = sjson.SetBytes(contentPart, "file.file_data", data) + return contentPart, true + } +} + +func openAIContentPartFromGeminiFileData(part gjson.Result) ([]byte, bool) { + fileData := part.Get("fileData") + if !fileData.Exists() { + fileData = part.Get("file_data") + } + if !fileData.Exists() { + return nil, false + } + fileURI := fileData.Get("fileUri").String() + if fileURI == "" { + fileURI = fileData.Get("file_uri").String() + } + if fileURI == "" { + return nil, false + } + mimeType := fileData.Get("mimeType").String() + if mimeType == "" { + mimeType = fileData.Get("mime_type").String() + } + lowerMimeType := strings.ToLower(mimeType) + if strings.HasPrefix(lowerMimeType, "image/") { + contentPart := []byte(`{"type":"image_url","image_url":{"url":""}}`) + contentPart, _ = sjson.SetBytes(contentPart, "image_url.url", fileURI) + return contentPart, true + } + if strings.HasPrefix(lowerMimeType, "video/") { + contentPart := []byte(`{"type":"video_url","video_url":{"url":""}}`) + contentPart, _ = sjson.SetBytes(contentPart, "video_url.url", fileURI) + return contentPart, true + } + if strings.HasPrefix(lowerMimeType, "application/") || strings.HasPrefix(lowerMimeType, "text/") { + contentPart := []byte(`{"type":"file","file":{"filename":"","file_url":""}}`) + contentPart, _ = sjson.SetBytes(contentPart, "file.filename", openAIFileNameFromMIME(mimeType)) + contentPart, _ = sjson.SetBytes(contentPart, "file.file_url", fileURI) + return contentPart, true + } + fileInfo := "File: " + fileURI + if mimeType != "" { + fileInfo += " (Type: " + mimeType + ")" + } + contentPart := []byte(`{"type":"text","text":""}`) + contentPart, _ = sjson.SetBytes(contentPart, "text", fileInfo) + return contentPart, true +} + +func openAIInputAudioFormatFromMIME(mimeType string) string { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "audio/wav", "audio/wave", "audio/x-wav": + return "wav" + case "audio/flac": + return "flac" + case "audio/opus", "audio/ogg": + return "opus" + case "audio/pcm", "audio/l16": + return "pcm16" + default: + return "mp3" + } +} + +func openAIFileNameFromMIME(mimeType string) string { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "application/pdf": + return "document.pdf" + case "text/plain": + return "document.txt" + case "text/csv": + return "document.csv" + case "application/json": + return "document.json" + case "application/xml", "text/xml": + return "document.xml" + default: + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(mimeType)), "video/") { + return "video" + } + return "document" + } +} diff --git a/internal/translator/openai/gemini/openai_gemini_request_test.go b/internal/translator/openai/gemini/openai_gemini_request_test.go index 7bfbaad54..f1e2e7092 100644 --- a/internal/translator/openai/gemini/openai_gemini_request_test.go +++ b/internal/translator/openai/gemini/openai_gemini_request_test.go @@ -104,3 +104,68 @@ func TestConvertGeminiRequestToOpenAI_ExtraFunctionResponsesUseFallbackID(t *tes t.Fatalf("extra response reused consumed tool_call_id %q. Output: %s", extraResponseID, string(out)) } } + +func TestConvertGeminiRequestToOpenAI_PreservesExplicitFunctionCallIDs(t *testing.T) { + tests := []struct { + name string + callField string + responseField string + want string + }{ + { + name: "id", + callField: `"id":"call_gateway_id"`, + responseField: `"id":"call_gateway_id"`, + want: "call_gateway_id", + }, + { + name: "call_id", + callField: `"call_id":"call_gateway_call_id"`, + responseField: `"call_id":"call_gateway_call_id"`, + want: "call_gateway_call_id", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inputJSON := []byte(`{ + "contents": [ + {"role": "model", "parts": [{"functionCall": {"name": "lookup", ` + tt.callField + `, "args": {"q": "x"}}}]}, + {"role": "function", "parts": [{"functionResponse": {"name": "lookup", ` + tt.responseField + `, "response": {"result": "ok"}}}]} + ] + }`) + + out := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false) + if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != tt.want { + t.Fatalf("tool call id = %q, want %q. Output: %s", got, tt.want, string(out)) + } + if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != tt.want { + t.Fatalf("tool response id = %q, want %q. Output: %s", got, tt.want, string(out)) + } + }) + } +} + +func TestConvertGeminiRequestToOpenAI_AcceptsSnakeInlineData(t *testing.T) { + out := ConvertGeminiRequestToOpenAI("gpt-test", []byte(`{"contents":[{"role":"user","parts":[{"inline_data":{"mime_type":"image/png","data":"aGVsbG8="}}]}]}`), false) + if got := gjson.GetBytes(out, "messages.0.content.0.image_url.url").String(); got != "data:image/png;base64,aGVsbG8=" { + t.Fatalf("image url = %q, want data:image/png;base64,aGVsbG8=. Output: %s", got, string(out)) + } +} + +func TestConvertGeminiRequestToOpenAI_SplitsNonImageInlineDataByMIME(t *testing.T) { + out := ConvertGeminiRequestToOpenAI("gpt-test", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"audio/wav","data":"UklGRg=="}},{"inlineData":{"mimeType":"video/mp4","data":"AAAAIGZ0eXA="}},{"inlineData":{"mimeType":"application/pdf","data":"JVBERi0="}}]}]}`), false) + + if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "input_audio" { + t.Fatalf("audio content type = %q, want input_audio. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.0.content.1.type").String(); got != "video_url" { + t.Fatalf("video content type = %q, want video_url. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.0.content.2.type").String(); got != "file" { + t.Fatalf("document content type = %q, want file. Output: %s", got, string(out)) + } + if gjson.GetBytes(out, "messages.0.content.#(type==\"image_url\")").Exists() { + t.Fatalf("non-image inlineData must not be converted to image_url. Output: %s", string(out)) + } +} diff --git a/internal/translator/openai/gemini/openai_gemini_response.go b/internal/translator/openai/gemini/openai_gemini_response.go index 439ae8fbd..f421cdd96 100644 --- a/internal/translator/openai/gemini/openai_gemini_response.go +++ b/internal/translator/openai/gemini/openai_gemini_response.go @@ -84,12 +84,7 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR template, _ = sjson.SetBytes(template, "model", model.String()) } - template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", usage.Get("prompt_tokens").Int()) - template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", usage.Get("completion_tokens").Int()) - template, _ = sjson.SetBytes(template, "usageMetadata.totalTokenCount", usage.Get("total_tokens").Int()) - if reasoningTokens := reasoningTokensFromUsage(usage); reasoningTokens > 0 { - template, _ = sjson.SetBytes(template, "usageMetadata.thoughtsTokenCount", reasoningTokens) - } + template = setGeminiUsageMetadataFromOpenAIUsage(template, usage) return [][]byte{template} } return [][]byte{} @@ -214,8 +209,12 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR if len((*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator) > 0 { partIndex := 0 for _, accumulator := range (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator { + idPath := fmt.Sprintf("candidates.0.content.parts.%d.functionCall.id", partIndex) namePath := fmt.Sprintf("candidates.0.content.parts.%d.functionCall.name", partIndex) argsPath := fmt.Sprintf("candidates.0.content.parts.%d.functionCall.args", partIndex) + if accumulator.ID != "" { + template, _ = sjson.SetBytes(template, idPath, accumulator.ID) + } template, _ = sjson.SetBytes(template, namePath, accumulator.Name) template, _ = sjson.SetRawBytes(template, argsPath, []byte(parseArgsToObjectRaw(accumulator.Arguments.String()))) partIndex++ @@ -231,12 +230,7 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR // Handle usage information if usage := root.Get("usage"); usage.Exists() { - template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", usage.Get("prompt_tokens").Int()) - template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", usage.Get("completion_tokens").Int()) - template, _ = sjson.SetBytes(template, "usageMetadata.totalTokenCount", usage.Get("total_tokens").Int()) - if reasoningTokens := reasoningTokensFromUsage(usage); reasoningTokens > 0 { - template, _ = sjson.SetBytes(template, "usageMetadata.thoughtsTokenCount", reasoningTokens) - } + template = setGeminiUsageMetadataFromOpenAIUsage(template, usage) results = append(results, template) return true } @@ -584,9 +578,14 @@ func ConvertOpenAIResponseToGeminiNonStream(_ context.Context, _ string, origina function := toolCall.Get("function") functionName := function.Get("name").String() functionArgs := function.Get("arguments").String() + functionID := toolCall.Get("id").String() + idPath := fmt.Sprintf("candidates.0.content.parts.%d.functionCall.id", partIndex) namePath := fmt.Sprintf("candidates.0.content.parts.%d.functionCall.name", partIndex) argsPath := fmt.Sprintf("candidates.0.content.parts.%d.functionCall.args", partIndex) + if functionID != "" { + out, _ = sjson.SetBytes(out, idPath, functionID) + } out, _ = sjson.SetBytes(out, namePath, functionName) out, _ = sjson.SetRawBytes(out, argsPath, []byte(parseArgsToObjectRaw(functionArgs))) partIndex++ @@ -610,12 +609,7 @@ func ConvertOpenAIResponseToGeminiNonStream(_ context.Context, _ string, origina // Handle usage information if usage := root.Get("usage"); usage.Exists() { - out, _ = sjson.SetBytes(out, "usageMetadata.promptTokenCount", usage.Get("prompt_tokens").Int()) - out, _ = sjson.SetBytes(out, "usageMetadata.candidatesTokenCount", usage.Get("completion_tokens").Int()) - out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", usage.Get("total_tokens").Int()) - if reasoningTokens := reasoningTokensFromUsage(usage); reasoningTokens > 0 { - out, _ = sjson.SetBytes(out, "usageMetadata.thoughtsTokenCount", reasoningTokens) - } + out = setGeminiUsageMetadataFromOpenAIUsage(out, usage) } return out @@ -637,6 +631,51 @@ func reasoningTokensFromUsage(usage gjson.Result) int64 { return 0 } +func setGeminiUsageMetadataFromOpenAIUsage(out []byte, usage gjson.Result) []byte { + promptTokens, hasPromptTokens := tokenCountFromUsage(usage, "prompt_tokens", "input_tokens") + completionTokens, hasCompletionTokens := tokenCountFromUsage(usage, "completion_tokens", "output_tokens") + totalTokens, hasTotalTokens := tokenCountFromUsage(usage, "total_tokens") + if hasPromptTokens { + out, _ = sjson.SetBytes(out, "usageMetadata.promptTokenCount", promptTokens) + } + if hasCompletionTokens { + out, _ = sjson.SetBytes(out, "usageMetadata.candidatesTokenCount", completionTokens) + } + if hasTotalTokens { + out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", totalTokens) + } else if hasPromptTokens || hasCompletionTokens { + out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", promptTokens+completionTokens) + } + if reasoningTokens := reasoningTokensFromUsage(usage); reasoningTokens > 0 { + out, _ = sjson.SetBytes(out, "usageMetadata.thoughtsTokenCount", reasoningTokens) + } + if cachedTokens := cachedTokensFromUsage(usage); cachedTokens > 0 { + out, _ = sjson.SetBytes(out, "usageMetadata.cachedContentTokenCount", cachedTokens) + } + return out +} + +func tokenCountFromUsage(usage gjson.Result, paths ...string) (int64, bool) { + for _, path := range paths { + if v := usage.Get(path); v.Exists() { + return v.Int(), true + } + } + return 0, false +} + +func cachedTokensFromUsage(usage gjson.Result) int64 { + if usage.Exists() { + if v := usage.Get("prompt_tokens_details.cached_tokens"); v.Exists() { + return v.Int() + } + if v := usage.Get("input_tokens_details.cached_tokens"); v.Exists() { + return v.Int() + } + } + return 0 +} + func extractReasoningTexts(node gjson.Result) []string { var texts []string if !node.Exists() { diff --git a/internal/translator/openai/gemini/openai_gemini_response_test.go b/internal/translator/openai/gemini/openai_gemini_response_test.go new file mode 100644 index 000000000..9f2c3f127 --- /dev/null +++ b/internal/translator/openai/gemini/openai_gemini_response_test.go @@ -0,0 +1,34 @@ +package gemini + +import ( + "context" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertOpenAIResponseToGeminiNonStreamPreservesToolCallID(t *testing.T) { + raw := []byte(`{"choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_chat_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]}}]}`) + out := ConvertOpenAIResponseToGeminiNonStream(context.Background(), "gpt-test", nil, nil, raw, nil) + if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.id").String(); got != "call_chat_1" { + t.Fatalf("functionCall.id = %q, want call_chat_1", got) + } + if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.args.q").String(); got != "x" { + t.Fatalf("functionCall.args.q = %q, want x", got) + } +} + +func TestConvertOpenAIResponseToGeminiStreamPreservesToolCallID(t *testing.T) { + var param any + ConvertOpenAIResponseToGemini(context.Background(), "gpt-test", nil, nil, []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_stream_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]}}]}`), ¶m) + out := ConvertOpenAIResponseToGemini(context.Background(), "gpt-test", nil, nil, []byte(`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`), ¶m) + if len(out) == 0 { + t.Fatalf("stream output is empty") + } + if got := gjson.GetBytes(out[len(out)-1], "candidates.0.content.parts.0.functionCall.id").String(); got != "call_stream_1" { + t.Fatalf("functionCall.id = %q, want call_stream_1", got) + } + if got := gjson.GetBytes(out[len(out)-1], "candidates.0.content.parts.0.functionCall.args.q").String(); got != "x" { + t.Fatalf("functionCall.args.q = %q, want x", got) + } +} diff --git a/internal/translator/openai/interactions/chat-completions/init.go b/internal/translator/openai/interactions/chat-completions/init.go new file mode 100644 index 000000000..031017277 --- /dev/null +++ b/internal/translator/openai/interactions/chat-completions/init.go @@ -0,0 +1,28 @@ +package chat_completions + +import ( + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" +) + +func init() { + translator.Register( + OpenAI, + Interactions, + ConvertOpenAIRequestToInteractions, + interfaces.TranslateResponse{ + Stream: ConvertInteractionsResponseToOpenAI, + NonStream: ConvertInteractionsResponseToOpenAINonStream, + }, + ) + translator.Register( + Interactions, + OpenAI, + ConvertInteractionsRequestToOpenAI, + interfaces.TranslateResponse{ + Stream: ConvertOpenAIResponseToInteractions, + NonStream: ConvertOpenAIResponseToInteractionsNonStream, + }, + ) +} diff --git a/internal/translator/openai/interactions/chat-completions/interactions_openai_request.go b/internal/translator/openai/interactions/chat-completions/interactions_openai_request.go new file mode 100644 index 000000000..98d601fe9 --- /dev/null +++ b/internal/translator/openai/interactions/chat-completions/interactions_openai_request.go @@ -0,0 +1,396 @@ +package chat_completions + +import ( + "fmt" + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func ConvertInteractionsRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) []byte { + root := gjson.ParseBytes(inputRawJSON) + out := []byte(`{"model":"","messages":[]}`) + out, _ = sjson.SetBytes(out, "model", firstNonEmpty(modelName, root.Get("model").String())) + if stream || root.Get("stream").Bool() { + out, _ = sjson.SetBytes(out, "stream", true) + } + out = copyInteractionsSystemToOpenAI(out, root) + out = appendInteractionsInputToOpenAIMessages(out, root.Get("input")) + out = copyInteractionsToolsToOpenAI(out, root) + out = copyInteractionsGenerationConfigToOpenAI(out, root) + out = copyInteractionsOpenAITopLevel(out, root) + return out +} + +func copyInteractionsSystemToOpenAI(out []byte, root gjson.Result) []byte { + text := interactionsText(root.Get("system_instruction")) + if text == "" { + return out + } + msg := []byte(`{"role":"system","content":""}`) + msg, _ = sjson.SetBytes(msg, "content", text) + out, _ = sjson.SetRawBytes(out, "messages.-1", msg) + return out +} + +func appendInteractionsInputToOpenAIMessages(out []byte, input gjson.Result) []byte { + if input.Type == gjson.String { + msg := []byte(`{"role":"user","content":""}`) + msg, _ = sjson.SetBytes(msg, "content", input.String()) + out, _ = sjson.SetRawBytes(out, "messages.-1", msg) + return out + } + if input.IsArray() { + input.ForEach(func(_, step gjson.Result) bool { + out = appendInteractionsStepToOpenAI(out, step, "user") + return true + }) + return out + } + if input.IsObject() { + return appendInteractionsStepToOpenAI(out, input, "user") + } + return out +} + +func appendInteractionsStepToOpenAI(out []byte, step gjson.Result, defaultRole string) []byte { + switch step.Get("type").String() { + case "user_input": + return appendInteractionsMessageToOpenAI(out, step, "user") + case "model_output": + return appendInteractionsMessageToOpenAI(out, step, "assistant") + case "thought": + return appendInteractionsThoughtToOpenAI(out, step) + case "function_call": + return appendInteractionsFunctionCallToOpenAI(out, step) + case "function_result": + return appendInteractionsFunctionResultToOpenAI(out, step) + default: + if step.Type == gjson.String { + msg := []byte(`{"role":"","content":""}`) + msg, _ = sjson.SetBytes(msg, "role", defaultRole) + msg, _ = sjson.SetBytes(msg, "content", step.String()) + out, _ = sjson.SetRawBytes(out, "messages.-1", msg) + } + } + return out +} + +func appendInteractionsMessageToOpenAI(out []byte, step gjson.Result, role string) []byte { + msg := []byte(`{"role":"","content":""}`) + msg, _ = sjson.SetBytes(msg, "role", role) + content := step.Get("content") + if content.Type == gjson.String { + msg, _ = sjson.SetBytes(msg, "content", content.String()) + out, _ = sjson.SetRawBytes(out, "messages.-1", msg) + return out + } + msg = appendInteractionsContentToOpenAIMessage(msg, content, role) + out, _ = sjson.SetRawBytes(out, "messages.-1", msg) + return out +} + +func appendInteractionsThoughtToOpenAI(out []byte, step gjson.Result) []byte { + msg := []byte(`{"role":"assistant","content":"","reasoning_content":""}`) + msg, _ = sjson.SetBytes(msg, "reasoning_content", interactionsText(step.Get("content"))) + out, _ = sjson.SetRawBytes(out, "messages.-1", msg) + return out +} + +func appendInteractionsContentToOpenAIMessage(msg []byte, content gjson.Result, role string) []byte { + if !content.Exists() { + return msg + } + if content.Type == gjson.String { + msg, _ = sjson.SetBytes(msg, "content", content.String()) + return msg + } + contentWrapper := []byte(`{"items":[]}`) + textOnly := true + var textBuilder strings.Builder + appendPart := func(part gjson.Result) { + converted, ok := interactionsContentPartToOpenAI(part, role) + if !ok { + return + } + if gjson.GetBytes(converted, "type").String() == "text" { + textBuilder.WriteString(gjson.GetBytes(converted, "text").String()) + } else { + textOnly = false + } + contentWrapper, _ = sjson.SetRawBytes(contentWrapper, "items.-1", converted) + } + if content.IsArray() { + content.ForEach(func(_, part gjson.Result) bool { + appendPart(part) + return true + }) + } else if content.IsObject() { + appendPart(content) + } + if count := gjson.GetBytes(contentWrapper, "items.#").Int(); count > 0 { + if textOnly { + msg, _ = sjson.SetBytes(msg, "content", textBuilder.String()) + } else { + msg, _ = sjson.SetRawBytes(msg, "content", []byte(gjson.GetBytes(contentWrapper, "items").Raw)) + } + } + return msg +} + +func appendInteractionsFunctionCallToOpenAI(out []byte, step gjson.Result) []byte { + msg := []byte(`{"role":"assistant","content":"","tool_calls":[]}`) + toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":"{}"}}`) + callID := firstNonEmpty(step.Get("call_id").String(), step.Get("id").String(), "call_0") + toolCall, _ = sjson.SetBytes(toolCall, "id", callID) + toolCall, _ = sjson.SetBytes(toolCall, "function.name", step.Get("name").String()) + toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", jsonStringValue(step.Get("arguments"), "{}")) + msg, _ = sjson.SetRawBytes(msg, "tool_calls.-1", toolCall) + out, _ = sjson.SetRawBytes(out, "messages.-1", msg) + return out +} + +func appendInteractionsFunctionResultToOpenAI(out []byte, step gjson.Result) []byte { + msg := []byte(`{"role":"tool","tool_call_id":"","content":""}`) + msg, _ = sjson.SetBytes(msg, "tool_call_id", firstNonEmpty(step.Get("call_id").String(), step.Get("id").String())) + msg, _ = sjson.SetBytes(msg, "content", jsonStringValue(firstExisting(step.Get("result"), step.Get("output")), "")) + out, _ = sjson.SetRawBytes(out, "messages.-1", msg) + return out +} + +func copyInteractionsToolsToOpenAI(out []byte, root gjson.Result) []byte { + tools := root.Get("tools") + if !tools.Exists() || !tools.IsArray() { + return out + } + tools.ForEach(func(_, tool gjson.Result) bool { + if converted, ok := openAIToolFromInteractionsTool(tool); ok { + out, _ = sjson.SetRawBytes(out, "tools.-1", converted) + } + if decls := firstExisting(tool.Get("function_declarations"), tool.Get("functionDeclarations")); decls.Exists() && decls.IsArray() { + decls.ForEach(func(_, decl gjson.Result) bool { + if converted, ok := openAIToolFromInteractionsTool(decl); ok { + out, _ = sjson.SetRawBytes(out, "tools.-1", converted) + } + return true + }) + } + return true + }) + return out +} + +func copyInteractionsGenerationConfigToOpenAI(out []byte, root gjson.Result) []byte { + gen := root.Get("generation_config") + if !gen.Exists() { + gen = root.Get("generationConfig") + } + copyNumber(&out, "temperature", firstExisting(gen.Get("temperature"), root.Get("temperature"))) + copyNumber(&out, "max_tokens", firstExisting(gen.Get("max_output_tokens"), gen.Get("maxOutputTokens"), root.Get("max_tokens"), root.Get("max_completion_tokens"))) + copyNumber(&out, "top_p", firstExisting(gen.Get("top_p"), gen.Get("topP"), root.Get("top_p"))) + copyNumber(&out, "top_k", firstExisting(gen.Get("top_k"), gen.Get("topK"))) + copyNumber(&out, "n", firstExisting(gen.Get("candidate_count"), gen.Get("candidateCount"), root.Get("n"))) + if stop := firstExisting(gen.Get("stop_sequences"), gen.Get("stopSequences"), root.Get("stop")); stop.Exists() { + out, _ = sjson.SetRawBytes(out, "stop", []byte(stop.Raw)) + } + if toolChoice := firstExisting(gen.Get("tool_choice"), root.Get("tool_choice")); toolChoice.Exists() { + out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(toolChoice.Raw)) + } + if effort := interactionsReasoningEffort(root, gen); effort != "" { + out, _ = sjson.SetBytes(out, "reasoning_effort", effort) + } + if responseModalities := root.Get("response_modalities"); responseModalities.Exists() { + out, _ = sjson.SetRawBytes(out, "modalities", []byte(responseModalities.Raw)) + } + return out +} + +func copyInteractionsOpenAITopLevel(out []byte, root gjson.Result) []byte { + if format := root.Get("response_format"); format.Exists() { + out, _ = sjson.SetRawBytes(out, "response_format", []byte(format.Raw)) + } + if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String { + out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String()) + } + for _, key := range []string{"parallel_tool_calls", "seed", "user"} { + if value := root.Get(key); value.Exists() { + out, _ = sjson.SetRawBytes(out, key, []byte(value.Raw)) + } + } + return out +} + +func interactionsContentPartToOpenAI(part gjson.Result, role string) ([]byte, bool) { + partType := part.Get("type").String() + if partType == "" && part.Get("text").Exists() { + partType = "text" + } + switch partType { + case "text": + out := []byte(`{"type":"text","text":""}`) + out, _ = sjson.SetBytes(out, "text", part.Get("text").String()) + return out, true + case "image": + out := []byte(`{"type":"image_url","image_url":{"url":""}}`) + out, _ = sjson.SetBytes(out, "image_url.url", interactionsMediaDataURL(part, "application/octet-stream")) + return out, true + case "audio": + out := []byte(`{"type":"input_audio","input_audio":{"data":"","format":""}}`) + out, _ = sjson.SetBytes(out, "input_audio.data", part.Get("data").String()) + out, _ = sjson.SetBytes(out, "input_audio.format", openAIInputAudioFormatFromMIME(part.Get("mime_type").String())) + return out, true + case "video": + out := []byte(`{"type":"video_url","video_url":{"url":""}}`) + out, _ = sjson.SetBytes(out, "video_url.url", interactionsMediaDataURL(part, "video/mp4")) + return out, true + case "document", "file": + out := []byte(`{"type":"file","file":{"filename":"","file_data":""}}`) + out, _ = sjson.SetBytes(out, "file.filename", firstNonEmpty(part.Get("filename").String(), openAIFileNameFromMIME(part.Get("mime_type").String()))) + out, _ = sjson.SetBytes(out, "file.file_data", part.Get("data").String()) + if url := firstNonEmpty(part.Get("file_url").String(), part.Get("url").String()); url != "" { + out, _ = sjson.DeleteBytes(out, "file.file_data") + out, _ = sjson.SetBytes(out, "file.file_url", url) + } + return out, true + default: + _ = role + } + return nil, false +} + +func openAIToolFromInteractionsTool(tool gjson.Result) ([]byte, bool) { + name := firstNonEmpty(tool.Get("name").String(), tool.Get("function.name").String()) + if name == "" { + return nil, false + } + out := []byte(`{"type":"function","function":{"name":""}}`) + out, _ = sjson.SetBytes(out, "function.name", name) + if desc := firstExisting(tool.Get("description"), tool.Get("function.description")); desc.Exists() { + out, _ = sjson.SetBytes(out, "function.description", desc.String()) + } + if params := firstExisting(tool.Get("parameters"), tool.Get("function.parameters"), tool.Get("parametersJsonSchema")); params.Exists() { + out, _ = sjson.SetRawBytes(out, "function.parameters", []byte(params.Raw)) + } + return out, true +} + +func interactionsText(value gjson.Result) string { + if !value.Exists() { + return "" + } + if value.Type == gjson.String { + return value.String() + } + if text := value.Get("text"); text.Exists() { + return text.String() + } + for _, path := range []string{"content", "parts"} { + parts := value.Get(path) + if !parts.Exists() || !parts.IsArray() { + continue + } + var builder strings.Builder + parts.ForEach(func(_, part gjson.Result) bool { + builder.WriteString(firstNonEmpty(part.Get("text").String(), part.Get("content.text").String())) + return true + }) + return builder.String() + } + return "" +} + +func interactionsReasoningEffort(root, gen gjson.Result) string { + for _, value := range []gjson.Result{ + gen.Get("reasoning_effort"), + gen.Get("thinking_level"), + gen.Get("thinkingLevel"), + gen.Get("thinking_config.thinking_level"), + gen.Get("thinkingConfig.thinkingLevel"), + root.Get("reasoning_effort"), + } { + if value.Exists() && value.Type == gjson.String { + return strings.ToLower(strings.TrimSpace(value.String())) + } + } + return "" +} + +func interactionsMediaDataURL(part gjson.Result, fallbackMimeType string) string { + if url := firstNonEmpty(part.Get("image_url").String(), part.Get("file_data").String(), part.Get("url").String()); url != "" { + return url + } + data := part.Get("data").String() + if data == "" { + return "" + } + mimeType := firstNonEmpty(part.Get("mime_type").String(), fallbackMimeType) + return "data:" + mimeType + ";base64," + data +} + +func openAIInputAudioFormatFromMIME(mimeType string) string { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "audio/wav", "audio/wave", "audio/x-wav": + return "wav" + case "audio/flac": + return "flac" + case "audio/opus", "audio/ogg": + return "opus" + case "audio/pcm", "audio/l16": + return "pcm16" + default: + return "mp3" + } +} + +func openAIFileNameFromMIME(mimeType string) string { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "application/pdf": + return "document.pdf" + case "text/plain": + return "document.txt" + case "text/csv": + return "document.csv" + case "application/json": + return "document.json" + default: + if _, suffix, ok := strings.Cut(mimeType, "/"); ok && suffix != "" { + return fmt.Sprintf("document.%s", strings.ReplaceAll(suffix, "+", ".")) + } + return "document.bin" + } +} + +func copyNumber(out *[]byte, path string, value gjson.Result) { + if value.Exists() { + *out, _ = sjson.SetRawBytes(*out, path, []byte(value.Raw)) + } +} + +func jsonStringValue(value gjson.Result, fallback string) string { + if !value.Exists() { + return fallback + } + if value.Type == gjson.String { + return value.String() + } + return value.Raw +} + +func firstExisting(values ...gjson.Result) gjson.Result { + for _, value := range values { + if value.Exists() { + return value + } + } + return gjson.Result{} +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/internal/translator/openai/interactions/chat-completions/interactions_openai_request_test.go b/internal/translator/openai/interactions/chat-completions/interactions_openai_request_test.go new file mode 100644 index 000000000..db9ae7fa8 --- /dev/null +++ b/internal/translator/openai/interactions/chat-completions/interactions_openai_request_test.go @@ -0,0 +1,121 @@ +package chat_completions + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertInteractionsRequestToOpenAIPreservesExpressibleFields(t *testing.T) { + out := ConvertInteractionsRequestToOpenAI("gpt-test", []byte(`{"model":"gpt-test","tool_choice":{"type":"function","function":{"name":"lookup"}},"response_modalities":["text","image"],"service_tier":"priority","input":"hi"}`), false) + if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "function" { + t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tool_choice.function.name").String(); got != "lookup" { + t.Fatalf("tool_choice.function.name = %q, want lookup. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "modalities.0").String(); got != "text" { + t.Fatalf("modalities.0 = %q, want text. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "modalities.1").String(); got != "image" { + t.Fatalf("modalities.1 = %q, want image. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "service_tier").String(); got != "priority" { + t.Fatalf("service_tier = %q, want priority. Output: %s", got, string(out)) + } +} + +func TestConvertOpenAIRequestToInteractionsMapsMessagesToolsAndStream(t *testing.T) { + raw := []byte(`{"model":"gemini-3.1-flash-lite","stream":true,"messages":[{"role":"system","content":"be brief"},{"role":"user","content":"今天北京的天气怎么样?"}],"tools":[{"type":"function","function":{"name":"get_weather","description":"weather","parameters":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}}}],"tool_choice":"auto","max_completion_tokens":128}`) + out := ConvertOpenAIRequestToInteractions("gemini-3.1-flash-lite", raw, false) + if got := gjson.GetBytes(out, "model").String(); got != "gemini-3.1-flash-lite" { + t.Fatalf("model = %q, want gemini-3.1-flash-lite. Output: %s", got, string(out)) + } + if !gjson.GetBytes(out, "stream").Bool() { + t.Fatalf("stream should be true. Output: %s", string(out)) + } + if got := gjson.GetBytes(out, "system_instruction").String(); got != "be brief" { + t.Fatalf("system_instruction = %q, want be brief. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.0.type").String(); got != "user_input" { + t.Fatalf("input.0.type = %q, want user_input. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "今天北京的天气怎么样?" { + t.Fatalf("input text = %q. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" { + t.Fatalf("tools.0.type = %q, want function. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.name").String(); got != "get_weather" { + t.Fatalf("tool name = %q, want get_weather. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.parameters.properties.location.type").String(); got != "string" { + t.Fatalf("tool schema missing. Output: %s", string(out)) + } + if got := gjson.GetBytes(out, "generation_config.tool_choice").String(); got != "auto" { + t.Fatalf("tool_choice = %q, want auto. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "generation_config.max_output_tokens").Int(); got != 128 { + t.Fatalf("max_output_tokens = %d, want 128. Output: %s", got, string(out)) + } +} + +func TestConvertOpenAIRequestToInteractionsMapsToolCallsAndResults(t *testing.T) { + raw := []byte(`{"model":"gemini-3.1-flash-lite","messages":[{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"}]}`) + out := ConvertOpenAIRequestToInteractions("gemini-3.1-flash-lite", raw, false) + if got := gjson.GetBytes(out, "input.0.type").String(); got != "function_call" { + t.Fatalf("input.0.type = %q, want function_call. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.0.call_id").String(); got != "call_1" { + t.Fatalf("call_id = %q, want call_1. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.0.arguments.q").String(); got != "x" { + t.Fatalf("arguments.q = %q, want x. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.1.type").String(); got != "function_result" { + t.Fatalf("input.1.type = %q, want function_result. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.1.result").String(); got != "ok" { + t.Fatalf("result = %q, want ok. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToOpenAIAcceptsImageContent(t *testing.T) { + out := ConvertInteractionsRequestToOpenAI("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"image","mime_type":"image/png","data":"aGVsbG8="}]}]}`), false) + if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "image_url" { + t.Fatalf("content type = %q, want image_url. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.0.content.0.image_url.url").String(); got != "data:image/png;base64,aGVsbG8=" { + t.Fatalf("image url = %q, want data:image/png;base64,aGVsbG8=. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToOpenAIPreservesNonImageMediaContent(t *testing.T) { + out := ConvertInteractionsRequestToOpenAI("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="},{"type":"video","mime_type":"video/mp4","data":"AAAAIGZ0eXA="},{"type":"document","mime_type":"application/pdf","data":"JVBERi0="}]}]}`), false) + + if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "input_audio" { + t.Fatalf("audio content type = %q, want input_audio. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.0.content.0.input_audio.format").String(); got != "wav" { + t.Fatalf("audio format = %q, want wav. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.0.content.1.type").String(); got != "video_url" { + t.Fatalf("video content type = %q, want video_url. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.0.content.2.type").String(); got != "file" { + t.Fatalf("document content type = %q, want file. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToOpenAIWithToolMessagesDirect(t *testing.T) { + out := ConvertInteractionsRequestToOpenAI("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}]}`), false) + if got := gjson.GetBytes(out, "messages.1.tool_calls.0.function.name").String(); got != "lookup" { + t.Fatalf("tool call name = %q, want lookup. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.1.tool_calls.0.function.arguments").String(); got != `{"q":"x"}` { + t.Fatalf("tool call arguments = %q, want JSON object string. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != "call_1" { + t.Fatalf("tool_call_id = %q, want call_1. Output: %s", got, string(out)) + } +} diff --git a/internal/translator/openai/interactions/chat-completions/interactions_openai_response.go b/internal/translator/openai/interactions/chat-completions/interactions_openai_response.go new file mode 100644 index 000000000..e2c81ec3a --- /dev/null +++ b/internal/translator/openai/interactions/chat-completions/interactions_openai_response.go @@ -0,0 +1,402 @@ +package chat_completions + +import ( + "bytes" + "context" + "fmt" + "strings" + "time" + + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type openAIToInteractionsStreamState struct { + Created bool + StatusUpdated bool + Completed bool + Done bool + CurrentStepType string + CurrentStepID string + ToolCallIDs map[int]string + ToolCallNames map[int]string + ID string + StepIndex int + ActiveStepIndex int + ActiveStepOpen bool + Usage gjson.Result +} + +func ConvertOpenAIResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { + _ = ctx + _ = originalRequestRawJSON + _ = requestRawJSON + if param == nil { + var local any + param = &local + } + if *param == nil { + *param = &openAIToInteractionsStreamState{} + } + st := (*param).(*openAIToInteractionsStreamState) + if st.ToolCallIDs == nil { + st.ToolCallIDs = make(map[int]string) + } + if st.ToolCallNames == nil { + st.ToolCallNames = make(map[int]string) + } + return convertOpenAIChatStreamToInteractions(modelName, rawJSON, st) +} + +func ConvertOpenAIResponseToInteractionsNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { + _ = ctx + _ = originalRequestRawJSON + _ = requestRawJSON + root := gjson.ParseBytes(rawJSON) + out := []byte(`{"id":"","status":"completed","object":"interaction","model":"","steps":[]}`) + out, _ = sjson.SetBytes(out, "id", firstNonEmpty(root.Get("id").String(), fmt.Sprintf("interaction_%d", time.Now().UnixNano()))) + out, _ = sjson.SetBytes(out, "model", firstNonEmpty(modelName, root.Get("model").String())) + choices := root.Get("choices") + choices.ForEach(func(_, choice gjson.Result) bool { + message := choice.Get("message") + if reasoning := message.Get("reasoning_content"); reasoning.Exists() { + for _, text := range openAIReasoningTexts(reasoning) { + out, _ = sjson.SetRawBytes(out, "steps.-1", interactionsTextStep("thought", text)) + } + } + if content := message.Get("content"); content.Exists() && content.String() != "" { + out, _ = sjson.SetRawBytes(out, "steps.-1", interactionsTextStep("model_output", content.String())) + } + if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() { + toolCalls.ForEach(func(_, toolCall gjson.Result) bool { + if step, ok := openAIToolCallToInteractionsStep(toolCall); ok { + out, _ = sjson.SetRawBytes(out, "steps.-1", step) + } + return true + }) + } + if finishReason := choice.Get("finish_reason"); finishReason.Exists() { + out, _ = sjson.SetBytes(out, "finish_reason", finishReason.String()) + } + return true + }) + out = setInteractionsUsageFromOpenAIChat(out, "usage", root.Get("usage")) + return out +} + +func convertOpenAIChatStreamToInteractions(modelName string, rawJSON []byte, st *openAIToInteractionsStreamState) [][]byte { + payload := openAIChatSSEPayload(rawJSON) + if len(payload) == 0 { + return nil + } + if bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) { + out := make([][]byte, 0, 3) + out = appendInteractionsStepStop(out, st) + if !st.Completed { + out = appendInteractionsCompleted(out, st, modelName, gjson.Result{}) + } + return appendInteractionsDone(out, st) + } + root := gjson.ParseBytes(payload) + if !root.Exists() { + return nil + } + if usage := root.Get("usage"); usage.Exists() { + st.Usage = usage + } + out := make([][]byte, 0) + if choices := root.Get("choices"); choices.Exists() && choices.IsArray() { + if len(choices.Array()) == 0 { + if root.Get("usage").Exists() { + out = appendInteractionsStepStop(out, st) + out = appendInteractionsCompleted(out, st, modelName, root) + } + return out + } + choices.ForEach(func(_, choice gjson.Result) bool { + delta := choice.Get("delta") + if reasoning := delta.Get("reasoning_content"); reasoning.Exists() { + for _, text := range openAIReasoningTexts(reasoning) { + out = ensureInteractionsStep(out, st, modelName, "thought", root) + out = appendInteractionsTextDelta(out, st, text, true) + } + } + if content := delta.Get("content"); content.Exists() && content.String() != "" { + out = ensureInteractionsStep(out, st, modelName, "model_output", root) + out = appendInteractionsTextDelta(out, st, content.String(), false) + } + if toolCalls := delta.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() { + toolCalls.ForEach(func(_, toolCall gjson.Result) bool { + out = appendOpenAIToolCallDelta(out, st, modelName, root, toolCall) + return true + }) + } + if finishReason := choice.Get("finish_reason"); finishReason.Exists() { + out = appendInteractionsStepStop(out, st) + } + return true + }) + } + return out +} + +func appendOpenAIToolCallDelta(out [][]byte, st *openAIToInteractionsStreamState, modelName string, root, toolCall gjson.Result) [][]byte { + index := int(toolCall.Get("index").Int()) + if id := toolCall.Get("id").String(); id != "" { + st.ToolCallIDs[index] = id + } + function := toolCall.Get("function") + if name := function.Get("name").String(); name != "" { + st.ToolCallNames[index] = name + } + stepID := firstNonEmpty(st.ToolCallIDs[index], fmt.Sprintf("call_%d", index)) + stepName := st.ToolCallNames[index] + if st.CurrentStepType != "function_call" || st.CurrentStepID != stepID { + out = appendInteractionsStepStop(out, st) + step := []byte(`{"type":"function_call","id":"","call_id":"","name":"","arguments":{}}`) + step, _ = sjson.SetBytes(step, "id", stepID) + step, _ = sjson.SetBytes(step, "call_id", stepID) + step, _ = sjson.SetBytes(step, "name", stepName) + out = appendInteractionsCreated(out, st, modelName, root) + out = appendInteractionsStepStart(out, st, "function_call", gjson.ParseBytes(step)) + } + if args := function.Get("arguments"); args.Exists() && args.String() != "" { + out = appendInteractionsArgumentsDelta(out, st, args.String()) + } + return out +} + +func appendInteractionsCreated(out [][]byte, st *openAIToInteractionsStreamState, modelName string, root gjson.Result) [][]byte { + if st.Created { + return out + } + st.ID = firstNonEmpty(root.Get("id").String(), st.ID, fmt.Sprintf("interaction_%d", time.Now().UnixNano())) + created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`) + created, _ = sjson.SetBytes(created, "interaction.id", st.ID) + created, _ = sjson.SetBytes(created, "interaction.model", firstNonEmpty(modelName, root.Get("model").String())) + out = append(out, translatorcommon.SSEEventData("interaction.created", created)) + st.Created = true + return appendInteractionsStatusUpdate(out, st) +} + +func appendInteractionsStatusUpdate(out [][]byte, st *openAIToInteractionsStreamState) [][]byte { + if st.StatusUpdated { + return out + } + statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`) + statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID) + out = append(out, translatorcommon.SSEEventData("interaction.status_update", statusUpdate)) + st.StatusUpdated = true + return out +} + +func ensureInteractionsStep(out [][]byte, st *openAIToInteractionsStreamState, modelName, stepType string, step gjson.Result) [][]byte { + out = appendInteractionsCreated(out, st, modelName, step) + if st.ActiveStepOpen && st.CurrentStepType == stepType { + return out + } + out = appendInteractionsStepStop(out, st) + return appendInteractionsStepStart(out, st, stepType, step) +} + +func appendInteractionsStepStart(out [][]byte, st *openAIToInteractionsStreamState, stepType string, step gjson.Result) [][]byte { + index := st.StepIndex + st.StepIndex++ + st.ActiveStepIndex = index + st.CurrentStepType = stepType + st.ActiveStepOpen = true + payload := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`) + payload, _ = sjson.SetBytes(payload, "index", index) + payload, _ = sjson.SetBytes(payload, "step.type", stepType) + if stepType == "function_call" { + id := firstNonEmpty(step.Get("call_id").String(), step.Get("id").String(), st.CurrentStepID) + st.CurrentStepID = id + if id != "" { + payload, _ = sjson.SetBytes(payload, "step.id", id) + payload, _ = sjson.SetBytes(payload, "step.call_id", id) + } + payload, _ = sjson.SetBytes(payload, "step.name", step.Get("name").String()) + payload, _ = sjson.SetRawBytes(payload, "step.arguments", []byte(`{}`)) + } else { + st.CurrentStepID = "" + } + return append(out, translatorcommon.SSEEventData("step.start", payload)) +} + +func appendInteractionsTextDelta(out [][]byte, st *openAIToInteractionsStreamState, text string, thought bool) [][]byte { + if thought { + payload := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`) + payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex) + payload, _ = sjson.SetBytes(payload, "delta.content.text", text) + return append(out, translatorcommon.SSEEventData("step.delta", payload)) + } + payload := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`) + payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex) + payload, _ = sjson.SetBytes(payload, "delta.text", text) + return append(out, translatorcommon.SSEEventData("step.delta", payload)) +} + +func appendInteractionsArgumentsDelta(out [][]byte, st *openAIToInteractionsStreamState, arguments string) [][]byte { + payload := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`) + payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex) + payload, _ = sjson.SetBytes(payload, "delta.arguments", arguments) + return append(out, translatorcommon.SSEEventData("step.delta", payload)) +} + +func appendInteractionsStepStop(out [][]byte, st *openAIToInteractionsStreamState) [][]byte { + if !st.ActiveStepOpen { + return out + } + payload := []byte(`{"index":0,"event_type":"step.stop"}`) + payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex) + out = append(out, translatorcommon.SSEEventData("step.stop", payload)) + st.ActiveStepOpen = false + st.CurrentStepType = "" + st.CurrentStepID = "" + return out +} + +func appendInteractionsCompleted(out [][]byte, st *openAIToInteractionsStreamState, modelName string, root gjson.Result) [][]byte { + if st.Completed { + return out + } + if !st.Created { + out = appendInteractionsCreated(out, st, modelName, root) + } + now := time.Now().UTC().Format(time.RFC3339) + payload := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`) + payload, _ = sjson.SetBytes(payload, "interaction.id", st.ID) + payload, _ = sjson.SetBytes(payload, "interaction.created", now) + payload, _ = sjson.SetBytes(payload, "interaction.updated", now) + payload, _ = sjson.SetBytes(payload, "interaction.model", firstNonEmpty(modelName, root.Get("model").String())) + usage := root.Get("usage") + if !usage.Exists() { + usage = st.Usage + } + payload = setInteractionsUsageFromOpenAIChat(payload, "interaction.usage", usage) + out = append(out, translatorcommon.SSEEventData("interaction.completed", payload)) + st.Completed = true + return out +} + +func appendInteractionsDone(out [][]byte, st *openAIToInteractionsStreamState) [][]byte { + if st.Done { + return out + } + out = append(out, translatorcommon.SSEEventData("done", []byte("[DONE]"))) + st.Done = true + return out +} + +func isOpenAIStreamDone(rawJSON []byte) bool { + return bytes.Equal(bytes.TrimSpace(openAIChatSSEPayload(rawJSON)), []byte("[DONE]")) +} + +func openAIChatSSEPayload(rawJSON []byte) []byte { + trimmed := bytes.TrimSpace(rawJSON) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("[DONE]")) { + return trimmed + } + if bytes.HasPrefix(trimmed, []byte("data:")) { + return bytes.TrimSpace(trimmed[len("data:"):]) + } + var dataLines [][]byte + for _, line := range bytes.Split(trimmed, []byte("\n")) { + line = bytes.TrimSpace(line) + if bytes.HasPrefix(line, []byte("data:")) { + dataLines = append(dataLines, bytes.TrimSpace(line[len("data:"):])) + } + } + if len(dataLines) > 0 { + return bytes.Join(dataLines, []byte("\n")) + } + return trimmed +} + +func interactionsTextStep(stepType, text string) []byte { + step := []byte(`{"type":"","content":[{"type":"text","text":""}]}`) + step, _ = sjson.SetBytes(step, "type", stepType) + step, _ = sjson.SetBytes(step, "content.0.text", text) + return step +} + +func openAIToolCallToInteractionsStep(toolCall gjson.Result) ([]byte, bool) { + if toolType := toolCall.Get("type").String(); toolType != "" && toolType != "function" { + return nil, false + } + function := toolCall.Get("function") + if !function.Exists() { + return nil, false + } + step := []byte(`{"type":"function_call","name":"","arguments":{}}`) + if id := toolCall.Get("id").String(); id != "" { + step, _ = sjson.SetBytes(step, "id", id) + step, _ = sjson.SetBytes(step, "call_id", id) + } + step, _ = sjson.SetBytes(step, "name", function.Get("name").String()) + setRawJSONValue(&step, "arguments", function.Get("arguments"), []byte(`{}`)) + return step, true +} + +func setInteractionsUsageFromOpenAIChat(out []byte, path string, usage gjson.Result) []byte { + if !usage.Exists() { + return out + } + if value := usage.Get("prompt_tokens"); value.Exists() { + out, _ = sjson.SetBytes(out, path+".input_tokens", value.Int()) + out, _ = sjson.SetBytes(out, path+".total_input_tokens", value.Int()) + } + if value := usage.Get("completion_tokens"); value.Exists() { + out, _ = sjson.SetBytes(out, path+".output_tokens", value.Int()) + out, _ = sjson.SetBytes(out, path+".total_output_tokens", value.Int()) + } + if value := usage.Get("total_tokens"); value.Exists() { + out, _ = sjson.SetBytes(out, path+".total_tokens", value.Int()) + } + if value := usage.Get("prompt_tokens_details.cached_tokens"); value.Exists() { + out, _ = sjson.SetBytes(out, path+".cached_tokens", value.Int()) + out, _ = sjson.SetBytes(out, path+".total_cached_tokens", value.Int()) + } + if value := usage.Get("completion_tokens_details.reasoning_tokens"); value.Exists() { + out, _ = sjson.SetBytes(out, path+".reasoning_tokens", value.Int()) + out, _ = sjson.SetBytes(out, path+".total_thought_tokens", value.Int()) + } + return out +} + +func openAIReasoningTexts(reasoning gjson.Result) []string { + if reasoning.Type == gjson.String { + if reasoning.String() == "" { + return nil + } + return []string{reasoning.String()} + } + texts := make([]string, 0) + if reasoning.IsArray() { + reasoning.ForEach(func(_, item gjson.Result) bool { + if text := firstNonEmpty(item.Get("text").String(), item.Get("content").String()); text != "" { + texts = append(texts, text) + } + return true + }) + } + return texts +} + +func setRawJSONValue(out *[]byte, path string, value gjson.Result, fallback []byte) { + if !value.Exists() { + *out, _ = sjson.SetRawBytes(*out, path, fallback) + return + } + raw := strings.TrimSpace(value.String()) + if value.Type == gjson.String && gjson.Valid(raw) { + *out, _ = sjson.SetRawBytes(*out, path, []byte(raw)) + return + } + if value.Type == gjson.String { + *out, _ = sjson.SetBytes(*out, path, value.String()) + return + } + *out, _ = sjson.SetRawBytes(*out, path, []byte(value.Raw)) +} diff --git a/internal/translator/openai/interactions/chat-completions/interactions_openai_response_test.go b/internal/translator/openai/interactions/chat-completions/interactions_openai_response_test.go new file mode 100644 index 000000000..83f8c590d --- /dev/null +++ b/internal/translator/openai/interactions/chat-completions/interactions_openai_response_test.go @@ -0,0 +1,223 @@ +package chat_completions + +import ( + "bytes" + "context" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertOpenAIResponseToInteractionsStreamUsageOnlyTerminalChunk(t *testing.T) { + var param any + finishRaw := []byte(`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-test","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`) + usageRaw := []byte(`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-test","choices":[],"usage":{"prompt_tokens":3,"completion_tokens":4,"total_tokens":7}}`) + doneRaw := []byte(`data: [DONE]`) + + finishOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, finishRaw, ¶m) + usageOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, usageRaw, ¶m) + doneOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, doneRaw, ¶m) + + if got := countInteractionsEvents(finishOut, "interaction.completed"); got != 0 { + t.Fatalf("finish interaction.completed count = %d, want 0", got) + } + if got := countInteractionsEvents(usageOut, "interaction.completed"); got != 1 { + t.Fatalf("usage interaction.completed count = %d, want 1", got) + } + if got := countInteractionsEvents(doneOut, "interaction.completed"); got != 0 { + t.Fatalf("done interaction.completed count = %d, want 0", got) + } + if got := countInteractionsEvents(doneOut, "done"); got != 1 { + t.Fatalf("done event count = %d, want 1", got) + } + payload := findInteractionsEventPayload(usageOut, "interaction.completed") + if got := gjson.GetBytes(payload, "interaction.usage.total_input_tokens").Int(); got != 3 { + t.Fatalf("total_input_tokens = %d, want 3. Payload: %s", got, string(payload)) + } + if got := gjson.GetBytes(payload, "interaction.usage.total_output_tokens").Int(); got != 4 { + t.Fatalf("total_output_tokens = %d, want 4. Payload: %s", got, string(payload)) + } + if got := gjson.GetBytes(payload, "interaction.usage.total_tokens").Int(); got != 7 { + t.Fatalf("total_tokens = %d, want 7. Payload: %s", got, string(payload)) + } +} + +func TestConvertOpenAIResponseToInteractionsCompletesOnDoneWithoutUsage(t *testing.T) { + var param any + finishRaw := []byte(`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-test","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`) + doneRaw := []byte(`data: [DONE]`) + + finishOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, finishRaw, ¶m) + doneOut := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, doneRaw, ¶m) + + if got := countInteractionsEvents(finishOut, "interaction.completed"); got != 0 { + t.Fatalf("finish interaction.completed count = %d, want 0", got) + } + if got := countInteractionsEvents(doneOut, "interaction.completed"); got != 1 { + t.Fatalf("done interaction.completed count = %d, want 1", got) + } + if got := countInteractionsEvents(doneOut, "done"); got != 1 { + t.Fatalf("done event count = %d, want 1", got) + } +} + +func TestConvertOpenAIResponseToInteractionsStreamCreatedUsesChunkIdentity(t *testing.T) { + var param any + raw := []byte(`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-test","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":null}]}`) + out := ConvertOpenAIResponseToInteractions(context.Background(), "", nil, nil, raw, ¶m) + payload := findInteractionsEventPayload(out, "interaction.created") + if got := gjson.GetBytes(payload, "interaction.id").String(); got != "chatcmpl_1" { + t.Fatalf("interaction.id = %q, want chatcmpl_1. Payload: %s", got, string(payload)) + } + if got := gjson.GetBytes(payload, "interaction.model").String(); got != "gpt-test" { + t.Fatalf("interaction.model = %q, want gpt-test. Payload: %s", got, string(payload)) + } +} + +func TestConvertOpenAIResponseToInteractionsNonStreamDirectToolCall(t *testing.T) { + raw := []byte(`{"id":"chatcmpl_1","model":"gpt-test","choices":[{"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":2,"completion_tokens":3,"total_tokens":5}}`) + out := ConvertOpenAIResponseToInteractionsNonStream(context.Background(), "gpt-test", nil, nil, raw, nil) + if got := gjson.GetBytes(out, "steps.0.type").String(); got != "function_call" { + t.Fatalf("step type = %q, want function_call. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "steps.0.call_id").String(); got != "call_1" { + t.Fatalf("call_id = %q, want call_1. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "steps.0.arguments.q").String(); got != "x" { + t.Fatalf("arguments.q = %q, want x. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsResponseToOpenAIStreamToolCall(t *testing.T) { + var param any + chunks := [][]byte{ + []byte(`data: {"event_type":"interaction.created","interaction":{"id":"i1","model":"gemini-3.1-flash-lite"}}`), + []byte(`data: {"event_type":"step.start","index":0,"step":{"type":"function_call","id":"call_1","name":"get_weather","arguments":{}}}`), + []byte(`data: {"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":"{\"location\":\"北京\"}"}}`), + []byte(`data: {"event_type":"step.stop","index":0}`), + []byte(`data: {"event_type":"interaction.completed","interaction":{"id":"i1","status":"requires_action","usage":{"total_input_tokens":2,"total_output_tokens":3,"total_tokens":5}}}`), + } + var out [][]byte + for _, chunk := range chunks { + out = append(out, ConvertInteractionsResponseToOpenAI(context.Background(), "gemini-3.1-flash-lite", nil, nil, chunk, ¶m)...) + } + toolStart := findOpenAIChatChunk(out, "choices.0.delta.tool_calls.0.function.name") + if got := gjson.GetBytes(toolStart, "choices.0.delta.tool_calls.0.id").String(); got != "call_1" { + t.Fatalf("tool call id = %q, want call_1. Payload: %s", got, string(toolStart)) + } + if got := gjson.GetBytes(toolStart, "choices.0.delta.tool_calls.0.function.name").String(); got != "get_weather" { + t.Fatalf("tool name = %q, want get_weather. Payload: %s", got, string(toolStart)) + } + toolArgs := findOpenAIChatChunkValue(out, "choices.0.delta.tool_calls.0.function.arguments", `{"location":"北京"}`) + if got := gjson.GetBytes(toolArgs, "choices.0.delta.tool_calls.0.function.arguments").String(); got != `{"location":"北京"}` { + t.Fatalf("tool args = %q, want location JSON. Payload: %s", got, string(toolArgs)) + } + completed := findOpenAIChatChunkValue(out, "choices.0.finish_reason", "tool_calls") + if got := gjson.GetBytes(completed, "choices.0.finish_reason").String(); got != "tool_calls" { + t.Fatalf("finish_reason = %q, want tool_calls. Payload: %s", got, string(completed)) + } + if got := gjson.GetBytes(completed, "usage.prompt_tokens").Int(); got != 2 { + t.Fatalf("prompt_tokens = %d, want 2. Payload: %s", got, string(completed)) + } +} + +func TestConvertInteractionsResponseToOpenAIStreamFinishMetadataUsage(t *testing.T) { + var param any + out := ConvertInteractionsResponseToOpenAI(context.Background(), "gpt-test", nil, nil, []byte(`data: {"event_type":"finish","metadata":{"total_usage":{"total_input_tokens":2,"total_output_tokens":6,"total_thought_tokens":3,"total_cached_tokens":1,"total_tokens":11}}}`), ¶m) + completed := findOpenAIChatChunkValue(out, "choices.0.finish_reason", "stop") + if len(completed) == 0 { + t.Fatalf("completion chunk not found") + } + if got := gjson.GetBytes(completed, "usage.prompt_tokens").Int(); got != 2 { + t.Fatalf("prompt_tokens = %d, want 2. Payload: %s", got, string(completed)) + } + if got := gjson.GetBytes(completed, "usage.completion_tokens").Int(); got != 6 { + t.Fatalf("completion_tokens = %d, want 6. Payload: %s", got, string(completed)) + } + if got := gjson.GetBytes(completed, "usage.completion_tokens_details.reasoning_tokens").Int(); got != 3 { + t.Fatalf("reasoning_tokens = %d, want 3. Payload: %s", got, string(completed)) + } + if got := gjson.GetBytes(completed, "usage.prompt_tokens_details.cached_tokens").Int(); got != 1 { + t.Fatalf("cached_tokens = %d, want 1. Payload: %s", got, string(completed)) + } + if got := gjson.GetBytes(completed, "usage.total_tokens").Int(); got != 11 { + t.Fatalf("total_tokens = %d, want 11. Payload: %s", got, string(completed)) + } +} + +func TestConvertInteractionsResponseToOpenAINonStreamToolCall(t *testing.T) { + raw := []byte(`{"id":"i1","model":"gemini-3.1-flash-lite","steps":[{"type":"function_call","id":"call_1","name":"get_weather","arguments":{"location":"北京"}}],"usage":{"total_input_tokens":2,"total_output_tokens":3,"total_tokens":5}}`) + out := ConvertInteractionsResponseToOpenAINonStream(context.Background(), "gemini-3.1-flash-lite", nil, nil, raw, nil) + if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.id").String(); got != "call_1" { + t.Fatalf("tool call id = %q, want call_1. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.function.name").String(); got != "get_weather" { + t.Fatalf("tool name = %q, want get_weather. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.function.arguments").String(); got != `{"location":"北京"}` { + t.Fatalf("tool args = %q, want location JSON. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "choices.0.finish_reason").String(); got != "tool_calls" { + t.Fatalf("finish_reason = %q, want tool_calls. Output: %s", got, string(out)) + } +} + +func findInteractionsEventPayload(events [][]byte, eventType string) []byte { + for _, event := range events { + payload := interactionsSSEPayload(event) + if interactionsEventName(event, payload) == eventType { + return payload + } + } + return nil +} + +func countInteractionsEvents(events [][]byte, eventType string) int { + count := 0 + for _, event := range events { + payload := interactionsSSEPayload(event) + if interactionsEventName(event, payload) == eventType { + count++ + } + } + return count +} + +func interactionsEventName(event, payload []byte) string { + if eventType := gjson.GetBytes(payload, "event_type").String(); eventType != "" { + return eventType + } + const prefix = "event: " + lineEnd := bytes.IndexByte(event, '\n') + if lineEnd < 0 || !bytes.HasPrefix(event, []byte(prefix)) { + return "" + } + return string(event[len(prefix):lineEnd]) +} + +func interactionsSSEPayload(event []byte) []byte { + const prefix = "\ndata: " + idx := bytes.Index(event, []byte(prefix)) + if idx < 0 { + return nil + } + return event[idx+len(prefix):] +} + +func findOpenAIChatChunk(chunks [][]byte, path string) []byte { + for _, chunk := range chunks { + if gjson.GetBytes(chunk, path).Exists() { + return chunk + } + } + return nil +} + +func findOpenAIChatChunkValue(chunks [][]byte, path, want string) []byte { + for _, chunk := range chunks { + if gjson.GetBytes(chunk, path).String() == want { + return chunk + } + } + return nil +} diff --git a/internal/translator/openai/interactions/chat-completions/openai_interactions_request.go b/internal/translator/openai/interactions/chat-completions/openai_interactions_request.go new file mode 100644 index 000000000..5d60fbcc3 --- /dev/null +++ b/internal/translator/openai/interactions/chat-completions/openai_interactions_request.go @@ -0,0 +1,306 @@ +package chat_completions + +import ( + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func ConvertOpenAIRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) []byte { + root := gjson.ParseBytes(inputRawJSON) + out := []byte(`{"model":"","input":[]}`) + out, _ = sjson.SetBytes(out, "model", firstNonEmpty(modelName, root.Get("model").String())) + if streamValue, ok := openAIRequestStreamValue(root, stream); ok { + out, _ = sjson.SetBytes(out, "stream", streamValue) + } + out = appendOpenAIMessagesToInteractions(out, root.Get("messages")) + out = copyOpenAIChatGenerationConfigToInteractions(out, root) + out = appendOpenAIChatToolsToInteractions(out, root.Get("tools")) + return out +} + +func openAIRequestStreamValue(root gjson.Result, stream bool) (bool, bool) { + if value := root.Get("stream"); value.Exists() { + return value.Bool(), true + } + if stream { + return true, true + } + return false, false +} + +func appendOpenAIMessagesToInteractions(out []byte, messages gjson.Result) []byte { + if !messages.Exists() || !messages.IsArray() { + return out + } + var systemBuilder strings.Builder + messages.ForEach(func(_, message gjson.Result) bool { + role := strings.ToLower(strings.TrimSpace(message.Get("role").String())) + switch role { + case "system", "developer": + if text := openAIChatContentText(message.Get("content")); text != "" { + if systemBuilder.Len() > 0 { + systemBuilder.WriteByte('\n') + } + systemBuilder.WriteString(text) + } + default: + out = appendOpenAIMessageToInteractions(out, message) + } + return true + }) + if systemBuilder.Len() > 0 { + out, _ = sjson.SetBytes(out, "system_instruction", systemBuilder.String()) + } + return out +} + +func appendOpenAIMessageToInteractions(out []byte, message gjson.Result) []byte { + role := strings.ToLower(strings.TrimSpace(message.Get("role").String())) + switch role { + case "assistant": + if reasoning := message.Get("reasoning_content"); reasoning.Exists() { + for _, text := range openAIReasoningTexts(reasoning) { + out, _ = sjson.SetRawBytes(out, "input.-1", interactionsTextStep("thought", text)) + } + } + if step, ok := openAIChatContentStep("model_output", message.Get("content")); ok { + out, _ = sjson.SetRawBytes(out, "input.-1", step) + } + if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() { + toolCalls.ForEach(func(_, toolCall gjson.Result) bool { + if step, ok := openAIToolCallToInteractionsStep(toolCall); ok { + out, _ = sjson.SetRawBytes(out, "input.-1", step) + } + return true + }) + } + case "tool", "function": + out, _ = sjson.SetRawBytes(out, "input.-1", openAIToolResultToInteractions(message)) + default: + if step, ok := openAIChatContentStep("user_input", message.Get("content")); ok { + out, _ = sjson.SetRawBytes(out, "input.-1", step) + } + } + return out +} + +func openAIChatContentStep(stepType string, content gjson.Result) ([]byte, bool) { + step := []byte(`{"type":"","content":[]}`) + step, _ = sjson.SetBytes(step, "type", stepType) + if content.Type == gjson.String { + if content.String() == "" { + return nil, false + } + part := []byte(`{"type":"text","text":""}`) + part, _ = sjson.SetBytes(part, "text", content.String()) + step, _ = sjson.SetRawBytes(step, "content.-1", part) + return step, true + } + appendPart := func(part gjson.Result) { + if converted, ok := openAIChatContentPartToInteractions(part); ok { + step, _ = sjson.SetRawBytes(step, "content.-1", converted) + } + } + if content.IsArray() { + content.ForEach(func(_, part gjson.Result) bool { + appendPart(part) + return true + }) + } else if content.IsObject() { + appendPart(content) + } + return step, gjson.GetBytes(step, "content.#").Int() > 0 +} + +func openAIChatContentPartToInteractions(part gjson.Result) ([]byte, bool) { + partType := strings.ToLower(strings.TrimSpace(part.Get("type").String())) + if partType == "" && part.Get("text").Exists() { + partType = "text" + } + switch partType { + case "text", "input_text", "output_text": + out := []byte(`{"type":"text","text":""}`) + out, _ = sjson.SetBytes(out, "text", part.Get("text").String()) + return out, true + case "image_url", "input_image", "image": + return openAIChatImagePartToInteractions(part), true + case "input_audio", "audio": + out := []byte(`{"type":"audio","data":""}`) + audio := part.Get("input_audio") + data := firstNonEmpty(audio.Get("data").String(), part.Get("data").String()) + if data == "" { + return nil, false + } + out, _ = sjson.SetBytes(out, "data", data) + if format := firstNonEmpty(audio.Get("format").String(), part.Get("format").String()); format != "" { + out, _ = sjson.SetBytes(out, "mime_type", openAIInputAudioMIMEType(format)) + } + return out, true + case "file", "input_file", "document": + file := part.Get("file") + out := []byte(`{"type":"document"}`) + if filename := firstNonEmpty(file.Get("filename").String(), part.Get("filename").String()); filename != "" { + out, _ = sjson.SetBytes(out, "filename", filename) + } + if data := firstNonEmpty(file.Get("file_data").String(), part.Get("file_data").String(), part.Get("data").String()); data != "" { + out, _ = sjson.SetBytes(out, "data", data) + } + if url := firstNonEmpty(file.Get("file_url").String(), part.Get("file_url").String(), part.Get("url").String()); url != "" { + out, _ = sjson.SetBytes(out, "file_url", url) + } + return out, true + } + return nil, false +} + +func openAIChatImagePartToInteractions(part gjson.Result) []byte { + out := []byte(`{"type":"image"}`) + imageURL := firstNonEmpty(part.Get("image_url.url").String(), part.Get("image_url").String(), part.Get("url").String()) + if mimeType, data, ok := openAIChatParseDataURL(imageURL); ok { + out, _ = sjson.SetBytes(out, "mime_type", mimeType) + out, _ = sjson.SetBytes(out, "data", data) + return out + } + if data := part.Get("data").String(); data != "" { + out, _ = sjson.SetBytes(out, "data", data) + if mimeType := part.Get("mime_type").String(); mimeType != "" { + out, _ = sjson.SetBytes(out, "mime_type", mimeType) + } + return out + } + if imageURL != "" { + out, _ = sjson.SetBytes(out, "image_url", imageURL) + } + return out +} + +func openAIToolResultToInteractions(message gjson.Result) []byte { + out := []byte(`{"type":"function_result","result":""}`) + if callID := firstNonEmpty(message.Get("tool_call_id").String(), message.Get("id").String()); callID != "" { + out, _ = sjson.SetBytes(out, "id", callID) + out, _ = sjson.SetBytes(out, "call_id", callID) + } + if name := message.Get("name").String(); name != "" { + out, _ = sjson.SetBytes(out, "name", name) + } + content := message.Get("content") + if content.Exists() && content.Type == gjson.String { + out, _ = sjson.SetBytes(out, "result", content.String()) + } else if content.Exists() { + out, _ = sjson.SetRawBytes(out, "result", []byte(content.Raw)) + } + return out +} + +func copyOpenAIChatGenerationConfigToInteractions(out []byte, root gjson.Result) []byte { + copyNumber(&out, "generation_config.max_output_tokens", firstExisting(root.Get("max_completion_tokens"), root.Get("max_tokens"))) + copyNumber(&out, "generation_config.temperature", root.Get("temperature")) + copyNumber(&out, "generation_config.top_p", root.Get("top_p")) + copyNumber(&out, "generation_config.presence_penalty", root.Get("presence_penalty")) + copyNumber(&out, "generation_config.frequency_penalty", root.Get("frequency_penalty")) + copyNumber(&out, "generation_config.candidate_count", root.Get("n")) + if stop := root.Get("stop"); stop.Exists() { + out, _ = sjson.SetRawBytes(out, "generation_config.stop_sequences", []byte(stop.Raw)) + } + if toolChoice := root.Get("tool_choice"); toolChoice.Exists() { + out, _ = sjson.SetRawBytes(out, "generation_config.tool_choice", []byte(toolChoice.Raw)) + } + if effort := root.Get("reasoning_effort"); effort.Exists() && effort.Type == gjson.String { + out, _ = sjson.SetBytes(out, "generation_config.thinking_level", strings.ToLower(strings.TrimSpace(effort.String()))) + } + if responseFormat := root.Get("response_format"); responseFormat.Exists() { + out, _ = sjson.SetRawBytes(out, "response_format", []byte(responseFormat.Raw)) + } + if modalities := root.Get("modalities"); modalities.Exists() { + out, _ = sjson.SetRawBytes(out, "response_modalities", []byte(modalities.Raw)) + } + if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String { + out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String()) + } + return out +} + +func appendOpenAIChatToolsToInteractions(out []byte, tools gjson.Result) []byte { + if !tools.Exists() || !tools.IsArray() { + return out + } + tools.ForEach(func(_, tool gjson.Result) bool { + if converted, ok := openAIChatToolToInteractions(tool); ok { + out, _ = sjson.SetRawBytes(out, "tools.-1", converted) + } + return true + }) + return out +} + +func openAIChatToolToInteractions(tool gjson.Result) ([]byte, bool) { + toolType := strings.ToLower(strings.TrimSpace(tool.Get("type").String())) + if toolType != "" && toolType != "function" { + return nil, false + } + name := firstNonEmpty(tool.Get("function.name").String(), tool.Get("name").String()) + if name == "" { + return nil, false + } + out := []byte(`{"type":"function","name":""}`) + out, _ = sjson.SetBytes(out, "name", name) + if desc := firstExisting(tool.Get("function.description"), tool.Get("description")); desc.Exists() { + out, _ = sjson.SetBytes(out, "description", desc.String()) + } + if parameters := firstExisting(tool.Get("function.parameters"), tool.Get("parameters")); parameters.Exists() { + out, _ = sjson.SetRawBytes(out, "parameters", []byte(parameters.Raw)) + } + return out, true +} + +func openAIChatContentText(content gjson.Result) string { + if content.Type == gjson.String { + return content.String() + } + if content.IsObject() { + return content.Get("text").String() + } + if !content.IsArray() { + return "" + } + var builder strings.Builder + content.ForEach(func(_, part gjson.Result) bool { + if text := part.Get("text").String(); text != "" { + builder.WriteString(text) + } + return true + }) + return builder.String() +} + +func openAIInputAudioMIMEType(format string) string { + switch strings.ToLower(strings.TrimSpace(format)) { + case "wav": + return "audio/wav" + case "flac": + return "audio/flac" + case "opus": + return "audio/opus" + case "pcm16": + return "audio/pcm" + default: + return "audio/mpeg" + } +} + +func openAIChatParseDataURL(value string) (string, string, bool) { + if !strings.HasPrefix(value, "data:") { + return "", "", false + } + meta, data, ok := strings.Cut(strings.TrimPrefix(value, "data:"), ",") + if !ok { + return "", "", false + } + mimeType, encoding, _ := strings.Cut(meta, ";") + if !strings.EqualFold(encoding, "base64") || strings.TrimSpace(mimeType) == "" || data == "" { + return "", "", false + } + return mimeType, data, true +} diff --git a/internal/translator/openai/interactions/chat-completions/openai_interactions_response.go b/internal/translator/openai/interactions/chat-completions/openai_interactions_response.go new file mode 100644 index 000000000..c4b6e2ffd --- /dev/null +++ b/internal/translator/openai/interactions/chat-completions/openai_interactions_response.go @@ -0,0 +1,343 @@ +package chat_completions + +import ( + "bytes" + "context" + "fmt" + "strings" + "time" + + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type interactionsToOpenAIChatStreamState struct { + ID string + Model string + Created int64 + Started bool + Completed bool + SawToolCall bool + StepTypes map[int]string + ToolIDs map[int]string + ToolNames map[int]string + ToolArguments map[int]*strings.Builder + TextByStepIndex map[int]*strings.Builder +} + +func ConvertInteractionsResponseToOpenAI(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { + _ = ctx + _ = originalRequestRawJSON + _ = requestRawJSON + if param == nil { + var local any + param = &local + } + if *param == nil { + *param = &interactionsToOpenAIChatStreamState{Model: modelName} + } + st := (*param).(*interactionsToOpenAIChatStreamState) + st.Model = firstNonEmpty(st.Model, modelName) + st.ensureMaps() + return convertInteractionsEventToOpenAIChat(modelName, rawJSON, st) +} + +func ConvertInteractionsResponseToOpenAINonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { + _ = ctx + _ = originalRequestRawJSON + _ = requestRawJSON + root := gjson.ParseBytes(rawJSON) + interaction := root + if nested := root.Get("interaction"); nested.Exists() { + interaction = nested + } + out := []byte(`{"id":"","object":"chat.completion","created":0,"model":"","choices":[{"index":0,"message":{"role":"assistant","content":""},"finish_reason":"stop"}]}`) + out, _ = sjson.SetBytes(out, "id", firstNonEmpty(interaction.Get("id").String(), root.Get("id").String(), fmt.Sprintf("chatcmpl_%d", time.Now().UnixNano()))) + out, _ = sjson.SetBytes(out, "created", time.Now().Unix()) + out, _ = sjson.SetBytes(out, "model", firstNonEmpty(interaction.Get("model").String(), modelName)) + steps := interaction.Get("steps") + if !steps.Exists() { + steps = root.Get("steps") + } + var textBuilder strings.Builder + var reasoningBuilder strings.Builder + sawToolCall := false + steps.ForEach(func(_, step gjson.Result) bool { + switch step.Get("type").String() { + case "model_output": + for _, text := range interactionsContentTextsForOpenAIChat(step.Get("content")) { + textBuilder.WriteString(text) + } + case "thought": + for _, text := range interactionsContentTextsForOpenAIChat(step.Get("content")) { + reasoningBuilder.WriteString(text) + } + case "function_call": + sawToolCall = true + out, _ = sjson.SetRawBytes(out, "choices.0.message.tool_calls.-1", openAIChatToolCallFromInteractions(step, gjson.Result{})) + } + return true + }) + if textBuilder.Len() > 0 { + out, _ = sjson.SetBytes(out, "choices.0.message.content", textBuilder.String()) + } + if reasoningBuilder.Len() > 0 { + out, _ = sjson.SetBytes(out, "choices.0.message.reasoning_content", reasoningBuilder.String()) + } + if sawToolCall { + out, _ = sjson.SetBytes(out, "choices.0.message.content", nil) + out, _ = sjson.SetBytes(out, "choices.0.finish_reason", "tool_calls") + } + out = setOpenAIChatUsageFromInteractions(out, "usage", translatorcommon.InteractionsUsage(root)) + return out +} + +func convertInteractionsEventToOpenAIChat(modelName string, rawJSON []byte, st *interactionsToOpenAIChatStreamState) [][]byte { + payload := openAIChatInteractionsPayload(rawJSON) + if len(payload) == 0 || bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) { + return nil + } + root := gjson.ParseBytes(payload) + if !root.Exists() { + return nil + } + switch root.Get("event_type").String() { + case "interaction.created": + interaction := root.Get("interaction") + st.ID = firstNonEmpty(interaction.Get("id").String(), st.ID) + st.Model = firstNonEmpty(interaction.Get("model").String(), st.Model, modelName) + return ensureOpenAIChatStarted(nil, st) + case "step.start": + return interactionsStepStartToOpenAIChat(modelName, root, st) + case "step.delta": + return interactionsStepDeltaToOpenAIChat(modelName, root, st) + case "interaction.completed", "finish": + return appendOpenAIChatCompleted(nil, root, st) + case "done": + return nil + } + return nil +} + +func interactionsStepStartToOpenAIChat(modelName string, root gjson.Result, st *interactionsToOpenAIChatStreamState) [][]byte { + _ = modelName + out := ensureOpenAIChatStarted(nil, st) + index := int(root.Get("index").Int()) + step := root.Get("step") + stepType := step.Get("type").String() + st.StepTypes[index] = stepType + switch stepType { + case "function_call": + st.SawToolCall = true + st.ToolIDs[index] = firstNonEmpty(step.Get("call_id").String(), step.Get("id").String(), fmt.Sprintf("call_%d", index)) + st.ToolNames[index] = step.Get("name").String() + if st.ToolArguments[index] == nil { + st.ToolArguments[index] = &strings.Builder{} + } + if args := step.Get("arguments"); args.Exists() && strings.TrimSpace(args.Raw) != "{}" { + st.ToolArguments[index].WriteString(jsonStringValue(args, "{}")) + } + return append(out, openAIChatToolCallStartChunk(st, index)) + default: + return out + } +} + +func interactionsStepDeltaToOpenAIChat(modelName string, root gjson.Result, st *interactionsToOpenAIChatStreamState) [][]byte { + _ = modelName + index := int(root.Get("index").Int()) + delta := root.Get("delta") + out := ensureOpenAIChatStarted(nil, st) + switch delta.Get("type").String() { + case "thought_summary": + text := firstNonEmpty(delta.Get("content.text").String(), delta.Get("text").String()) + if text == "" { + return out + } + return append(out, openAIChatDeltaChunk(st, "reasoning_content", text)) + case "arguments_delta": + args := delta.Get("arguments").String() + if st.ToolArguments[index] == nil { + st.ToolArguments[index] = &strings.Builder{} + } + st.ToolArguments[index].WriteString(args) + return append(out, openAIChatToolCallArgumentsChunk(st, index, args)) + default: + text := delta.Get("text").String() + if text == "" { + return out + } + if st.TextByStepIndex[index] == nil { + st.TextByStepIndex[index] = &strings.Builder{} + } + st.TextByStepIndex[index].WriteString(text) + return append(out, openAIChatDeltaChunk(st, "content", text)) + } +} + +func ensureOpenAIChatStarted(out [][]byte, st *interactionsToOpenAIChatStreamState) [][]byte { + if st.Started { + return out + } + chunk := openAIChatBaseChunk(st) + chunk, _ = sjson.SetBytes(chunk, "choices.0.delta.role", "assistant") + st.Started = true + return append(out, chunk) +} + +func appendOpenAIChatCompleted(out [][]byte, root gjson.Result, st *interactionsToOpenAIChatStreamState) [][]byte { + if st.Completed { + return out + } + out = ensureOpenAIChatStarted(out, st) + chunk := openAIChatBaseChunk(st) + finishReason := "stop" + if st.SawToolCall { + finishReason = "tool_calls" + } + chunk, _ = sjson.SetBytes(chunk, "choices.0.finish_reason", finishReason) + chunk = setOpenAIChatUsageFromInteractions(chunk, "usage", translatorcommon.InteractionsUsage(root)) + st.Completed = true + return append(out, chunk) +} + +func openAIChatBaseChunk(st *interactionsToOpenAIChatStreamState) []byte { + chunk := []byte(`{"id":"","object":"chat.completion.chunk","created":0,"model":"","choices":[{"index":0,"delta":{},"finish_reason":null}]}`) + chunk, _ = sjson.SetBytes(chunk, "id", firstNonEmpty(st.ID, fmt.Sprintf("chatcmpl_%d", time.Now().UnixNano()))) + chunk, _ = sjson.SetBytes(chunk, "created", openAIChatCreated(st)) + chunk, _ = sjson.SetBytes(chunk, "model", st.Model) + return chunk +} + +func openAIChatDeltaChunk(st *interactionsToOpenAIChatStreamState, field, value string) []byte { + chunk := openAIChatBaseChunk(st) + chunk, _ = sjson.SetBytes(chunk, "choices.0.delta."+field, value) + return chunk +} + +func openAIChatToolCallStartChunk(st *interactionsToOpenAIChatStreamState, index int) []byte { + chunk := openAIChatBaseChunk(st) + toolCall := []byte(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`) + toolCall, _ = sjson.SetBytes(toolCall, "index", index) + toolCall, _ = sjson.SetBytes(toolCall, "id", firstNonEmpty(st.ToolIDs[index], fmt.Sprintf("call_%d", index))) + toolCall, _ = sjson.SetBytes(toolCall, "function.name", st.ToolNames[index]) + chunk, _ = sjson.SetRawBytes(chunk, "choices.0.delta.tool_calls.-1", toolCall) + return chunk +} + +func openAIChatToolCallArgumentsChunk(st *interactionsToOpenAIChatStreamState, index int, arguments string) []byte { + chunk := openAIChatBaseChunk(st) + toolCall := []byte(`{"index":0,"function":{"arguments":""}}`) + toolCall, _ = sjson.SetBytes(toolCall, "index", index) + toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments) + chunk, _ = sjson.SetRawBytes(chunk, "choices.0.delta.tool_calls.-1", toolCall) + return chunk +} + +func openAIChatToolCallFromInteractions(step, fallbackArgs gjson.Result) []byte { + toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":"{}"}}`) + callID := firstNonEmpty(step.Get("call_id").String(), step.Get("id").String(), "call_0") + toolCall, _ = sjson.SetBytes(toolCall, "id", callID) + toolCall, _ = sjson.SetBytes(toolCall, "function.name", step.Get("name").String()) + args := step.Get("arguments") + if !args.Exists() { + args = fallbackArgs + } + toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", jsonStringValue(args, "{}")) + return toolCall +} + +func setOpenAIChatUsageFromInteractions(out []byte, path string, usage gjson.Result) []byte { + if !usage.Exists() { + return out + } + if value, ok := interactionsUsageInt(usage, "input_tokens", "total_input_tokens"); ok { + out, _ = sjson.SetBytes(out, path+".prompt_tokens", value) + } + if value, ok := interactionsUsageInt(usage, "output_tokens", "total_output_tokens"); ok { + out, _ = sjson.SetBytes(out, path+".completion_tokens", value) + } + if value, ok := interactionsUsageInt(usage, "total_tokens"); ok { + out, _ = sjson.SetBytes(out, path+".total_tokens", value) + } + if value, ok := interactionsUsageInt(usage, "cached_tokens", "total_cached_tokens"); ok { + out, _ = sjson.SetBytes(out, path+".prompt_tokens_details.cached_tokens", value) + } + if value, ok := interactionsUsageInt(usage, "reasoning_tokens", "total_thought_tokens"); ok { + out, _ = sjson.SetBytes(out, path+".completion_tokens_details.reasoning_tokens", value) + } + return out +} + +func interactionsUsageInt(root gjson.Result, paths ...string) (int64, bool) { + for _, path := range paths { + if value := root.Get(path); value.Exists() { + return value.Int(), true + } + } + return 0, false +} + +func interactionsContentTextsForOpenAIChat(content gjson.Result) []string { + if !content.Exists() { + return nil + } + if content.Type == gjson.String { + return []string{content.String()} + } + var out []string + content.ForEach(func(_, part gjson.Result) bool { + if text := firstNonEmpty(part.Get("text").String(), part.Get("content.text").String()); text != "" { + out = append(out, text) + } + return true + }) + return out +} + +func openAIChatInteractionsPayload(rawJSON []byte) []byte { + trimmed := bytes.TrimSpace(rawJSON) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("[DONE]")) { + return trimmed + } + if bytes.HasPrefix(trimmed, []byte("data:")) { + return bytes.TrimSpace(trimmed[len("data:"):]) + } + var dataLines [][]byte + for _, line := range bytes.Split(trimmed, []byte("\n")) { + line = bytes.TrimSpace(line) + if bytes.HasPrefix(line, []byte("data:")) { + dataLines = append(dataLines, bytes.TrimSpace(line[len("data:"):])) + } + } + if len(dataLines) > 0 { + return bytes.Join(dataLines, []byte("\n")) + } + return trimmed +} + +func openAIChatCreated(st *interactionsToOpenAIChatStreamState) int64 { + if st.Created == 0 { + st.Created = time.Now().Unix() + } + return st.Created +} + +func (st *interactionsToOpenAIChatStreamState) ensureMaps() { + if st.StepTypes == nil { + st.StepTypes = make(map[int]string) + } + if st.ToolIDs == nil { + st.ToolIDs = make(map[int]string) + } + if st.ToolNames == nil { + st.ToolNames = make(map[int]string) + } + if st.ToolArguments == nil { + st.ToolArguments = make(map[int]*strings.Builder) + } + if st.TextByStepIndex == nil { + st.TextByStepIndex = make(map[int]*strings.Builder) + } +} diff --git a/internal/translator/openai/interactions/responses/init.go b/internal/translator/openai/interactions/responses/init.go new file mode 100644 index 000000000..c6fe53500 --- /dev/null +++ b/internal/translator/openai/interactions/responses/init.go @@ -0,0 +1,28 @@ +package responses + +import ( + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator" +) + +func init() { + translator.Register( + OpenaiResponse, + Interactions, + ConvertOpenAIResponsesRequestToInteractions, + interfaces.TranslateResponse{ + Stream: ConvertInteractionsResponseToOpenAIResponses, + NonStream: ConvertInteractionsResponseToOpenAIResponsesNonStream, + }, + ) + translator.Register( + Interactions, + OpenaiResponse, + ConvertInteractionsRequestToOpenAIResponses, + interfaces.TranslateResponse{ + Stream: ConvertOpenAIResponsesResponseToInteractions, + NonStream: ConvertOpenAIResponsesResponseToInteractionsNonStream, + }, + ) +} diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_request.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_request.go new file mode 100644 index 000000000..d6e45bade --- /dev/null +++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_request.go @@ -0,0 +1,676 @@ +package responses + +import ( + "strings" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +func ConvertOpenAIResponsesRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) []byte { + root := gjson.ParseBytes(inputRawJSON) + out := []byte(`{"model":"","input":[]}`) + out, _ = sjson.SetBytes(out, "model", requestModel(modelName, root)) + if streamValue, ok := requestStreamValue(root, stream); ok { + out, _ = sjson.SetBytes(out, "stream", streamValue) + } + if instructions := root.Get("instructions"); instructions.Exists() { + out, _ = sjson.SetBytes(out, "system_instruction", responsesInstructionsText(instructions)) + } + if previousResponseID := root.Get("previous_response_id"); previousResponseID.Exists() && previousResponseID.Type == gjson.String { + out, _ = sjson.SetBytes(out, "previous_interaction_id", previousResponseID.String()) + } + if input := root.Get("input"); input.Exists() { + out = appendResponsesInputToInteractions(out, input) + } + out = appendResponsesToolsToInteractions(out, root.Get("tools")) + if toolChoice := root.Get("tool_choice"); toolChoice.Exists() { + out, _ = sjson.SetRawBytes(out, "generation_config.tool_choice", []byte(toolChoice.Raw)) + } + if effort := root.Get("reasoning.effort"); effort.Exists() && effort.Type == gjson.String { + out, _ = sjson.SetBytes(out, "generation_config.thinking_level", strings.ToLower(strings.TrimSpace(effort.String()))) + } + if summary := root.Get("reasoning.summary"); summary.Exists() && summary.Type == gjson.String { + out, _ = sjson.SetBytes(out, "generation_config.thinking_summaries", summary.String()) + } + if format := root.Get("response_format"); format.Exists() { + out, _ = sjson.SetRawBytes(out, "response_format", []byte(format.Raw)) + } else if format := root.Get("text.format"); format.Exists() { + out, _ = sjson.SetRawBytes(out, "response_format", []byte(format.Raw)) + } + return out +} + +func ConvertInteractionsRequestToOpenAIResponses(modelName string, inputRawJSON []byte, stream bool) []byte { + root := gjson.ParseBytes(inputRawJSON) + out := []byte(`{"model":"","input":[]}`) + out, _ = sjson.SetBytes(out, "model", requestModel(modelName, root)) + if stream || root.Get("stream").Bool() { + out, _ = sjson.SetBytes(out, "stream", true) + } + if instructions := interactionsSystemInstructionText(root); instructions != "" { + out, _ = sjson.SetBytes(out, "instructions", instructions) + } + if previousInteractionID := root.Get("previous_interaction_id"); previousInteractionID.Exists() && previousInteractionID.Type == gjson.String { + out, _ = sjson.SetBytes(out, "previous_response_id", previousInteractionID.String()) + } + if input := root.Get("input"); input.Exists() { + out = appendInteractionsInputToResponses(out, input) + } + out = appendInteractionsToolsToResponses(out, root.Get("tools")) + if toolChoice := root.Get("generation_config.tool_choice"); toolChoice.Exists() { + out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(toolChoice.Raw)) + } else if toolChoice := root.Get("tool_choice"); toolChoice.Exists() { + out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(toolChoice.Raw)) + } + if effort := interactionsThinkingEffort(root); effort != "" { + out, _ = sjson.SetBytes(out, "reasoning.effort", effort) + } + if summary := root.Get("generation_config.thinking_summaries"); summary.Exists() && summary.Type == gjson.String { + out, _ = sjson.SetBytes(out, "reasoning.summary", summary.String()) + } + if responseModalities := root.Get("response_modalities"); responseModalities.Exists() { + out, _ = sjson.SetRawBytes(out, "modalities", []byte(responseModalities.Raw)) + } + if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String { + out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String()) + } + if format := root.Get("response_format"); format.Exists() { + out, _ = sjson.SetRawBytes(out, "text.format", []byte(format.Raw)) + } + return out +} + +func requestModel(modelName string, root gjson.Result) string { + if strings.TrimSpace(modelName) != "" { + return modelName + } + return root.Get("model").String() +} + +func requestStreamValue(root gjson.Result, stream bool) (bool, bool) { + if value := root.Get("stream"); value.Exists() { + return value.Bool(), true + } + if stream { + return true, true + } + return false, false +} + +func responsesInstructionsText(instructions gjson.Result) string { + if instructions.Type == gjson.String { + return instructions.String() + } + if text := instructions.Get("text"); text.Exists() { + return text.String() + } + if parts := instructions.Get("content"); parts.Exists() && parts.IsArray() { + var builder strings.Builder + parts.ForEach(func(_, part gjson.Result) bool { + if text := part.Get("text").String(); text != "" { + builder.WriteString(text) + } + return true + }) + return builder.String() + } + return instructions.String() +} + +func interactionsSystemInstructionText(root gjson.Result) string { + sys := root.Get("system_instruction") + if !sys.Exists() { + return "" + } + if sys.Type == gjson.String { + return sys.String() + } + if text := sys.Get("text"); text.Exists() { + return text.String() + } + if parts := sys.Get("parts"); parts.Exists() && parts.IsArray() { + var builder strings.Builder + parts.ForEach(func(_, part gjson.Result) bool { + if text := part.Get("text").String(); text != "" { + builder.WriteString(text) + } + return true + }) + return builder.String() + } + return "" +} + +func interactionsThinkingEffort(root gjson.Result) string { + for _, path := range []string{ + "generation_config.thinking_level", + "generation_config.thinkingConfig.thinkingLevel", + "generation_config.thinkingConfig.thinking_level", + "generation_config.thinking_config.thinking_level", + } { + if level := root.Get(path); level.Exists() && level.Type == gjson.String { + return strings.ToLower(strings.TrimSpace(level.String())) + } + } + return "" +} + +func appendResponsesInputToInteractions(out []byte, input gjson.Result) []byte { + functionNamesByCallID := make(map[string]string) + if input.Type == gjson.String { + return appendInteractionsTextStep(out, "user_input", input.String()) + } + if input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + out = appendResponsesInputItemToInteractions(out, item, functionNamesByCallID) + return true + }) + return out + } + if input.IsObject() { + return appendResponsesInputItemToInteractions(out, input, functionNamesByCallID) + } + return out +} + +func appendResponsesInputItemToInteractions(out []byte, item gjson.Result, functionNamesByCallID map[string]string) []byte { + switch item.Get("type").String() { + case "message": + stepType := "user_input" + if role := item.Get("role").String(); role == "assistant" || role == "model" { + stepType = "model_output" + } + step := []byte(`{"type":"","content":[]}`) + step, _ = sjson.SetBytes(step, "type", stepType) + step = appendResponsesContentToInteractions(step, item.Get("content"), stepType) + out, _ = sjson.SetRawBytes(out, "input.-1", step) + case "function_call": + callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String()) + if callID != "" { + if name := item.Get("name").String(); name != "" { + functionNamesByCallID[callID] = name + } + } + out, _ = sjson.SetRawBytes(out, "input.-1", responsesFunctionCallToInteractions(item)) + case "function_call_output": + out, _ = sjson.SetRawBytes(out, "input.-1", responsesFunctionOutputToInteractions(item, functionNamesByCallID)) + case "input_text", "output_text", "text": + stepType := "user_input" + if item.Get("type").String() == "output_text" { + stepType = "model_output" + } + out = appendInteractionsTextStep(out, stepType, item.Get("text").String()) + case "input_image", "output_image": + stepType := "user_input" + if item.Get("type").String() == "output_image" { + stepType = "model_output" + } + step := []byte(`{"type":"","content":[]}`) + step, _ = sjson.SetBytes(step, "type", stepType) + if part, ok := responsesContentPartToInteractions(item); ok { + step, _ = sjson.SetRawBytes(step, "content.-1", part) + } + out, _ = sjson.SetRawBytes(out, "input.-1", step) + default: + if content := item.Get("content"); content.Exists() { + step := []byte(`{"type":"user_input","content":[]}`) + step = appendResponsesContentToInteractions(step, content, "user_input") + out, _ = sjson.SetRawBytes(out, "input.-1", step) + } + } + return out +} + +func appendResponsesContentToInteractions(step []byte, content gjson.Result, stepType string) []byte { + if content.Type == gjson.String { + part := []byte(`{"type":"text","text":""}`) + part, _ = sjson.SetBytes(part, "text", content.String()) + step, _ = sjson.SetRawBytes(step, "content.-1", part) + return step + } + if content.IsArray() { + content.ForEach(func(_, item gjson.Result) bool { + if part, ok := responsesContentPartToInteractions(item); ok { + step, _ = sjson.SetRawBytes(step, "content.-1", part) + } + return true + }) + return step + } + if content.IsObject() { + if part, ok := responsesContentPartToInteractions(content); ok { + step, _ = sjson.SetRawBytes(step, "content.-1", part) + } + return step + } + if stepType == "model_output" { + return step + } + return step +} + +func responsesContentPartToInteractions(part gjson.Result) ([]byte, bool) { + switch part.Get("type").String() { + case "input_text", "output_text", "text": + out := []byte(`{"type":"text","text":""}`) + out, _ = sjson.SetBytes(out, "text", part.Get("text").String()) + return out, true + case "input_image", "output_image": + return responsesImagePartToInteractions(part), true + } + if text := part.Get("text"); text.Exists() { + out := []byte(`{"type":"text","text":""}`) + out, _ = sjson.SetBytes(out, "text", text.String()) + return out, true + } + return nil, false +} + +func responsesImagePartToInteractions(part gjson.Result) []byte { + out := []byte(`{"type":"image"}`) + imageURL := firstNonEmpty(part.Get("image_url").String(), part.Get("url").String()) + if mimeType, data, ok := parseDataURL(imageURL); ok { + out, _ = sjson.SetBytes(out, "mime_type", mimeType) + out, _ = sjson.SetBytes(out, "data", data) + return out + } + if data := part.Get("data").String(); data != "" { + out, _ = sjson.SetBytes(out, "data", data) + if mimeType := part.Get("mime_type").String(); mimeType != "" { + out, _ = sjson.SetBytes(out, "mime_type", mimeType) + } + return out + } + if imageURL != "" { + out, _ = sjson.SetBytes(out, "image_url", imageURL) + } + return out +} + +func responsesFunctionCallToInteractions(item gjson.Result) []byte { + out := []byte(`{"type":"function_call","name":"","arguments":{}}`) + out, _ = sjson.SetBytes(out, "name", item.Get("name").String()) + if callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String()); callID != "" { + out, _ = sjson.SetBytes(out, "call_id", callID) + } + setJSONValue(&out, "arguments", item.Get("arguments"), []byte(`{}`)) + return out +} + +func responsesFunctionOutputToInteractions(item gjson.Result, functionNamesByCallID map[string]string) []byte { + out := []byte(`{"type":"function_result","name":"","result":{}}`) + callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String()) + if name := item.Get("name").String(); name != "" { + out, _ = sjson.SetBytes(out, "name", name) + } else if name := functionNamesByCallID[callID]; name != "" { + out, _ = sjson.SetBytes(out, "name", name) + } + if callID != "" { + out, _ = sjson.SetBytes(out, "call_id", callID) + } + result := item.Get("output") + if !result.Exists() { + result = item.Get("result") + } + setJSONValue(&out, "result", result, []byte(`{}`)) + return out +} + +func appendInteractionsTextStep(out []byte, stepType, text string) []byte { + step := []byte(`{"type":"","content":[{"type":"text","text":""}]}`) + step, _ = sjson.SetBytes(step, "type", stepType) + step, _ = sjson.SetBytes(step, "content.0.text", text) + out, _ = sjson.SetRawBytes(out, "input.-1", step) + return out +} + +func appendResponsesToolsToInteractions(out []byte, tools gjson.Result) []byte { + if !tools.Exists() || !tools.IsArray() { + return out + } + tools.ForEach(func(_, tool gjson.Result) bool { + switch tool.Get("type").String() { + case "function", "": + if converted, ok := functionToolToInteractions(tool); ok { + out, _ = sjson.SetRawBytes(out, "tools.-1", converted) + } + case "namespace": + group := []byte(`{"function_declarations":[]}`) + children := tool.Get("children") + if !children.Exists() { + children = tool.Get("tools") + } + children.ForEach(func(_, child gjson.Result) bool { + if converted, ok := functionDeclarationFromTool(child); ok { + group, _ = sjson.SetRawBytes(group, "function_declarations.-1", converted) + } + return true + }) + if gjson.GetBytes(group, "function_declarations.#").Int() > 0 { + out, _ = sjson.SetRawBytes(out, "tools.-1", group) + } + } + return true + }) + return out +} + +func functionToolToInteractions(tool gjson.Result) ([]byte, bool) { + name := firstNonEmpty(tool.Get("name").String(), tool.Get("function.name").String()) + if name == "" { + return nil, false + } + out := []byte(`{"type":"function","name":""}`) + out, _ = sjson.SetBytes(out, "name", name) + copyOptionalString(&out, "description", firstExisting(tool.Get("description"), tool.Get("function.description"))) + copyOptionalRaw(&out, "parameters", firstExisting(tool.Get("parameters"), tool.Get("function.parameters"))) + return out, true +} + +func functionDeclarationFromTool(tool gjson.Result) ([]byte, bool) { + name := firstNonEmpty(tool.Get("name").String(), tool.Get("function.name").String()) + if name == "" { + return nil, false + } + out := []byte(`{"name":""}`) + out, _ = sjson.SetBytes(out, "name", name) + copyOptionalString(&out, "description", firstExisting(tool.Get("description"), tool.Get("function.description"))) + copyOptionalRaw(&out, "parameters", firstExisting(tool.Get("parameters"), tool.Get("function.parameters"))) + return out, true +} + +func appendInteractionsInputToResponses(out []byte, input gjson.Result) []byte { + if input.Type == gjson.String { + item := []byte(`{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}`) + item, _ = sjson.SetBytes(item, "content.0.text", input.String()) + out, _ = sjson.SetRawBytes(out, "input.-1", item) + return out + } + if input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + out = appendInteractionsInputItemToResponses(out, item) + return true + }) + return out + } + if input.IsObject() { + return appendInteractionsInputItemToResponses(out, input) + } + return out +} + +func appendInteractionsInputItemToResponses(out []byte, item gjson.Result) []byte { + switch item.Get("type").String() { + case "user_input": + out, _ = sjson.SetRawBytes(out, "input.-1", interactionsMessageToResponses(item, "user")) + case "model_output": + out, _ = sjson.SetRawBytes(out, "input.-1", interactionsMessageToResponses(item, "assistant")) + case "thought": + out, _ = sjson.SetRawBytes(out, "input.-1", interactionsThoughtToResponses(item)) + case "function_call": + out, _ = sjson.SetRawBytes(out, "input.-1", interactionsFunctionCallToResponses(item)) + case "function_result": + out, _ = sjson.SetRawBytes(out, "input.-1", interactionsFunctionResultToResponses(item)) + default: + if item.Type == gjson.String { + return appendInteractionsInputToResponses(out, item) + } + } + return out +} + +func interactionsMessageToResponses(item gjson.Result, role string) []byte { + out := []byte(`{"type":"message","role":"","content":[]}`) + out, _ = sjson.SetBytes(out, "role", role) + content := item.Get("content") + if content.Type == gjson.String { + partType := "input_text" + if role == "assistant" { + partType = "output_text" + } + part := []byte(`{"type":"","text":""}`) + part, _ = sjson.SetBytes(part, "type", partType) + part, _ = sjson.SetBytes(part, "text", content.String()) + out, _ = sjson.SetRawBytes(out, "content.-1", part) + return out + } + content.ForEach(func(_, part gjson.Result) bool { + if converted, ok := interactionsContentPartToResponses(part, role); ok { + out, _ = sjson.SetRawBytes(out, "content.-1", converted) + } + return true + }) + return out +} + +func interactionsThoughtToResponses(item gjson.Result) []byte { + out := []byte(`{"type":"reasoning","summary":[]}`) + for _, text := range interactionsContentTexts(item.Get("content")) { + part := []byte(`{"type":"summary_text","text":""}`) + part, _ = sjson.SetBytes(part, "text", text) + out, _ = sjson.SetRawBytes(out, "summary.-1", part) + } + return out +} + +func interactionsContentPartToResponses(part gjson.Result, role string) ([]byte, bool) { + partType := part.Get("type").String() + if partType == "" && part.Get("text").Exists() { + partType = "text" + } + switch partType { + case "text": + outType := "input_text" + if role == "assistant" { + outType = "output_text" + } + out := []byte(`{"type":"","text":""}`) + out, _ = sjson.SetBytes(out, "type", outType) + out, _ = sjson.SetBytes(out, "text", part.Get("text").String()) + return out, true + case "image": + outType := "input_image" + if role == "assistant" { + outType = "output_image" + } + out := []byte(`{"type":""}`) + out, _ = sjson.SetBytes(out, "type", outType) + imageURL := interactionsMediaDataURL(part) + if imageURL != "" { + out, _ = sjson.SetBytes(out, "image_url", imageURL) + } + return out, true + case "audio": + out := []byte(`{"type":"output_text","text":""}`) + format := mediaFormat(part.Get("mime_type").String()) + out, _ = sjson.SetBytes(out, "text", "Audio content: inline data (Format: "+format+")") + return out, true + case "video", "document": + outType := "input_file" + if role == "assistant" { + outType = "output_file" + } + out := []byte(`{"type":""}`) + out, _ = sjson.SetBytes(out, "type", outType) + if dataURL := interactionsMediaDataURL(part); dataURL != "" { + out, _ = sjson.SetBytes(out, "file_data", dataURL) + } + if filename := part.Get("filename").String(); filename != "" { + out, _ = sjson.SetBytes(out, "filename", filename) + } + return out, true + } + return nil, false +} + +func interactionsFunctionCallToResponses(item gjson.Result) []byte { + out := []byte(`{"type":"function_call","call_id":"","name":"","arguments":"{}"}`) + if callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String()); callID != "" { + out, _ = sjson.SetBytes(out, "call_id", callID) + } + out, _ = sjson.SetBytes(out, "name", item.Get("name").String()) + out, _ = sjson.SetBytes(out, "arguments", jsonStringValue(item.Get("arguments"), "{}")) + return out +} + +func interactionsFunctionResultToResponses(item gjson.Result) []byte { + out := []byte(`{"type":"function_call_output","call_id":"","output":""}`) + if callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String()); callID != "" { + out, _ = sjson.SetBytes(out, "call_id", callID) + } + if name := item.Get("name").String(); name != "" { + out, _ = sjson.SetBytes(out, "name", name) + } + result := item.Get("result") + if !result.Exists() { + result = item.Get("output") + } + out, _ = sjson.SetBytes(out, "output", jsonStringValue(result, "")) + return out +} + +func appendInteractionsToolsToResponses(out []byte, tools gjson.Result) []byte { + if !tools.Exists() || !tools.IsArray() { + return out + } + tools.ForEach(func(_, tool gjson.Result) bool { + if converted, ok := responsesToolFromInteractionsTool(tool); ok { + out, _ = sjson.SetRawBytes(out, "tools.-1", converted) + } + if decls := tool.Get("function_declarations"); decls.Exists() && decls.IsArray() { + decls.ForEach(func(_, decl gjson.Result) bool { + if converted, ok := responsesToolFromInteractionsTool(decl); ok { + out, _ = sjson.SetRawBytes(out, "tools.-1", converted) + } + return true + }) + } + return true + }) + return out +} + +func responsesToolFromInteractionsTool(tool gjson.Result) ([]byte, bool) { + name := firstNonEmpty(tool.Get("name").String(), tool.Get("function.name").String()) + if name == "" { + return nil, false + } + out := []byte(`{"type":"function","name":""}`) + out, _ = sjson.SetBytes(out, "name", name) + copyOptionalString(&out, "description", firstExisting(tool.Get("description"), tool.Get("function.description"))) + copyOptionalRaw(&out, "parameters", firstExisting(tool.Get("parameters"), tool.Get("function.parameters"), tool.Get("parametersJsonSchema"))) + return out, true +} + +func interactionsContentTexts(content gjson.Result) []string { + texts := make([]string, 0) + if content.Type == gjson.String { + return append(texts, content.String()) + } + if content.IsArray() { + content.ForEach(func(_, part gjson.Result) bool { + if text := firstNonEmpty(part.Get("text").String(), part.Get("content.text").String()); text != "" { + texts = append(texts, text) + } + return true + }) + } + return texts +} + +func interactionsMediaDataURL(part gjson.Result) string { + if url := firstNonEmpty(part.Get("image_url").String(), part.Get("file_data").String(), part.Get("url").String()); url != "" { + return url + } + data := part.Get("data").String() + if data == "" { + return "" + } + mimeType := part.Get("mime_type").String() + if mimeType == "" { + mimeType = "application/octet-stream" + } + return "data:" + mimeType + ";base64," + data +} + +func mediaFormat(mimeType string) string { + if mimeType == "" { + return "unknown" + } + if _, format, ok := strings.Cut(mimeType, "/"); ok && format != "" { + return format + } + return mimeType +} + +func parseDataURL(value string) (string, string, bool) { + if !strings.HasPrefix(value, "data:") { + return "", "", false + } + header, data, ok := strings.Cut(strings.TrimPrefix(value, "data:"), ",") + if !ok { + return "", "", false + } + mimeType, _, _ := strings.Cut(header, ";") + if mimeType == "" { + mimeType = "application/octet-stream" + } + return mimeType, data, true +} + +func setJSONValue(out *[]byte, path string, value gjson.Result, defaultRaw []byte) { + if !value.Exists() { + *out, _ = sjson.SetRawBytes(*out, path, defaultRaw) + return + } + if value.Type == gjson.String && gjson.Valid(value.String()) { + *out, _ = sjson.SetRawBytes(*out, path, []byte(value.String())) + return + } + if value.Type == gjson.String { + *out, _ = sjson.SetBytes(*out, path, value.String()) + return + } + *out, _ = sjson.SetRawBytes(*out, path, []byte(value.Raw)) +} + +func jsonStringValue(value gjson.Result, fallback string) string { + if !value.Exists() { + return fallback + } + if value.Type == gjson.String { + return value.String() + } + return value.Raw +} + +func copyOptionalString(out *[]byte, path string, value gjson.Result) { + if value.Exists() { + *out, _ = sjson.SetBytes(*out, path, value.String()) + } +} + +func copyOptionalRaw(out *[]byte, path string, value gjson.Result) { + if value.Exists() { + *out, _ = sjson.SetRawBytes(*out, path, []byte(value.Raw)) + } +} + +func firstExisting(values ...gjson.Result) gjson.Result { + for _, value := range values { + if value.Exists() { + return value + } + } + return gjson.Result{} +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_request_test.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_request_test.go new file mode 100644 index 000000000..068f2a1da --- /dev/null +++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_request_test.go @@ -0,0 +1,297 @@ +package responses + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertOpenAIResponsesRequestToInteractions(t *testing.T) { + raw := []byte(`{ + "model":"gpt-test", + "instructions":"be brief", + "input":[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"hi"},{"type":"input_image","image_url":"data:image/png;base64,aGVsbG8="}]}, + {"type":"function_call","name":"lookup","call_id":"call_1","arguments":"{\"q\":\"x\"}"}, + {"type":"function_call_output","call_id":"call_1","output":{"ok":true}} + ], + "tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}], + "tool_choice":"auto", + "reasoning":{"effort":"high","summary":"auto"}, + "response_format":{"type":"json_object"}, + "stream":true + }`) + out := ConvertOpenAIResponsesRequestToInteractions("gpt-test", raw, true) + if got := gjson.GetBytes(out, "input.0.type").String(); got != "user_input" { + t.Fatalf("input.0.type = %q, want user_input. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "text" { + t.Fatalf("content.0.type = %q, want text. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hi" { + t.Fatalf("input text = %q, want hi. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.0.content.1.mime_type").String(); got != "image/png" { + t.Fatalf("image mime_type = %q, want image/png. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.1.call_id").String(); got != "call_1" { + t.Fatalf("function call_id = %q, want call_1. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.2.type").String(); got != "function_result" { + t.Fatalf("function result type = %q, want function_result. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.2.name").String(); got != "lookup" { + t.Fatalf("function result name = %q, want lookup. Output: %s", got, string(out)) + } + sys := gjson.GetBytes(out, "system_instruction") + if sys.Type != gjson.String { + t.Fatalf("system_instruction type = %v, want string. Output: %s", sys.Type, string(out)) + } + if got := sys.String(); got != "be brief" { + t.Fatalf("system_instruction = %q, want be brief. Output: %s", got, string(out)) + } + if gjson.GetBytes(out, "system_instruction.parts").Exists() { + t.Fatalf("system_instruction.parts should not be forwarded. Output: %s", string(out)) + } + if got := gjson.GetBytes(out, "generation_config.thinking_level").String(); got != "high" { + t.Fatalf("thinking_level = %q, want high. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.name").String(); got != "lookup" { + t.Fatalf("tool name = %q, want lookup. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "generation_config.tool_choice").String(); got != "auto" { + t.Fatalf("tool_choice = %q, want auto. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "response_format.type").String(); got != "json_object" { + t.Fatalf("response_format.type = %q, want json_object. Output: %s", got, string(out)) + } +} + +func TestConvertOpenAIResponsesRequestToInteractionsPreservesRequestStream(t *testing.T) { + out := ConvertOpenAIResponsesRequestToInteractions("gpt-test", []byte(`{"model":"gpt-test","input":"hi","stream":true}`), false) + if got := gjson.GetBytes(out, "stream").Bool(); !got { + t.Fatalf("stream = %v, want true. Output: %s", got, string(out)) + } + + out = ConvertOpenAIResponsesRequestToInteractions("gpt-test", []byte(`{"model":"gpt-test","input":"hi","stream":false}`), true) + if got := gjson.GetBytes(out, "stream").Bool(); got { + t.Fatalf("stream = %v, want false. Output: %s", got, string(out)) + } +} + +func TestConvertOpenAIResponsesRequestToInteractionsPreservesPreviousResponseID(t *testing.T) { + out := ConvertOpenAIResponsesRequestToInteractions("gpt-test", []byte(`{"model":"gpt-test","input":"hi","previous_response_id":"resp_123"}`), false) + if got := gjson.GetBytes(out, "previous_interaction_id").String(); got != "resp_123" { + t.Fatalf("previous_interaction_id = %q, want resp_123. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToOpenAIResponsesWithToolMessages(t *testing.T) { + raw := []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}]}`) + out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, false) + + foundFunctionCall := false + foundFunctionOutput := false + gjson.GetBytes(out, "input").ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "function_call" { + foundFunctionCall = true + if item.Get("name").String() != "lookup" { + t.Fatalf("name = %q, want lookup", item.Get("name").String()) + } + } + if item.Get("type").String() == "function_call_output" { + foundFunctionOutput = true + } + return true + }) + if !foundFunctionCall { + t.Fatal("function_call input not found") + } + if !foundFunctionOutput { + t.Fatal("function_call_output input not found") + } +} + +func TestConvertInteractionsRequestToOpenAIResponsesPreservesStringSystemAndThinkingConfig(t *testing.T) { + raw := []byte(`{"model":"gpt-test","system_instruction":"You are a helpful assistant.","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]}],"tools":[{"name":"lookup","type":"function","parameters":{"type":"object"}}],"generation_config":{"tool_choice":"auto","thinking_level":"high","thinking_summaries":"auto"},"stream":true}`) + out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, true) + if got := gjson.GetBytes(out, "instructions").String(); got != "You are a helpful assistant." { + t.Fatalf("instructions = %q, want system instruction. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tool_choice").String(); got != "auto" { + t.Fatalf("tool_choice = %q, want auto. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "high" { + t.Fatalf("reasoning.effort = %q, want high. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "reasoning.summary").String(); got != "auto" { + t.Fatalf("reasoning.summary = %q, want auto. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToOpenAIResponsesPreservesInteractionStream(t *testing.T) { + out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","input":"hi","stream":true}`), false) + if got := gjson.GetBytes(out, "stream").Bool(); !got { + t.Fatalf("stream = %v, want true. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToOpenAIResponsesPreservesPreviousInteractionID(t *testing.T) { + out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","input":"hi","previous_interaction_id":"interaction_123"}`), false) + if got := gjson.GetBytes(out, "previous_response_id").String(); got != "interaction_123" { + t.Fatalf("previous_response_id = %q, want interaction_123. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToOpenAIResponsesPreservesToolCallID(t *testing.T) { + out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"function_call","name":"lookup","call_id":"call_gateway","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"call_gateway","result":{"ok":true}}]}`), false) + + foundFunctionCall := false + foundFunctionOutput := false + gjson.GetBytes(out, "input").ForEach(func(_, item gjson.Result) bool { + switch item.Get("type").String() { + case "function_call": + foundFunctionCall = true + if got := item.Get("call_id").String(); got != "call_gateway" { + t.Fatalf("function_call call_id = %q, want call_gateway. Output: %s", got, string(out)) + } + case "function_call_output": + foundFunctionOutput = true + if got := item.Get("call_id").String(); got != "call_gateway" { + t.Fatalf("function_call_output call_id = %q, want call_gateway. Output: %s", got, string(out)) + } + } + return true + }) + if !foundFunctionCall { + t.Fatal("function_call input not found") + } + if !foundFunctionOutput { + t.Fatal("function_call_output input not found") + } +} + +func TestConvertInteractionsRequestToOpenAIResponsesConvertsSimpleTools(t *testing.T) { + out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","tools":[{"name":"lookup","description":"Find data","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}],"input":"hi"}`), false) + if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" { + t.Fatalf("tools.0.type = %q, want function. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.name").String(); got != "lookup" { + t.Fatalf("tools.0.name = %q, want lookup. Output: %s", got, string(out)) + } + if gjson.GetBytes(out, "tools.0.function").Exists() { + t.Fatalf("tools.0.function should not be forwarded. Output: %s", string(out)) + } + if got := gjson.GetBytes(out, "tools.0.parameters.properties.q.type").String(); got != "string" { + t.Fatalf("tools.0.parameters.properties.q.type = %q, want string. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsRequestToOpenAIResponsesConvertsFunctionDeclarationsTools(t *testing.T) { + out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","tools":[{"function_declarations":[{"name":"lookup","description":"Find data","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}]}],"input":"hi"}`), false) + if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" { + t.Fatalf("tools.0.type = %q, want function. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tools.0.name").String(); got != "lookup" { + t.Fatalf("tools.0.name = %q, want lookup. Output: %s", got, string(out)) + } + if gjson.GetBytes(out, "tools.0.function_declarations").Exists() { + t.Fatalf("tools.0.function_declarations should not be forwarded. Output: %s", string(out)) + } +} + +func TestConvertInteractionsRequestToOpenAIResponsesWithImageContent(t *testing.T) { + raw := []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"text","text":"describe"},{"type":"image","mime_type":"image/png","data":"aGVsbG8="}]}]}`) + out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, false) + if got := gjson.GetBytes(out, "input.0.content.1.type").String(); got != "input_image" { + t.Fatalf("content.1.type = %q, want input_image", got) + } + if got := gjson.GetBytes(out, "input.0.content.1.image_url").String(); got != "data:image/png;base64,aGVsbG8=" { + t.Fatalf("image_url = %q, want data URL", got) + } +} + +func TestConvertInteractionsRequestToOpenAIResponsesPreservesNonImageMediaContent(t *testing.T) { + out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"model_output","content":[{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="},{"type":"video","mime_type":"video/mp4","data":"AAAAIGZ0eXA="},{"type":"document","mime_type":"application/pdf","data":"JVBERi0="}]}]}`), false) + + if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "output_text" { + t.Fatalf("audio fallback type = %q, want output_text. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.0.content.1.type").String(); got != "output_file" { + t.Fatalf("video type = %q, want output_file. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "input.0.content.2.type").String(); got != "output_file" { + t.Fatalf("document type = %q, want output_file. Output: %s", got, string(out)) + } + if gjson.GetBytes(out, "input.0.content.#(type==\"output_image\")").Exists() { + t.Fatalf("non-image media must not be converted to output_image. Output: %s", string(out)) + } +} + +func TestConvertInteractionsRequestToOpenAIResponsesWithAssistantTextContent(t *testing.T) { + raw := []byte(`{"model":"gpt-test","input":[{"type":"model_output","content":[{"type":"text","text":"hello"}]}]}`) + out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, false) + if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "output_text" { + t.Fatalf("content.0.type = %q, want output_text", got) + } + if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hello" { + t.Fatalf("content.0.text = %q, want hello", got) + } +} + +func TestConvertInteractionsRequestToOpenAIResponsesWithUserObjectContent(t *testing.T) { + raw := []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]}]}`) + out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, false) + if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "input_text" { + t.Fatalf("content.0.type = %q, want input_text", got) + } + if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hi" { + t.Fatalf("content.0.text = %q, want hi", got) + } +} + +func TestConvertInteractionsRequestToOpenAIResponsesWithStringFunctionArguments(t *testing.T) { + raw := []byte(`{"model":"gpt-test","input":[{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}]}`) + out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, false) + + found := false + gjson.GetBytes(out, "input").ForEach(func(_, item gjson.Result) bool { + if item.Get("type").String() == "function_call" { + found = true + if item.Get("arguments").Type != gjson.String { + t.Fatalf("arguments should be string, got %v", item.Get("arguments").Type) + } + if got := item.Get("arguments").String(); got != `{"q":"x"}` { + t.Fatalf("arguments = %q, want {\"q\":\"x\"}", got) + } + } + return true + }) + if !found { + t.Fatal("function_call input not found") + } +} + +func TestConvertInteractionsRequestToOpenAIResponsesPreservesExpressibleFields(t *testing.T) { + out := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","tool_choice":{"type":"function","function":{"name":"lookup"}},"response_modalities":["text","image"],"service_tier":"priority","store":true,"background":true,"webhook_config":{"url":"https://example.com"},"input":"hi"}`), false) + if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "function" { + t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "tool_choice.function.name").String(); got != "lookup" { + t.Fatalf("tool_choice.function.name = %q, want lookup. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "modalities.0").String(); got != "text" { + t.Fatalf("modalities.0 = %q, want text. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "modalities.1").String(); got != "image" { + t.Fatalf("modalities.1 = %q, want image. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "service_tier").String(); got != "priority" { + t.Fatalf("service_tier = %q, want priority. Output: %s", got, string(out)) + } + for _, path := range []string{"store", "background", "webhook_config"} { + if gjson.GetBytes(out, path).Exists() { + t.Fatalf("%s should not be forwarded. Output: %s", path, string(out)) + } + } +} diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go new file mode 100644 index 000000000..f2f61704e --- /dev/null +++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go @@ -0,0 +1,994 @@ +package responses + +import ( + "bytes" + "context" + "fmt" + "strings" + "time" + + translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +type interactionsToResponsesStreamState struct { + FunctionCalls map[int]*interactionsFunctionCallState + ItemIDs map[int]string + ItemTypes map[int]string + ReasoningEncrypted map[int]string + ReasoningSummaries map[int][]string + TextOutputs map[int]*strings.Builder + Seq int + Done bool +} + +type interactionsFunctionCallState struct { + ID string + Name string + Arguments strings.Builder +} + +type responsesToInteractionsStreamState struct { + ID string + Created bool + StatusUpdated bool + Completed bool + Done bool + StepIndex int + ActiveStepIndex int + ActiveStepType string + ActiveStepOpen bool + SentText map[string]bool + UnkeyedTextDelta bool + FunctionCallIndexes map[string]int + FunctionArgsSent map[string]bool +} + +func ConvertInteractionsResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { + _ = ctx + _ = originalRequestRawJSON + _ = requestRawJSON + if param == nil { + var local any + param = &local + } + if *param == nil { + *param = &interactionsToResponsesStreamState{} + } + st := (*param).(*interactionsToResponsesStreamState) + if st.FunctionCalls == nil { + st.FunctionCalls = make(map[int]*interactionsFunctionCallState) + } + if st.ItemIDs == nil { + st.ItemIDs = make(map[int]string) + } + if st.ItemTypes == nil { + st.ItemTypes = make(map[int]string) + } + if st.ReasoningEncrypted == nil { + st.ReasoningEncrypted = make(map[int]string) + } + if st.ReasoningSummaries == nil { + st.ReasoningSummaries = make(map[int][]string) + } + if st.TextOutputs == nil { + st.TextOutputs = make(map[int]*strings.Builder) + } + return convertInteractionsEventToResponses(modelName, rawJSON, st) +} + +func ConvertInteractionsResponseToOpenAIResponsesNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { + _ = ctx + _ = originalRequestRawJSON + _ = requestRawJSON + root := gjson.ParseBytes(rawJSON) + out := []byte(`{"id":"","object":"response","status":"completed","model":"","output":[]}`) + out, _ = sjson.SetBytes(out, "id", firstNonEmpty(root.Get("id").String(), root.Get("interaction.id").String())) + out, _ = sjson.SetBytes(out, "model", responseModel(modelName, root)) + steps := root.Get("steps") + if !steps.Exists() { + steps = root.Get("interaction.steps") + } + steps.ForEach(func(_, step gjson.Result) bool { + if item, ok := interactionsStepToResponsesOutput(step); ok { + out, _ = sjson.SetRawBytes(out, "output.-1", item) + } + return true + }) + out = setResponsesUsageFromInteractions(out, "usage", translatorcommon.InteractionsUsage(root)) + return out +} + +func convertInteractionsEventToResponses(modelName string, rawJSON []byte, st *interactionsToResponsesStreamState) [][]byte { + payload := interactionsSSEPayload(rawJSON) + if len(payload) == 0 { + return nil + } + if bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) { + if st.Done { + return nil + } + st.Done = true + return [][]byte{[]byte("data: [DONE]")} + } + root := gjson.ParseBytes(payload) + if !root.Exists() { + return nil + } + switch root.Get("event_type").String() { + case "interaction.created": + return [][]byte{responsesCreatedEvent(modelName, root, st)} + case "step.start": + return interactionsStepStartToResponses(root, st) + case "step.delta": + return interactionsStepDeltaToResponses(root, st) + case "step.stop": + return interactionsStepStopToResponses(root, st) + case "interaction.completed", "finish": + return [][]byte{responsesCompletedEvent(modelName, root, st)} + case "done": + if st.Done { + return nil + } + st.Done = true + return [][]byte{[]byte("data: [DONE]")} + } + return nil +} + +func interactionsStepToResponsesOutput(step gjson.Result) ([]byte, bool) { + switch step.Get("type").String() { + case "model_output": + item := []byte(`{"type":"message","role":"assistant","content":[]}`) + if id := firstNonEmpty(step.Get("id").String(), step.Get("step_id").String()); id != "" { + item, _ = sjson.SetBytes(item, "id", id) + } + content := step.Get("content") + if content.Type == gjson.String { + part := []byte(`{"type":"output_text","text":""}`) + part, _ = sjson.SetBytes(part, "text", content.String()) + item, _ = sjson.SetRawBytes(item, "content.-1", part) + } else { + content.ForEach(func(_, part gjson.Result) bool { + if converted, ok := interactionsContentPartToResponses(part, "assistant"); ok { + item, _ = sjson.SetRawBytes(item, "content.-1", converted) + } + return true + }) + } + return item, true + case "thought": + item := []byte(`{"type":"reasoning","summary":[]}`) + if signature := interactionsThoughtSignature(step); signature != "" { + item, _ = sjson.SetBytes(item, "encrypted_content", signature) + } + for _, text := range interactionsContentTexts(step.Get("content")) { + part := []byte(`{"type":"summary_text","text":""}`) + part, _ = sjson.SetBytes(part, "text", text) + item, _ = sjson.SetRawBytes(item, "summary.-1", part) + } + return item, true + case "function_call": + return interactionsFunctionCallToResponses(step), true + } + return nil, false +} + +func responsesCreatedEvent(modelName string, root gjson.Result, st *interactionsToResponsesStreamState) []byte { + payload := []byte(`{"type":"response.created","response":{"id":"","object":"response","status":"in_progress","model":""}}`) + payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st)) + payload, _ = sjson.SetBytes(payload, "response.id", firstNonEmpty(root.Get("interaction.id").String(), root.Get("id").String())) + payload, _ = sjson.SetBytes(payload, "response.model", modelName) + return emitResponsesEvent("response.created", payload) +} + +func interactionsStepStartToResponses(root gjson.Result, st *interactionsToResponsesStreamState) [][]byte { + index := int(root.Get("index").Int()) + step := root.Get("step") + stepType := step.Get("type").String() + itemID := firstNonEmpty(step.Get("id").String(), step.Get("call_id").String(), fmt.Sprintf("item_%d", index)) + st.ItemIDs[index] = itemID + st.ItemTypes[index] = stepType + switch stepType { + case "model_output": + added := []byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"","type":"message","status":"in_progress","role":"assistant","content":[]}}`) + added, _ = sjson.SetBytes(added, "sequence_number", nextResponsesSeq(st)) + added, _ = sjson.SetBytes(added, "output_index", index) + added, _ = sjson.SetBytes(added, "item.id", itemID) + part := []byte(`{"type":"response.content_part.added","output_index":0,"content_index":0,"item_id":"","part":{"type":"output_text","text":""}}`) + part, _ = sjson.SetBytes(part, "sequence_number", nextResponsesSeq(st)) + part, _ = sjson.SetBytes(part, "output_index", index) + part, _ = sjson.SetBytes(part, "item_id", itemID) + return [][]byte{emitResponsesEvent("response.output_item.added", added), emitResponsesEvent("response.content_part.added", part)} + case "thought": + added := []byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","encrypted_content":"","summary":[]}}`) + added, _ = sjson.SetBytes(added, "sequence_number", nextResponsesSeq(st)) + added, _ = sjson.SetBytes(added, "output_index", index) + added, _ = sjson.SetBytes(added, "item.id", itemID) + if signature := st.ReasoningEncrypted[index]; signature != "" { + added, _ = sjson.SetBytes(added, "item.encrypted_content", signature) + } + return [][]byte{emitResponsesEvent("response.output_item.added", added)} + case "function_call": + call := &interactionsFunctionCallState{ + ID: itemID, + Name: step.Get("name").String(), + } + if args := step.Get("arguments"); args.Exists() && strings.TrimSpace(args.Raw) != "{}" { + call.Arguments.WriteString(jsonStringValue(args, "{}")) + } + st.FunctionCalls[index] = call + added := []byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"","type":"function_call","call_id":"","name":"","arguments":""}}`) + added, _ = sjson.SetBytes(added, "sequence_number", nextResponsesSeq(st)) + added, _ = sjson.SetBytes(added, "output_index", index) + added, _ = sjson.SetBytes(added, "item.id", itemID) + added, _ = sjson.SetBytes(added, "item.call_id", itemID) + added, _ = sjson.SetBytes(added, "item.name", call.Name) + return [][]byte{emitResponsesEvent("response.output_item.added", added)} + } + return nil +} + +func interactionsStepDeltaToResponses(root gjson.Result, st *interactionsToResponsesStreamState) [][]byte { + index := int(root.Get("index").Int()) + delta := root.Get("delta") + switch delta.Get("type").String() { + case "thought_summary": + text := firstNonEmpty(delta.Get("content.text").String(), delta.Get("text").String()) + recordResponsesReasoningSummary(st, index, text) + payload := []byte(`{"type":"response.reasoning_summary_text.delta","output_index":0,"delta":""}`) + payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st)) + payload, _ = sjson.SetBytes(payload, "output_index", index) + payload, _ = sjson.SetBytes(payload, "delta", text) + return [][]byte{emitResponsesEvent("response.reasoning_summary_text.delta", payload)} + case "thought_signature": + if signature := delta.Get("signature").String(); signature != "" { + st.ReasoningEncrypted[index] = signature + } + return nil + case "arguments_delta": + if call := st.FunctionCalls[index]; call != nil { + call.Arguments.WriteString(delta.Get("arguments").String()) + } + payload := []byte(`{"type":"response.function_call_arguments.delta","output_index":0,"delta":""}`) + payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st)) + payload, _ = sjson.SetBytes(payload, "output_index", index) + payload, _ = sjson.SetBytes(payload, "item_id", st.ItemIDs[index]) + payload, _ = sjson.SetBytes(payload, "delta", delta.Get("arguments").String()) + return [][]byte{emitResponsesEvent("response.function_call_arguments.delta", payload)} + default: + payload := []byte(`{"type":"response.output_text.delta","output_index":0,"content_index":0,"item_id":"","delta":""}`) + payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st)) + payload, _ = sjson.SetBytes(payload, "output_index", index) + payload, _ = sjson.SetBytes(payload, "item_id", st.ItemIDs[index]) + text := delta.Get("text").String() + recordResponsesTextOutput(st, index, text) + payload, _ = sjson.SetBytes(payload, "delta", text) + return [][]byte{emitResponsesEvent("response.output_text.delta", payload)} + } +} + +func interactionsStepStopToResponses(root gjson.Result, st *interactionsToResponsesStreamState) [][]byte { + index := int(root.Get("index").Int()) + itemID := st.ItemIDs[index] + switch st.ItemTypes[index] { + case "model_output": + text := "" + if builder := st.TextOutputs[index]; builder != nil { + text = builder.String() + } + textDone := []byte(`{"type":"response.output_text.done","output_index":0,"content_index":0,"item_id":"","text":"","logprobs":[]}`) + textDone, _ = sjson.SetBytes(textDone, "sequence_number", nextResponsesSeq(st)) + textDone, _ = sjson.SetBytes(textDone, "output_index", index) + textDone, _ = sjson.SetBytes(textDone, "item_id", itemID) + textDone, _ = sjson.SetBytes(textDone, "text", text) + part := []byte(`{"type":"response.content_part.done","output_index":0,"content_index":0,"item_id":"","part":{"type":"output_text","text":""}}`) + part, _ = sjson.SetBytes(part, "sequence_number", nextResponsesSeq(st)) + part, _ = sjson.SetBytes(part, "output_index", index) + part, _ = sjson.SetBytes(part, "item_id", itemID) + part, _ = sjson.SetBytes(part, "part.text", text) + done := []byte(`{"type":"response.output_item.done","output_index":0,"item":{"id":"","type":"message","status":"completed","role":"assistant","content":[]}}`) + done, _ = sjson.SetBytes(done, "sequence_number", nextResponsesSeq(st)) + done, _ = sjson.SetBytes(done, "output_index", index) + done, _ = sjson.SetBytes(done, "item.id", itemID) + outputText := []byte(`{"type":"output_text","text":""}`) + outputText, _ = sjson.SetBytes(outputText, "text", text) + done, _ = sjson.SetRawBytes(done, "item.content.-1", outputText) + return [][]byte{emitResponsesEvent("response.output_text.done", textDone), emitResponsesEvent("response.content_part.done", part), emitResponsesEvent("response.output_item.done", done)} + case "function_call": + call := st.FunctionCalls[index] + done := []byte(`{"type":"response.output_item.done","output_index":0,"item":{"id":"","type":"function_call","call_id":"","name":"","arguments":""}}`) + done, _ = sjson.SetBytes(done, "sequence_number", nextResponsesSeq(st)) + done, _ = sjson.SetBytes(done, "output_index", index) + done, _ = sjson.SetBytes(done, "item.id", itemID) + done, _ = sjson.SetBytes(done, "item.call_id", itemID) + if call != nil { + done, _ = sjson.SetBytes(done, "item.name", call.Name) + done, _ = sjson.SetBytes(done, "item.arguments", call.Arguments.String()) + } + return [][]byte{emitResponsesEvent("response.output_item.done", done)} + default: + done := []byte(`{"type":"response.output_item.done","output_index":0,"item":{}}`) + done, _ = sjson.SetBytes(done, "sequence_number", nextResponsesSeq(st)) + done, _ = sjson.SetBytes(done, "output_index", index) + done, _ = sjson.SetRawBytes(done, "item", responsesReasoningItem(index, st)) + return [][]byte{emitResponsesEvent("response.output_item.done", done)} + } +} + +func responsesCompletedEvent(modelName string, root gjson.Result, st *interactionsToResponsesStreamState) []byte { + payload := []byte(`{"type":"response.completed","response":{"id":"","object":"response","status":"completed","model":"","output":[],"usage":{}}}`) + payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st)) + interaction := root.Get("interaction") + payload, _ = sjson.SetBytes(payload, "response.id", firstNonEmpty(interaction.Get("id").String(), root.Get("id").String())) + payload, _ = sjson.SetBytes(payload, "response.model", firstNonEmpty(interaction.Get("model").String(), modelName)) + payload = setResponsesCompletedOutput(payload, st) + payload = setResponsesUsageFromInteractions(payload, "response.usage", translatorcommon.InteractionsUsage(root)) + return emitResponsesEvent("response.completed", payload) +} + +func interactionsThoughtSignature(step gjson.Result) string { + for _, path := range []string{ + "encrypted_content", + "signature", + "thought_signature", + "thoughtSignature", + "extra_content.google.thought_signature", + } { + if signature := step.Get(path).String(); signature != "" { + return signature + } + } + content := step.Get("content") + if content.IsArray() { + var signature string + content.ForEach(func(_, part gjson.Result) bool { + signature = firstNonEmpty( + part.Get("signature").String(), + part.Get("thought_signature").String(), + part.Get("thoughtSignature").String(), + part.Get("extra_content.google.thought_signature").String(), + ) + return signature == "" + }) + return signature + } + return "" +} + +func recordResponsesReasoningSummary(st *interactionsToResponsesStreamState, index int, text string) { + if text == "" { + return + } + st.ReasoningSummaries[index] = append(st.ReasoningSummaries[index], text) +} + +func recordResponsesTextOutput(st *interactionsToResponsesStreamState, index int, text string) { + if text == "" { + return + } + if st.TextOutputs[index] == nil { + st.TextOutputs[index] = &strings.Builder{} + } + st.TextOutputs[index].WriteString(text) +} + +func setResponsesCompletedOutput(payload []byte, st *interactionsToResponsesStreamState) []byte { + maxIndex := -1 + for index := range st.ItemTypes { + if index > maxIndex { + maxIndex = index + } + } + for index := 0; index <= maxIndex; index++ { + itemType, ok := st.ItemTypes[index] + if !ok { + continue + } + item, ok := responsesCompletedOutputItem(index, itemType, st) + if ok { + payload, _ = sjson.SetRawBytes(payload, "response.output.-1", item) + } + } + return payload +} + +func responsesCompletedOutputItem(index int, itemType string, st *interactionsToResponsesStreamState) ([]byte, bool) { + switch itemType { + case "model_output": + item := []byte(`{"id":"","type":"message","status":"completed","role":"assistant","content":[]}`) + item, _ = sjson.SetBytes(item, "id", st.ItemIDs[index]) + if builder := st.TextOutputs[index]; builder != nil && builder.String() != "" { + part := []byte(`{"type":"output_text","text":""}`) + part, _ = sjson.SetBytes(part, "text", builder.String()) + item, _ = sjson.SetRawBytes(item, "content.-1", part) + } + return item, true + case "thought": + return responsesReasoningItem(index, st), true + case "function_call": + item := []byte(`{"id":"","type":"function_call","call_id":"","name":"","arguments":""}`) + itemID := st.ItemIDs[index] + item, _ = sjson.SetBytes(item, "id", itemID) + item, _ = sjson.SetBytes(item, "call_id", itemID) + if call := st.FunctionCalls[index]; call != nil { + item, _ = sjson.SetBytes(item, "name", call.Name) + item, _ = sjson.SetBytes(item, "arguments", call.Arguments.String()) + } + return item, true + } + return nil, false +} + +func responsesReasoningItem(index int, st *interactionsToResponsesStreamState) []byte { + item := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`) + item, _ = sjson.SetBytes(item, "id", st.ItemIDs[index]) + if signature := st.ReasoningEncrypted[index]; signature != "" { + item, _ = sjson.SetBytes(item, "encrypted_content", signature) + } + for _, text := range st.ReasoningSummaries[index] { + part := []byte(`{"type":"summary_text","text":""}`) + part, _ = sjson.SetBytes(part, "text", text) + item, _ = sjson.SetRawBytes(item, "summary.-1", part) + } + return item +} + +func setResponsesUsageFromInteractions(out []byte, path string, usage gjson.Result) []byte { + if !usage.Exists() { + return out + } + if v, ok := firstUsageInt(usage, "input_tokens", "total_input_tokens"); ok { + out, _ = sjson.SetBytes(out, path+".input_tokens", v) + } + if v, ok := firstUsageInt(usage, "output_tokens", "total_output_tokens"); ok { + out, _ = sjson.SetBytes(out, path+".output_tokens", v) + } + if v, ok := firstUsageInt(usage, "total_tokens"); ok { + out, _ = sjson.SetBytes(out, path+".total_tokens", v) + } + if v, ok := firstUsageInt(usage, "cached_tokens", "total_cached_tokens"); ok { + out, _ = sjson.SetBytes(out, path+".input_tokens_details.cached_tokens", v) + } + if v, ok := firstUsageInt(usage, "reasoning_tokens", "total_thought_tokens"); ok { + out, _ = sjson.SetBytes(out, path+".output_tokens_details.reasoning_tokens", v) + } + return out +} + +func ConvertOpenAIResponsesResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { + _ = ctx + _ = originalRequestRawJSON + _ = requestRawJSON + if param == nil { + var local any + param = &local + } + if *param == nil { + *param = &responsesToInteractionsStreamState{} + } + st := (*param).(*responsesToInteractionsStreamState) + if st.FunctionCallIndexes == nil { + st.FunctionCallIndexes = make(map[string]int) + } + if st.FunctionArgsSent == nil { + st.FunctionArgsSent = make(map[string]bool) + } + return convertOpenAIResponsesEventToInteractions(modelName, rawJSON, st) +} + +func ConvertOpenAIResponsesResponseToInteractionsNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { + _ = ctx + _ = originalRequestRawJSON + _ = requestRawJSON + root := gjson.ParseBytes(rawJSON) + out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`) + out, _ = sjson.SetBytes(out, "id", root.Get("id").String()) + out, _ = sjson.SetBytes(out, "model", responseModel(modelName, root)) + root.Get("output").ForEach(func(_, item gjson.Result) bool { + if step, ok := openAIResponsesOutputItemToInteractionsStep(item); ok { + out, _ = sjson.SetRawBytes(out, "steps.-1", step) + } + return true + }) + out = setInteractionsUsageFromResponses(out, "usage", root.Get("usage")) + return out +} + +func convertOpenAIResponsesEventToInteractions(modelName string, rawJSON []byte, st *responsesToInteractionsStreamState) [][]byte { + payload := interactionsSSEPayload(rawJSON) + if len(payload) == 0 { + return nil + } + if bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) { + return appendInteractionsDoneDirect(nil, st) + } + root := gjson.ParseBytes(payload) + if !root.Exists() { + return nil + } + switch root.Get("type").String() { + case "response.created": + return appendInteractionsCreatedDirect(nil, st, modelName, root.Get("response")) + case "response.output_text.delta": + out := ensureInteractionsStepDirect(nil, st, modelName, "model_output", gjson.Result{}) + out = appendInteractionsTextDeltaDirect(out, st, root.Get("delta").String(), false) + st.markTextSent(textKeysFromResponsesEvent(root)) + return out + case "response.reasoning_summary_text.delta": + out := ensureInteractionsStepDirect(nil, st, modelName, "thought", gjson.Result{}) + return appendInteractionsTextDeltaDirect(out, st, root.Get("delta").String(), true) + case "response.output_item.added": + return openAIResponsesOutputItemAddedToInteractions(modelName, root, st) + case "response.function_call_arguments.delta": + out := ensureInteractionsFunctionCallStep(nil, st, modelName, root) + out = appendInteractionsArgumentsDeltaDirect(out, st, root.Get("delta").String()) + st.markFunctionArgsSent(functionArgsKeysFromResponsesEvent(root)) + return out + case "response.output_item.done": + return openAIResponsesOutputItemDoneToInteractions(modelName, root, st) + case "response.completed": + return openAIResponsesCompletedToInteractions(modelName, root.Get("response"), st) + } + return nil +} + +func openAIResponsesOutputItemToInteractionsStep(item gjson.Result) ([]byte, bool) { + switch item.Get("type").String() { + case "message": + step := []byte(`{"type":"model_output","content":[]}`) + item.Get("content").ForEach(func(_, part gjson.Result) bool { + if converted, ok := responsesContentPartToInteractions(part); ok { + step, _ = sjson.SetRawBytes(step, "content.-1", converted) + } + return true + }) + return step, true + case "function_call": + return responsesFunctionCallToInteractions(item), true + case "reasoning": + step := []byte(`{"type":"thought","content":[]}`) + item.Get("summary").ForEach(func(_, summary gjson.Result) bool { + if text := summary.Get("text").String(); text != "" { + part := []byte(`{"type":"text","text":""}`) + part, _ = sjson.SetBytes(part, "text", text) + step, _ = sjson.SetRawBytes(step, "content.-1", part) + } + return true + }) + return step, true + } + return nil, false +} + +func openAIResponsesOutputItemAddedToInteractions(modelName string, root gjson.Result, st *responsesToInteractionsStreamState) [][]byte { + item := root.Get("item") + switch item.Get("type").String() { + case "function_call": + out := ensureInteractionsCreatedDirect(nil, st, modelName) + out = appendInteractionsStepStopDirect(out, st) + step := []byte(`{"type":"function_call","name":"","arguments":{}}`) + step, _ = sjson.SetBytes(step, "name", item.Get("name").String()) + if callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String()); callID != "" { + step, _ = sjson.SetBytes(step, "id", callID) + step, _ = sjson.SetBytes(step, "call_id", callID) + st.FunctionCallIndexes[callID] = st.StepIndex + } + out = appendInteractionsStepStartDirect(out, st, "function_call", gjson.ParseBytes(step)) + return out + case "message": + return ensureInteractionsStepDirect(nil, st, modelName, "model_output", gjson.Result{}) + case "reasoning": + return ensureInteractionsStepDirect(nil, st, modelName, "thought", gjson.Result{}) + } + return nil +} + +func openAIResponsesOutputItemDoneToInteractions(modelName string, root gjson.Result, st *responsesToInteractionsStreamState) [][]byte { + item := root.Get("item") + switch item.Get("type").String() { + case "function_call": + out := ensureInteractionsFunctionCallStep(nil, st, modelName, root) + if args := item.Get("arguments"); args.Exists() && args.String() != "" && !st.hasSentFunctionArgs(functionArgsKeysFromResponsesEvent(root)) { + out = appendInteractionsArgumentsDeltaDirect(out, st, jsonStringValue(args, "{}")) + } + return appendInteractionsStepStopDirect(out, st) + case "reasoning": + out := ensureInteractionsStepDirect(nil, st, modelName, "thought", gjson.Result{}) + item.Get("summary").ForEach(func(_, summary gjson.Result) bool { + if text := summary.Get("text").String(); text != "" { + out = appendInteractionsTextDeltaDirect(out, st, text, true) + } + return true + }) + return appendInteractionsStepStopDirect(out, st) + case "message": + return appendResponsesMessageFallbackToInteractions(nil, modelName, item, root, st, true) + } + return nil +} + +func openAIResponsesCompletedToInteractions(modelName string, response gjson.Result, st *responsesToInteractionsStreamState) [][]byte { + var out [][]byte + response.Get("output").ForEach(func(outputIndex, item gjson.Result) bool { + if item.Get("type").String() == "message" { + out = appendResponsesMessageFallbackToInteractions(out, modelName, item, responseOutputIndexRoot(item, outputIndex), st, false) + } + return true + }) + out = appendInteractionsStepStopDirect(out, st) + out = appendInteractionsCompletedDirect(out, st, modelName, response) + return appendInteractionsDoneDirect(out, st) +} + +func appendResponsesMessageFallbackToInteractions(out [][]byte, modelName string, item, root gjson.Result, st *responsesToInteractionsStreamState, stop bool) [][]byte { + itemID := item.Get("id").String() + outputIndex := int(root.Get("output_index").Int()) + hasOutputIndex := root.Get("output_index").Exists() + item.Get("content").ForEach(func(contentIndex, part gjson.Result) bool { + if part.Get("type").String() != "output_text" && part.Get("type").String() != "text" { + return true + } + hasContentIndex := contentIndex.Exists() + keys := openAIResponsesTextKeys(itemID, outputIndex, hasOutputIndex, int(contentIndex.Int()), hasContentIndex) + unkeyedKeys := openAIResponsesUnkeyedTextKeys(itemID, outputIndex, hasOutputIndex) + if st.hasSentText(keys, hasContentIndex) || st.hasSentUnkeyedText(unkeyedKeys) { + return true + } + text := part.Get("text").String() + if text == "" { + return true + } + out = ensureInteractionsStepDirect(out, st, modelName, "model_output", gjson.Result{}) + out = appendInteractionsTextDeltaDirect(out, st, text, false) + st.markTextSent(keys) + return true + }) + if stop { + return appendInteractionsStepStopDirect(out, st) + } + return out +} + +func responseOutputIndexRoot(item, outputIndex gjson.Result) gjson.Result { + raw := []byte(`{"output_index":0}`) + raw, _ = sjson.SetBytes(raw, "output_index", outputIndex.Int()) + if id := item.Get("id").String(); id != "" { + raw, _ = sjson.SetBytes(raw, "item_id", id) + } + return gjson.ParseBytes(raw) +} + +func appendInteractionsCreatedDirect(out [][]byte, st *responsesToInteractionsStreamState, modelName string, response gjson.Result, markStatus ...bool) [][]byte { + if st.Created { + return out + } + st.ID = firstNonEmpty(response.Get("id").String(), st.ID, fmt.Sprintf("interaction_%d", time.Now().UnixNano())) + created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`) + created, _ = sjson.SetBytes(created, "interaction.id", st.ID) + created, _ = sjson.SetBytes(created, "interaction.model", responseModel(modelName, response)) + out = append(out, emitInteractionsEvent("interaction.created", created)) + st.Created = true + if len(markStatus) == 0 || markStatus[0] { + out = appendInteractionsStatusUpdateDirect(out, st) + } + return out +} + +func appendInteractionsStatusUpdateDirect(out [][]byte, st *responsesToInteractionsStreamState) [][]byte { + if st.StatusUpdated { + return out + } + statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`) + statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID) + out = append(out, emitInteractionsEvent("interaction.status_update", statusUpdate)) + st.StatusUpdated = true + return out +} + +func ensureInteractionsStepDirect(out [][]byte, st *responsesToInteractionsStreamState, modelName, stepType string, step gjson.Result) [][]byte { + out = ensureInteractionsCreatedDirect(out, st, modelName) + if st.ActiveStepOpen && st.ActiveStepType == stepType { + return out + } + out = appendInteractionsStepStopDirect(out, st) + return appendInteractionsStepStartDirect(out, st, stepType, step) +} + +func ensureInteractionsCreatedDirect(out [][]byte, st *responsesToInteractionsStreamState, modelName string) [][]byte { + return appendInteractionsCreatedDirect(out, st, modelName, gjson.Result{}) +} + +func appendInteractionsStepStartDirect(out [][]byte, st *responsesToInteractionsStreamState, stepType string, step gjson.Result) [][]byte { + index := st.StepIndex + st.StepIndex++ + st.ActiveStepIndex = index + st.ActiveStepType = stepType + st.ActiveStepOpen = true + payload := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`) + payload, _ = sjson.SetBytes(payload, "index", index) + payload, _ = sjson.SetBytes(payload, "step.type", stepType) + if stepType == "function_call" { + if id := firstNonEmpty(step.Get("call_id").String(), step.Get("id").String()); id != "" { + payload, _ = sjson.SetBytes(payload, "step.id", id) + payload, _ = sjson.SetBytes(payload, "step.call_id", id) + } + payload, _ = sjson.SetBytes(payload, "step.name", step.Get("name").String()) + payload, _ = sjson.SetRawBytes(payload, "step.arguments", []byte(`{}`)) + } + return append(out, emitInteractionsEvent("step.start", payload)) +} + +func appendInteractionsTextDeltaDirect(out [][]byte, st *responsesToInteractionsStreamState, text string, thought bool) [][]byte { + if thought { + payload := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`) + payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex) + payload, _ = sjson.SetBytes(payload, "delta.content.text", text) + return append(out, emitInteractionsEvent("step.delta", payload)) + } + payload := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`) + payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex) + payload, _ = sjson.SetBytes(payload, "delta.text", text) + return append(out, emitInteractionsEvent("step.delta", payload)) +} + +func appendInteractionsArgumentsDeltaDirect(out [][]byte, st *responsesToInteractionsStreamState, arguments string) [][]byte { + payload := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`) + payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex) + payload, _ = sjson.SetBytes(payload, "delta.arguments", arguments) + return append(out, emitInteractionsEvent("step.delta", payload)) +} + +func appendInteractionsStepStopDirect(out [][]byte, st *responsesToInteractionsStreamState) [][]byte { + if !st.ActiveStepOpen { + return out + } + payload := []byte(`{"index":0,"event_type":"step.stop"}`) + payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex) + out = append(out, emitInteractionsEvent("step.stop", payload)) + st.ActiveStepOpen = false + st.ActiveStepType = "" + return out +} + +func appendInteractionsCompletedDirect(out [][]byte, st *responsesToInteractionsStreamState, modelName string, response gjson.Result) [][]byte { + if st.Completed { + return out + } + now := time.Now().UTC().Format(time.RFC3339) + payload := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`) + payload, _ = sjson.SetBytes(payload, "interaction.id", st.ID) + payload, _ = sjson.SetBytes(payload, "interaction.created", now) + payload, _ = sjson.SetBytes(payload, "interaction.updated", now) + payload, _ = sjson.SetBytes(payload, "interaction.model", responseModel(modelName, response)) + payload = setInteractionsUsageFromResponses(payload, "interaction.usage", response.Get("usage")) + out = append(out, emitInteractionsEvent("interaction.completed", payload)) + st.Completed = true + return out +} + +func appendInteractionsDoneDirect(out [][]byte, st *responsesToInteractionsStreamState) [][]byte { + if st.Done { + return out + } + out = append(out, emitInteractionsEvent("done", []byte("[DONE]"))) + st.Done = true + return out +} + +func ensureInteractionsFunctionCallStep(out [][]byte, st *responsesToInteractionsStreamState, modelName string, root gjson.Result) [][]byte { + if st.ActiveStepOpen && st.ActiveStepType == "function_call" { + return out + } + item := root.Get("item") + if !item.Exists() { + item = root + } + step := []byte(`{"type":"function_call","name":"","arguments":{}}`) + step, _ = sjson.SetBytes(step, "name", item.Get("name").String()) + if callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String(), root.Get("call_id").String(), root.Get("item_id").String()); callID != "" { + step, _ = sjson.SetBytes(step, "id", callID) + step, _ = sjson.SetBytes(step, "call_id", callID) + } + out = ensureInteractionsCreatedDirect(out, st, modelName) + out = appendInteractionsStepStopDirect(out, st) + return appendInteractionsStepStartDirect(out, st, "function_call", gjson.ParseBytes(step)) +} + +func setInteractionsUsageFromResponses(out []byte, path string, usage gjson.Result) []byte { + if !usage.Exists() { + return out + } + if v := usage.Get("input_tokens"); v.Exists() { + out, _ = sjson.SetBytes(out, path+".input_tokens", v.Int()) + out, _ = sjson.SetBytes(out, path+".total_input_tokens", v.Int()) + } + if v := usage.Get("output_tokens"); v.Exists() { + out, _ = sjson.SetBytes(out, path+".output_tokens", v.Int()) + out, _ = sjson.SetBytes(out, path+".total_output_tokens", v.Int()) + } + if v := usage.Get("total_tokens"); v.Exists() { + out, _ = sjson.SetBytes(out, path+".total_tokens", v.Int()) + } + if v := usage.Get("input_tokens_details.cached_tokens"); v.Exists() { + out, _ = sjson.SetBytes(out, path+".cached_tokens", v.Int()) + out, _ = sjson.SetBytes(out, path+".total_cached_tokens", v.Int()) + } + if v := usage.Get("output_tokens_details.reasoning_tokens"); v.Exists() { + out, _ = sjson.SetBytes(out, path+".reasoning_tokens", v.Int()) + out, _ = sjson.SetBytes(out, path+".total_thought_tokens", v.Int()) + } + return out +} + +func interactionsSSEPayload(rawJSON []byte) []byte { + trimmed := bytes.TrimSpace(rawJSON) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("[DONE]")) { + return trimmed + } + if bytes.HasPrefix(trimmed, []byte("data:")) { + return bytes.TrimSpace(trimmed[len("data:"):]) + } + var dataLines [][]byte + for _, line := range bytes.Split(trimmed, []byte("\n")) { + line = bytes.TrimSpace(line) + if bytes.HasPrefix(line, []byte("data:")) { + dataLines = append(dataLines, bytes.TrimSpace(line[len("data:"):])) + } + } + if len(dataLines) > 0 { + return bytes.Join(dataLines, []byte("\n")) + } + return trimmed +} + +func responseModel(modelName string, root gjson.Result) string { + return firstNonEmpty(modelName, root.Get("model").String(), root.Get("response.model").String(), root.Get("interaction.model").String()) +} + +func firstUsageInt(root gjson.Result, paths ...string) (int64, bool) { + for _, path := range paths { + if v := root.Get(path); v.Exists() { + return v.Int(), true + } + } + return 0, false +} + +func nextResponsesSeq(st *interactionsToResponsesStreamState) int { + st.Seq++ + return st.Seq +} + +func emitResponsesEvent(event string, payload []byte) []byte { + return translatorcommon.SSEEventData(event, payload) +} + +func emitInteractionsEvent(event string, payload []byte) []byte { + return translatorcommon.SSEEventData(event, payload) +} + +func textKeysFromResponsesEvent(root gjson.Result) []string { + itemID := root.Get("item_id").String() + outputIndex := int(root.Get("output_index").Int()) + hasOutputIndex := root.Get("output_index").Exists() + contentIndex := int(root.Get("content_index").Int()) + hasContentIndex := root.Get("content_index").Exists() + if !hasContentIndex { + return openAIResponsesUnkeyedTextKeys(itemID, outputIndex, hasOutputIndex) + } + return openAIResponsesTextKeys(itemID, outputIndex, hasOutputIndex, contentIndex, hasContentIndex) +} + +func functionArgsKeysFromResponsesEvent(root gjson.Result) []string { + item := root.Get("item") + outputIndex := int(root.Get("output_index").Int()) + hasOutputIndex := root.Get("output_index").Exists() + keys := make([]string, 0, 5) + for _, id := range []string{ + root.Get("item_id").String(), + root.Get("call_id").String(), + item.Get("call_id").String(), + item.Get("id").String(), + } { + if id == "" { + continue + } + key := fmt.Sprintf("item:%s", id) + if !stringSliceContains(keys, key) { + keys = append(keys, key) + } + } + if hasOutputIndex { + keys = append(keys, fmt.Sprintf("output:%d", outputIndex)) + } + return keys +} + +func stringSliceContains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func openAIResponsesTextKeys(itemID string, outputIndex int, hasOutputIndex bool, contentIndex int, hasContentIndex bool) []string { + if !hasContentIndex { + return nil + } + keys := make([]string, 0, 3) + if itemID != "" { + keys = append(keys, fmt.Sprintf("item:%s:content:%d", itemID, contentIndex)) + } + if hasOutputIndex { + keys = append(keys, fmt.Sprintf("output:%d:content:%d", outputIndex, contentIndex)) + } + keys = append(keys, fmt.Sprintf("content:%d", contentIndex)) + return keys +} + +func openAIResponsesUnkeyedTextKeys(itemID string, outputIndex int, hasOutputIndex bool) []string { + keys := make([]string, 0, 2) + if itemID != "" { + keys = append(keys, fmt.Sprintf("item:%s", itemID)) + } + if hasOutputIndex { + keys = append(keys, fmt.Sprintf("output:%d", outputIndex)) + } + return keys +} + +func (st *responsesToInteractionsStreamState) markTextSent(keys []string) { + if len(keys) == 0 { + st.UnkeyedTextDelta = true + return + } + if st.SentText == nil { + st.SentText = map[string]bool{} + } + for _, key := range keys { + st.SentText[key] = true + } +} + +func (st *responsesToInteractionsStreamState) hasSentText(keys []string, hasContentIndex bool) bool { + if !hasContentIndex && st.UnkeyedTextDelta { + return true + } + for _, key := range keys { + if st.SentText[key] { + return true + } + } + return false +} + +func (st *responsesToInteractionsStreamState) hasSentUnkeyedText(keys []string) bool { + if len(keys) == 0 { + return st.UnkeyedTextDelta + } + for _, key := range keys { + if st.SentText[key] { + return true + } + } + return false +} + +func (st *responsesToInteractionsStreamState) markFunctionArgsSent(keys []string) { + for _, key := range keys { + st.FunctionArgsSent[key] = true + } +} + +func (st *responsesToInteractionsStreamState) hasSentFunctionArgs(keys []string) bool { + for _, key := range keys { + if st.FunctionArgsSent[key] { + return true + } + } + return false +} diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_response_test.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_response_test.go new file mode 100644 index 000000000..182b41e81 --- /dev/null +++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_response_test.go @@ -0,0 +1,487 @@ +package responses + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertInteractionsResponseToOpenAIResponsesNonStream(t *testing.T) { + raw := []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`) + out := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "gpt-test", []byte(`{"model":"gpt-test"}`), nil, raw, nil) + if got := gjson.GetBytes(out, "output.0.content.0.text").String(); got != "ok" { + t.Fatalf("response text = %q, want ok. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 3 { + t.Fatalf("usage.total_tokens = %d, want 3. Output: %s", got, string(out)) + } +} + +func TestConvertInteractionsResponseToOpenAIResponsesStream(t *testing.T) { + var param any + var out [][]byte + for _, raw := range [][]byte{ + []byte(`event: step.delta +data: {"index":0,"delta":{"content":{"text":"thinking","type":"text"},"type":"thought_summary"},"event_type":"step.delta"} + +`), + []byte(`event: step.delta +data: {"index":1,"delta":{"text":"I will call a tool.","type":"text"},"event_type":"step.delta"} + +`), + []byte(`event: step.start +data: {"index":2,"step":{"id":"call_1","type":"function_call","name":"get_weather","arguments":{}},"event_type":"step.start"} + +`), + []byte(`event: step.delta +data: {"index":2,"delta":{"arguments":"{\"location\":\"北京\"}","type":"arguments_delta"},"event_type":"step.delta"} + +`), + []byte(`event: step.stop +data: {"index":2,"event_type":"step.stop"} + +`), + []byte(`event: interaction.completed +data: {"interaction":{"id":"interaction_1","status":"completed","usage":{"total_tokens":399,"total_input_tokens":123,"total_cached_tokens":5,"total_output_tokens":36,"total_thought_tokens":240},"created":"2026-07-06T06:01:35Z","object":"interaction","model":"gpt-test"},"event_type":"interaction.completed"} + +`), + []byte(`event: done +data: [DONE] + +`), + } { + out = append(out, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", []byte(`{"model":"gpt-test"}`), nil, raw, ¶m)...) + } + + if payload := findResponsesEventPayload(out, "response.output_text.delta"); gjson.GetBytes(payload, "delta").String() != "I will call a tool." { + t.Fatalf("output_text delta payload = %s", string(payload)) + } + if payload := findResponsesEventPayload(out, "response.function_call_arguments.delta"); gjson.GetBytes(payload, "delta").String() != `{"location":"北京"}` { + t.Fatalf("function args delta payload = %s", string(payload)) + } + completedPayload := findResponsesEventPayload(out, "response.completed") + if got := gjson.GetBytes(completedPayload, "response.usage.total_tokens").Int(); got != 399 { + t.Fatalf("total_tokens = %d, want 399. Payload: %s", got, string(completedPayload)) + } + if got := gjson.GetBytes(completedPayload, "response.usage.output_tokens_details.reasoning_tokens").Int(); got != 240 { + t.Fatalf("reasoning_tokens = %d, want 240. Payload: %s", got, string(completedPayload)) + } + if got := strings.Join(responsesEventNames(out), ","); !strings.Contains(got, "response.completed") { + t.Fatalf("events = %s, want response.completed", got) + } +} + +func TestConvertInteractionsResponseToOpenAIResponsesStreamModelOutputDoneIncludesText(t *testing.T) { + var param any + var out [][]byte + for _, raw := range [][]byte{ + []byte(`event: step.start +data: {"index":0,"step":{"id":"msg_1","type":"model_output"},"event_type":"step.start"} + +`), + []byte(`event: step.delta +data: {"index":0,"delta":{"text":"hello","type":"text"},"event_type":"step.delta"} + +`), + []byte(`event: step.delta +data: {"index":0,"delta":{"text":" world","type":"text"},"event_type":"step.delta"} + +`), + []byte(`event: step.stop +data: {"index":0,"event_type":"step.stop"} + +`), + } { + out = append(out, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", []byte(`{"model":"gpt-test"}`), nil, raw, ¶m)...) + } + + if payload := findResponsesEventPayload(out, "response.output_text.done"); gjson.GetBytes(payload, "text").String() != "hello world" { + t.Fatalf("output_text done payload = %s", string(payload)) + } + if payload := findResponsesEventPayload(out, "response.content_part.done"); gjson.GetBytes(payload, "part.text").String() != "hello world" { + t.Fatalf("content_part done payload = %s", string(payload)) + } + if payload := findResponsesEventPayload(out, "response.output_item.done"); gjson.GetBytes(payload, "item.content.0.text").String() != "hello world" { + t.Fatalf("output_item done payload = %s", string(payload)) + } +} + +func TestConvertInteractionsResponseToOpenAIResponsesStreamPreservesThoughtSignature(t *testing.T) { + var param any + signature := "EtoRtestThoughtSignature" + var out [][]byte + for _, raw := range [][]byte{ + []byte(`event: step.start +data: {"index":0,"step":{"type":"thought"},"event_type":"step.start"} + +`), + []byte(`event: step.delta +data: {"index":0,"delta":{"content":{"text":"thinking","type":"text"},"type":"thought_summary"},"event_type":"step.delta"} + +`), + []byte(`event: step.delta +data: {"index":0,"delta":{"signature":"","type":"thought_signature"},"event_type":"step.delta"} + +`), + []byte(`event: step.delta +data: {"index":0,"delta":{"signature":"` + signature + `","type":"thought_signature"},"event_type":"step.delta"} + +`), + []byte(`event: step.stop +data: {"index":0,"event_type":"step.stop"} + +`), + []byte(`event: interaction.completed +data: {"interaction":{"id":"interaction_1","status":"completed","object":"interaction","model":"gpt-test"},"event_type":"interaction.completed"} + +`), + } { + out = append(out, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", []byte(`{"model":"gpt-test"}`), nil, raw, ¶m)...) + } + + if got := strings.Join(responsesEventNames(out), ","); strings.Contains(got, "response.output_text.delta") { + t.Fatalf("events = %s, did not expect output_text delta for thought signature", got) + } + donePayload := findResponsesEventPayload(out, "response.output_item.done") + if got := gjson.GetBytes(donePayload, "item.encrypted_content").String(); got != signature { + t.Fatalf("done encrypted_content = %q, want %q. Payload: %s", got, signature, string(donePayload)) + } + if got := gjson.GetBytes(donePayload, "item.summary.0.text").String(); got != "thinking" { + t.Fatalf("done summary = %q, want thinking. Payload: %s", got, string(donePayload)) + } + completedPayload := findResponsesEventPayload(out, "response.completed") + if got := gjson.GetBytes(completedPayload, "response.output.0.encrypted_content").String(); got != signature { + t.Fatalf("completed encrypted_content = %q, want %q. Payload: %s", got, signature, string(completedPayload)) + } +} + +func TestConvertOpenAIResponsesResponseToInteractionsNonStreamFunctionCall(t *testing.T) { + raw := []byte(`{"id":"resp_1","output":[{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`) + out := ConvertOpenAIResponsesResponseToInteractionsNonStream(context.Background(), "gpt-test", nil, nil, raw, nil) + if got := gjson.GetBytes(out, "steps.0.type").String(); got != "function_call" { + t.Fatalf("step type = %q, want function_call", got) + } + if got := gjson.GetBytes(out, "steps.0.name").String(); got != "lookup" { + t.Fatalf("name = %q, want lookup", got) + } + if got := gjson.GetBytes(out, "steps.0.call_id").String(); got != "call_1" { + t.Fatalf("call_id = %q, want call_1", got) + } +} + +func TestConvertOpenAIResponsesResponseToInteractionsNonStreamFunctionCallStringArgs(t *testing.T) { + raw := []byte(`{"id":"resp_1","output":[{"type":"function_call","name":"lookup","call_id":"call_1","arguments":"{\"q\":\"x\"}"}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`) + out := ConvertOpenAIResponsesResponseToInteractionsNonStream(context.Background(), "gpt-test", nil, nil, raw, nil) + if got := gjson.GetBytes(out, "steps.0.type").String(); got != "function_call" { + t.Fatalf("step type = %q, want function_call", got) + } + if got := gjson.GetBytes(out, "steps.0.arguments.q").String(); got != "x" { + t.Fatalf("arguments.q = %q, want x", got) + } +} + +func TestConvertOpenAIResponsesResponseToInteractionsNonStreamUsageDetails(t *testing.T) { + raw := []byte(`{"id":"resp_1","output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":11,"output_tokens":13,"total_tokens":24,"input_tokens_details":{"cached_tokens":5},"output_tokens_details":{"reasoning_tokens":7}}}`) + out := ConvertOpenAIResponsesResponseToInteractionsNonStream(context.Background(), "gpt-test", nil, nil, raw, nil) + if got := gjson.GetBytes(out, "id").String(); got != "resp_1" { + t.Fatalf("id = %q, want resp_1. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "usage.input_tokens").Int(); got != 11 { + t.Fatalf("usage.input_tokens = %d, want 11. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "usage.output_tokens").Int(); got != 13 { + t.Fatalf("usage.output_tokens = %d, want 13. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "usage.reasoning_tokens").Int(); got != 7 { + t.Fatalf("usage.reasoning_tokens = %d, want 7. Output: %s", got, string(out)) + } + if got := gjson.GetBytes(out, "usage.cached_tokens").Int(); got != 5 { + t.Fatalf("usage.cached_tokens = %d, want 5. Output: %s", got, string(out)) + } +} + +func TestConvertOpenAIResponsesResponseToInteractionsStreamFunctionCallCallID(t *testing.T) { + var param any + raw := []byte(`{"type":"response.output_item.done","item":{"type":"function_call","id":"fc_1","call_id":"call_stream_1","name":"lookup","arguments":"{\"q\":\"x\"}"}}`) + out := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, raw, ¶m) + payload := findInteractionsStepDeltaPayload(out) + if len(payload) == 0 { + t.Fatalf("step.delta payload not found") + } + startPayload := findInteractionsEventPayload(out, "step.start") + if got := gjson.GetBytes(startPayload, "step.id").String(); got != "call_stream_1" { + t.Fatalf("step.id = %q, want call_stream_1", got) + } + if got := gjson.GetBytes(payload, "delta.arguments").String(); got != `{"q":"x"}` { + t.Fatalf("delta.arguments = %q, want JSON string", got) + } +} + +func TestConvertOpenAIResponsesResponseToInteractionsStreamSkipsDoneArgumentsAfterDelta(t *testing.T) { + var param any + deltaRaw := []byte(`{"type":"response.function_call_arguments.delta","output_index":0,"item_id":"fc_1","call_id":"call_1","delta":"{\"q\":\"x\"}"}`) + deltaOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, deltaRaw, ¶m) + payload := findInteractionsStepDeltaPayload(deltaOut) + if len(payload) == 0 { + t.Fatalf("delta step.delta payload not found") + } + if got := gjson.GetBytes(payload, "delta.arguments").String(); got != `{"q":"x"}` { + t.Fatalf("delta.arguments = %q, want JSON string. Payload: %s", got, string(payload)) + } + + doneRaw := []byte(`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"x\"}"}}`) + doneOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, doneRaw, ¶m) + if got := countInteractionsEventType(doneOut, "step.delta"); got != 0 { + t.Fatalf("done step.delta count = %d, want 0", got) + } + if got := countInteractionsEventType(doneOut, "step.stop"); got != 1 { + t.Fatalf("done step.stop count = %d, want 1", got) + } +} + +func TestConvertOpenAIResponsesResponseToInteractionsStreamSkipsDoneTextAfterDelta(t *testing.T) { + var param any + deltaRaw := []byte(`{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"hi"}`) + deltaOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, deltaRaw, ¶m) + payload := findInteractionsStepDeltaPayload(deltaOut) + if len(payload) == 0 { + t.Fatalf("delta step.delta payload not found") + } + if got := gjson.GetBytes(payload, "delta.text").String(); got != "hi" { + t.Fatalf("delta.text = %q, want hi. Payload: %s", got, string(payload)) + } + + doneRaw := []byte(`{"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_1","content":[{"type":"output_text","text":"hi"}]}}`) + doneOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, doneRaw, ¶m) + if got := countInteractionsEventType(doneOut, "step.delta"); got != 0 { + t.Fatalf("done step.delta count = %d, want 0", got) + } +} + +func TestConvertOpenAIResponsesResponseToInteractionsStreamSkipsDoneTextAfterUnkeyedDelta(t *testing.T) { + var param any + deltaRaw := []byte(`{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"delta":"hi"}`) + deltaOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, deltaRaw, ¶m) + payload := findInteractionsStepDeltaPayload(deltaOut) + if len(payload) == 0 { + t.Fatalf("delta step.delta payload not found") + } + if got := gjson.GetBytes(payload, "delta.text").String(); got != "hi" { + t.Fatalf("delta.text = %q, want hi. Payload: %s", got, string(payload)) + } + + doneRaw := []byte(`{"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_1","content":[{"type":"output_text","text":"hi"}]}}`) + doneOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, doneRaw, ¶m) + if got := countInteractionsEventType(doneOut, "step.delta"); got != 0 { + t.Fatalf("done step.delta count = %d, want 0", got) + } +} + +func TestConvertOpenAIResponsesResponseToInteractionsStreamCompletedOutputFallback(t *testing.T) { + var param any + raw := []byte(`{"type":"response.completed","response":{"output":[{"type":"message","id":"msg_1","content":[{"type":"output_text","text":"final"}]}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`) + out := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, raw, ¶m) + payload := findInteractionsStepDeltaPayload(out) + if len(payload) == 0 { + t.Fatalf("fallback step.delta payload not found") + } + if got := gjson.GetBytes(payload, "delta.text").String(); got != "final" { + t.Fatalf("delta.text = %q, want final. Payload: %s", got, string(payload)) + } + if got := countInteractionsEventType(out, "interaction.completed"); got != 1 { + t.Fatalf("interaction.completed count = %d, want 1", got) + } +} + +func TestConvertOpenAIResponsesResponseToInteractionsStreamEmitsDone(t *testing.T) { + var param any + completedRaw := []byte(`{"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`) + completedOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, completedRaw, ¶m) + doneOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, []byte(`data: [DONE]`), ¶m) + + if got := countInteractionsEventType(completedOut, "interaction.completed"); got != 1 { + t.Fatalf("completed interaction.completed count = %d, want 1", got) + } + if got := countInteractionsEventType(completedOut, "done"); got != 1 { + t.Fatalf("completed done count = %d, want 1", got) + } + if got := countInteractionsEventType(doneOut, "interaction.completed"); got != 0 { + t.Fatalf("done interaction.completed count = %d, want 0", got) + } + if got := countInteractionsEventType(doneOut, "done"); got != 0 { + t.Fatalf("done event count = %d, want 0", got) + } + if payload := findInteractionsEventPayload(completedOut, "done"); string(payload) != "[DONE]" { + t.Fatalf("done payload = %q, want [DONE]", string(payload)) + } +} + +func TestConvertInteractionsResponseToOpenAIResponsesStreamFinishMetadataUsage(t *testing.T) { + var param any + out := ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", nil, nil, []byte(`data: {"event_type":"finish","metadata":{"total_usage":{"total_input_tokens":2,"total_output_tokens":6,"total_thought_tokens":3,"total_cached_tokens":1,"total_tokens":11}}}`), ¶m) + payload := findResponsesEventPayload(out, "response.completed") + if len(payload) == 0 { + t.Fatalf("response.completed payload not found") + } + if got := gjson.GetBytes(payload, "response.usage.input_tokens").Int(); got != 2 { + t.Fatalf("input_tokens = %d, want 2. Payload: %s", got, string(payload)) + } + if got := gjson.GetBytes(payload, "response.usage.output_tokens").Int(); got != 6 { + t.Fatalf("output_tokens = %d, want 6. Payload: %s", got, string(payload)) + } + if got := gjson.GetBytes(payload, "response.usage.output_tokens_details.reasoning_tokens").Int(); got != 3 { + t.Fatalf("reasoning_tokens = %d, want 3. Payload: %s", got, string(payload)) + } + if got := gjson.GetBytes(payload, "response.usage.input_tokens_details.cached_tokens").Int(); got != 1 { + t.Fatalf("cached_tokens = %d, want 1. Payload: %s", got, string(payload)) + } + if got := gjson.GetBytes(payload, "response.usage.total_tokens").Int(); got != 11 { + t.Fatalf("total_tokens = %d, want 11. Payload: %s", got, string(payload)) + } +} + +func TestConvertOpenAIResponsesResponseToInteractionsStreamCreatedThenDelta(t *testing.T) { + var param any + var out [][]byte + for _, raw := range [][]byte{ + []byte(`{"type":"response.created","response":{"id":"resp_1","model":"gpt-test"}}`), + []byte(`{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"hi"}`), + } { + out = append(out, ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, raw, ¶m)...) + } + + got := strings.Join(interactionsEventNames(out), ",") + want := "interaction.created,interaction.status_update,step.start,step.delta" + if got != want { + t.Fatalf("events = %s, want %s", got, want) + } + payload := findInteractionsEventPayload(out, "interaction.status_update") + if gotID := gjson.GetBytes(payload, "interaction_id").String(); gotID != "resp_1" { + t.Fatalf("interaction_id = %q, want resp_1. Payload: %s", gotID, string(payload)) + } +} + +func TestConvertOpenAIResponsesResponseToInteractionsStreamCompletesAfterSteps(t *testing.T) { + var param any + var out [][]byte + for _, raw := range [][]byte{ + []byte(`{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"我将调用工具。"}`), + []byte(`{"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"weather\"}"}}`), + []byte(`{"type":"response.completed","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`), + } { + out = append(out, ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, raw, ¶m)...) + } + + got := strings.Join(interactionsEventNames(out), ",") + want := "interaction.created,interaction.status_update,step.start,step.delta,step.stop,step.start,step.delta,step.stop,interaction.completed,done" + if got != want { + t.Fatalf("events = %s, want %s", got, want) + } + completedPayload := findInteractionsEventPayload(out, "interaction.completed") + if gotTokens := gjson.GetBytes(completedPayload, "interaction.usage.total_tokens").Int(); gotTokens != 3 { + t.Fatalf("total_tokens = %d, want 3. Payload: %s", gotTokens, string(completedPayload)) + } +} + +func TestConvertOpenAIResponsesResponseToInteractionsStreamSkipsCompletedTextAfterUnkeyedDelta(t *testing.T) { + var param any + deltaRaw := []byte(`{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"delta":"final"}`) + deltaOut := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, deltaRaw, ¶m) + payload := findInteractionsStepDeltaPayload(deltaOut) + if len(payload) == 0 { + t.Fatalf("delta step.delta payload not found") + } + if got := gjson.GetBytes(payload, "delta.text").String(); got != "final" { + t.Fatalf("delta.text = %q, want final. Payload: %s", got, string(payload)) + } + + raw := []byte(`{"type":"response.completed","response":{"output":[{"type":"message","id":"msg_1","content":[{"type":"output_text","text":"final"}]}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`) + out := ConvertOpenAIResponsesResponseToInteractions(context.Background(), "gpt-test", nil, nil, raw, ¶m) + if got := countInteractionsEventType(out, "step.delta"); got != 0 { + t.Fatalf("completed step.delta count = %d, want 0", got) + } + if got := countInteractionsEventType(out, "interaction.completed"); got != 1 { + t.Fatalf("interaction.completed count = %d, want 1", got) + } +} + +func findInteractionsStepDeltaPayload(events [][]byte) []byte { + return findInteractionsEventPayload(events, "step.delta") +} + +func findInteractionsEventPayload(events [][]byte, eventType string) []byte { + for _, event := range events { + payload := ssePayload(event) + if interactionsEventName(event, payload) == eventType { + return payload + } + } + return nil +} + +func ssePayload(event []byte) []byte { + const prefix = "\ndata: " + idx := bytes.Index(event, []byte(prefix)) + if idx < 0 { + return nil + } + return event[idx+len(prefix):] +} + +func countInteractionsEventType(events [][]byte, eventType string) int { + count := 0 + for _, event := range events { + payload := ssePayload(event) + if interactionsEventName(event, payload) == eventType { + count++ + } + } + return count +} + +func interactionsEventNames(events [][]byte) []string { + names := make([]string, 0, len(events)) + for _, event := range events { + payload := ssePayload(event) + if name := interactionsEventName(event, payload); name != "" { + names = append(names, name) + } + } + return names +} + +func interactionsEventName(event, payload []byte) string { + if eventType := gjson.GetBytes(payload, "event_type").String(); eventType != "" { + return eventType + } + const prefix = "event: " + lineEnd := bytes.IndexByte(event, '\n') + if lineEnd < 0 || !bytes.HasPrefix(event, []byte(prefix)) { + return "" + } + return string(event[len(prefix):lineEnd]) +} + +func findResponsesEventPayload(events [][]byte, eventType string) []byte { + for _, event := range events { + payload := ssePayload(event) + if gjson.GetBytes(payload, "type").String() == eventType { + return payload + } + } + return nil +} + +func responsesEventNames(events [][]byte) []string { + names := make([]string, 0, len(events)) + for _, event := range events { + payload := ssePayload(event) + if name := gjson.GetBytes(payload, "type").String(); name != "" { + names = append(names, name) + } + } + return names +} diff --git a/internal/tui/client.go b/internal/tui/client.go index 747f30b98..130b53958 100644 --- a/internal/tui/client.go +++ b/internal/tui/client.go @@ -290,6 +290,12 @@ func (c *Client) GetGeminiKeys() ([]map[string]any, error) { return c.getWrappedKeyList("/v0/management/gemini-api-key", "gemini-api-key") } +// GetInteractionsKeys fetches native Interactions API keys. +// API returns {"interactions-api-key": [...]}. +func (c *Client) GetInteractionsKeys() ([]map[string]any, error) { + return c.getWrappedKeyList("/v0/management/interactions-api-key", "interactions-api-key") +} + // GetClaudeKeys fetches Claude API keys. func (c *Client) GetClaudeKeys() ([]map[string]any, error) { return c.getWrappedKeyList("/v0/management/claude-api-key", "claude-api-key") diff --git a/internal/tui/keys_tab.go b/internal/tui/keys_tab.go index 770f7f1e5..e5118f8fe 100644 --- a/internal/tui/keys_tab.go +++ b/internal/tui/keys_tab.go @@ -13,21 +13,22 @@ import ( // keysTabModel displays and manages API keys. type keysTabModel struct { - client *Client - viewport viewport.Model - keys []string - gemini []map[string]any - claude []map[string]any - codex []map[string]any - vertex []map[string]any - openai []map[string]any - err error - width int - height int - ready bool - cursor int - confirm int // -1 = no deletion pending - status string + client *Client + viewport viewport.Model + keys []string + gemini []map[string]any + interactions []map[string]any + claude []map[string]any + codex []map[string]any + vertex []map[string]any + openai []map[string]any + err error + width int + height int + ready bool + cursor int + confirm int // -1 = no deletion pending + status string // Editing / Adding editing bool @@ -37,13 +38,14 @@ type keysTabModel struct { } type keysDataMsg struct { - apiKeys []string - gemini []map[string]any - claude []map[string]any - codex []map[string]any - vertex []map[string]any - openai []map[string]any - err error + apiKeys []string + gemini []map[string]any + interactions []map[string]any + claude []map[string]any + codex []map[string]any + vertex []map[string]any + openai []map[string]any + err error } type keyActionMsg struct { @@ -75,6 +77,7 @@ func (m keysTabModel) fetchKeys() tea.Msg { } result.apiKeys = apiKeys result.gemini, _ = m.client.GetGeminiKeys() + result.interactions, _ = m.client.GetInteractionsKeys() result.claude, _ = m.client.GetClaudeKeys() result.codex, _ = m.client.GetCodexKeys() result.vertex, _ = m.client.GetVertexKeys() @@ -94,6 +97,7 @@ func (m keysTabModel) Update(msg tea.Msg) (keysTabModel, tea.Cmd) { m.err = nil m.keys = msg.apiKeys m.gemini = msg.gemini + m.interactions = msg.interactions m.claude = msg.claude m.codex = msg.codex m.vertex = msg.vertex @@ -340,6 +344,7 @@ func (m keysTabModel) renderContent() string { // ━━━ Provider Keys (read-only display) ━━━ renderProviderKeys(&sb, "Gemini API Keys", m.gemini) + renderProviderKeys(&sb, "Interactions API Keys", m.interactions) renderProviderKeys(&sb, "Claude API Keys", m.claude) renderProviderKeys(&sb, "Codex API Keys", m.codex) renderProviderKeys(&sb, "Vertex API Keys", m.vertex) diff --git a/internal/watcher/clients.go b/internal/watcher/clients.go index 8f1aca7a6..dbfda9e0a 100644 --- a/internal/watcher/clients.go +++ b/internal/watcher/clients.go @@ -384,6 +384,9 @@ func BuildAPIKeyClients(cfg *config.Config) (int, int, int, int, int) { if len(cfg.GeminiKey) > 0 { geminiAPIKeyCount += len(cfg.GeminiKey) } + if len(cfg.InteractionsKey) > 0 { + geminiAPIKeyCount += len(cfg.InteractionsKey) + } if len(cfg.VertexCompatAPIKey) > 0 { vertexCompatAPIKeyCount += len(cfg.VertexCompatAPIKey) } diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index 80cc44ddc..d73160e8c 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -152,6 +152,39 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { } } } + if len(oldCfg.InteractionsKey) != len(newCfg.InteractionsKey) { + changes = append(changes, fmt.Sprintf("interactions-api-key count: %d -> %d", len(oldCfg.InteractionsKey), len(newCfg.InteractionsKey))) + } else { + for i := range oldCfg.InteractionsKey { + o := oldCfg.InteractionsKey[i] + n := newCfg.InteractionsKey[i] + if strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) { + changes = append(changes, fmt.Sprintf("interactions[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL))) + } + if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) { + changes = append(changes, fmt.Sprintf("interactions[%d].proxy-url: %s -> %s", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL))) + } + if strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) { + changes = append(changes, fmt.Sprintf("interactions[%d].prefix: %s -> %s", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix))) + } + if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) { + changes = append(changes, fmt.Sprintf("interactions[%d].api-key: updated", i)) + } + if !equalStringMap(o.Headers, n.Headers) { + changes = append(changes, fmt.Sprintf("interactions[%d].headers: updated", i)) + } + oldModels := SummarizeGeminiModels(o.Models) + newModels := SummarizeGeminiModels(n.Models) + if oldModels.hash != newModels.hash { + changes = append(changes, fmt.Sprintf("interactions[%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("interactions[%d].excluded-models: updated (%d -> %d entries)", i, oldExcluded.count, newExcluded.count)) + } + } + } // Claude keys (do not print key material) if len(oldCfg.ClaudeKey) != len(newCfg.ClaudeKey) { diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go index 82a75cf78..a776b58c2 100644 --- a/internal/watcher/synthesizer/config.go +++ b/internal/watcher/synthesizer/config.go @@ -5,13 +5,15 @@ import ( "strconv" "strings" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) // ConfigSynthesizer generates Auth entries from configuration API keys. -// It handles Gemini, Claude, Codex, OpenAI-compat, and Vertex-compat providers. +// It handles Gemini, Interactions, Claude, Codex, OpenAI-compat, and Vertex-compat providers. type ConfigSynthesizer struct{} // NewConfigSynthesizer creates a new ConfigSynthesizer instance. @@ -28,6 +30,8 @@ func (s *ConfigSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, // Gemini API Keys out = append(out, s.synthesizeGeminiKeys(ctx)...) + // Native Interactions API Keys + out = append(out, s.synthesizeInteractionsKeys(ctx)...) // Claude API Keys out = append(out, s.synthesizeClaudeKeys(ctx)...) // Codex API Keys @@ -42,13 +46,22 @@ func (s *ConfigSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, // synthesizeGeminiKeys creates Auth entries for Gemini API keys. func (s *ConfigSynthesizer) synthesizeGeminiKeys(ctx *SynthesisContext) []*coreauth.Auth { + return s.synthesizeGeminiKeyEntries(ctx, ctx.Config.GeminiKey, "gemini:apikey", "gemini", "gemini-apikey", constant.Gemini) +} + +// synthesizeInteractionsKeys creates Auth entries for native Interactions API keys. +func (s *ConfigSynthesizer) synthesizeInteractionsKeys(ctx *SynthesisContext) []*coreauth.Auth { + return s.synthesizeGeminiKeyEntries(ctx, ctx.Config.InteractionsKey, "gemini-interactions:apikey", "interactions", "interactions-apikey", constant.GeminiInteractions) +} + +func (s *ConfigSynthesizer) synthesizeGeminiKeyEntries(ctx *SynthesisContext, entries []config.GeminiKey, idKind, sourceName, label, provider string) []*coreauth.Auth { cfg := ctx.Config now := ctx.Now idGen := ctx.IDGenerator - out := make([]*coreauth.Auth, 0, len(cfg.GeminiKey)) - for i := range cfg.GeminiKey { - entry := cfg.GeminiKey[i] + out := make([]*coreauth.Auth, 0, len(entries)) + for i := range entries { + entry := entries[i] key := strings.TrimSpace(entry.APIKey) if key == "" { continue @@ -56,9 +69,9 @@ func (s *ConfigSynthesizer) synthesizeGeminiKeys(ctx *SynthesisContext) []*corea prefix := strings.TrimSpace(entry.Prefix) base := strings.TrimSpace(entry.BaseURL) proxyURL := strings.TrimSpace(entry.ProxyURL) - id, token := idGen.Next("gemini:apikey", key, base) + id, token := idGen.Next(idKind, key, base) attrs := map[string]string{ - "source": fmt.Sprintf("config:gemini[%s]", token), + "source": fmt.Sprintf("config:%s[%s]", sourceName, token), "api_key": key, } metadata := map[string]any{} @@ -77,8 +90,8 @@ func (s *ConfigSynthesizer) synthesizeGeminiKeys(ctx *SynthesisContext) []*corea addConfigHeadersToAttrs(entry.Headers, attrs) a := &coreauth.Auth{ ID: id, - Provider: "gemini", - Label: "gemini-apikey", + Provider: provider, + Label: label, Prefix: prefix, Status: coreauth.StatusActive, ProxyURL: proxyURL, diff --git a/internal/watcher/synthesizer/config_test.go b/internal/watcher/synthesizer/config_test.go index 5646ef871..a0b726ff2 100644 --- a/internal/watcher/synthesizer/config_test.go +++ b/internal/watcher/synthesizer/config_test.go @@ -169,6 +169,53 @@ func TestConfigSynthesizer_GeminiKeys(t *testing.T) { } } +func TestConfigSynthesizer_InteractionsKeys(t *testing.T) { + synth := NewConfigSynthesizer() + ctx := &SynthesisContext{ + Config: &config.Config{ + InteractionsKey: []config.GeminiKey{{ + APIKey: "interactions-key", + BaseURL: "https://interactions.example.com", + ProxyURL: "http://proxy.local:8080", + Prefix: "native", + Headers: map[string]string{"X-Custom": "value"}, + }}, + }, + Now: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + IDGenerator: NewStableIDGenerator(), + } + + auths, errSynthesize := synth.Synthesize(ctx) + if errSynthesize != nil { + t.Fatalf("Synthesize() error = %v", errSynthesize) + } + if len(auths) != 1 { + t.Fatalf("auth count = %d, want 1", len(auths)) + } + auth := auths[0] + if auth.Provider != "gemini-interactions" { + t.Fatalf("provider = %q, want gemini-interactions", auth.Provider) + } + if auth.Label != "interactions-apikey" { + t.Fatalf("label = %q, want interactions-apikey", auth.Label) + } + if auth.Prefix != "native" { + t.Fatalf("prefix = %q, want native", auth.Prefix) + } + if auth.ProxyURL != "http://proxy.local:8080" { + t.Fatalf("proxy URL = %q, want http://proxy.local:8080", auth.ProxyURL) + } + if got := auth.Attributes["api_key"]; got != "interactions-key" { + t.Fatalf("api_key = %q, want interactions-key", got) + } + if got := auth.Attributes["base_url"]; got != "https://interactions.example.com" { + t.Fatalf("base_url = %q, want https://interactions.example.com", got) + } + if got := auth.Attributes["header:X-Custom"]; got != "value" { + t.Fatalf("header:X-Custom = %q, want value", got) + } +} + func TestConfigSynthesizer_ClaudeKeys(t *testing.T) { synth := NewConfigSynthesizer() ctx := &SynthesisContext{ diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 319aa5ab9..ddc1c03fa 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -63,7 +63,8 @@ func TestApplyAuthExcludedModelsMeta_OAuthProvider(t *testing.T) { func TestBuildAPIKeyClientsCounts(t *testing.T) { cfg := &config.Config{ - GeminiKey: []config.GeminiKey{{APIKey: "g1"}, {APIKey: "g2"}}, + GeminiKey: []config.GeminiKey{{APIKey: "g1"}, {APIKey: "g2"}}, + InteractionsKey: []config.GeminiKey{{APIKey: "i1"}}, VertexCompatAPIKey: []config.VertexCompatKey{ {APIKey: "v1"}, }, @@ -75,7 +76,7 @@ func TestBuildAPIKeyClientsCounts(t *testing.T) { } gemini, vertex, claude, codex, compat := BuildAPIKeyClients(cfg) - if gemini != 2 || vertex != 1 || claude != 1 || codex != 2 || compat != 2 { + if gemini != 3 || vertex != 1 || claude != 1 || codex != 2 || compat != 2 { t.Fatalf("unexpected counts: %d %d %d %d %d", gemini, vertex, claude, codex, compat) } } diff --git a/sdk/api/handlers/gemini/interactions_handlers.go b/sdk/api/handlers/gemini/interactions_handlers.go new file mode 100644 index 000000000..b05a8c536 --- /dev/null +++ b/sdk/api/handlers/gemini/interactions_handlers.go @@ -0,0 +1,202 @@ +package gemini + +import ( + "bytes" + "context" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const interactionsAgentAuthSelectionModel = "gemini-2.5-flash" + +type interactionsRequestTarget struct { + Model string + Agent string + Stream bool +} + +func parseInteractionsRequestTarget(rawJSON []byte) (interactionsRequestTarget, error) { + if !gjson.ValidBytes(rawJSON) { + return interactionsRequestTarget{}, fmt.Errorf("invalid JSON body") + } + root := gjson.ParseBytes(rawJSON) + model := strings.TrimSpace(root.Get("model").String()) + agent := strings.TrimSpace(root.Get("agent").String()) + if model == "" && agent == "" { + return interactionsRequestTarget{}, fmt.Errorf("request requires exactly one of model or agent") + } + if model != "" && agent != "" { + return interactionsRequestTarget{}, fmt.Errorf("request requires exactly one of model or agent") + } + streamNode := root.Get("stream") + stream := false + if streamNode.Exists() { + if !streamNode.IsBool() { + return interactionsRequestTarget{}, fmt.Errorf("stream must be a boolean") + } + stream = streamNode.Bool() + } + return interactionsRequestTarget{Model: model, Agent: agent, Stream: stream}, nil +} + +func prepareInteractionsExecutionTarget(rawJSON []byte, target interactionsRequestTarget) (string, []byte) { + if target.Agent != "" { + return target.Agent, rawJSON + } + model := normalizeGeminiModelResourceName(target.Model) + if model == target.Model { + return model, rawJSON + } + updatedRawJSON, errSet := sjson.SetBytes(rawJSON, "model", model) + if errSet != nil { + return model, rawJSON + } + return model, updatedRawJSON +} + +func normalizeGeminiModelResourceName(model string) string { + model = strings.TrimSpace(model) + if strings.HasPrefix(model, "models/") && len(model) > len("models/") { + return strings.TrimPrefix(model, "models/") + } + return model +} + +func buildInteractionsExecutionRequest(target interactionsRequestTarget, modelName string, rawJSON []byte, alt string) handlers.ProtocolExecutionRequest { + forcedProvider := "" + authSelectionModel := "" + if target.Agent != "" { + forcedProvider = GeminiInteractions + authSelectionModel = interactionsAgentAuthSelectionModel + } + return handlers.ProtocolExecutionRequest{ + EntryProtocol: Interactions, + ExitProtocol: Interactions, + ForcedProvider: forcedProvider, + AuthSelectionModel: authSelectionModel, + Model: modelName, + Stream: target.Stream, + Body: rawJSON, + Alt: alt, + } +} + +// Interactions handles POST /v1beta/interactions. +func (h *GeminiAPIHandler) Interactions(c *gin.Context) { + rawJSON, errRead := c.GetRawData() + if errRead != nil { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{Error: handlers.ErrorDetail{Message: errRead.Error(), Type: "invalid_request_error"}}) + return + } + target, errParse := parseInteractionsRequestTarget(rawJSON) + if errParse != nil { + c.JSON(http.StatusBadRequest, handlers.ErrorResponse{Error: handlers.ErrorDetail{Message: errParse.Error(), Type: "invalid_request_error"}}) + return + } + + modelName, resolvedRawJSON := prepareInteractionsExecutionTarget(rawJSON, target) + rawJSON = resolvedRawJSON + + alt := h.GetAlt(c) + cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background()) + defer cliCancel(nil) + + req := buildInteractionsExecutionRequest(target, modelName, rawJSON, alt) + if target.Stream { + h.handleInteractionsStream(c, cliCtx, cliCancel, req) + return + } + h.handleInteractionsNonStream(c, cliCtx, cliCancel, req) +} + +func (h *GeminiAPIHandler) handleInteractionsNonStream(c *gin.Context, cliCtx context.Context, cliCancel handlers.APIHandlerCancelFunc, req handlers.ProtocolExecutionRequest) { + c.Header("Content-Type", "application/json") + stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx) + resp, errMsg := h.ExecuteProtocolWithAuthManager(cliCtx, req) + stopKeepAlive() + if errMsg != nil { + h.WriteErrorResponse(c, errMsg) + cliCancel(errMsg.Error) + return + } + handlers.WriteUpstreamHeaders(c.Writer.Header(), resp.Headers) + _, _ = c.Writer.Write(resp.Body) +} + +func (h *GeminiAPIHandler) handleInteractionsStream(c *gin.Context, cliCtx context.Context, cliCancel handlers.APIHandlerCancelFunc, req handlers.ProtocolExecutionRequest) { + flusher, ok := c.Writer.(http.Flusher) + if !ok { + c.JSON(http.StatusInternalServerError, handlers.ErrorResponse{Error: handlers.ErrorDetail{Message: "Streaming not supported", Type: "server_error"}}) + return + } + stream, errMsg := h.ExecuteProtocolStreamWithAuthManager(cliCtx, req) + if errMsg != nil { + h.WriteErrorResponse(c, errMsg) + cliCancel(errMsg.Error) + return + } + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("Access-Control-Allow-Origin", "*") + handlers.WriteUpstreamHeaders(c.Writer.Header(), stream.Headers) + data := make(chan []byte) + errs := make(chan *interfaces.ErrorMessage, 1) + go func() { + defer close(data) + defer close(errs) + for chunk := range stream.Chunks { + if chunk.Err != nil { + errs <- &interfaces.ErrorMessage{StatusCode: chunk.Err.StatusCode, Error: chunk.Err} + return + } + if len(chunk.Payload) > 0 { + data <- chunk.Payload + } + } + }() + h.forwardInteractionsStream(c, flusher, func(err error) { cliCancel(err) }, data, errs) +} + +func (h *GeminiAPIHandler) forwardInteractionsStream(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) { + h.ForwardStream(c, flusher, cancel, data, errs, handlers.StreamForwardOptions{ + WriteChunk: func(chunk []byte) { + if len(chunk) == 0 { + return + } + trimmed := bytes.TrimSpace(chunk) + if bytes.HasPrefix(trimmed, []byte("event:")) || bytes.HasPrefix(trimmed, []byte("data:")) { + _, _ = c.Writer.Write(chunk) + } else { + _, _ = c.Writer.Write([]byte("data: ")) + _, _ = c.Writer.Write(chunk) + } + if !bytes.HasSuffix(chunk, []byte("\n\n")) { + _, _ = c.Writer.Write([]byte("\n\n")) + } + }, + WriteTerminalError: func(errMsg *interfaces.ErrorMessage) { + if errMsg == nil { + return + } + status := http.StatusInternalServerError + if errMsg.StatusCode > 0 { + status = errMsg.StatusCode + } + errText := http.StatusText(status) + if errMsg.Error != nil && errMsg.Error.Error() != "" { + errText = errMsg.Error.Error() + } + body := handlers.BuildErrorResponseBody(status, errText) + _, _ = fmt.Fprintf(c.Writer, "event: error\ndata: %s\n\n", string(body)) + }, + }) +} diff --git a/sdk/api/handlers/gemini/interactions_handlers_test.go b/sdk/api/handlers/gemini/interactions_handlers_test.go new file mode 100644 index 000000000..b5bff4206 --- /dev/null +++ b/sdk/api/handlers/gemini/interactions_handlers_test.go @@ -0,0 +1,320 @@ +package gemini + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" + "github.com/tidwall/gjson" +) + +func TestParseInteractionsRequestTarget(t *testing.T) { + tests := []struct { + name string + body string + wantModel string + wantAgent string + wantErr bool + }{ + {name: "model", body: `{"model":"gemini-3.5-flash","input":"hi"}`, wantModel: "gemini-3.5-flash"}, + {name: "model resource name", body: `{"model":"models/gemini-3.5-flash","input":"hi"}`, wantModel: "models/gemini-3.5-flash"}, + {name: "agent", body: `{"agent":"agents/test-agent","input":"hi"}`, wantAgent: "agents/test-agent"}, + {name: "missing", body: `{"input":"hi"}`, wantErr: true}, + {name: "both", body: `{"model":"gemini-3.5-flash","agent":"agents/test-agent","input":"hi"}`, wantErr: true}, + {name: "stream string", body: `{"model":"gemini-3.5-flash","stream":"true","input":"hi"}`, wantErr: true}, + {name: "stream true", body: `{"model":"gemini-3.5-flash","stream":true,"input":"hi"}`, wantModel: "gemini-3.5-flash"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target, errParse := parseInteractionsRequestTarget([]byte(tt.body)) + if tt.wantErr { + if errParse == nil { + t.Fatal("parseInteractionsRequestTarget() error = nil, want error") + } + return + } + if errParse != nil { + t.Fatalf("parseInteractionsRequestTarget() error = %v", errParse) + } + if target.Model != tt.wantModel || target.Agent != tt.wantAgent { + t.Fatalf("target = %#v, want model %q agent %q", target, tt.wantModel, tt.wantAgent) + } + }) + } +} + +func TestPrepareInteractionsExecutionTargetNormalizesModelResourceName(t *testing.T) { + target, errParse := parseInteractionsRequestTarget([]byte(`{"model":"models/gemini-3.5-flash","input":"hi"}`)) + if errParse != nil { + t.Fatalf("parseInteractionsRequestTarget() error = %v", errParse) + } + model, body := prepareInteractionsExecutionTarget([]byte(`{"model":"models/gemini-3.5-flash","input":"hi"}`), target) + if model != "gemini-3.5-flash" { + t.Fatalf("model = %q, want gemini-3.5-flash", model) + } + if got := gjson.GetBytes(body, "model").String(); got != "gemini-3.5-flash" { + t.Fatalf("body model = %q, want gemini-3.5-flash. Body: %s", got, string(body)) + } +} + +func TestPrepareInteractionsExecutionTargetPreservesBareModel(t *testing.T) { + target, errParse := parseInteractionsRequestTarget([]byte(`{"model":"gemini-3.5-flash","input":"hi"}`)) + if errParse != nil { + t.Fatalf("parseInteractionsRequestTarget() error = %v", errParse) + } + model, body := prepareInteractionsExecutionTarget([]byte(`{"model":"gemini-3.5-flash","input":"hi"}`), target) + if model != "gemini-3.5-flash" { + t.Fatalf("model = %q, want gemini-3.5-flash", model) + } + if got := gjson.GetBytes(body, "model").String(); got != "gemini-3.5-flash" { + t.Fatalf("body model = %q, want gemini-3.5-flash. Body: %s", got, string(body)) + } +} + +func TestBuildInteractionsExecutionRequestUsesAgentAuthSelectionModel(t *testing.T) { + target, errParse := parseInteractionsRequestTarget([]byte(`{"agent":"agents/test-agent","input":"hi"}`)) + if errParse != nil { + t.Fatalf("parseInteractionsRequestTarget() error = %v", errParse) + } + req := buildInteractionsExecutionRequest(target, "agents/test-agent", []byte(`{"agent":"agents/test-agent","input":"hi"}`), "") + if req.ForcedProvider != "gemini-interactions" { + t.Fatalf("ForcedProvider = %q, want gemini-interactions", req.ForcedProvider) + } + if req.AuthSelectionModel != interactionsAgentAuthSelectionModel { + t.Fatalf("AuthSelectionModel = %q, want %q", req.AuthSelectionModel, interactionsAgentAuthSelectionModel) + } + if req.Model != "agents/test-agent" { + t.Fatalf("Model = %q, want agents/test-agent", req.Model) + } + if got := gjson.GetBytes(req.Body, "agent").String(); got != "agents/test-agent" { + t.Fatalf("body agent = %q, want agents/test-agent", got) + } +} + +func TestInteractionsRejectsInvalidJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1beta/interactions", strings.NewReader(`{`)) + h := NewGeminiAPIHandler(&handlers.BaseAPIHandler{}) + + h.Interactions(ctx) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "invalid_request_error") { + t.Fatalf("body = %s, want invalid_request_error", rec.Body.String()) + } +} + +func TestInteractionsRejectsMissingModelAndAgent(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1beta/interactions", strings.NewReader(`{"input":"hi"}`)) + h := NewGeminiAPIHandler(&handlers.BaseAPIHandler{}) + + h.Interactions(ctx) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "exactly one of model or agent") { + t.Fatalf("body = %s, want model/agent validation error", rec.Body.String()) + } +} + +func TestInteractionsRejectsBothModelAndAgent(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1beta/interactions", strings.NewReader(`{"model":"gemini-3.5-flash","agent":"agents/test-agent","input":"hi"}`)) + h := NewGeminiAPIHandler(&handlers.BaseAPIHandler{}) + + h.Interactions(ctx) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "exactly one of model or agent") { + t.Fatalf("body = %s, want model/agent validation error", rec.Body.String()) + } +} + +func TestInteractionsRejectsNonBooleanStream(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1beta/interactions", strings.NewReader(`{"model":"gemini-3.5-flash","stream":"true","input":"hi"}`)) + h := NewGeminiAPIHandler(&handlers.BaseAPIHandler{}) + + h.Interactions(ctx) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "invalid_request_error") { + t.Fatalf("body = %s, want invalid_request_error", rec.Body.String()) + } +} + +func TestInteractionsAgentUsesNativeInteractionsEndpoint(t *testing.T) { + gin.SetMode(gin.TestMode) + var gotPath string + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + http.Error(w, errRead.Error(), http.StatusBadRequest) + return + } + upstreamBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`)) + })) + defer server.Close() + + manager := coreauth.NewManager(nil, nil, nil) + manager.RegisterExecutor(executor.NewGeminiInteractionsExecutor(&config.Config{RequestRetry: 1})) + auth := &coreauth.Auth{ + ID: "interactions-agent-native-auth", + Provider: "gemini-interactions", + Status: coreauth.StatusActive, + Attributes: map[string]string{ + "api_key": "test-key", + "base_url": server.URL, + }, + Metadata: map[string]any{"email": "interactions-agent@example.com"}, + } + if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { + t.Fatalf("manager.Register(): %v", errRegister) + } + registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: interactionsAgentAuthSelectionModel}}) + t.Cleanup(func() { + registry.GetGlobalRegistry().UnregisterClient(auth.ID) + }) + + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1beta/interactions", strings.NewReader(`{"agent":"agents/test-agent","input":"hi"}`)) + h := NewGeminiAPIHandler(handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)) + + h.Interactions(ctx) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if gotPath != "/v1beta/interactions" { + t.Fatalf("path = %q, want /v1beta/interactions", gotPath) + } + if got := gjson.GetBytes(upstreamBody, "agent").String(); got != "agents/test-agent" { + t.Fatalf("upstream agent = %q, want agents/test-agent. Body: %s", got, string(upstreamBody)) + } + if got := gjson.GetBytes(rec.Body.Bytes(), "id").String(); got != "interaction_1" { + t.Fatalf("response id = %q, want interaction_1. Body: %s", got, rec.Body.String()) + } +} + +func TestInteractionsAntigravityModelUsesTranslatorBridge(t *testing.T) { + gin.SetMode(gin.TestMode) + model := "interactions-antigravity-bridge-model" + var upstreamBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1internal:generateContent" { + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound) + return + } + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + http.Error(w, errRead.Error(), http.StatusBadRequest) + return + } + upstreamBody = body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"response":{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"text":"translated-ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2,"totalTokenCount":3}}}`)) + })) + defer server.Close() + + manager := coreauth.NewManager(nil, nil, nil) + manager.RegisterExecutor(executor.NewAntigravityExecutor(&config.Config{RequestRetry: 1})) + auth := &coreauth.Auth{ + ID: "interactions-antigravity-bridge-auth", + Provider: "antigravity", + Status: coreauth.StatusActive, + Attributes: map[string]string{ + "base_url": server.URL, + }, + Metadata: map[string]any{ + "access_token": "token", + "project_id": "project-1", + "expired": time.Now().Add(time.Hour).Format(time.RFC3339), + }, + } + if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { + t.Fatalf("manager.Register(): %v", errRegister) + } + registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: model}}) + t.Cleanup(func() { + registry.GetGlobalRegistry().UnregisterClient(auth.ID) + }) + + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1beta/interactions", strings.NewReader(`{"model":"`+model+`","input":"hi","generation_config":{"top_p":0.8}}`)) + h := NewGeminiAPIHandler(handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)) + + h.Interactions(ctx) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if gjson.GetBytes(upstreamBody, "input").Exists() { + t.Fatalf("upstream body still contains raw interactions input: %s", string(upstreamBody)) + } + if got := gjson.GetBytes(upstreamBody, "request.contents.0.parts.0.text").String(); got != "hi" { + t.Fatalf("upstream request text = %q, want hi. Body: %s", got, string(upstreamBody)) + } + if got := gjson.GetBytes(upstreamBody, "request.generationConfig.topP").Float(); got != 0.8 { + t.Fatalf("upstream topP = %v, want 0.8. Body: %s", got, string(upstreamBody)) + } + if got := gjson.GetBytes(rec.Body.Bytes(), "steps.0.content.0.text").String(); got != "translated-ok" { + t.Fatalf("response text = %q, want translated-ok. Body: %s", got, rec.Body.String()) + } + if gjson.GetBytes(rec.Body.Bytes(), "response").Exists() { + t.Fatalf("response still contains raw antigravity response wrapper: %s", rec.Body.String()) + } +} + +func TestForwardInteractionsStreamWrapsBareJSONAsSSEData(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1beta/interactions", strings.NewReader(`{}`)) + data := make(chan []byte, 1) + errs := make(chan *interfaces.ErrorMessage) + data <- []byte(`{"type":"interaction.completed"}`) + close(data) + close(errs) + h := NewGeminiAPIHandler(&handlers.BaseAPIHandler{}) + + h.forwardInteractionsStream(ctx, rec, func(error) {}, data, errs) + + if got := rec.Body.String(); got != "data: {\"type\":\"interaction.completed\"}\n\n" { + t.Fatalf("body = %q, want SSE data frame", got) + } +} diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go index 74ef0d954..cb2bec489 100644 --- a/sdk/api/handlers/handlers.go +++ b/sdk/api/handlers/handlers.go @@ -16,6 +16,7 @@ import ( "time" "github.com/gin-gonic/gin" + . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" @@ -291,6 +292,17 @@ func requestExecutionMetadata(ctx context.Context) map[string]any { return meta } +func addAuthSelectionModelMetadata(meta map[string]any, model string) { + if meta == nil { + return + } + model = strings.TrimSpace(model) + if model == "" { + return + } + meta[coreexecutor.AuthSelectionModelMetadataKey] = model +} + func setReasoningEffortMetadata(meta map[string]any, handlerType, model string, rawJSON []byte) { if meta == nil { return @@ -710,15 +722,20 @@ func (h *BaseAPIHandler) executeWithAuthManagerFormats(ctx context.Context, entr originalRequestedModel := modelName routeDecision := h.applyModelRouter(ctx, entryProtocol, modelName, rawJSON, false, execOptions) responseProtocol := modelExecutionResponseProtocol(entryProtocol, exitProtocol) + if errMsg := validateNativeInteractionsExecution(entryProtocol, execOptions, routeDecision); errMsg != nil { + return nil, nil, errMsg + } if routeDecision.ExecutorPluginID != "" { return h.executeWithPluginExecutor(ctx, entryProtocol, responseProtocol, modelName, originalRequestedModel, rawJSON, alt, routeDecision.ExecutorPluginID, execOptions) } - providers, normalizedModel, errMsg := h.providersForExecution(modelName, originalRequestedModel, allowImageModel, routeDecision) + providers, normalizedModel, errMsg := h.providersForExecution(modelName, originalRequestedModel, allowImageModel, routeDecision, execOptions) if errMsg != nil { return nil, nil, errMsg } + providers = adjustExecutionProvidersForEntryProtocol(entryProtocol, providers) reqMeta := requestExecutionMetadata(ctx) reqMeta[coreexecutor.RequestedModelMetadataKey] = originalRequestedModel + addAuthSelectionModelMetadata(reqMeta, execOptions.AuthSelectionModel) addModelExecutionSourceMetadata(reqMeta, execOptions.InternalSource) setReasoningEffortMetadata(reqMeta, entryProtocol, normalizedModel, rawJSON) setServiceTierMetadata(reqMeta, rawJSON) @@ -779,12 +796,14 @@ func (h *BaseAPIHandler) executeCountWithAuthManager(ctx context.Context, handle if routeDecision.ExecutorPluginID != "" { return h.countWithPluginExecutor(ctx, handlerType, modelName, originalRequestedModel, rawJSON, alt, routeDecision.ExecutorPluginID, execOptions) } - providers, normalizedModel, errMsg := h.providersForExecution(modelName, originalRequestedModel, false, routeDecision) + providers, normalizedModel, errMsg := h.providersForExecution(modelName, originalRequestedModel, false, routeDecision, execOptions) if errMsg != nil { return nil, nil, errMsg } + providers = adjustExecutionProvidersForEntryProtocol(handlerType, providers) reqMeta := requestExecutionMetadata(ctx) reqMeta[coreexecutor.RequestedModelMetadataKey] = originalRequestedModel + addAuthSelectionModelMetadata(reqMeta, execOptions.AuthSelectionModel) setReasoningEffortMetadata(reqMeta, handlerType, normalizedModel, rawJSON) setServiceTierMetadata(reqMeta, rawJSON) payload := rawJSON @@ -870,6 +889,7 @@ func (h *BaseAPIHandler) countWithPluginExecutor(ctx context.Context, handlerTyp func (h *BaseAPIHandler) pluginExecutorRequest(ctx context.Context, entryProtocol, responseProtocol, modelName, originalRequestedModel string, rawJSON []byte, alt string, stream bool, execOptions modelExecutionOptions) (coreexecutor.Request, coreexecutor.Options) { reqMeta := requestExecutionMetadata(ctx) reqMeta[coreexecutor.RequestedModelMetadataKey] = originalRequestedModel + addAuthSelectionModelMetadata(reqMeta, execOptions.AuthSelectionModel) addModelExecutionSourceMetadata(reqMeta, execOptions.InternalSource) setReasoningEffortMetadata(reqMeta, entryProtocol, modelName, rawJSON) setServiceTierMetadata(reqMeta, rawJSON) @@ -1097,18 +1117,26 @@ func (h *BaseAPIHandler) executeStreamWithAuthManagerFormats(ctx context.Context originalRequestedModel := modelName routeDecision := h.applyModelRouter(ctx, entryProtocol, modelName, rawJSON, true, execOptions) responseProtocol := modelExecutionResponseProtocol(entryProtocol, exitProtocol) + if errMsg := validateNativeInteractionsExecution(entryProtocol, execOptions, routeDecision); errMsg != nil { + errChan := make(chan *interfaces.ErrorMessage, 1) + errChan <- errMsg + close(errChan) + return nil, nil, errChan + } if routeDecision.ExecutorPluginID != "" { return h.streamWithPluginExecutor(ctx, entryProtocol, responseProtocol, modelName, originalRequestedModel, rawJSON, alt, routeDecision.ExecutorPluginID, execOptions) } - providers, normalizedModel, errMsg := h.providersForExecution(modelName, originalRequestedModel, allowImageModel, routeDecision) + providers, normalizedModel, errMsg := h.providersForExecution(modelName, originalRequestedModel, allowImageModel, routeDecision, execOptions) if errMsg != nil { errChan := make(chan *interfaces.ErrorMessage, 1) errChan <- errMsg close(errChan) return nil, nil, errChan } + providers = adjustExecutionProvidersForEntryProtocol(entryProtocol, providers) reqMeta := requestExecutionMetadata(ctx) reqMeta[coreexecutor.RequestedModelMetadataKey] = originalRequestedModel + addAuthSelectionModelMetadata(reqMeta, execOptions.AuthSelectionModel) addModelExecutionSourceMetadata(reqMeta, execOptions.InternalSource) setReasoningEffortMetadata(reqMeta, entryProtocol, normalizedModel, rawJSON) setServiceTierMetadata(reqMeta, rawJSON) @@ -1418,6 +1446,68 @@ func validateSSEDataJSON(chunk []byte) error { return nil } +func preferExecutionProvider(providers []string, preferred string) []string { + preferred = strings.ToLower(strings.TrimSpace(preferred)) + if preferred == "" || len(providers) < 2 { + return providers + } + preferredIndex := -1 + for i := range providers { + if strings.ToLower(strings.TrimSpace(providers[i])) == preferred { + preferredIndex = i + break + } + } + if preferredIndex <= 0 { + return providers + } + out := make([]string, 0, len(providers)) + out = append(out, providers[preferredIndex]) + out = append(out, providers[:preferredIndex]...) + out = append(out, providers[preferredIndex+1:]...) + return out +} + +func adjustExecutionProvidersForEntryProtocol(entryProtocol string, providers []string) []string { + if entryProtocol == Interactions { + return preferExecutionProvider(providers, GeminiInteractions) + } + if supportsNativeInteractionsEntryProtocol(entryProtocol) { + return providers + } + return excludeExecutionProvider(providers, GeminiInteractions) +} + +func supportsNativeInteractionsEntryProtocol(entryProtocol string) bool { + switch entryProtocol { + case Interactions, OpenAI, OpenaiResponse, Claude, Gemini: + return true + default: + return false + } +} + +func excludeExecutionProvider(providers []string, excluded string) []string { + excluded = strings.ToLower(strings.TrimSpace(excluded)) + if excluded == "" || len(providers) == 0 { + return providers + } + excludedIndex := -1 + for i := range providers { + if strings.ToLower(strings.TrimSpace(providers[i])) == excluded { + excludedIndex = i + break + } + } + if excludedIndex == -1 { + return providers + } + out := make([]string, 0, len(providers)-1) + out = append(out, providers[:excludedIndex]...) + out = append(out, providers[excludedIndex+1:]...) + return out +} + func statusFromError(err error) int { if err == nil { return 0 @@ -1434,10 +1524,48 @@ func (h *BaseAPIHandler) getRequestDetails(modelName string) (providers []string return h.getRequestDetailsWithOptions(modelName, false) } +func validateNativeInteractionsExecution(entryProtocol string, execOptions modelExecutionOptions, routeDecision modelRouteDecision) *interfaces.ErrorMessage { + forcedProvider := strings.ToLower(strings.TrimSpace(execOptions.ForcedProvider)) + if forcedProvider == "" || entryProtocol != Interactions { + return nil + } + if routeDecision.ExecutorPluginID != "" { + return nativeInteractionsExecutionError() + } + if routeProvider := strings.ToLower(strings.TrimSpace(routeDecision.Provider)); routeProvider != "" && routeProvider != forcedProvider { + return nativeInteractionsExecutionError() + } + return nil +} + +func nativeInteractionsExecutionError() *interfaces.ErrorMessage { + return &interfaces.ErrorMessage{ + StatusCode: http.StatusBadRequest, + Error: fmt.Errorf("agent is only supported for native interactions execution"), + } +} + // providersForExecution resolves the providers and normalized model for a request. When a model // router selected a built-in provider, it skips model->provider resolution and uses the router's // provider (with an optional target model); otherwise it falls back to the registry-based path. -func (h *BaseAPIHandler) providersForExecution(modelName, originalRequestedModel string, allowImageModel bool, routeDecision modelRouteDecision) ([]string, string, *interfaces.ErrorMessage) { +func (h *BaseAPIHandler) providersForExecution(modelName, originalRequestedModel string, allowImageModel bool, routeDecision modelRouteDecision, execOptions modelExecutionOptions) ([]string, string, *interfaces.ErrorMessage) { + forcedProvider := strings.ToLower(strings.TrimSpace(execOptions.ForcedProvider)) + if forcedProvider != "" { + if routeDecision.ExecutorPluginID != "" { + return nil, "", nativeInteractionsExecutionError() + } + if routeProvider := strings.ToLower(strings.TrimSpace(routeDecision.Provider)); routeProvider != "" && routeProvider != forcedProvider { + return nil, "", nativeInteractionsExecutionError() + } + normalizedModel := strings.TrimSpace(modelName) + if normalizedModel == "" { + normalizedModel = strings.TrimSpace(originalRequestedModel) + } + if errMsg := h.validateImageOnlyModel(normalizedModel, allowImageModel); errMsg != nil { + return nil, "", errMsg + } + return []string{forcedProvider}, normalizedModel, nil + } if routeDecision.Provider != "" { normalizedModel := originalRequestedModel if routeDecision.Model != "" { diff --git a/sdk/api/handlers/handlers_model_router_test.go b/sdk/api/handlers/handlers_model_router_test.go index 5a7587222..f631f1d46 100644 --- a/sdk/api/handlers/handlers_model_router_test.go +++ b/sdk/api/handlers/handlers_model_router_test.go @@ -455,7 +455,7 @@ func TestExecuteModelPropagatesRouterSkipPluginID(t *testing.T) { func TestHandlerProvidersForExecutionUsesRouterProvider(t *testing.T) { handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) decision := modelRouteDecision{Provider: "claude", Model: "claude-sonnet-4"} - providers, normalizedModel, errMsg := handler.providersForExecution("ignored-by-router", "original-model", false, decision) + providers, normalizedModel, errMsg := handler.providersForExecution("ignored-by-router", "original-model", false, decision, modelExecutionOptions{}) if errMsg != nil { t.Fatalf("providersForExecution() error = %+v", errMsg) } @@ -470,7 +470,7 @@ func TestHandlerProvidersForExecutionUsesRouterProvider(t *testing.T) { func TestHandlerProvidersForExecutionFallsBackToOriginalModel(t *testing.T) { handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) decision := modelRouteDecision{Provider: "claude"} - providers, normalizedModel, errMsg := handler.providersForExecution("ignored-by-router", "original-model", false, decision) + providers, normalizedModel, errMsg := handler.providersForExecution("ignored-by-router", "original-model", false, decision, modelExecutionOptions{}) if errMsg != nil { t.Fatalf("providersForExecution() error = %+v", errMsg) } @@ -532,7 +532,7 @@ func TestHandlerProvidersForExecutionRejectsImageOnlyModelOnProviderRoute(t *tes } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - _, _, errMsg := handler.providersForExecution("ignored", tc.originalModel, false, tc.decision) + _, _, errMsg := handler.providersForExecution("ignored", tc.originalModel, false, tc.decision, modelExecutionOptions{}) if errMsg == nil || errMsg.StatusCode != http.StatusServiceUnavailable { t.Fatalf("providersForExecution() error = %+v, want image-only service unavailable", errMsg) } diff --git a/sdk/api/handlers/model_execution.go b/sdk/api/handlers/model_execution.go index be072ba05..32194f7c6 100644 --- a/sdk/api/handlers/model_execution.go +++ b/sdk/api/handlers/model_execution.go @@ -20,6 +20,22 @@ type modelExecutionOptions struct { InternalSource bool SkipInterceptorPluginID string SkipRouterPluginID string + ForcedProvider string + AuthSelectionModel string +} + +// ProtocolExecutionRequest describes a route-level model execution request with explicit protocols. +type ProtocolExecutionRequest struct { + EntryProtocol string + ExitProtocol string + ForcedProvider string + AuthSelectionModel string + Model string + Stream bool + Body []byte + Headers http.Header + Query url.Values + Alt string } // ModelExecutionRequest describes an internal model execution request. @@ -125,6 +141,49 @@ func (h *BaseAPIHandler) ExecuteModelStream(ctx context.Context, req ModelExecut }, nil } +// ExecuteProtocolWithAuthManager executes a route-level non-streaming request with explicit protocols. +func (h *BaseAPIHandler) ExecuteProtocolWithAuthManager(ctx context.Context, req ProtocolExecutionRequest) (ModelExecutionResponse, *interfaces.ErrorMessage) { + if req.Stream { + return ModelExecutionResponse{}, modelExecutionModeError("ExecuteProtocolWithAuthManager requires Stream=false") + } + body, headers, errMsg := h.executeWithAuthManagerFormats(ctx, req.EntryProtocol, req.ExitProtocol, req.Model, cloneBytes(req.Body), req.Alt, false, modelExecutionOptions{ + Headers: req.Headers, + Query: req.Query, + ForcedProvider: req.ForcedProvider, + AuthSelectionModel: req.AuthSelectionModel, + }) + if errMsg != nil { + return ModelExecutionResponse{}, errMsg + } + return ModelExecutionResponse{ + StatusCode: http.StatusOK, + Headers: cloneHeader(headers), + Body: cloneBytes(body), + }, nil +} + +// ExecuteProtocolStreamWithAuthManager executes a route-level streaming request with explicit protocols. +func (h *BaseAPIHandler) ExecuteProtocolStreamWithAuthManager(ctx context.Context, req ProtocolExecutionRequest) (ModelExecutionStream, *interfaces.ErrorMessage) { + if !req.Stream { + return ModelExecutionStream{}, modelExecutionModeError("ExecuteProtocolStreamWithAuthManager requires Stream=true") + } + dataChan, headers, errChan := h.executeStreamWithAuthManagerFormats(ctx, req.EntryProtocol, req.ExitProtocol, req.Model, cloneBytes(req.Body), req.Alt, false, modelExecutionOptions{ + Headers: req.Headers, + Query: req.Query, + ForcedProvider: req.ForcedProvider, + AuthSelectionModel: req.AuthSelectionModel, + }) + chunks, errMsg := prepareModelExecutionStream(ctx, dataChan, errChan) + if errMsg != nil { + return ModelExecutionStream{}, errMsg + } + return ModelExecutionStream{ + StatusCode: http.StatusOK, + Headers: cloneHeader(headers), + Chunks: chunks, + }, nil +} + func modelExecutionModeError(message string) *interfaces.ErrorMessage { return &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: errors.New(message)} } diff --git a/sdk/api/handlers/model_execution_test.go b/sdk/api/handlers/model_execution_test.go index 37f98d10a..e83337a21 100644 --- a/sdk/api/handlers/model_execution_test.go +++ b/sdk/api/handlers/model_execution_test.go @@ -5,10 +5,12 @@ import ( "fmt" "net/http" "net/url" + "strings" "sync" "testing" "time" + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" @@ -518,3 +520,269 @@ func TestExecuteModelStreamContextCancel(t *testing.T) { t.Fatal("stream chunks did not close after context cancellation") } } + +func TestExecuteProtocolWithAuthManagerUsesForcedProvider(t *testing.T) { + model := "interactions-agent-target" + requestBody := []byte(`{"agent":"agents/test-agent","input":"hi"}`) + executor := &modelExecutionCaptureExecutor{ + provider: "gemini", + execute: func(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) { + return coreexecutor.Response{Payload: []byte(`{"id":"interaction_1"}`)}, nil + }, + } + handler := newModelExecutionHandler(t, model, executor, &sdkconfig.SDKConfig{}) + + resp, errMsg := handler.ExecuteProtocolWithAuthManager(context.Background(), ProtocolExecutionRequest{ + EntryProtocol: "interactions", + ExitProtocol: "interactions", + ForcedProvider: "gemini", + Model: model, + Body: requestBody, + }) + if errMsg != nil { + t.Fatalf("ExecuteProtocolWithAuthManager() error = %+v", errMsg) + } + if string(resp.Body) != `{"id":"interaction_1"}` { + t.Fatalf("body = %q, want native interactions response", resp.Body) + } + + gotReq, gotOpts := executor.captured() + if gotReq.Model != model { + t.Fatalf("executor model = %q, want %q", gotReq.Model, model) + } + if gotOpts.SourceFormat != sdktranslator.FormatInteractions { + t.Fatalf("SourceFormat = %q, want %q", gotOpts.SourceFormat, sdktranslator.FormatInteractions) + } + if gotOpts.ResponseFormat != sdktranslator.FormatInteractions { + t.Fatalf("ResponseFormat = %q, want %q", gotOpts.ResponseFormat, sdktranslator.FormatInteractions) + } + if gotOpts.Metadata[coreexecutor.RequestedModelMetadataKey] != model { + t.Fatalf("requested model metadata = %#v, want %q", gotOpts.Metadata[coreexecutor.RequestedModelMetadataKey], model) + } +} + +func TestPreferExecutionProviderMovesPreferredFirst(t *testing.T) { + providers := preferExecutionProvider([]string{"gemini", "gemini-interactions", "claude"}, "gemini-interactions") + want := []string{"gemini-interactions", "gemini", "claude"} + if len(providers) != len(want) { + t.Fatalf("providers = %#v, want %#v", providers, want) + } + for i := range want { + if providers[i] != want[i] { + t.Fatalf("providers = %#v, want %#v", providers, want) + } + } +} + +func TestAdjustExecutionProvidersExcludesInteractionsProviderForUnsupportedEntry(t *testing.T) { + providers := adjustExecutionProvidersForEntryProtocol("codex", []string{"gemini-interactions", "codex"}) + want := []string{"codex"} + if len(providers) != len(want) { + t.Fatalf("providers = %#v, want %#v", providers, want) + } + for i := range want { + if providers[i] != want[i] { + t.Fatalf("providers = %#v, want %#v", providers, want) + } + } +} + +func TestAdjustExecutionProvidersKeepsInteractionsProviderForSupportedNativeInteractionsEntries(t *testing.T) { + for _, entryProtocol := range []string{constant.OpenAI, constant.OpenaiResponse, constant.Claude, constant.Gemini} { + t.Run(entryProtocol, func(t *testing.T) { + providers := adjustExecutionProvidersForEntryProtocol(entryProtocol, []string{"gemini-interactions"}) + want := []string{"gemini-interactions"} + if len(providers) != len(want) { + t.Fatalf("providers = %#v, want %#v", providers, want) + } + for i := range want { + if providers[i] != want[i] { + t.Fatalf("providers = %#v, want %#v", providers, want) + } + } + }) + } +} + +func TestExecuteModelStreamKeepsInteractionsProviderForOpenAIEntry(t *testing.T) { + model := "gemini-3.1-flash-lite" + requestBody := []byte(`{"model":"gemini-3.1-flash-lite","stream":true,"messages":[{"role":"user","content":"hi"}]}`) + executor := &modelExecutionCaptureExecutor{ + provider: constant.GeminiInteractions, + stream: func(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) { + chunks := make(chan coreexecutor.StreamChunk, 1) + chunks <- coreexecutor.StreamChunk{Payload: []byte(`{"id":"chunk_1","object":"chat.completion.chunk","choices":[]}`)} + close(chunks) + return &coreexecutor.StreamResult{Chunks: chunks}, nil + }, + } + handler := newModelExecutionHandler(t, model, executor, &sdkconfig.SDKConfig{}) + + stream, errMsg := handler.ExecuteModelStream(context.Background(), ModelExecutionRequest{ + EntryProtocol: constant.OpenAI, + ExitProtocol: constant.OpenAI, + Model: model, + Stream: true, + Body: requestBody, + }) + if errMsg != nil { + t.Fatalf("ExecuteModelStream() error = %+v", errMsg) + } + for range stream.Chunks { + } + gotReq, gotOpts := executor.captured() + if gotReq.Model != model { + t.Fatalf("executor model = %q, want %q", gotReq.Model, model) + } + if gotOpts.SourceFormat != sdktranslator.FormatOpenAI { + t.Fatalf("SourceFormat = %q, want %q", gotOpts.SourceFormat, sdktranslator.FormatOpenAI) + } +} + +func TestExecuteProtocolWithAuthManagerAgentUsesSelectionModelForAuth(t *testing.T) { + selectionModel := "gemini-2.5-flash" + agentModel := "agents/test-agent" + requestBody := []byte(`{"agent":"agents/test-agent","input":"hi"}`) + executor := &modelExecutionCaptureExecutor{ + provider: constant.GeminiInteractions, + execute: func(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) { + return coreexecutor.Response{Payload: []byte(`{"id":"interaction_1"}`)}, nil + }, + } + manager := coreauth.NewManager(nil, nil, nil) + manager.RegisterExecutor(executor) + auth := &coreauth.Auth{ + ID: "model-execution-agent-selection", + Provider: constant.GeminiInteractions, + Status: coreauth.StatusActive, + Metadata: map[string]any{"email": "agent-selection@example.com"}, + } + registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: selectionModel}, {ID: agentModel}}) + t.Cleanup(func() { + registry.GetGlobalRegistry().UnregisterClient(auth.ID) + }) + if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { + t.Fatalf("manager.Register(): %v", errRegister) + } + manager.RefreshSchedulerEntry(auth.ID) + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager) + + resp, errMsg := handler.ExecuteProtocolWithAuthManager(context.Background(), ProtocolExecutionRequest{ + EntryProtocol: "interactions", + ExitProtocol: "interactions", + ForcedProvider: constant.GeminiInteractions, + AuthSelectionModel: selectionModel, + Model: agentModel, + Body: requestBody, + }) + if errMsg != nil { + t.Fatalf("ExecuteProtocolWithAuthManager() error = %+v", errMsg) + } + if string(resp.Body) != `{"id":"interaction_1"}` { + t.Fatalf("body = %q, want native interactions response", resp.Body) + } + gotReq, gotOpts := executor.captured() + if gotReq.Model != agentModel { + t.Fatalf("executor model = %q, want %q", gotReq.Model, agentModel) + } + if string(gotReq.Payload) != string(requestBody) { + t.Fatalf("executor payload = %q, want %q", gotReq.Payload, requestBody) + } + if gotOpts.Metadata[coreexecutor.AuthSelectionModelMetadataKey] != selectionModel { + t.Fatalf("auth selection metadata = %#v, want %q", gotOpts.Metadata[coreexecutor.AuthSelectionModelMetadataKey], selectionModel) + } +} + +func TestExecuteProtocolStreamWithAuthManagerAgentUsesSelectionModelForAuth(t *testing.T) { + selectionModel := "gemini-2.5-flash" + agentModel := "agents/test-agent" + requestBody := []byte(`{"agent":"agents/test-agent","input":"hi","stream":true}`) + executor := &modelExecutionCaptureExecutor{ + provider: constant.GeminiInteractions, + stream: func(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) { + chunks := make(chan coreexecutor.StreamChunk, 1) + chunks <- coreexecutor.StreamChunk{Payload: []byte(`{"id":"interaction_1"}`)} + close(chunks) + return &coreexecutor.StreamResult{Chunks: chunks}, nil + }, + } + manager := coreauth.NewManager(nil, nil, nil) + manager.RegisterExecutor(executor) + auth := &coreauth.Auth{ + ID: "model-execution-agent-stream-selection", + Provider: constant.GeminiInteractions, + Status: coreauth.StatusActive, + Metadata: map[string]any{"email": "agent-stream-selection@example.com"}, + } + registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: selectionModel}, {ID: agentModel}}) + t.Cleanup(func() { + registry.GetGlobalRegistry().UnregisterClient(auth.ID) + }) + if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { + t.Fatalf("manager.Register(): %v", errRegister) + } + manager.RefreshSchedulerEntry(auth.ID) + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager) + + stream, errMsg := handler.ExecuteProtocolStreamWithAuthManager(context.Background(), ProtocolExecutionRequest{ + EntryProtocol: "interactions", + ExitProtocol: "interactions", + ForcedProvider: constant.GeminiInteractions, + AuthSelectionModel: selectionModel, + Model: agentModel, + Stream: true, + Body: requestBody, + }) + if errMsg != nil { + t.Fatalf("ExecuteProtocolStreamWithAuthManager() error = %+v", errMsg) + } + chunk, ok := <-stream.Chunks + if !ok { + t.Fatal("stream chunks closed before payload") + } + if chunk.Err != nil { + t.Fatalf("stream chunk error = %+v", chunk.Err) + } + if string(chunk.Payload) != `{"id":"interaction_1"}` { + t.Fatalf("stream chunk payload = %q, want native interactions response", chunk.Payload) + } + gotReq, gotOpts := executor.captured() + if gotReq.Model != agentModel { + t.Fatalf("executor model = %q, want %q", gotReq.Model, agentModel) + } + if string(gotReq.Payload) != string(requestBody) { + t.Fatalf("executor payload = %q, want %q", gotReq.Payload, requestBody) + } + if gotOpts.Metadata[coreexecutor.AuthSelectionModelMetadataKey] != selectionModel { + t.Fatalf("auth selection metadata = %#v, want %q", gotOpts.Metadata[coreexecutor.AuthSelectionModelMetadataKey], selectionModel) + } +} + +func TestProvidersForExecutionForcedGeminiRejectsRouterProvider(t *testing.T) { + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + decision := modelRouteDecision{Provider: "claude", Model: "claude-sonnet-4"} + _, _, errMsg := handler.providersForExecution("agents/test-agent", "agents/test-agent", false, decision, modelExecutionOptions{ForcedProvider: "gemini"}) + if errMsg == nil { + t.Fatal("providersForExecution() error = nil, want native interactions error") + } + if errMsg.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", errMsg.StatusCode, http.StatusBadRequest) + } + if errMsg.Error == nil || !strings.Contains(errMsg.Error.Error(), "native interactions") { + t.Fatalf("error = %v, want native interactions message", errMsg.Error) + } +} + +func TestProvidersForExecutionForcedGeminiUsesGeminiProvider(t *testing.T) { + handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil) + providers, model, errMsg := handler.providersForExecution("agents/test-agent", "agents/test-agent", false, modelRouteDecision{}, modelExecutionOptions{ForcedProvider: "gemini"}) + if errMsg != nil { + t.Fatalf("providersForExecution() error = %+v", errMsg) + } + if len(providers) != 1 || providers[0] != "gemini" { + t.Fatalf("providers = %#v, want [gemini]", providers) + } + if model != "agents/test-agent" { + t.Fatalf("model = %q, want agents/test-agent", model) + } +} diff --git a/sdk/cliproxy/auth/api_key_model_alias_test.go b/sdk/cliproxy/auth/api_key_model_alias_test.go index 165315638..b918ff8d4 100644 --- a/sdk/cliproxy/auth/api_key_model_alias_test.go +++ b/sdk/cliproxy/auth/api_key_model_alias_test.go @@ -66,6 +66,27 @@ func TestLookupAPIKeyUpstreamModel(t *testing.T) { } } +func TestLookupAPIKeyUpstreamModel_InteractionsKey(t *testing.T) { + cfg := &internalconfig.Config{ + InteractionsKey: []internalconfig.GeminiKey{{ + APIKey: "interactions-key", + BaseURL: "https://interactions.example.com", + Models: []internalconfig.GeminiModel{{Name: "gemini-2.5-flash", Alias: "native-flash"}}, + }}, + } + + mgr := NewManager(nil, nil, nil) + mgr.SetConfig(cfg) + + ctx := context.Background() + _, _ = mgr.Register(ctx, &Auth{ID: "interactions-auth", Provider: "gemini-interactions", Attributes: map[string]string{"api_key": "interactions-key", "base_url": "https://interactions.example.com"}}) + + resolved := mgr.lookupAPIKeyUpstreamModel("interactions-auth", "native-flash") + if resolved != "gemini-2.5-flash" { + t.Fatalf("lookupAPIKeyUpstreamModel() = %q, want gemini-2.5-flash", resolved) + } +} + func TestAPIKeyModelAlias_ConfigHotReload(t *testing.T) { cfg := &internalconfig.Config{ GeminiKey: []internalconfig.GeminiKey{ diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index 842ef7ca0..00e4d4e51 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -1282,6 +1282,10 @@ func (m *Manager) resolveAPIKeyModelAliasWithResult(auth *Auth, requestedModel s if entry := resolveGeminiAPIKeyConfig(cfg, auth); entry != nil { models = asModelAliasEntries(entry.Models) } + case "gemini-interactions": + if entry := resolveInteractionsAPIKeyConfig(cfg, auth); entry != nil { + models = asModelAliasEntries(entry.Models) + } case "claude": if entry := resolveClaudeAPIKeyConfig(cfg, auth); entry != nil { models = asModelAliasEntries(entry.Models) @@ -1819,7 +1823,7 @@ func (m *Manager) wrapStreamResult(ctx context.Context, auth *Auth, provider, re return &cliproxyexecutor.StreamResult{Headers: headers, Chunks: out} } -func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor ProviderExecutor, auth *Auth, provider string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, routeModel string, execModels []string, pooled bool, aliasResult OAuthModelAliasResult) (*cliproxyexecutor.StreamResult, error) { +func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor ProviderExecutor, auth *Auth, provider string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, routeModel, executionModel string, execModels []string, pooled bool, aliasResult OAuthModelAliasResult) (*cliproxyexecutor.StreamResult, error) { if executor == nil { return nil, &Error{Code: "executor_not_found", Message: "executor not registered"} } @@ -1829,6 +1833,9 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi resultModel := m.stateModelForExecution(auth, routeModel, execModel, pooled) execReq := req execReq.Model = execModel + if executionModel != "" { + execReq.Model = executionModel + } execOpts := opts execReq, execOpts = applyRequestAfterAuthInterceptor(ctx, executor, provider, execReq, execOpts, requestedModelAliasFromOptions(execOpts, routeModel)) streamResult, errStream := executor.ExecuteStream(ctx, auth, execReq, execOpts) @@ -1960,6 +1967,10 @@ func (m *Manager) rebuildAPIKeyModelAliasLocked(cfg *internalconfig.Config) { if entry := resolveGeminiAPIKeyConfig(cfg, auth); entry != nil { compileAPIKeyModelAliasForModels(byAlias, entry.Models) } + case "gemini-interactions": + if entry := resolveInteractionsAPIKeyConfig(cfg, auth); entry != nil { + compileAPIKeyModelAliasForModels(byAlias, entry.Models) + } case "claude": if entry := resolveClaudeAPIKeyConfig(cfg, auth); entry != nil { compileAPIKeyModelAliasForModels(byAlias, entry.Models) @@ -2278,13 +2289,14 @@ func (m *Manager) Execute(ctx context.Context, providers []string, req cliproxye _, maxRetryCredentials, maxWait := m.retrySettings() var lastErr error + retryModel := authSelectionModelFromOptions(opts, req.Model) for attempt := 0; ; attempt++ { resp, errExec := m.executeMixedOnce(ctx, normalized, req, opts, maxRetryCredentials) if errExec == nil { return resp, nil } lastErr = errExec - wait, shouldRetry := m.shouldRetryAfterError(errExec, attempt, normalized, req.Model, maxWait) + wait, shouldRetry := m.shouldRetryAfterError(errExec, attempt, normalized, retryModel, maxWait) if !shouldRetry { break } @@ -2315,13 +2327,14 @@ func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req clip _, maxRetryCredentials, maxWait := m.retrySettings() var lastErr error + retryModel := authSelectionModelFromOptions(opts, req.Model) for attempt := 0; ; attempt++ { resp, errExec := m.executeCountMixedOnce(ctx, normalized, req, opts, maxRetryCredentials) if errExec == nil { return resp, nil } lastErr = errExec - wait, shouldRetry := m.shouldRetryAfterError(errExec, attempt, normalized, req.Model, maxWait) + wait, shouldRetry := m.shouldRetryAfterError(errExec, attempt, normalized, retryModel, maxWait) if !shouldRetry { break } @@ -2346,13 +2359,14 @@ func (m *Manager) ExecuteStream(ctx context.Context, providers []string, req cli _, maxRetryCredentials, maxWait := m.retrySettings() var lastErr error + retryModel := authSelectionModelFromOptions(opts, req.Model) for attempt := 0; ; attempt++ { result, errStream := m.executeStreamMixedOnce(ctx, normalized, req, opts, maxRetryCredentials) if errStream == nil { return result, nil } lastErr = errStream - wait, shouldRetry := m.shouldRetryAfterError(errStream, attempt, normalized, req.Model, maxWait) + wait, shouldRetry := m.shouldRetryAfterError(errStream, attempt, normalized, retryModel, maxWait) if !shouldRetry { break } @@ -2472,7 +2486,8 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req if len(providers) == 0 { return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"} } - routeModel := req.Model + routeModel := authSelectionModelFromOptions(opts, req.Model) + executionModel, restoreExecutionModel := executionModelForAuthSelection(opts, req.Model) opts = ensureRequestedModelMetadata(opts, routeModel) homeMode := m.HomeEnabled() homeAuthCount := 1 @@ -2499,7 +2514,7 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req } entry := logEntryWithRequestID(ctx) - debugLogAuthSelection(entry, auth, provider, req.Model) + debugLogAuthSelection(entry, auth, provider, routeModel) publishSelectedAuthMetadata(opts.Metadata, auth.ID) tried[auth.ID] = struct{}{} @@ -2531,6 +2546,9 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req resultModel := m.stateModelForExecution(auth, routeModel, upstreamModel, pooled) execReq := req execReq.Model = upstreamModel + if restoreExecutionModel { + execReq.Model = executionModel + } execOpts := opts execReq, execOpts = applyRequestAfterAuthInterceptor(execCtx, executor, provider, execReq, execOpts, requestedModelAliasFromOptions(execOpts, routeModel)) resp, errExec := executor.Execute(execCtx, auth, execReq, execOpts) @@ -2574,7 +2592,8 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, if len(providers) == 0 { return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"} } - routeModel := req.Model + routeModel := authSelectionModelFromOptions(opts, req.Model) + executionModel, restoreExecutionModel := executionModelForAuthSelection(opts, req.Model) opts = ensureRequestedModelMetadata(opts, routeModel) homeMode := m.HomeEnabled() homeAuthCount := 1 @@ -2601,7 +2620,7 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, } entry := logEntryWithRequestID(ctx) - debugLogAuthSelection(entry, auth, provider, req.Model) + debugLogAuthSelection(entry, auth, provider, routeModel) publishSelectedAuthMetadata(opts.Metadata, auth.ID) tried[auth.ID] = struct{}{} @@ -2633,6 +2652,9 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string, resultModel := m.stateModelForExecution(auth, routeModel, upstreamModel, pooled) execReq := req execReq.Model = upstreamModel + if restoreExecutionModel { + execReq.Model = executionModel + } execOpts := opts execReq, execOpts = applyRequestAfterAuthInterceptor(execCtx, executor, provider, execReq, execOpts, requestedModelAliasFromOptions(execOpts, routeModel)) resp, errExec := executor.CountTokens(execCtx, auth, execReq, execOpts) @@ -2676,7 +2698,8 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string if len(providers) == 0 { return nil, &Error{Code: "provider_not_found", Message: "no provider supplied"} } - routeModel := req.Model + routeModel := authSelectionModelFromOptions(opts, req.Model) + executionModel, restoreExecutionModel := executionModelForAuthSelection(opts, req.Model) opts = ensureRequestedModelMetadata(opts, routeModel) homeMode := m.HomeEnabled() homeAuthCount := 1 @@ -2703,7 +2726,7 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string } entry := logEntryWithRequestID(ctx) - debugLogAuthSelection(entry, auth, provider, req.Model) + debugLogAuthSelection(entry, auth, provider, routeModel) publishSelectedAuthMetadata(opts.Metadata, auth.ID) tried[auth.ID] = struct{}{} @@ -2729,7 +2752,11 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string continue } execReq := sanitizeDownstreamWebsocketFallbackRequest(execCtx, auth, req) - streamResult, errStream := m.executeStreamWithModelPool(execCtx, executor, auth, provider, execReq, opts, routeModel, models, pooled, aliasResult) + streamExecutionModel := "" + if restoreExecutionModel { + streamExecutionModel = executionModel + } + streamResult, errStream := m.executeStreamWithModelPool(execCtx, executor, auth, provider, execReq, opts, routeModel, streamExecutionModel, models, pooled, aliasResult) if errStream != nil { if errCtx := execCtx.Err(); errCtx != nil { return nil, errCtx @@ -2780,6 +2807,40 @@ func ensureRequestedModelMetadata(opts cliproxyexecutor.Options, requestedModel return opts } +func authSelectionModelFromOptions(opts cliproxyexecutor.Options, fallback string) string { + fallback = strings.TrimSpace(fallback) + if len(opts.Metadata) == 0 { + return fallback + } + raw, ok := opts.Metadata[cliproxyexecutor.AuthSelectionModelMetadataKey] + if !ok || raw == nil { + return fallback + } + switch value := raw.(type) { + case string: + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + case []byte: + if strings.TrimSpace(string(value)) != "" { + return strings.TrimSpace(string(value)) + } + } + return fallback +} + +func executionModelForAuthSelection(opts cliproxyexecutor.Options, model string) (string, bool) { + model = strings.TrimSpace(model) + if model == "" { + return "", false + } + selectionModel := authSelectionModelFromOptions(opts, model) + if selectionModel == model { + return "", false + } + return model, true +} + func withHomeAuthCount(opts cliproxyexecutor.Options, count int) cliproxyexecutor.Options { if count <= 0 { count = 1 @@ -3073,6 +3134,8 @@ func (m *Manager) applyAPIKeyModelAlias(auth *Auth, requestedModel string) strin switch provider { case "gemini": upstreamModel = resolveUpstreamModelForGeminiAPIKey(cfg, auth, requestedModel) + case "gemini-interactions": + upstreamModel = resolveUpstreamModelForInteractionsAPIKey(cfg, auth, requestedModel) case "claude": upstreamModel = resolveUpstreamModelForClaudeAPIKey(cfg, auth, requestedModel) case "codex": @@ -3142,6 +3205,13 @@ func resolveGeminiAPIKeyConfig(cfg *internalconfig.Config, auth *Auth) *internal return resolveAPIKeyConfig(cfg.GeminiKey, auth) } +func resolveInteractionsAPIKeyConfig(cfg *internalconfig.Config, auth *Auth) *internalconfig.GeminiKey { + if cfg == nil { + return nil + } + return resolveAPIKeyConfig(cfg.InteractionsKey, auth) +} + func resolveClaudeAPIKeyConfig(cfg *internalconfig.Config, auth *Auth) *internalconfig.ClaudeKey { if cfg == nil { return nil @@ -3171,6 +3241,14 @@ func resolveUpstreamModelForGeminiAPIKey(cfg *internalconfig.Config, auth *Auth, return resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models)) } +func resolveUpstreamModelForInteractionsAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string { + entry := resolveInteractionsAPIKeyConfig(cfg, auth) + if entry == nil { + return "" + } + return resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models)) +} + func resolveUpstreamModelForClaudeAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string { entry := resolveClaudeAPIKeyConfig(cfg, auth) if entry == nil { @@ -5227,7 +5305,7 @@ func (m *Manager) tryAntigravityCreditsExecuteStream(ctx context.Context, req cl if len(models) == 0 { continue } - result, errStream := m.executeStreamWithModelPool(creditsCtx, c.executor, c.auth, c.provider, req, creditsOpts, routeModel, models, pooled, aliasResult) + result, errStream := m.executeStreamWithModelPool(creditsCtx, c.executor, c.auth, c.provider, req, creditsOpts, routeModel, "", models, pooled, aliasResult) if errStream != nil { continue } diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index 4926ddc12..60688899e 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -357,6 +357,8 @@ func (a *Auth) indexSeed() string { apiPrefix = "openai-compatibility" case strings.EqualFold(provider, "gemini"): apiPrefix = "gemini-api-key" + case strings.EqualFold(provider, "gemini-interactions"): + apiPrefix = "interactions-api-key" case strings.EqualFold(provider, "codex"): apiPrefix = "codex-api-key" case strings.EqualFold(provider, "claude"): diff --git a/sdk/cliproxy/executor/types.go b/sdk/cliproxy/executor/types.go index e27a821b9..ae3f18817 100644 --- a/sdk/cliproxy/executor/types.go +++ b/sdk/cliproxy/executor/types.go @@ -18,6 +18,9 @@ const RequestPathMetadataKey = "request_path" // DisallowFreeAuthMetadataKey instructs auth selection to skip known free-tier credentials. const DisallowFreeAuthMetadataKey = "disallow_free_auth" +// AuthSelectionModelMetadataKey overrides the model used only for auth selection. +const AuthSelectionModelMetadataKey = "auth_selection_model" + // ReasoningEffortMetadataKey stores the client-requested reasoning effort for usage logs. const ReasoningEffortMetadataKey = "reasoning_effort" diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 9b512788e..d1040c960 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -13,6 +13,7 @@ import ( "time" "github.com/router-for-me/CLIProxyAPI/v7/internal/api" + "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" "github.com/router-for-me/CLIProxyAPI/v7/internal/home" "github.com/router-for-me/CLIProxyAPI/v7/internal/homeplugins" "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" @@ -986,7 +987,8 @@ func baselineExecutorAuths() []*coreauth.Auth { providers := []string{ "codex", "claude", - "gemini", + constant.Gemini, + constant.GeminiInteractions, "vertex", "aistudio", "antigravity", @@ -1062,8 +1064,10 @@ func (s *Service) registerExecutorForAuth(a *coreauth.Auth, forceReplace bool) { return } switch strings.ToLower(a.Provider) { - case "gemini": + case constant.Gemini: s.coreManager.RegisterExecutor(executor.NewGeminiExecutor(s.cfg)) + case constant.GeminiInteractions: + s.coreManager.RegisterExecutor(executor.NewGeminiInteractionsExecutor(s.cfg)) case "vertex": s.coreManager.RegisterExecutor(executor.NewGeminiVertexExecutor(s.cfg)) case "aistudio": @@ -1940,7 +1944,7 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut } var models []*ModelInfo switch provider { - case "gemini": + case constant.Gemini: models = registry.GetGeminiModels() if entry := s.resolveConfigGeminiKey(a); entry != nil { if len(entry.Models) > 0 { @@ -1951,6 +1955,17 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut } } models = applyExcludedModels(models, excluded) + case constant.GeminiInteractions: + models = registry.GetGeminiModels() + if entry := s.resolveConfigInteractionsKey(a); entry != nil { + if len(entry.Models) > 0 { + models = buildGeminiConfigModels(entry) + } + if authKind == "apikey" { + excluded = entry.ExcludedModels + } + } + models = applyExcludedModels(models, excluded) case "vertex": // Vertex AI Gemini supports the same model identifiers as Gemini. models = registry.GetGeminiVertexModels() @@ -2221,6 +2236,20 @@ func (s *Service) resolveConfigClaudeKey(auth *coreauth.Auth) *config.ClaudeKey } func (s *Service) resolveConfigGeminiKey(auth *coreauth.Auth) *config.GeminiKey { + if s == nil || s.cfg == nil { + return nil + } + return s.resolveConfigGeminiKeyEntry(auth, s.cfg.GeminiKey) +} + +func (s *Service) resolveConfigInteractionsKey(auth *coreauth.Auth) *config.GeminiKey { + if s == nil || s.cfg == nil { + return nil + } + return s.resolveConfigGeminiKeyEntry(auth, s.cfg.InteractionsKey) +} + +func (s *Service) resolveConfigGeminiKeyEntry(auth *coreauth.Auth, entries []config.GeminiKey) *config.GeminiKey { if auth == nil || s.cfg == nil { return nil } @@ -2229,8 +2258,8 @@ func (s *Service) resolveConfigGeminiKey(auth *coreauth.Auth) *config.GeminiKey attrKey = strings.TrimSpace(auth.Attributes["api_key"]) attrBase = strings.TrimSpace(auth.Attributes["base_url"]) } - for i := range s.cfg.GeminiKey { - entry := &s.cfg.GeminiKey[i] + for i := range entries { + entry := &entries[i] cfgKey := strings.TrimSpace(entry.APIKey) cfgBase := strings.TrimSpace(entry.BaseURL) if attrKey != "" && strings.EqualFold(cfgKey, attrKey) { diff --git a/sdk/cliproxy/service_executor_registration_test.go b/sdk/cliproxy/service_executor_registration_test.go index 5366fa09a..11d997d6d 100644 --- a/sdk/cliproxy/service_executor_registration_test.go +++ b/sdk/cliproxy/service_executor_registration_test.go @@ -79,6 +79,7 @@ func TestRegisterAvailableExecutors(t *testing.T) { "codex", "claude", "gemini", + "gemini-interactions", "vertex", "aistudio", "antigravity", diff --git a/sdk/cliproxy/types.go b/sdk/cliproxy/types.go index d6c2b3990..a7d8cffeb 100644 --- a/sdk/cliproxy/types.go +++ b/sdk/cliproxy/types.go @@ -52,7 +52,8 @@ type APIKeyClientProvider interface { // APIKeyClientResult is returned by APIKeyClientProvider.Load() type APIKeyClientResult struct { - // GeminiKeyCount is the number of Gemini API keys loaded + // GeminiKeyCount is the number of Gemini-family API keys loaded. + // It includes native Interactions API keys. GeminiKeyCount int // VertexCompatKeyCount is the number of Vertex-compatible API keys loaded diff --git a/sdk/translator/formats.go b/sdk/translator/formats.go index d03bbf74d..4cdf5bfc3 100644 --- a/sdk/translator/formats.go +++ b/sdk/translator/formats.go @@ -8,4 +8,5 @@ const ( FormatGemini Format = "gemini" FormatCodex Format = "codex" FormatAntigravity Format = "antigravity" + FormatInteractions Format = "interactions" ) diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index fa0e3313f..b959b385c 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -12,6 +12,7 @@ import ( _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/claude" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/codex" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/gemini" + _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/interactions" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/kimi" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/openai" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking/provider/xai" @@ -2363,6 +2364,30 @@ func TestThinkingE2ENewProviderTargets(t *testing.T) { expectField: "reasoning.effort", expectValue: "high", }, + + // Interactions target: native API uses generation_config.thinking_level and thinking_summaries. + { + name: "I1", + from: "interactions", + to: "interactions", + model: "gemini-zero-mixed-model", + inputJSON: `{"model":"gemini-zero-mixed-model","generation_config":{"thinking_level":"high","thinking_summaries":"auto"},"input":"hi"}`, + expectField: "generation_config.thinking_level", + expectValue: "high", + expectField2: "generation_config.thinking_summaries", + expectValue2: "auto", + }, + { + name: "I2", + from: "interactions", + to: "interactions", + model: "gemini-zero-mixed-model(8192)", + inputJSON: `{"model":"gemini-zero-mixed-model(8192)","input":"hi"}`, + expectField: "generation_config.thinking_level", + expectValue: "medium", + expectField2: "generation_config.thinking_summaries", + expectValue2: "auto", + }, } runThinkingTests(t, cases)