From 3ebee065161b38ebfdff8f4fa51fcfd50c552c40 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 2 Oct 2026 03:48:53 +0800 Subject: [PATCH] fix(interactions): bridge apply_patch tool calls in responses translation - Handle apply_patch custom tool call streaming deltas and snapshots for Responses API translation - Track and resolve step indexes, function call identities, and error states across stream events - Propagate apply_patch tool input errors and upstream failure states in stream and non-stream responses --- .../api/server_models_interceptor_test.go | 115 ++- internal/api/server_routes.go | 29 +- internal/api/server_test.go | 276 +++++- internal/client/codex/apply-patch/tool.go | 117 +++ .../client/codex/apply-patch/tool_test.go | 338 ++++++++ internal/client/codex/models/apply_patch.go | 50 ++ .../client/codex/models/apply_patch_test.go | 140 +++ internal/client/codex/models/models.go | 28 +- internal/client/codex/models/models_test.go | 12 +- .../plugin_refresh_compat_executor.go | 9 + .../plugin_refresh_compat_executor_test.go | 33 + .../runtime/executor/aistudio_executor.go | 43 +- .../executor/aistudio_executor_test.go | 201 +++++ .../runtime/executor/antigravity_executor.go | 3 + .../executor/antigravity_executor_execute.go | 19 +- .../executor/antigravity_executor_stream.go | 17 +- .../executor/apply_patch_bridge_test.go | 702 +++++++++++++++ .../executor/apply_patch_capability_test.go | 35 + .../executor/apply_patch_identity_test.go | 372 ++++++++ .../executor/apply_patch_integration_test.go | 731 ++++++++++++++++ .../executor/apply_patch_repair_test.go | 623 +++++++++++++ .../executor/apply_patch_source_stop_test.go | 81 ++ internal/runtime/executor/claude_executor.go | 3 + .../executor/claude_executor_execute.go | 14 +- .../executor/claude_executor_stream.go | 12 +- internal/runtime/executor/codex_executor.go | 3 + .../executor/codex_executor_execute.go | 18 +- .../executor/codex_websockets_executor.go | 5 + internal/runtime/executor/devin_executor.go | 101 ++- .../runtime/executor/devin_executor_test.go | 86 ++ internal/runtime/executor/gemini_executor.go | 51 +- .../runtime/executor/gemini_executor_test.go | 17 +- .../executor/gemini_vertex_executor.go | 57 +- .../executor/gemini_vertex_executor_test.go | 113 +++ .../runtime/executor/helps/apply_patch.go | 99 +++ .../executor/helps/apply_patch_responses.go | 619 +++++++++++++ .../helps/apply_patch_responses_test.go | 497 +++++++++++ .../executor/helps/apply_patch_test.go | 126 +++ .../executor/helps/claude_input_tokens.go | 3 + internal/runtime/executor/kimi_executor.go | 122 ++- .../runtime/executor/kimi_executor_test.go | 71 ++ internal/runtime/executor/meta_executor.go | 3 + .../runtime/executor/meta_executor_execute.go | 48 +- .../runtime/executor/meta_executor_stream.go | 48 +- .../runtime/executor/meta_executor_test.go | 4 + .../executor/openai_compat_executor.go | 25 +- internal/runtime/executor/xai_executor.go | 3 + .../runtime/executor/xai_executor_execute.go | 97 ++- .../runtime/executor/xai_executor_request.go | 16 +- .../runtime/executor/xai_executor_stream.go | 79 +- .../runtime/executor/xai_executor_test.go | 466 +++++++++- .../executor/xai_websockets_executor.go | 219 +++-- .../executor/xai_websockets_executor_test.go | 160 ++++ ...igravity_openai-responses_response_test.go | 40 + .../claude_openai-responses_request.go | 17 +- .../claude_openai-responses_request_test.go | 43 +- .../claude_openai-responses_response.go | 475 ++++++++-- .../claude_openai-responses_response_test.go | 816 ++++++++++++++++++ .../chat-completions/codex_openai_request.go | 10 +- .../codex_openai_request_test.go | 29 +- .../chat-completions/codex_openai_response.go | 66 +- .../codex_openai_response_test.go | 72 ++ .../codex_openai-responses_response.go | 56 +- .../codex_openai-responses_response_test.go | 30 + .../translator/common/apply_patch_events.go | 74 ++ .../common/apply_patch_events_test.go | 174 ++++ .../common/apply_patch_identity_test.go | 164 ++++ .../translator/common/apply_patch_input.go | 284 ++++++ .../common/apply_patch_input_test.go | 337 ++++++++ .../common/apply_patch_responses.go | 716 +++++++++++++++ .../common/apply_patch_responses_test.go | 410 +++++++++ .../responses/apply_patch_review_test.go | 181 ++++ .../openai/responses/apply_patch_test.go | 281 ++++++ .../gemini_openai-responses_response.go | 262 +++++- .../responses/apply_patch_identity_test.go | 235 +++++ .../responses/apply_patch_rereview_test.go | 219 +++++ .../responses/apply_patch_review_test.go | 603 +++++++++++++ .../responses/apply_patch_source_stop_test.go | 221 +++++ .../responses/apply_patch_test.go | 398 +++++++++ .../interactions_openai_responses_request.go | 11 +- .../interactions_openai_responses_response.go | 764 +++++++++++++--- .../openai_openai-responses_request_test.go | 23 + .../openai_openai-responses_response.go | 183 +++- .../openai_openai-responses_response_test.go | 448 ++++++++++ .../openai_openai-responses_tools.go | 11 + internal/util/nocopy_invariant_test.go | 1 + internal/util/responses_tools.go | 20 +- internal/util/responses_tools_test.go | 41 + sdk/api/handlers/apply_patch_capability.go | 13 + .../handlers/apply_patch_capability_test.go | 51 ++ .../handlers/openai/codex_client_models.go | 3 +- .../openai/codex_client_models_test.go | 64 ++ sdk/cliproxy/auth/apply_patch_capability.go | 25 + .../auth/apply_patch_capability_test.go | 34 + sdk/translator/registry.go | 16 + sdk/translator/registry_test.go | 50 ++ 96 files changed, 14576 insertions(+), 554 deletions(-) create mode 100644 internal/client/codex/apply-patch/tool.go create mode 100644 internal/client/codex/apply-patch/tool_test.go create mode 100644 internal/client/codex/models/apply_patch.go create mode 100644 internal/client/codex/models/apply_patch_test.go create mode 100644 internal/runtime/executor/apply_patch_bridge_test.go create mode 100644 internal/runtime/executor/apply_patch_capability_test.go create mode 100644 internal/runtime/executor/apply_patch_identity_test.go create mode 100644 internal/runtime/executor/apply_patch_integration_test.go create mode 100644 internal/runtime/executor/apply_patch_repair_test.go create mode 100644 internal/runtime/executor/apply_patch_source_stop_test.go create mode 100644 internal/runtime/executor/gemini_vertex_executor_test.go create mode 100644 internal/runtime/executor/helps/apply_patch.go create mode 100644 internal/runtime/executor/helps/apply_patch_responses.go create mode 100644 internal/runtime/executor/helps/apply_patch_responses_test.go create mode 100644 internal/runtime/executor/helps/apply_patch_test.go create mode 100644 internal/translator/common/apply_patch_events.go create mode 100644 internal/translator/common/apply_patch_events_test.go create mode 100644 internal/translator/common/apply_patch_identity_test.go create mode 100644 internal/translator/common/apply_patch_input.go create mode 100644 internal/translator/common/apply_patch_input_test.go create mode 100644 internal/translator/common/apply_patch_responses.go create mode 100644 internal/translator/common/apply_patch_responses_test.go create mode 100644 internal/translator/gemini/openai/responses/apply_patch_review_test.go create mode 100644 internal/translator/gemini/openai/responses/apply_patch_test.go create mode 100644 internal/translator/openai/interactions/responses/apply_patch_identity_test.go create mode 100644 internal/translator/openai/interactions/responses/apply_patch_rereview_test.go create mode 100644 internal/translator/openai/interactions/responses/apply_patch_review_test.go create mode 100644 internal/translator/openai/interactions/responses/apply_patch_source_stop_test.go create mode 100644 internal/translator/openai/interactions/responses/apply_patch_test.go create mode 100644 sdk/api/handlers/apply_patch_capability.go create mode 100644 sdk/api/handlers/apply_patch_capability_test.go create mode 100644 sdk/cliproxy/auth/apply_patch_capability.go create mode 100644 sdk/cliproxy/auth/apply_patch_capability_test.go diff --git a/internal/api/server_models_interceptor_test.go b/internal/api/server_models_interceptor_test.go index 50b339766..3ff1a075a 100644 --- a/internal/api/server_models_interceptor_test.go +++ b/internal/api/server_models_interceptor_test.go @@ -9,6 +9,10 @@ import ( "testing" "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + "github.com/router-for-me/CLIProxyAPI/v8/internal/home" + "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor" "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" ) @@ -172,44 +176,83 @@ func TestModelsEndpoint_ExposesResponseToPluginInterceptors_Gemini(t *testing.T) } func TestModelsEndpoint_ExposesResponseToPluginInterceptors_CodexClientVersion(t *testing.T) { - server := newTestServer(t) - - var intercepted bool - var capturedReq pluginapi.ResponseInterceptRequest - - server.handlers.SetPluginHost(&mockModelListInterceptorHost{ - interceptResponse: func(_ context.Context, req pluginapi.ResponseInterceptRequest) pluginapi.ResponseInterceptResponse { - intercepted = true - capturedReq = req - var parsed map[string]any - if err := json.Unmarshal(req.Body, &parsed); err != nil { - t.Fatalf("failed to unmarshal original body: %v", err) + for _, useHome := range []bool{false, true} { + name := "local" + if useHome { + name = "home" + } + t.Run(name, func(t *testing.T) { + server := newTestServer(t) + server.handlers.AuthManager.RegisterExecutor(executor.NewOpenAICompatExecutor("interceptor-patch", &config.Config{})) + modelRegistry := registry.GetGlobalRegistry() + modelRegistry.RegisterClient("interceptor-patch-models", "interceptor-patch", []*registry.ModelInfo{{ID: "interceptor-patch-supported"}}) + modelRegistry.RegisterClient("interceptor-unknown-models", "remote", []*registry.ModelInfo{{ID: "interceptor-patch-unknown"}}) + t.Cleanup(func() { + modelRegistry.UnregisterClient("interceptor-patch-models") + modelRegistry.UnregisterClient("interceptor-unknown-models") + }) + if useHome { + server.cfg.Home.Enabled = true + client := newHomeCatalogClient(t, `{"interceptor-patch":[{"id":"interceptor-patch-supported"}],"remote":[{"id":"interceptor-patch-unknown"}]}`) + previousHome := home.Current() + home.SetCurrent(client) + t.Cleanup(func() { home.SetCurrent(previousHome) }) } - parsed["intercepted_by_plugin"] = true - modified, _ := json.Marshal(parsed) - return pluginapi.ResponseInterceptResponse{ - Headers: req.ResponseHeaders, - Body: modified, + intercepted := false + server.handlers.SetPluginHost(&mockModelListInterceptorHost{ + interceptResponse: func(_ context.Context, req pluginapi.ResponseInterceptRequest) pluginapi.ResponseInterceptResponse { + intercepted = true + if req.SourceFormat != "openai" { + t.Fatalf("SourceFormat = %q, want openai", req.SourceFormat) + } + var parsed map[string]any + if errUnmarshal := json.Unmarshal(req.Body, &parsed); errUnmarshal != nil { + t.Fatalf("failed to unmarshal original body: %v", errUnmarshal) + } + seen := 0 + for _, raw := range parsed["models"].([]any) { + entry := raw.(map[string]any) + switch entry["slug"] { + case "interceptor-patch-supported": + seen++ + if entry["apply_patch_tool_type"] != "freeform" { + t.Fatal("interceptor did not receive freeform capability") + } + case "interceptor-patch-unknown": + seen++ + assertCodexNullableCatalogField(t, entry, "apply_patch_tool_type") + } + } + if seen != 2 { + t.Fatalf("interceptor saw %d expected entries, want 2", seen) + } + parsed["intercepted_by_plugin"] = true + modified, errMarshal := json.Marshal(parsed) + if errMarshal != nil { + t.Fatalf("marshal intercepted catalog: %v", errMarshal) + } + return pluginapi.ResponseInterceptResponse{Headers: req.ResponseHeaders, Body: modified} + }, + }) + req := httptest.NewRequest(http.MethodGet, "/v1/models?client_version=0.137.0", nil) + req.Header.Set("Authorization", "Bearer test-key") + rr := httptest.NewRecorder() + engine := server.engine + if useHome { + engine = gin.New() + engine.GET("/v1/models", server.unifiedModelsHandler(nil, nil)) } - }, - }) - - req := httptest.NewRequest(http.MethodGet, "/v1/models?client_version=0.137.0", nil) - req.Header.Set("Authorization", "Bearer test-key") - rr := httptest.NewRecorder() - server.engine.ServeHTTP(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("status = %d, want %d body = %s", rr.Code, http.StatusOK, rr.Body.String()) - } - if !intercepted { - t.Fatal("plugin InterceptResponse was not called for /v1/models?client_version") - } - if capturedReq.SourceFormat != "openai" { - t.Fatalf("captured SourceFormat = %q, want %q", capturedReq.SourceFormat, "openai") - } - if !strings.Contains(rr.Body.String(), "intercepted_by_plugin") { - t.Fatalf("response body did not contain intercepted marker: %s", rr.Body.String()) + engine.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + if !intercepted { + t.Fatal("plugin InterceptResponse was not called for /v1/models?client_version") + } + if !strings.Contains(rr.Body.String(), "intercepted_by_plugin") { + t.Fatal("response body did not contain intercepted marker") + } + }) } } diff --git a/internal/api/server_routes.go b/internal/api/server_routes.go index 546ca7315..148086b46 100644 --- a/internal/api/server_routes.go +++ b/internal/api/server_routes.go @@ -690,7 +690,11 @@ func (s *Server) handleHomeCodexClientModels(c *gin.Context, clientVersion strin if clientVersion == "cpa" { webSearchCapabilityForModel = homeWebSearchCapabilityForModel(entries) } - payload := codexmodels.BuildResponseForClientWithCPACapabilities(models, nil, webSearchCapabilityForModel, s.cfg.Codex.OptimizeMultiAgentV2, clientVersion) + var manager *auth.Manager + if s.handlers != nil { + manager = s.handlers.AuthManager + } + payload := codexmodels.BuildResponseForClientWithToolCapabilities(models, nil, webSearchCapabilityForModel, homeApplyPatchCapabilityForModel(entries, manager), s.cfg.Codex.OptimizeMultiAgentV2, clientVersion) body, errMarshal := codexmodels.MarshalCompact(payload) if errMarshal != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": errMarshal.Error()}) @@ -699,6 +703,29 @@ func (s *Server) handleHomeCodexClientModels(c *gin.Context, clientVersion strin s.writeModelListResponse(c, "openai", body) } +// homeApplyPatchCapabilityForModel uses only Home's exact public routing evidence, +// never the local model registry or the template's metadata model ID. +func homeApplyPatchCapabilityForModel(entries []homeModelEntry, manager *auth.Manager) codexmodels.ApplyPatchCapabilityForModelFunc { + providersByID := make(map[string][]string, len(entries)) + for _, entry := range entries { + id := strings.TrimSpace(entry.id) + providersByID[id] = append(providersByID[id], entry.providers...) + if len(entry.providers) == 0 { + providersByID[id] = append(providersByID[id], "") + } + // The decoder omits empty section names from providers, but an unknown + // route must still veto support when combined with a known section. + for _, route := range entry.nativeCapabilityRoutes { + if strings.TrimSpace(route.Provider) == "" { + providersByID[id] = append(providersByID[id], "") + } + } + } + return func(id string) bool { + return manager.SupportsApplyPatchForProviders(providersByID[strings.TrimSpace(id)]) + } +} + func homeWebSearchCapabilityForModel(entries []homeModelEntry) codexmodels.WebSearchCapabilityForModelFunc { routesByID := make(map[string][]registry.NativeCapabilityRoute, len(entries)) for _, entry := range entries { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index a089f2833..45fdaf88b 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1,15 +1,19 @@ package api import ( + "bufio" "context" "encoding/json" "errors" + "fmt" "io" + "net" "net/http" "net/http/httptest" "os" "path/filepath" "reflect" + "strconv" "strings" "sync" "sync/atomic" @@ -2163,6 +2167,7 @@ func TestModelsWithClientVersionReturnsCodexCatalog(t *testing.T) { }) server := newTestServer(t) + server.handlers.AuthManager.RegisterExecutor(executor.NewCodexAutoExecutor(&proxyconfig.Config{})) req := httptest.NewRequest(http.MethodGet, "/v1/models?client_version", nil) req.Header.Set("Authorization", "Bearer test-key") @@ -2203,6 +2208,9 @@ func TestModelsWithClientVersionReturnsCodexCatalog(t *testing.T) { if gpt55 == nil { t.Fatal("expected gpt-5.5 codex catalog entry") } + if got := gpt55["apply_patch_tool_type"]; got != "freeform" { + t.Fatalf("gpt-5.5 apply_patch_tool_type = %#v, want freeform", got) + } if _, ok := gpt55["minimal_client_version"]; !ok { t.Fatal("expected minimal_client_version in codex catalog") } @@ -2250,7 +2258,9 @@ func TestModelsWithClientVersionReturnsCodexCatalog(t *testing.T) { if !ok || len(customServiceTiers) != 0 { t.Fatalf("expected custom model service_tiers = [], got %#v", custom["service_tiers"]) } - assertCodexNullableCatalogField(t, custom, "apply_patch_tool_type") + if value, present := custom["apply_patch_tool_type"]; !present || value != "freeform" { + t.Fatalf("custom apply_patch_tool_type = %#v (present %v), want freeform", value, present) + } assertCodexNullableCatalogField(t, custom, "upgrade") assertCodexNullableCatalogField(t, custom, "availability_nux") @@ -3352,3 +3362,267 @@ func assertSerializedCPAWebSearch(t *testing.T, model map[string]any, want bool) t.Fatalf("model %v web_search = %#v, want %v", model["slug"], capabilities["web_search"], want) } } + +// modelCatalogUnknownExecutor implements only the mandatory provider contract. +type modelCatalogUnknownExecutor struct{ auth.ProviderExecutor } + +type modelCatalogUnsupportedExecutor struct{ auth.ProviderExecutor } + +func (modelCatalogUnsupportedExecutor) SupportsApplyPatch() bool { return false } + +func TestHomeApplyPatchCapabilityForModel(t *testing.T) { + entries, errDecodeHomeModels := decodeHomeModels([]byte(`{ + "codex":[{"id":"home-patch-native"},{"id":"home-patch-mixed"},{"id":"home-patch-partial"},{"id":"home-patch-blank"}], + "home-custom":[{"id":"home-patch-synthetic"},{"id":"home-patch-mixed"}], + "remote":[{"id":"home-patch-remote"},{"id":"home-patch-partial"}], + "plugin":[{"id":"home-patch-plugin"}], + "disabled":[{"id":"home-patch-disabled"}], + "":[{"id":"home-patch-blank"}] + }`)) + if errDecodeHomeModels != nil { + t.Fatalf("decode Home models: %v", errDecodeHomeModels) + } + entries = append(entries, homeModelEntry{id: "home-patch-no-routes"}) + manager := auth.NewManager(nil, nil, nil) + cfg := &proxyconfig.Config{} + manager.RegisterExecutor(executor.NewCodexAutoExecutor(cfg)) + manager.RegisterExecutor(executor.NewOpenAICompatExecutor("home-custom", cfg)) + manager.RegisterExecutor(modelCatalogUnknownExecutor{executor.NewOpenAICompatExecutor("remote", cfg)}) + manager.RegisterExecutor(modelCatalogUnknownExecutor{executor.NewOpenAICompatExecutor("plugin", cfg)}) + manager.RegisterExecutor(modelCatalogUnsupportedExecutor{executor.NewOpenAICompatExecutor("disabled", cfg)}) + lookup := homeApplyPatchCapabilityForModel(entries, manager) + for _, tc := range []struct { + id string + want bool + }{ + {"home-patch-native", true}, {" home-patch-synthetic ", true}, {"home-patch-mixed", true}, + {"home-patch-remote", false}, {"home-patch-plugin", false}, {"home-patch-partial", false}, + {"home-patch-disabled", false}, {"home-patch-no-routes", false}, {"home-patch-blank", false}, + {"team/home-patch-native", false}, {"home-patch-native(high)", false}, {"missing", false}, + } { + if got := lookup(tc.id); got != tc.want { + t.Errorf("%q capability = %v, want %v", tc.id, got, tc.want) + } + if homeApplyPatchCapabilityForModel(entries, nil)(tc.id) { + t.Errorf("%q supported without a manager", tc.id) + } + } + // Multiple entries must never overwrite an unknown routing candidate. + duplicateLookup := homeApplyPatchCapabilityForModel([]homeModelEntry{ + {id: "duplicate", providers: []string{"remote"}}, + {id: "duplicate", providers: []string{"codex"}}, + }, manager) + if duplicateLookup("duplicate") || homeApplyPatchCapabilityForModel(nil, manager)("home-patch-native") { + t.Fatal("unknown or absent route was advertised") + } +} + +// newHomeCatalogClient serves the existing Home models protocol without new metadata. +func newHomeCatalogClient(t *testing.T, payload string) *home.Client { + t.Helper() + listener, errListen := net.Listen("tcp", "127.0.0.1:0") + if errListen != nil { + t.Fatalf("listen: %v", errListen) + } + done := make(chan struct{}) + var connections sync.WaitGroup + go func() { + defer close(done) + for { + conn, errAccept := listener.Accept() + if errAccept != nil { + return + } + connections.Add(1) + go func() { + defer connections.Done() + defer func() { + if errClose := conn.Close(); errClose != nil { + t.Errorf("close Home test connection: %v", errClose) + } + }() + reader := bufio.NewReader(conn) + for { + args, errReadRESPArrayOfBulkStrings := readRESPArrayOfBulkStrings(reader) + if errReadRESPArrayOfBulkStrings != nil || len(args) == 0 { + return + } + response := "+OK\r\n" + switch strings.ToUpper(string(args[0])) { + case "HELLO": + response = "-ERR unknown command 'hello'\r\n" + case "GET": + var request struct { + Type string `json:"type"` + } + if len(args) != 2 { + t.Error("invalid Home GET request") + return + } + if errUnmarshal := json.Unmarshal(args[1], &request); errUnmarshal != nil || request.Type != "models" { + t.Errorf("unexpected Home request %q: %v", args[1], errUnmarshal) + return + } + response = fmt.Sprintf("$%d\r\n%s\r\n", len(payload), payload) + } + if _, errWriteString := io.WriteString(conn, response); errWriteString != nil { + return + } + } + }() + } + }() + host, portText, errSplitHostPort := net.SplitHostPort(listener.Addr().String()) + if errSplitHostPort != nil { + t.Fatalf("split Home test address: %v", errSplitHostPort) + } + port, errAtoi := strconv.Atoi(portText) + if errAtoi != nil { + t.Fatalf("parse Home test port: %v", errAtoi) + } + client := home.New(proxyconfig.HomeConfig{Enabled: true, Host: host, Port: port, DisableClusterDiscovery: true}) + t.Cleanup(func() { + client.Close() + if errClose := listener.Close(); errClose != nil { + t.Errorf("close Home test listener: %v", errClose) + } + <-done + connections.Wait() + }) + return client +} + +func TestModelsWithClientVersionHomeApplyPatchRouting(t *testing.T) { + server := newTestServer(t) + server.cfg.Home.Enabled = true + // Exercise the production models router without unrelated heartbeat readiness. + engine := gin.New() + engine.GET("/v1/models", server.unifiedModelsHandler(nil, nil)) + manager := server.handlers.AuthManager + cfg := &proxyconfig.Config{} + manager.RegisterExecutor(executor.NewCodexAutoExecutor(cfg)) + manager.RegisterExecutor(executor.NewOpenAICompatExecutor("home-custom", cfg)) + manager.RegisterExecutor(modelCatalogUnknownExecutor{executor.NewOpenAICompatExecutor("remote", cfg)}) + modelRegistry := registry.GetGlobalRegistry() + // These local routes intentionally disagree with the Home routing evidence. + modelRegistry.RegisterClient("home-patch-local-native", "codex", []*registry.ModelInfo{{ID: "home-patch-local-only"}}) + modelRegistry.RegisterClient("home-patch-local-remote", "remote", []*registry.ModelInfo{{ID: "home-patch-supported"}}) + t.Cleanup(func() { + modelRegistry.UnregisterClient("home-patch-local-native") + modelRegistry.UnregisterClient("home-patch-local-remote") + }) + client := newHomeCatalogClient(t, `{ + "codex":[{"id":"gpt-5.5"},{"id":"gpt-reserve"},{"id":"gpt-image-2"},{"id":"home-patch-mixed"},{"id":"home-patch-partial"}], + "home-custom":[{"id":"home-patch-supported"},{"id":"home-patch-mixed"},{"id":"team/gpt-5.5"}], + "remote":[{"id":"home-patch-local-only"},{"id":"home-patch-partial"}] + }`) + previousHome := home.Current() + home.SetCurrent(client) + t.Cleanup(func() { home.SetCurrent(previousHome) }) + + for _, version := range []string{"", "0.137.0", "0.153.4", "cpa"} { + t.Run(version, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v1/models?client_version="+version, nil) + req.Header.Set("Authorization", "Bearer test-key") + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, req) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String()) + } + if strings.Contains(recorder.Body.String(), "\n") { + t.Fatal("Home catalog is not compact JSON") + } + var response struct { + Models []map[string]any `json:"models"` + } + if errUnmarshal := json.Unmarshal(recorder.Body.Bytes(), &response); errUnmarshal != nil { + t.Fatalf("decode Home catalog: %v", errUnmarshal) + } + entries := make(map[string]map[string]any) + for _, entry := range response.Models { + entries[entry["slug"].(string)] = entry + } + for _, id := range []string{"gpt-5.5", "gpt-reserve", "home-patch-supported", "home-patch-mixed", "team/gpt-5.5"} { + if value, present := entries[id]["apply_patch_tool_type"]; !present || value != "freeform" { + t.Errorf("%s apply_patch_tool_type = %#v (present %v), want freeform", id, value, present) + } + } + for _, id := range []string{"gpt-image-2", "home-patch-local-only", "home-patch-partial"} { + assertCodexNullableCatalogField(t, entries[id], "apply_patch_tool_type") + } + // Home retains its existing template and synthesized protocol restrictions. + if search, _ := entries["gpt-5.5"]["supports_search_tool"].(bool); !search { + t.Error("legacy Home template search support changed") + } + for _, id := range []string{"home-patch-supported", "home-patch-mixed"} { + entry := entries[id] + if entry["supports_search_tool"] != false || entry["prefer_websockets"] != false || len(entry["service_tiers"].([]any)) != 0 { + t.Errorf("%s unrelated protocol capabilities widened", id) + } + assertCodexNullableCatalogField(t, entry, "upgrade") + assertCodexNullableCatalogField(t, entry, "availability_nux") + } + }) + } + t.Run("missing-manager", func(t *testing.T) { + server.handlers.AuthManager = nil + defer func() { server.handlers.AuthManager = manager }() + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/v1/models?client_version=0.153.4", nil) + engine.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String()) + } + var response struct { + Models []map[string]any `json:"models"` + } + if errUnmarshal := json.Unmarshal(recorder.Body.Bytes(), &response); errUnmarshal != nil { + t.Fatalf("decode Home catalog: %v", errUnmarshal) + } + for _, entry := range response.Models { + assertCodexNullableCatalogField(t, entry, "apply_patch_tool_type") + } + }) + + request := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + request.Header.Set("Authorization", "Bearer test-key") + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"object":"list"`) || strings.Contains(recorder.Body.String(), "apply_patch_tool_type") { + t.Fatalf("ordinary Home catalog changed: %s", recorder.Body.String()) + } +} + +func TestModelsWithClientVersionApplyPatchRequiresExecutor(t *testing.T) { + modelRegistry := registry.GetGlobalRegistry() + clientID := "http-patch-missing-executor" + modelRegistry.RegisterClient(clientID, "codex", []*registry.ModelInfo{{ID: "gpt-5.5"}, {ID: "http-patch-synthetic"}}) + t.Cleanup(func() { modelRegistry.UnregisterClient(clientID) }) + server := newTestServer(t) + for _, version := range []string{"", "0.137.0", "0.153.4", "cpa"} { + request := httptest.NewRequest(http.MethodGet, "/v1/models?client_version="+version, nil) + request.Header.Set("Authorization", "Bearer test-key") + recorder := httptest.NewRecorder() + server.engine.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d", recorder.Code) + } + var response struct { + Models []map[string]any `json:"models"` + } + if errUnmarshal := json.Unmarshal(recorder.Body.Bytes(), &response); errUnmarshal != nil { + t.Fatalf("decode catalog: %v", errUnmarshal) + } + for _, entry := range response.Models { + assertCodexNullableCatalogField(t, entry, "apply_patch_tool_type") + } + } + server.handlers.AuthManager.RegisterExecutor(executor.NewCodexAutoExecutor(&proxyconfig.Config{})) + request := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + request.Header.Set("Authorization", "Bearer test-key") + recorder := httptest.NewRecorder() + server.engine.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"object":"list"`) || strings.Contains(recorder.Body.String(), "apply_patch_tool_type") { + t.Fatalf("ordinary local catalog changed: %s", recorder.Body.String()) + } +} diff --git a/internal/client/codex/apply-patch/tool.go b/internal/client/codex/apply-patch/tool.go new file mode 100644 index 000000000..602f819f7 --- /dev/null +++ b/internal/client/codex/apply-patch/tool.go @@ -0,0 +1,117 @@ +package applypatch + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/tidwall/gjson" +) + +const parametersJSON = `{"type":"object","properties":{"input":{"type":"string","description":"The complete apply_patch patch text."}},"required":["input"],"additionalProperties":false}` + +const patchInstructions = `Call this function with a JSON object whose input field contains the complete patch text. +Use the Codex apply_patch format, not a conventional git unified diff. +Start with *** Begin Patch and end with *** End Patch. +Use *** Add File: path, *** Delete File: path, or *** Update File: path. +Every added-file content line starts with +. +For updates, use @@; context lines start with one space, removed lines with -, and added lines with +. +Use *** Move to: path for a rename and *** End of File when required by the patch grammar. +Example input: +*** Begin Patch +*** Update File: src/main.go +@@ +-old ++new +*** End Patch` + +// IsCustomTool reports whether the declaration is the custom apply_patch tool. +func IsCustomTool(tool gjson.Result) bool { + return tool.Get("type").String() == "custom" && strings.TrimSpace(tool.Get("name").String()) == "apply_patch" +} + +// Parameters returns an independent copy of the patch input schema. +func Parameters() []byte { + return []byte(parametersJSON) +} + +// Description explains the JSON wrapper and preserves the original patch grammar. +func Description(tool gjson.Result) string { + original := strings.ReplaceAll(tool.Get("description").String(), "This is a FREEFORM tool, so do not wrap the patch in JSON.", "") + var description strings.Builder + if strings.TrimSpace(original) != "" { + description.WriteString(original) + description.WriteString("\n\n") + } + description.WriteString(patchInstructions) + grammar := tool.Get("format.definition").String() + if grammar != "" { + if strings.Contains(grammar, "*** Environment ID:") { + description.WriteString("\n\nUse *** Environment ID: as specified by the patch grammar.") + } + description.WriteString("\n\nOriginal patch grammar:\n") + description.WriteString(grammar) + } + return description.String() +} + +// WrapInput encodes the complete patch text as function arguments. +func WrapInput(input string) string { + body, _ := json.Marshal(struct { + Input string `json:"input"` + }{Input: input}) + return string(body) +} + +// UnwrapInput accepts only a JSON object containing one string field named input. +func UnwrapInput(arguments string) (string, error) { + decoder := json.NewDecoder(strings.NewReader(arguments)) + token, errToken := decoder.Token() + if errToken != nil { + return "", fmt.Errorf("decode apply_patch arguments object: %w", errToken) + } + if token != json.Delim('{') { + return "", fmt.Errorf("apply_patch arguments must be a JSON object") + } + + token, errToken = decoder.Token() + if errToken != nil { + return "", fmt.Errorf("decode apply_patch input key: %w", errToken) + } + if token != "input" { + return "", fmt.Errorf("apply_patch arguments must contain the input field") + } + + token, errToken = decoder.Token() + if errToken != nil { + return "", fmt.Errorf("decode apply_patch input value: %w", errToken) + } + input, ok := token.(string) + if !ok { + return "", fmt.Errorf("apply_patch input must be a string") + } + + token, errToken = decoder.Token() + if errToken != nil { + return "", fmt.Errorf("decode apply_patch arguments closing brace: %w", errToken) + } + if token != json.Delim('}') { + return "", fmt.Errorf("apply_patch arguments must contain only one input field") + } + + _, errToken = decoder.Token() + if errToken != io.EOF { + if errToken != nil { + return "", fmt.Errorf("decode apply_patch arguments end: %w", errToken) + } + return "", fmt.Errorf("apply_patch arguments must not contain trailing JSON") + } + return input, nil +} + +// EscapeInputFragment encodes patch text for use inside a JSON string without quotes. +func EscapeInputFragment(fragment string) string { + encoded, _ := json.Marshal(fragment) + return string(encoded[1 : len(encoded)-1]) +} diff --git a/internal/client/codex/apply-patch/tool_test.go b/internal/client/codex/apply-patch/tool_test.go new file mode 100644 index 000000000..7964c2e76 --- /dev/null +++ b/internal/client/codex/apply-patch/tool_test.go @@ -0,0 +1,338 @@ +package applypatch + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +func TestApplyPatchToolContract(t *testing.T) { + tool := gjson.Parse(`{"type":"custom","name":"apply_patch","description":"This is a FREEFORM tool, so do not wrap the patch in JSON.","format":{"type":"grammar","syntax":"lark","definition":"start: begin_patch hunk+ end_patch"}}`) + if !IsCustomTool(tool) { + t.Fatal("custom apply_patch was not recognized") + } + params := gjson.ParseBytes(Parameters()) + if params.Get("properties.input.type").String() != "string" || params.Get("additionalProperties").Bool() { + t.Fatalf("invalid schema: %s", Parameters()) + } + description := Description(tool) + for _, token := range []string{"*** Begin Patch", "*** End Patch", "*** Add File:", "*** Delete File:", "*** Update File:", "@@", "input", "start: begin_patch hunk+ end_patch"} { + if !strings.Contains(description, token) { + t.Fatalf("missing format instruction %q", token) + } + } + if strings.Contains(description, "do not wrap the patch in JSON") { + t.Fatal("freeform wrapper instruction leaked") + } + patch := "*** Begin Patch\n*** Add File: a.txt\n+hello\n*** End Patch\n" + got, errUnwrap := UnwrapInput(WrapInput(patch)) + if errUnwrap != nil || got != patch { + t.Fatalf("round trip: %q, %v", got, errUnwrap) + } +} + +func TestApplyPatchToolRecognition(t *testing.T) { + tests := []struct { + name string + tool string + want bool + }{ + {name: "custom", tool: `{"type":"custom","name":"apply_patch"}`, want: true}, + {name: "trimmed_name", tool: `{"type":"custom","name":" \tapply_patch\n"}`, want: true}, + {name: "ordinary_function", tool: `{"type":"function","name":"apply_patch"}`}, + {name: "nested_function", tool: `{"type":"function","function":{"name":"apply_patch"}}`}, + {name: "other_custom_tool", tool: `{"type":"custom","name":"shell"}`}, + {name: "case_sensitive_name", tool: `{"type":"custom","name":"APPLY_PATCH"}`}, + {name: "qualified_name", tool: `{"type":"custom","name":"functions.apply_patch"}`}, + {name: "exact_type", tool: `{"type":" custom ","name":"apply_patch"}`}, + {name: "missing_type", tool: `{"name":"apply_patch"}`}, + {name: "missing_name", tool: `{"type":"custom"}`}, + {name: "non_string_type", tool: `{"type":true,"name":"apply_patch"}`}, + {name: "non_string_name", tool: `{"type":"custom","name":42}`}, + {name: "null_name", tool: `{"type":"custom","name":null}`}, + {name: "empty_object", tool: `{}`}, + {name: "missing_tool", tool: ``}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := IsCustomTool(gjson.Parse(test.tool)); got != test.want { + t.Fatalf("IsCustomTool(%s) = %v, want %v", test.tool, got, test.want) + } + }) + } +} + +func TestApplyPatchToolParameters(t *testing.T) { + parameters := Parameters() + if !gjson.ValidBytes(parameters) { + t.Fatalf("invalid JSON schema: %s", parameters) + } + schema := gjson.ParseBytes(parameters) + if schema.Get("type").String() != "object" { + t.Fatalf("schema must describe an object: %s", parameters) + } + properties := schema.Get("properties").Map() + if len(properties) != 1 || properties["input"].Get("type").String() != "string" { + t.Fatalf("schema must expose only a string input: %s", parameters) + } + if properties["input"].Get("description").String() != "The complete apply_patch patch text." { + t.Fatalf("schema does not describe the complete patch input: %s", parameters) + } + required := schema.Get("required").Array() + if len(required) != 1 || required[0].String() != "input" { + t.Fatalf("input must be required: %s", parameters) + } + additional := schema.Get("additionalProperties") + if additional.Type != gjson.False { + t.Fatalf("additional properties must be explicitly forbidden: %s", parameters) + } +} + +func TestApplyPatchToolParametersAreIndependent(t *testing.T) { + first := Parameters() + second := Parameters() + first[0] = '[' + if second[0] != '{' || !gjson.ValidBytes(second) { + t.Fatalf("mutating one schema changed another: %s", second) + } + third := Parameters() + if third[0] != '{' || !gjson.ValidBytes(third) { + t.Fatalf("mutating one schema changed later calls: %s", third) + } +} + +func TestApplyPatchToolDescriptionRules(t *testing.T) { + tests := []struct { + name string + tool string + }{ + {name: "no_description_or_grammar", tool: `{"type":"custom","name":"apply_patch"}`}, + {name: "empty_description_and_grammar", tool: `{"description":"","format":{"definition":""}}`}, + {name: "format_without_definition", tool: `{"description":"Edit files.","format":{"type":"grammar","syntax":"lark"}}`}, + {name: "grammar_without_environment", tool: `{"format":{"definition":"start: begin_patch hunk+ end_patch"}}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + description := Description(gjson.Parse(test.tool)) + for _, instruction := range []string{ + "Call this function with a JSON object whose input field contains the complete patch text.", + "Use the Codex apply_patch format, not a conventional git unified diff.", + "Start with *** Begin Patch and end with *** End Patch.", + "Use *** Add File: path, *** Delete File: path, or *** Update File: path.", + "Every added-file content line starts with +.", + "For updates, use @@; context lines start with one space, removed lines with -, and added lines with +.", + "Use *** Move to: path for a rename and *** End of File when required by the patch grammar.", + "Example input:\n*** Begin Patch\n*** Update File: src/main.go\n@@\n-old\n+new\n*** End Patch", + } { + if !strings.Contains(description, instruction) { + t.Errorf("missing instruction %q in %q", instruction, description) + } + } + if strings.Contains(description, "*** Environment ID:") { + t.Fatalf("environment syntax invented without an environment grammar: %q", description) + } + }) + } +} + +func TestApplyPatchToolDescriptionPreservesNonWrapperInstructions(t *testing.T) { + tests := []struct { + name string + tool string + retained []string + }{ + { + name: "non_wrapper_description", + tool: `{"description":"Use apply_patch to edit files.\n Keep context lines intact.\nDo not run the patch on the proxy."}`, + retained: []string{"Use apply_patch to edit files.\n Keep context lines intact.\nDo not run the patch on the proxy."}, + }, + { + name: "wrapper_between_instructions", + tool: `{"description":"Edit files only. This is a FREEFORM tool, so do not wrap the patch in JSON. Preserve line endings.\n Keep indentation."}`, + retained: []string{"Edit files only.", "Preserve line endings.\n Keep indentation."}, + }, + { + name: "wrapper_only", + tool: `{"description":"This is a FREEFORM tool, so do not wrap the patch in JSON."}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tool := gjson.Parse(test.tool) + description := Description(tool) + if strings.Contains(description, "This is a FREEFORM tool") || strings.Contains(description, "do not wrap the patch in JSON") { + t.Fatalf("freeform wrapper instruction leaked: %q", description) + } + for _, retained := range test.retained { + if !strings.Contains(description, retained) { + t.Errorf("lost non-wrapper instruction %q in %q", retained, description) + } + } + if tool.Raw != test.tool { + t.Fatalf("description generation mutated the original tool: %s", tool.Raw) + } + }) + } +} + +func TestApplyPatchToolDescriptionPreservesGrammar(t *testing.T) { + tests := []struct { + name string + tool string + wantGrammar string + }{ + { + name: "whitespace_and_line_endings", + tool: `{"format":{"definition":" \nstart: begin_patch hunk+ end_patch\r\n begin_patch: \"*** Begin Patch\"\n\n"}}`, + wantGrammar: " \nstart: begin_patch hunk+ end_patch\r\n begin_patch: \"*** Begin Patch\"\n\n", + }, + { + name: "wrapper_text_inside_grammar_is_not_rewritten", + tool: `{"description":"This is a FREEFORM tool, so do not wrap the patch in JSON.","format":{"definition":"// This is a FREEFORM tool, so do not wrap the patch in JSON.\nstart: begin_patch hunk+ end_patch"}}`, + wantGrammar: "// This is a FREEFORM tool, so do not wrap the patch in JSON.\nstart: begin_patch hunk+ end_patch", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + description := Description(gjson.Parse(test.tool)) + if !strings.HasSuffix(description, test.wantGrammar) { + t.Fatalf("original grammar was not appended verbatim: %q", description) + } + beforeGrammar := strings.TrimSuffix(description, test.wantGrammar) + if strings.Contains(beforeGrammar, "do not wrap the patch in JSON") { + t.Fatalf("wrapper text leaked outside the original grammar: %q", beforeGrammar) + } + }) + } +} + +func TestApplyPatchToolDescriptionEnvironmentGrammar(t *testing.T) { + tool := gjson.Parse(`{"format":{"definition":"start: begin_patch environment_id? hunk+ end_patch\nenvironment_id: \"*** Environment ID:\" /[^\\n]+/\n"}}`) + description := Description(tool) + grammar := "start: begin_patch environment_id? hunk+ end_patch\nenvironment_id: \"*** Environment ID:\" /[^\\n]+/\n" + if !strings.HasSuffix(description, grammar) { + t.Fatalf("environment grammar changed: %q", description) + } + instructions := strings.TrimSuffix(description, grammar) + if !strings.Contains(instructions, "*** Environment ID:") { + t.Fatalf("environment syntax was not explained before its grammar: %q", instructions) + } +} + +func TestApplyPatchToolInputEncoding(t *testing.T) { + tests := []struct { + name string + input string + wantJSON string + wantEscaped string + }{ + {name: "empty", input: "", wantJSON: `{"input":""}`, wantEscaped: ``}, + {name: "plain", input: "patch", wantJSON: `{"input":"patch"}`, wantEscaped: `patch`}, + {name: "newlines_and_leading_spaces", input: " context\n+added\r\n\tline\n ", wantJSON: `{"input":" context\n+added\r\n\tline\n "}`, wantEscaped: ` context\n+added\r\n\tline\n `}, + {name: "quotes_and_backslashes", input: "\"C:\\file\"", wantJSON: `{"input":"\"C:\\file\""}`, wantEscaped: `\"C:\\file\"`}, + {name: "control_characters", input: "\x00\b\f\r\t", wantJSON: `{"input":"\u0000\b\f\r\t"}`, wantEscaped: `\u0000\b\f\r\t`}, + {name: "unicode", input: "补丁🙂", wantJSON: `{"input":"补丁🙂"}`, wantEscaped: `补丁🙂`}, + {name: "json_sensitive_characters", input: "<>&\u2028\u2029", wantJSON: `{"input":"\u003c\u003e\u0026\u2028\u2029"}`, wantEscaped: `\u003c\u003e\u0026\u2028\u2029`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := WrapInput(test.input); got != test.wantJSON { + t.Errorf("WrapInput(%q) = %q, want %q", test.input, got, test.wantJSON) + } + if got := EscapeInputFragment(test.input); got != test.wantEscaped { + t.Errorf("EscapeInputFragment(%q) = %q, want %q", test.input, got, test.wantEscaped) + } + got, errUnwrap := UnwrapInput(WrapInput(test.input)) + if errUnwrap != nil || got != test.input { + t.Fatalf("round trip = %q, %v, want %q", got, errUnwrap, test.input) + } + }) + } +} + +func TestApplyPatchToolEscapeInputFragmentsCompose(t *testing.T) { + fragments := []string{"*** Begin Patch\n", " context\n", "-\"old\"\r\n", "+C:\\new\n", "+补丁🙂\n", "*** End Patch\n"} + var encoded strings.Builder + for _, fragment := range fragments { + encoded.WriteString(EscapeInputFragment(fragment)) + } + var decoded struct { + Input string `json:"input"` + } + if errUnmarshal := json.Unmarshal([]byte(`{"input":"`+encoded.String()+`"}`), &decoded); errUnmarshal != nil { + t.Fatalf("escaped fragments did not form valid JSON: %v", errUnmarshal) + } + want := "*** Begin Patch\n context\n-\"old\"\r\n+C:\\new\n+补丁🙂\n*** End Patch\n" + if decoded.Input != want { + t.Fatalf("escaped fragments changed patch text: got %q, want %q", decoded.Input, want) + } +} + +func TestApplyPatchToolUnwrapInput(t *testing.T) { + tests := []struct { + name string + arguments string + want string + wantError bool + }{ + {name: "empty_input", arguments: `{"input":""}`, want: ""}, + {name: "plain_input", arguments: `{"input":"patch"}`, want: "patch"}, + {name: "whitespace_in_input", arguments: `{"input":" context\n\tline\r\n\n "}`, want: " context\n\tline\r\n\n "}, + {name: "json_whitespace", arguments: " \n\t{ \"input\" : \"patch\" }\r\n ", want: "patch"}, + {name: "escaped_field_name", arguments: `{"\u0069nput":"patch"}`, want: "patch"}, + {name: "unicode_input", arguments: `{"input":"补丁🙂"}`, want: "补丁🙂"}, + {name: "empty_arguments", arguments: "", wantError: true}, + {name: "whitespace_arguments", arguments: " \r\n\t ", wantError: true}, + {name: "missing_input", arguments: `{}`, wantError: true}, + {name: "wrong_field", arguments: `{"patch":"text"}`, wantError: true}, + {name: "case_sensitive_field", arguments: `{"Input":"text"}`, wantError: true}, + {name: "null_input", arguments: `{"input":null}`, wantError: true}, + {name: "boolean_input", arguments: `{"input":true}`, wantError: true}, + {name: "number_input", arguments: `{"input":1}`, wantError: true}, + {name: "array_input", arguments: `{"input":[]}`, wantError: true}, + {name: "object_input", arguments: `{"input":{}}`, wantError: true}, + {name: "extra_field_after_input", arguments: `{"input":"patch","other":"value"}`, wantError: true}, + {name: "extra_field_before_input", arguments: `{"other":"value","input":"patch"}`, wantError: true}, + {name: "duplicate_input", arguments: `{"input":"first","input":"second"}`, wantError: true}, + {name: "duplicate_identical_input", arguments: `{"input":"patch","input":"patch"}`, wantError: true}, + {name: "duplicate_escaped_input", arguments: `{"input":"first","\u0069nput":"second"}`, wantError: true}, + {name: "trailing_object", arguments: `{"input":"patch"}{"input":"second"}`, wantError: true}, + {name: "trailing_array", arguments: `{"input":"patch"} []`, wantError: true}, + {name: "trailing_null", arguments: `{"input":"patch"} null`, wantError: true}, + {name: "trailing_boolean", arguments: `{"input":"patch"} true`, wantError: true}, + {name: "trailing_garbage", arguments: `{"input":"patch"} invalid`, wantError: true}, + {name: "top_level_array", arguments: `["patch"]`, wantError: true}, + {name: "top_level_string", arguments: `"patch"`, wantError: true}, + {name: "top_level_null", arguments: `null`, wantError: true}, + {name: "top_level_number", arguments: `1`, wantError: true}, + {name: "top_level_boolean", arguments: `false`, wantError: true}, + {name: "raw_patch", arguments: "*** Begin Patch\n*** End Patch\n", wantError: true}, + {name: "missing_closing_brace", arguments: `{"input":"patch"`, wantError: true}, + {name: "missing_colon", arguments: `{"input" "patch"}`, wantError: true}, + {name: "missing_value", arguments: `{"input":}`, wantError: true}, + {name: "unterminated_string", arguments: `{"input":"patch}`, wantError: true}, + {name: "trailing_comma", arguments: `{"input":"patch",}`, wantError: true}, + {name: "unquoted_field", arguments: `{input:"patch"}`, wantError: true}, + {name: "unescaped_newline", arguments: "{\"input\":\"line\nline\"}", wantError: true}, + {name: "invalid_escape", arguments: `{"input":"\x"}`, wantError: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, errUnwrap := UnwrapInput(test.arguments) + if test.wantError { + if errUnwrap == nil { + t.Fatalf("UnwrapInput(%q) accepted an invalid wrapper: %q", test.arguments, got) + } + if got != "" { + t.Fatalf("invalid wrapper returned input or fallback text: %q", got) + } + return + } + if errUnwrap != nil || got != test.want { + t.Fatalf("UnwrapInput(%q) = %q, %v, want %q", test.arguments, got, errUnwrap, test.want) + } + }) + } +} diff --git a/internal/client/codex/models/apply_patch.go b/internal/client/codex/models/apply_patch.go new file mode 100644 index 000000000..601db6629 --- /dev/null +++ b/internal/client/codex/models/apply_patch.go @@ -0,0 +1,50 @@ +package models + +import "strings" + +// ApplyPatchCapabilityForModelFunc requires support from every actual routing +// candidate for the exact public model ID. false also represents unknown support. +type ApplyPatchCapabilityForModelFunc func(string) bool + +func applyCodexClientApplyPatchCapability(entry map[string]any, id string, capability ApplyPatchCapabilityForModelFunc) { + entry["apply_patch_tool_type"] = nil + if capability == nil { + return + } + // Built-in image/video IDs can inherit a generic text template in Home. + // This classification is a model restriction, not routing capability evidence. + baseID := strings.ToLower(strings.TrimSpace(id)) + if index := strings.LastIndex(baseID, "/"); index != -1 { + baseID = strings.TrimSpace(baseID[index+1:]) + } + if isCodexClientImageOrVideoModel(baseID) { + return + } + + // Hidden text models remain usable, but non-text catalog entries must not + // advertise a conversation tool even when their executor supports it. + supportsText := false + hasModalities := false + switch modalities := entry["input_modalities"].(type) { + case []any: + hasModalities = len(modalities) > 0 + for _, modality := range modalities { + if modality == "text" { + supportsText = true + } + } + case []string: + hasModalities = len(modalities) > 0 + for _, modality := range modalities { + if modality == "text" { + supportsText = true + } + } + } + if !supportsText && (hasModalities || entry["visibility"] == "hide") { + return + } + if capability(strings.TrimSpace(id)) { + entry["apply_patch_tool_type"] = "freeform" + } +} diff --git a/internal/client/codex/models/apply_patch_test.go b/internal/client/codex/models/apply_patch_test.go new file mode 100644 index 000000000..55b09f09a --- /dev/null +++ b/internal/client/codex/models/apply_patch_test.go @@ -0,0 +1,140 @@ +package models + +import ( + "reflect" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" +) + +func TestCodexCatalogApplyPatchCapability(t *testing.T) { + modelRegistry := registry.GetGlobalRegistry() + clientID := "catalog-patch-metadata-test" + modelRegistry.RegisterClient(clientID, "custom-compat", []*registry.ModelInfo{ + {ID: "catalog-patch-alias", MetadataModelID: "gpt-5.5"}, + {ID: "catalog-patch-image", Type: registry.OpenAIImageModelType}, + {ID: "catalog-patch-image-input", SupportedInputModalities: []string{"image"}}, + }) + t.Cleanup(func() { modelRegistry.UnregisterClient(clientID) }) + + for _, version := range []string{"", "0.137.0", "0.153.4", "cpa"} { + for _, model := range []struct { + id string + canText bool + }{ + {"gpt-5.5", true}, + {"custom-model", true}, + {"catalog-patch-alias", true}, + {"team/gpt-5.5", true}, + {"gpt-reserve", true}, + {"gpt-image-2", false}, + {"team/gpt-image-2", false}, + {"team/nested/gpt-image-2", false}, + {"GPT-IMAGE-2", false}, + {"grok-imagine-video", false}, + {"catalog-patch-image", false}, + {"catalog-patch-image-input", false}, + } { + for _, providerLookup := range []struct { + name string + get ProvidersForModelFunc + }{ + {"nil", nil}, + {"custom", func(string) []string { return []string{"custom-compat"} }}, + {"mixed", func(string) []string { return []string{"codex", "custom-compat"} }}, + } { + for _, capability := range []struct { + name string + get ApplyPatchCapabilityForModelFunc + want bool + }{ + {"nil", nil, false}, + {"unsupported", func(string) bool { return false }, false}, + {"supported", func(id string) bool { + if id != model.id { + t.Errorf("capability queried %q, want exact public ID %q", id, model.id) + } + return true + }, model.canText}, + } { + t.Run(version+"/"+model.id+"/"+providerLookup.name+"/"+capability.name, func(t *testing.T) { + available := []map[string]any{{"id": " " + model.id + " "}} + webSearch := func(string) *bool { supported := false; return &supported } + baseline := BuildResponseForClientWithCPACapabilities(available, providerLookup.get, webSearch, true, version) + response := BuildResponseForClientWithToolCapabilities(available, providerLookup.get, webSearch, capability.get, true, version) + entries := response["models"].([]map[string]any) + if len(entries) != 1 { + t.Fatalf("models count = %d, want 1", len(entries)) + } + entry := entries[0] + value, present := entry["apply_patch_tool_type"] + var want any + if capability.want { + want = "freeform" + } + if !present || value != want { + t.Fatalf("apply_patch_tool_type = %#v (present %v), want %#v", value, present, want) + } + // Capability opt-in must not modify any other catalog metadata. + delete(entry, "apply_patch_tool_type") + delete(baseline["models"].([]map[string]any)[0], "apply_patch_tool_type") + if !reflect.DeepEqual(response, baseline) { + t.Fatal("apply_patch opt-in changed unrelated metadata") + } + }) + } + } + } + } +} + +func TestCodexCatalogApplyPatchLegacyEntryPointsUnknown(t *testing.T) { + available := []map[string]any{{"id": "gpt-5.5"}, {"id": "custom-model"}, {"id": "gpt-reserve"}} + responses := []map[string]any{ + BuildResponse(available, nil, false), + BuildResponseForClient(available, nil, false, "0.153.4"), + BuildResponseForClientWithCPACapabilities(available, nil, nil, false, "cpa"), + } + for _, response := range responses { + for _, entry := range response["models"].([]map[string]any) { + assertCodexNullableFieldCleared(t, entry, "apply_patch_tool_type") + } + } +} + +func TestApplyPatchFieldModalities(t *testing.T) { + for _, tc := range []struct { + name string + visibility string + modalities any + want any + }{ + {"hidden-text-any", "hide", []any{"text", "image"}, "freeform"}, + {"hidden-text-strings", "hide", []string{"text"}, "freeform"}, + {"hidden-image-any", "hide", []any{"image"}, nil}, + {"hidden-image-strings", "hide", []string{"image"}, nil}, + {"hidden-unknown", "hide", nil, nil}, + {"hidden-empty", "hide", []any{}, nil}, + {"public-image", "list", []any{"image"}, nil}, + {"public-unconstrained", "list", []string{}, "freeform"}, + {"public-text", "list", []string{"text"}, "freeform"}, + } { + t.Run(tc.name, func(t *testing.T) { + entry := map[string]any{"visibility": tc.visibility, "input_modalities": tc.modalities, "apply_patch_tool_type": "function"} + called := false + applyCodexClientApplyPatchCapability(entry, " public-alias ", func(id string) bool { + called = true + if id != "public-alias" { + t.Fatalf("queried ID = %q, want public-alias", id) + } + return true + }) + if value, present := entry["apply_patch_tool_type"]; !present || value != tc.want { + t.Fatalf("apply_patch_tool_type = %#v (present %v), want %#v", value, present, tc.want) + } + if called != (tc.want != nil) { + t.Fatalf("callback called = %v, want %v", called, tc.want != nil) + } + }) + } +} diff --git a/internal/client/codex/models/models.go b/internal/client/codex/models/models.go index 4dde9789e..97001de2e 100644 --- a/internal/client/codex/models/models.go +++ b/internal/client/codex/models/models.go @@ -66,8 +66,14 @@ func BuildResponseForClient(availableModels []map[string]any, providersForModel // BuildResponseForClientWithCPACapabilities builds a client response while // allowing Home to supply capability metadata independent of the local registry. func BuildResponseForClientWithCPACapabilities(availableModels []map[string]any, providersForModel ProvidersForModelFunc, webSearchCapabilityForModel WebSearchCapabilityForModelFunc, optimizeMultiAgentV2 bool, clientVersion string) map[string]any { + return BuildResponseForClientWithToolCapabilities(availableModels, providersForModel, webSearchCapabilityForModel, nil, optimizeMultiAgentV2, clientVersion) +} + +// BuildResponseForClientWithToolCapabilities adds executor-backed tool support +// independently of template metadata and legacy provider restrictions. +func BuildResponseForClientWithToolCapabilities(availableModels []map[string]any, providersForModel ProvidersForModelFunc, webSearchCapabilityForModel WebSearchCapabilityForModelFunc, applyPatchCapabilityForModel ApplyPatchCapabilityForModelFunc, optimizeMultiAgentV2 bool, clientVersion string) map[string]any { return map[string]any{ - "models": buildCodexClientModels(availableModels, providersForModel, webSearchCapabilityForModel, optimizeMultiAgentV2, clientVersion), + "models": buildCodexClientModelsWithToolCapabilities(availableModels, providersForModel, webSearchCapabilityForModel, applyPatchCapabilityForModel, optimizeMultiAgentV2, clientVersion), } } @@ -84,8 +90,12 @@ func MarshalCompact(payload any) ([]byte, error) { } func buildCodexClientModels(models []map[string]any, providersForModel ProvidersForModelFunc, webSearchCapabilityForModel WebSearchCapabilityForModelFunc, optimizeMultiAgentV2 bool, clientVersion string) []map[string]any { - templates, defaultTemplate, err := loadCodexClientModelTemplates() - if err != nil || defaultTemplate == nil { + return buildCodexClientModelsWithToolCapabilities(models, providersForModel, webSearchCapabilityForModel, nil, optimizeMultiAgentV2, clientVersion) +} + +func buildCodexClientModelsWithToolCapabilities(models []map[string]any, providersForModel ProvidersForModelFunc, webSearchCapabilityForModel WebSearchCapabilityForModelFunc, applyPatchCapabilityForModel ApplyPatchCapabilityForModelFunc, optimizeMultiAgentV2 bool, clientVersion string) []map[string]any { + templates, defaultTemplate, errLoadCodexClientModelTemplates := loadCodexClientModelTemplates() + if errLoadCodexClientModelTemplates != nil || defaultTemplate == nil { return nil } @@ -119,6 +129,7 @@ func buildCodexClientModels(models []map[string]any, providersForModel Providers entry["multi_agent_version"] = "v2" } applyCodexClientDevinDisplayName(entry, id, model, providersForModel) + applyCodexClientApplyPatchCapability(entry, id, applyPatchCapabilityForModel) result = append(result, entry) continue } @@ -131,6 +142,7 @@ func buildCodexClientModels(models []map[string]any, providersForModel Providers sanitizeCodexClientReasoningMetadata(entry, clientVersion) applyCodexClientVisibilityOverride(entry, id) applyCodexClientDevinDisplayName(entry, id, model, providersForModel) + applyCodexClientApplyPatchCapability(entry, id, applyPatchCapabilityForModel) result = append(result, entry) } @@ -713,13 +725,21 @@ func codexClientThinkingSupport(model map[string]any) *registry.ThinkingSupport } func applyCodexClientVisibilityOverride(entry map[string]any, id string) { + if isCodexClientImageOrVideoModel(id) { + entry["visibility"] = "hide" + } +} + +func isCodexClientImageOrVideoModel(id string) bool { target := strings.TrimSpace(id) if idx := strings.Index(target, "/"); idx != -1 { target = strings.TrimSpace(target[idx+1:]) } switch target { case "grok-imagine-image-quality", "gpt-image-1.5", "gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5", "grok-imagine-image", "grok-imagine-image-2.0", "grok-imagine-video", "grok-imagine-video-1.5", "grok-imagine-video-1.5-preview": - entry["visibility"] = "hide" + return true + default: + return false } } diff --git a/internal/client/codex/models/models_test.go b/internal/client/codex/models/models_test.go index 4ec0dac71..ea981f419 100644 --- a/internal/client/codex/models/models_test.go +++ b/internal/client/codex/models/models_test.go @@ -885,7 +885,7 @@ func TestCodexClientModelsResponse_UnrelatedProviderCannotWidenSearchTool(t *tes }, } - resp := BuildResponseForClient(availableModels, modelRegistry.GetModelProviders, false, "0.153.4") + resp := BuildResponseForClientWithToolCapabilities(availableModels, modelRegistry.GetModelProviders, nil, func(string) bool { return true }, false, "0.153.4") models, ok := resp["models"].([]map[string]any) if !ok || len(models) != 1 { t.Fatalf("models = %#v, want one model", resp["models"]) @@ -909,7 +909,9 @@ func TestCodexClientModelsResponse_UnrelatedProviderCannotWidenSearchTool(t *tes if got, _ := entry["prefer_websockets"].(bool); got { t.Errorf("prefer_websockets = %v, want false for non-Codex provider", got) } - assertCodexNullableFieldCleared(t, entry, "apply_patch_tool_type") + if value, present := entry["apply_patch_tool_type"]; !present || value != "freeform" { + t.Errorf("apply_patch_tool_type = %#v (present %v), want freeform for bridged execution", value, present) + } if tiers, okTiers := entry["service_tiers"].([]any); !okTiers || len(tiers) != 0 { t.Errorf("service_tiers = %#v, want empty array for non-Codex provider", entry["service_tiers"]) } @@ -982,7 +984,7 @@ func TestCodexClientModelsResponse_MixedProvidersRestrictProtocolCapabilities(t {"id": aliasName}, } - resp := BuildResponseForClient(availableModels, modelRegistry.GetModelProviders, false, "0.153.4") + resp := BuildResponseForClientWithToolCapabilities(availableModels, modelRegistry.GetModelProviders, nil, func(string) bool { return true }, false, "0.153.4") models, ok := resp["models"].([]map[string]any) if !ok || len(models) != 1 { t.Fatalf("models = %#v, want one model", resp["models"]) @@ -1003,7 +1005,9 @@ func TestCodexClientModelsResponse_MixedProvidersRestrictProtocolCapabilities(t if got, _ := entry["prefer_websockets"].(bool); got { t.Errorf("prefer_websockets = %v, want false for mixed provider", got) } - assertCodexNullableFieldCleared(t, entry, "apply_patch_tool_type") + if value, present := entry["apply_patch_tool_type"]; !present || value != "freeform" { + t.Errorf("apply_patch_tool_type = %#v (present %v), want freeform for bridged execution", value, present) + } if tiers, okTiers := entry["service_tiers"].([]any); !okTiers || len(tiers) != 0 { t.Errorf("service_tiers = %#v, want empty array for mixed provider", entry["service_tiers"]) } diff --git a/internal/pluginhost/plugin_refresh_compat_executor.go b/internal/pluginhost/plugin_refresh_compat_executor.go index d106382a1..4ba61fceb 100644 --- a/internal/pluginhost/plugin_refresh_compat_executor.go +++ b/internal/pluginhost/plugin_refresh_compat_executor.go @@ -159,3 +159,12 @@ func authHasRefreshToken(auth *coreauth.Auth) bool { } return false } + +// SupportsApplyPatch delegates only the inner executor's optional contract. +func (e *pluginRefreshCompatExecutor) SupportsApplyPatch() bool { + if e == nil || e.inner == nil { + return false + } + support, okSupport := e.inner.(coreauth.ApplyPatchSupport) + return okSupport && support.SupportsApplyPatch() +} diff --git a/internal/pluginhost/plugin_refresh_compat_executor_test.go b/internal/pluginhost/plugin_refresh_compat_executor_test.go index ace313e62..0e926f76f 100644 --- a/internal/pluginhost/plugin_refresh_compat_executor_test.go +++ b/internal/pluginhost/plugin_refresh_compat_executor_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor" coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" @@ -174,3 +175,35 @@ func TestPluginRefreshCompatExecutorNoOpForAPIKeyAuth(t *testing.T) { t.Fatalf("Refresh() auth = %#v, want unchanged api key auth", refreshed) } } + +type patchCompatExecutor struct { + *stubCompatExecutor + supported bool +} + +func (e *patchCompatExecutor) SupportsApplyPatch() bool { return e.supported } + +func TestPluginRefreshApplyPatchOptionalDelegation(t *testing.T) { + for _, tc := range []struct { + inner coreauth.ProviderExecutor + want bool + }{ + {&stubCompatExecutor{id: "codex"}, false}, + {runtimeexecutor.NewOpenAICompatExecutor("arbitrary-plugin-name", &config.Config{}), true}, + {&patchCompatExecutor{&stubCompatExecutor{id: "custom"}, true}, true}, + {&patchCompatExecutor{&stubCompatExecutor{id: "custom"}, false}, false}, + } { + wrapped := NewPluginRefreshCompatExecutor(tc.inner, nil, nil) + support, okSupport := wrapped.(coreauth.ApplyPatchSupport) + if !okSupport || support.SupportsApplyPatch() != tc.want { + t.Errorf("%T: capability mismatch", tc.inner) + } + scoped := wrapped.(coreauth.APIKeyConfigExecutor).ForAPIKey() + if scoped.(coreauth.ApplyPatchSupport).SupportsApplyPatch() != tc.want { + t.Fatal("ForAPIKey lost inner capability") + } + } + if (&pluginRefreshCompatExecutor{}).SupportsApplyPatch() { + t.Fatal("empty wrapper advertised support") + } +} diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go index 0ff4e8a56..420bab667 100644 --- a/internal/runtime/executor/aistudio_executor.go +++ b/internal/runtime/executor/aistudio_executor.go @@ -189,10 +189,13 @@ func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, return resp, statusErr{code: wsResp.Status, msg: string(wsResp.Body)} } reporter.ObserveResponseModel(wsResp.Body) - reporter.Publish(ctx, helps.ParseGeminiUsage(wsResp.Body)) responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) var param any - out := sdktranslator.TranslateNonStream(ctx, body.toFormat, responseFormat, req.Model, opts.OriginalRequest, translatedReq, wsResp.Body, ¶m) + out := sdktranslator.TranslateNonStream(ctx, body.toFormat, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translatedReq, wsResp.Body, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + reporter.Publish(ctx, helps.ParseGeminiUsage(wsResp.Body)) if responseFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) } @@ -250,7 +253,13 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth AuthValue: authValue, }) reporter.StartResponseTTFT() - wsStream, err := e.relay.Stream(ctx, authID, wsReq) + streamCtx, cancelStream := context.WithCancel(ctx) + defer func() { + if err != nil { + cancelStream() + } + }() + wsStream, err := e.relay.Stream(streamCtx, authID, wsReq) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) return nil, err @@ -304,6 +313,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth out := make(chan cliproxyexecutor.StreamChunk) go func(first wsrelay.StreamEvent) { defer close(out) + defer cancelStream() defer reporter.EnsurePublished(ctx) responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) originalRequest := opts.OriginalRequest @@ -311,7 +321,10 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth originalRequest = req.Payload } claudeInputTokens := helps.NewClaudeInputTokenState(opts.SourceFormat, body.toFormat, responseFormat, originalRequest) + var streamUsage helps.StreamUsageBuffer + defer streamUsage.Publish(ctx, reporter) var param any + helps.InitializeApplyPatchStream(ctx, body.toFormat, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translatedReq, ¶m) metadataLogged := false processEvent := func(event wsrelay.StreamEvent) bool { if event.Err != nil { @@ -337,9 +350,10 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth reporter.ObserveResponseModel(event.Payload) filtered := helps.FilterSSEUsageMetadata(event.Payload) if detail, ok := helps.ParseGeminiStreamUsage(filtered); ok { - reporter.Publish(ctx, detail) + streamUsage.Observe(detail, true) } - lines := helps.TranslateStreamWithClaudeInputTokens(ctx, body.toFormat, responseFormat, req.Model, opts.OriginalRequest, translatedReq, filtered, ¶m, claudeInputTokens) + lines := helps.TranslateStreamWithClaudeInputTokens(ctx, body.toFormat, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translatedReq, filtered, ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}: @@ -347,9 +361,13 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth return false } } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return false + } break } case wsrelay.MessageTypeStreamEnd: + _ = helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) return false case wsrelay.MessageTypeHTTPResp: if !metadataLogged && event.Status > 0 { @@ -361,7 +379,8 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth reporter.MarkFirstResponseByte() helps.AppendAPIResponseChunk(ctx, e.cfg, event.Payload) } - lines := helps.TranslateStreamWithClaudeInputTokens(ctx, body.toFormat, responseFormat, req.Model, opts.OriginalRequest, translatedReq, event.Payload, ¶m, claudeInputTokens) + lines := helps.TranslateStreamWithClaudeInputTokens(ctx, body.toFormat, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translatedReq, event.Payload, ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}: @@ -369,8 +388,14 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth return false } } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return false + } + if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return false + } reporter.ObserveResponseModel(event.Payload) - reporter.Publish(ctx, helps.ParseGeminiUsage(event.Payload)) + streamUsage.Observe(helps.ParseGeminiUsage(event.Payload), true) return false case wsrelay.MessageTypeError: helps.RecordAPIResponseError(ctx, e.cfg, event.Err) @@ -391,6 +416,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth return } } + _ = helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) }(firstEvent) return &cliproxyexecutor.StreamResult{Headers: firstEvent.Headers.Clone(), Chunks: out}, nil } @@ -611,3 +637,6 @@ func ensureColonSpacedJSON(payload []byte) []byte { return compacted } + +// SupportsApplyPatch reports the actual executor contract, independent of its provider name. +func (e *AIStudioExecutor) SupportsApplyPatch() bool { return e != nil } diff --git a/internal/runtime/executor/aistudio_executor_test.go b/internal/runtime/executor/aistudio_executor_test.go index dc70da283..7e8e226c5 100644 --- a/internal/runtime/executor/aistudio_executor_test.go +++ b/internal/runtime/executor/aistudio_executor_test.go @@ -353,3 +353,204 @@ func waitForAIStudioUsageRecord(t *testing.T, records <-chan usage.Record, model } } } + +func TestAIStudioApplyPatchExecutorReuse(t *testing.T) { + const authID = "aistudio-patch" + connected := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + relay := wsrelay.NewManager(wsrelay.Options{ProviderFactory: func(*http.Request) (string, error) { return authID, nil }, OnConnected: func(string) { close(connected) }}) + server := httptest.NewServer(relay.Handler()) + defer server.Close() + defer func() { + if errStop := relay.Stop(context.Background()); errStop != nil { + t.Error(errStop) + } + }() + conn, _, errDial := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http")+relay.Path(), nil) + if errDial != nil { + t.Fatal(errDial) + } + defer func() { + if errClose := conn.Close(); errClose != nil { + t.Error(errClose) + } + }() + <-connected + defer unblock() + requests := make(chan []byte, 2) + clientDone := make(chan error, 1) + go func() { + for i := 0; i < 2; i++ { + var msg wsrelay.Message + if errReadJSON := conn.ReadJSON(&msg); errReadJSON != nil { + clientDone <- errReadJSON + return + } + body, _ := msg.Payload["body"].(string) + requests <- []byte(body) + var responses []wsrelay.Message + if i == 0 { + responses = []wsrelay.Message{{ID: msg.ID, Type: wsrelay.MessageTypeHTTPResp, Payload: map[string]any{"status": 200, "body": executorPatchResponse}}} + } else { + // A snapshot-only upstream can stream text, but not patch generation progress. + for _, response := range []wsrelay.Message{ + {ID: msg.ID, Type: wsrelay.MessageTypeStreamStart, Payload: map[string]any{"status": 200}}, + {ID: msg.ID, Type: wsrelay.MessageTypeStreamChunk, Payload: map[string]any{"data": "data: {\"responseId\":\"patch\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"preparing\"}]}}]}\n\n"}}, + } { + if errWriteJSON := conn.WriteJSON(response); errWriteJSON != nil { + clientDone <- errWriteJSON + return + } + } + select { + case <-release: + case <-t.Context().Done(): + return + } + responses = []wsrelay.Message{{ID: msg.ID, Type: wsrelay.MessageTypeStreamChunk, Payload: map[string]any{"data": "data: " + executorPatchResponse}}, {ID: msg.ID, Type: wsrelay.MessageTypeStreamEnd}} + } + for _, response := range responses { + if errWriteJSON := conn.WriteJSON(response); errWriteJSON != nil { + clientDone <- errWriteJSON + return + } + } + } + clientDone <- nil + }() + exec := NewAIStudioExecutor(&config.Config{}, "aistudio", relay) + auth := &cliproxyauth.Auth{ID: authID, Provider: "aistudio"} + req := cliproxyexecutor.Request{Model: "gemini-3.1-pro-preview", Payload: []byte(executorPatchRequest)} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, OriginalRequest: req.Payload} + response, errExecute := exec.Execute(context.Background(), auth, req, opts) + if errExecute != nil { + t.Fatal(errExecute) + } + assertExecutorPatchDeclaration(t, <-requests) + assertExecutorPatchOutput(t, response.Payload) + stream, errExecuteStream := exec.ExecuteStream(context.Background(), auth, req, opts) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + assertExecutorPatchDeclaration(t, <-requests) + assertExecutorPatchStream(t, stream.Chunks, unblock) + if errClient := <-clientDone; errClient != nil { + t.Fatal(errClient) + } +} + +func TestApplyPatchAIStudioErrorsAndEOF(t *testing.T) { + for _, mode := range []string{"invalid", "eof"} { + t.Run(mode, func(t *testing.T) { task6AIStudioFailure(t, mode) }) + } +} +func task6AIStudioFailure(t *testing.T, mode string, gateway ...bool) { + responseBody := strings.ReplaceAll(executorPatchResponse, `"input":" *** Begin Patch\n*** End Patch\n "`, `"input":7,"secret":"RAW_SECRET"`) + if mode == "eof" { + responseBody = strings.ReplaceAll(executorPatchResponse, `,"finishReason":"STOP"`, "") + } + if len(gateway) > 0 && gateway[0] { + responseBody = strings.ReplaceAll(responseBody, "functions__apply_patch", "apply_patch") + } + authID := "aistudio-" + t.Name() + connected := make(chan struct{}) + relay := wsrelay.NewManager(wsrelay.Options{ProviderFactory: func(*http.Request) (string, error) { return authID, nil }, OnConnected: func(string) { close(connected) }}) + server := httptest.NewServer(relay.Handler()) + defer server.Close() + defer func() { + if errStop := relay.Stop(context.Background()); errStop != nil { + t.Error(errStop) + } + }() + conn, _, errDial := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http")+relay.Path(), nil) + if errDial != nil { + t.Fatal(errDial) + } + defer func() { + if errClose := conn.Close(); errClose != nil { + t.Error(errClose) + } + }() + <-connected + requests := make(chan []byte, 2) + clientDone := make(chan error, 1) + go func() { + for i := 0; i < 2; i++ { + var msg wsrelay.Message + if errReadJSON := conn.ReadJSON(&msg); errReadJSON != nil { + clientDone <- errReadJSON + return + } + body, _ := msg.Payload["body"].(string) + requests <- []byte(body) + var responses []wsrelay.Message + if i == 0 { + responses = []wsrelay.Message{{ID: msg.ID, Type: wsrelay.MessageTypeHTTPResp, Payload: map[string]any{"status": 200, "body": responseBody}}} + } else { + responses = []wsrelay.Message{{ID: msg.ID, Type: wsrelay.MessageTypeStreamStart, Payload: map[string]any{"status": 200}}, {ID: msg.ID, Type: wsrelay.MessageTypeStreamChunk, Payload: map[string]any{"data": "data: " + responseBody}}, {ID: msg.ID, Type: wsrelay.MessageTypeStreamEnd}} + } + for _, response := range responses { + if errWriteJSON := conn.WriteJSON(response); errWriteJSON != nil { + clientDone <- errWriteJSON + return + } + } + } + clientDone <- nil + }() + exec := NewAIStudioExecutor(&config.Config{}, "aistudio", relay) + auth := &cliproxyauth.Auth{ID: authID, Provider: "aistudio"} + req := cliproxyexecutor.Request{Model: "gemini-3.1-pro-preview", Payload: []byte(executorPatchRequest)} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, OriginalRequest: req.Payload} + if len(gateway) > 0 && gateway[0] { + nonstream := task6Gateway(t, exec, auth, false) + if mode != "eof" && (nonstream.Code != http.StatusBadGateway || strings.Contains(nonstream.Body.String(), "RAW_SECRET")) { + t.Fatalf("relay HTTP nonstream error: %d %s", nonstream.Code, nonstream.Body.String()) + } + <-requests + task6DrainUsage(t) + checkUsage := task6CaptureFailureUsage(t, auth.ID) + streamResponse := task6Gateway(t, exec, auth, true) + checkUsage() + <-requests + body := streamResponse.Body.String() + if streamResponse.Code != http.StatusOK || strings.Count(body, "event: response.failed") != 1 || strings.Contains(body, "event: response.completed") || strings.Contains(body, "data: [DONE]") || strings.Contains(body, "RAW_SECRET") { + t.Fatalf("relay HTTP stream error: %d %s", streamResponse.Code, body) + } + } else { + if mode != "eof" { + checkUsage := task6CaptureFailureUsage(t, auth.ID) + response, errExecute := exec.Execute(context.Background(), auth, req, opts) + checkUsage() + assertTask6PatchError(t, errExecute) + if len(response.Payload) != 0 { + t.Fatal("failed nonstream payload") + } + } else { + if _, errExecute := exec.Execute(context.Background(), auth, req, opts); errExecute != nil { + t.Fatal(errExecute) + } + } + assertExecutorPatchDeclaration(t, <-requests) + task6DrainUsage(t) + checkUsage := task6CaptureFailureUsage(t, auth.ID) + stream, errExecuteStream := exec.ExecuteStream(context.Background(), auth, req, opts) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + assertExecutorPatchDeclaration(t, <-requests) + assertTask6FailedStream(t, stream.Chunks) + checkUsage() + } + if errClient := <-clientDone; errClient != nil { + t.Fatal(errClient) + } +} + +func TestApplyPatchAIStudioHTTPGatewayErrorMatrix(t *testing.T) { + for _, mode := range []string{"invalid", "eof"} { + t.Run(mode, func(t *testing.T) { task6AIStudioFailure(t, mode, true) }) + } +} diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go index d4d62f601..7a28df22a 100644 --- a/internal/runtime/executor/antigravity_executor.go +++ b/internal/runtime/executor/antigravity_executor.go @@ -971,3 +971,6 @@ func (e *AntigravityExecutor) HttpRequest(ctx context.Context, auth *cliproxyaut httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0) return httpClient.Do(httpReq) } + +// SupportsApplyPatch reports the actual executor contract, independent of its provider name. +func (e *AntigravityExecutor) SupportsApplyPatch() bool { return e != nil } diff --git a/internal/runtime/executor/antigravity_executor_execute.go b/internal/runtime/executor/antigravity_executor_execute.go index 9967edd2e..36401c3fc 100644 --- a/internal/runtime/executor/antigravity_executor_execute.go +++ b/internal/runtime/executor/antigravity_executor_execute.go @@ -190,9 +190,12 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au cacheAntigravityReasoningReplayFromResponse(ctx, replayScope, requestPayload, bodyBytes) bodyBytes = e.resolveWebSearchGroundingURLs(ctx, auth, from, originalPayload, translated, bodyBytes) reporter.ObserveResponseModel(bodyBytes) - reporter.Publish(ctx, helps.ParseAntigravityUsage(bodyBytes)) var param any - converted := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, bodyBytes, ¶m) + converted := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, bodyBytes, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(converted) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + reporter.Publish(ctx, helps.ParseAntigravityUsage(bodyBytes)) if responseFormat == sdktranslator.FormatOpenAIResponse { converted = helps.EnsureResponsesUsageDetails(converted) } @@ -428,10 +431,6 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * } reporter.ObserveResponseModel(payload) - if detail, ok := helps.ParseAntigravityStreamUsage(payload); ok { - reporter.Publish(ctx, detail) - } - out <- cliproxyexecutor.StreamChunk{Payload: payload} } if errScan := scanner.Err(); errScan != nil { @@ -442,7 +441,6 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * if replayAccumulator != nil { replayAccumulator.Commit(ctx) } - reporter.EnsurePublished(ctx) } }(httpResp) @@ -460,9 +458,12 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * resp.Payload = e.resolveWebSearchGroundingURLs(ctx, auth, from, originalPayload, translated, resp.Payload) reporter.ObserveResponseModel(resp.Payload) - reporter.Publish(ctx, helps.ParseAntigravityUsage(resp.Payload)) var param any - converted := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, resp.Payload, ¶m) + converted := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, resp.Payload, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(converted) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + reporter.Publish(ctx, helps.ParseAntigravityUsage(resp.Payload)) if responseFormat == sdktranslator.FormatOpenAIResponse { converted = helps.EnsureResponsesUsageDetails(converted) } diff --git a/internal/runtime/executor/antigravity_executor_stream.go b/internal/runtime/executor/antigravity_executor_stream.go index 9bcea0678..946c1f8a5 100644 --- a/internal/runtime/executor/antigravity_executor_stream.go +++ b/internal/runtime/executor/antigravity_executor_stream.go @@ -204,7 +204,10 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya scanner := bufio.NewScanner(resp.Body) scanner.Buffer(nil, streamScannerBuffer) claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload) + var streamUsage helps.StreamUsageBuffer + defer streamUsage.Publish(ctx, reporter) var param any + helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, ¶m) for scanner.Scan() { line := scanner.Bytes() helps.AppendAPIResponseChunk(ctx, e.cfg, line) @@ -223,11 +226,12 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya reporter.ObserveResponseModel(payload) if detail, ok := helps.ParseAntigravityStreamUsage(payload); ok { - reporter.Publish(ctx, detail) + streamUsage.Observe(detail, true) } payload = e.resolveWebSearchGroundingURLs(ctx, auth, from, originalPayload, translated, payload) - chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, bytes.Clone(payload), ¶m, claudeInputTokens) + chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, bytes.Clone(payload), ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range chunks { select { case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: @@ -235,6 +239,12 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya return } } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } + } + if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) @@ -247,7 +257,8 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya // Only a clean end of stream may produce a synthetic terminal event. // Translating [DONE] after a read error would report a truncated // stream as a successful completion. - tail := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, []byte("[DONE]"), ¶m, claudeInputTokens) + tail := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, []byte("[DONE]"), ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range tail { select { case out <- cliproxyexecutor.StreamChunk{Payload: tail[i]}: diff --git a/internal/runtime/executor/apply_patch_bridge_test.go b/internal/runtime/executor/apply_patch_bridge_test.go new file mode 100644 index 000000000..a197c1e06 --- /dev/null +++ b/internal/runtime/executor/apply_patch_bridge_test.go @@ -0,0 +1,702 @@ +package executor + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers/openai" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "google.golang.org/protobuf/encoding/protowire" +) + +const applyPatchTestInput = "*** Begin Patch\n*** Add File: a.txt\n+hello\n*** End Patch\n" +const applyPatchTestPartial = `{"input":"*** Begin Patch\n*** Add File: a.txt\n+hello\n` +const applyPatchTestRemainder = `*** End Patch\n"}` + +func applyPatchTestRequest() []byte { + return []byte(`{"model":"test","input":[{"role":"user","content":"edit a.txt"}],"tools":[{"type":"custom","name":"apply_patch","format":{"type":"grammar","syntax":"lark","definition":"start: patch"}}]}`) +} + +func applyPatchTestPayloads(chunk []byte) [][]byte { + var payloads [][]byte + for _, line := range bytes.Split(chunk, []byte("\n")) { + line = bytes.TrimSpace(line) + if bytes.HasPrefix(line, []byte("data:")) { + payload := bytes.TrimSpace(line[len("data:"):]) + if gjson.ValidBytes(payload) { + payloads = append(payloads, bytes.Clone(payload)) + } + } + } + if len(payloads) == 0 && gjson.ValidBytes(chunk) { + payloads = append(payloads, bytes.Clone(chunk)) + } + return payloads +} + +// applyPatchTestFrames separates real parameter fragments from terminal snapshots. +func applyPatchTestFrames(protocol, name string) (string, string) { + args := applyPatchTestPartial + applyPatchTestRemainder + switch protocol { + case "claude": + first := fmt.Sprintf("data: {\"type\":\"message_start\",\"message\":{\"id\":\"r1\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-6\"}}\n\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"c1\",\"name\":%q,\"input\":{}}}\n\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":%q}}\n\n", name, applyPatchTestPartial) + last := fmt.Sprintf("data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":%q}}\n\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n", applyPatchTestRemainder) + return first, last + case "interactions": + first := fmt.Sprintf("data: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"r1\"}}\n\ndata: {\"event_type\":\"step.start\",\"index\":0,\"step\":{\"type\":\"function_call\",\"id\":\"c1\",\"call_id\":\"c1\",\"name\":%q}}\n\ndata: {\"event_type\":\"step.delta\",\"index\":0,\"delta\":{\"type\":\"arguments_delta\",\"arguments\":%q}}\n\n", name, applyPatchTestPartial) + last := fmt.Sprintf("data: {\"event_type\":\"step.delta\",\"index\":0,\"delta\":{\"type\":\"arguments_delta\",\"arguments\":%q}}\n\ndata: {\"event_type\":\"step.stop\",\"index\":0}\n\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"r1\"}}\n\ndata: [DONE]\n\n", applyPatchTestRemainder) + return first, last + case "responses": + item := fmt.Sprintf(`{"type":"function_call","id":"a1","call_id":"c1","name":%q,"arguments":%q}`, name, args) + first := fmt.Sprintf("data: {\"type\":\"response.created\",\"response\":{\"id\":\"r1\"}}\n\ndata: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"id\":\"a1\",\"call_id\":\"c1\",\"name\":%q,\"arguments\":\"\"}}\n\ndata: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"item_id\":\"a1\",\"delta\":%q}\n\n", name, applyPatchTestPartial) + last := fmt.Sprintf("data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"item_id\":\"a1\",\"delta\":%q}\n\ndata: {\"type\":\"response.function_call_arguments.done\",\"output_index\":0,\"item_id\":\"a1\",\"arguments\":%q}\n\ndata: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":%s}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\",\"status\":\"completed\",\"output\":[%s]}}\n\n", applyPatchTestRemainder, args, item, item) + return first, last + case "devin": + return string(task6DevinFrames(applyPatchTestPartial, false, "", name)), string(task6DevinFrames(applyPatchTestRemainder, false, `{}`, name)) + case "gemini", "antigravity": + first := `{"responseId":"r1","candidates":[{"content":{"parts":[{"text":"preparing"}]}}]}` + last := fmt.Sprintf(`{"responseId":"r1","candidates":[{"content":{"parts":[{"functionCall":{"name":%q,"args":%s}}]},"finishReason":"STOP"}]}`, name, args) + if protocol == "antigravity" { + first, last = `{"response":`+first+`}`, `{"response":`+last+`}` + } + return "data: " + first + "\n\n", "data: " + last + "\n\n" + default: + first := fmt.Sprintf("data: {\"id\":\"r1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"c1\",\"type\":\"function\",\"function\":{\"name\":%q,\"arguments\":%q}}]}}]}\n\n", name, applyPatchTestPartial) + last := fmt.Sprintf("data: {\"id\":\"r1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":%q}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n", applyPatchTestRemainder) + return first, last + } +} + +func applyPatchTestDeclaration(t *testing.T, protocol string, body []byte) { + t.Helper() + var tool gjson.Result + parameters := "parameters" + switch protocol { + case "chat": + if gjson.GetBytes(body, "tools.0.type").String() != "function" { + t.Fatalf("not a standard function: %s", body) + } + tool = gjson.GetBytes(body, "tools.0.function") + case "gemini": + tool = gjson.GetBytes(body, "tools.0.functionDeclarations.0") + parameters = "parametersJsonSchema" + case "antigravity": + tool = gjson.GetBytes(body, "request.tools.0.functionDeclarations.0") + parameters = "parameters" + case "devin": + if len(body) < 5 { + t.Fatal("missing Connect envelope") + } + wire := applyPatchTestWireField(body[5:], 10) + tool = gjson.Parse(fmt.Sprintf(`{"name":%q,"description":%q,"parameters":%s}`, applyPatchTestWireField(wire, 1), applyPatchTestWireField(wire, 2), applyPatchTestWireField(wire, 3))) + default: + tool = gjson.GetBytes(body, "tools.0") + if protocol == "claude" { + parameters = "input_schema" + } + } + description := tool.Get("description").String() + for _, marker := range []string{"*** Begin Patch", "*** End Patch", "*** Add File:", "*** Update File:", "*** Delete File:", "@@", "start: patch"} { + if !strings.Contains(description, marker) { + t.Fatalf("missing patch format marker %q: %s", marker, tool.Raw) + } + } + if tool.Get(parameters+".properties.input.type").String() != "string" || tool.Get(parameters+".required.0").String() != "input" { + t.Fatalf("wrong standard input schema: %s", tool.Raw) + } + // Antigravity's existing schema sanitizer replaces additionalProperties with a description. + if protocol == "antigravity" { + if !strings.Contains(tool.Get(parameters+".description").String(), "No extra properties allowed") { + t.Fatalf("sanitized schema lost the strict contract: %s", tool.Raw) + } + } else if !tool.Get(parameters+".additionalProperties").Exists() || tool.Get(parameters+".additionalProperties").Bool() { + t.Fatalf("schema permits extra properties: %s", tool.Raw) + } +} + +func applyPatchTestWireField(data []byte, target protowire.Number) []byte { + for len(data) > 0 { + number, kind, n := protowire.ConsumeTag(data) + if n < 0 { + return nil + } + data = data[n:] + if kind == protowire.BytesType { + value, m := protowire.ConsumeBytes(data) + if m < 0 { + return nil + } + if number == target { + return value + } + data = data[m:] + } else { + m := protowire.ConsumeFieldValue(number, kind, data) + if m < 0 { + return nil + } + data = data[m:] + } + } + return nil +} + +// Use a real downstream socket, not ResponseRecorder, to prove flush reaches a client. +func newApplyPatchTestGateway(t *testing.T, exec cliproxyauth.ProviderExecutor, auth *cliproxyauth.Auth) *httptest.Server { + t.Helper() + gin.SetMode(gin.TestMode) + manager := cliproxyauth.NewManager(nil, nil, nil) + manager.RegisterExecutor(exec) + auth.Status = cliproxyauth.StatusActive + if _, errRegister := manager.Register(t.Context(), auth); errRegister != nil { + t.Fatal(errRegister) + } + registry.GetGlobalRegistry().RegisterClient(auth.ID, exec.Identifier(), []*registry.ModelInfo{{ID: "test"}}) + t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient(auth.ID) }) + base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager) + router := gin.New() + router.POST("/v1/responses", openai.NewOpenAIResponsesAPIHandler(base).Responses) + router.GET("/v1/models", openai.NewOpenAIAPIHandler(base).OpenAIModels) + server := httptest.NewServer(router) + t.Cleanup(server.Close) + return server +} + +// The server cannot send the suffix or terminal frame until the client acknowledges +// a complete decoded line. Snapshot paths instead acknowledge unrelated text. +func TestApplyPatchBridgeLiveHTTPPreviewMatrix(t *testing.T) { + for _, tc := range []struct { + provider, protocol string + snapshot bool + }{ + {"custom-compat", "chat", false}, {"claude", "claude", false}, {"claude-oauth", "claude", false}, + {"gemini-interactions", "interactions", false}, {"devin", "devin", false}, + {"xai", "responses", false}, {"meta", "responses", false}, {"kimi", "responses", false}, + {"gemini", "gemini", true}, {"vertex", "gemini", true}, {"antigravity", "antigravity", true}, + } { + t.Run(tc.provider, func(t *testing.T) { + release := make(chan struct{}) + requestBody := make(chan []byte, 1) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errReadAll := io.ReadAll(r.Body) + if errReadAll != nil { + t.Error(errReadAll) + return + } + requestBody <- body + name := "apply_patch" + if tc.protocol == "claude" { + name = gjson.GetBytes(body, "tools.0.name").String() + } + first, last := applyPatchTestFrames(tc.protocol, name) + w.Header().Set("Content-Type", "text/event-stream") + if _, errWriteString := io.WriteString(w, first); errWriteString != nil { + return + } + w.(http.Flusher).Flush() + select { + case <-release: + case <-r.Context().Done(): + return + } + _, _ = io.WriteString(w, last) + })) + defer upstream.Close() + defer unblock() + exec := task6Executor(tc.provider) + if tc.protocol == "responses" { + exec = task6RepairExecutor(tc.provider) + } else if tc.provider == "devin" { + exec = NewDevinExecutor(&config.Config{}) + } + auth := &cliproxyauth.Auth{ID: t.Name(), Provider: exec.Identifier(), Attributes: map[string]string{"api_key": "test", "base_url": upstream.URL}} + if tc.provider == "claude-oauth" { + auth.Attributes["api_key"] = "sk-ant-oat-test" + auth.Metadata = map[string]any{"account_uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"} + } + if tc.provider == "antigravity" { + auth.Metadata = map[string]any{"access_token": "test", "expires_in": 3600, "timestamp": "2099-01-01T00:00:00Z", "project_id": "test"} + } + gateway := newApplyPatchTestGateway(t, exec, auth) + // Catalog claims must be backed by the executor used by the POST below. + for _, version := range []string{"0.137.0", "0.153.4", "cpa"} { + applyPatchTestCatalog(t, gateway, version) + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + payload, errSetBytes := sjson.SetBytes(applyPatchTestRequest(), "stream", true) + if errSetBytes != nil { + t.Fatal(errSetBytes) + } + request, errNewRequestWithContext := http.NewRequestWithContext(ctx, http.MethodPost, gateway.URL+"/v1/responses", bytes.NewReader(payload)) + if errNewRequestWithContext != nil { + t.Fatal(errNewRequestWithContext) + } + request.Header.Set("Content-Type", "application/json") + response, errDo := gateway.Client().Do(request) + if errDo != nil { + t.Fatal(errDo) + } + defer func() { + if errClose := response.Body.Close(); errClose != nil { + t.Error(errClose) + } + }() + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d", response.StatusCode) + } + scanner := bufio.NewScanner(response.Body) + var lifecycle applyPatchTestLifecycle + acknowledged := false + for scanner.Scan() { + for _, raw := range applyPatchTestPayloads(scanner.Bytes()) { + event := gjson.ParseBytes(raw) + lifecycle.consume(t, event) + if !acknowledged { + if tc.snapshot { + if lifecycle.deltas.Len() != 0 { + t.Fatal("fabricated patch progress before upstream snapshot") + } + acknowledged = event.Get("type").String() == "response.output_text.delta" + } else { + acknowledged = strings.Contains(lifecycle.deltas.String(), "+hello\n") + } + if acknowledged { + if lifecycle.completed != 0 || lifecycle.inputDone != "" || lifecycle.itemDone != "" { + t.Fatal("preview arrived only after completion") + } + unblock() + } + } + } + } + if errScan := scanner.Err(); errScan != nil { + t.Fatal(errScan) + } + if !acknowledged { + t.Fatal("client never acknowledged the flushed frame") + } + lifecycle.assert(t) + applyPatchTestDeclaration(t, tc.protocol, <-requestBody) + }) + } +} + +func applyPatchTestCatalog(t *testing.T, server *httptest.Server, version string) { + t.Helper() + request, errNewRequestWithContext := http.NewRequestWithContext(t.Context(), http.MethodGet, server.URL+"/v1/models?client_version="+version, nil) + if errNewRequestWithContext != nil { + t.Fatal(errNewRequestWithContext) + } + response, errDo := server.Client().Do(request) + if errDo != nil { + t.Fatal(errDo) + } + defer func() { + if errClose := response.Body.Close(); errClose != nil { + t.Error(errClose) + } + }() + body, errReadAll := io.ReadAll(response.Body) + if errReadAll != nil { + t.Fatal(errReadAll) + } + entry := gjson.GetBytes(body, `models.#(slug=="test")`) + if response.StatusCode != http.StatusOK || entry.Get("apply_patch_tool_type").String() != "freeform" { + t.Fatalf("catalog not backed by the registered route: %d %s", response.StatusCode, body) + } + if entry.Get("prefer_websockets").Bool() || len(entry.Get("service_tiers").Array()) != 0 { + t.Fatalf("patch support enabled unrelated capabilities: %s", entry.Raw) + } +} + +type applyPatchTestLifecycle struct { + deltas strings.Builder + inputDone, itemDone, responseDone string + completed, inputs, items int + itemID, callID string +} + +func (s *applyPatchTestLifecycle) consume(t *testing.T, event gjson.Result) { + t.Helper() + switch event.Get("type").String() { + case "response.failed", "error", "response.function_call_arguments.delta": + t.Fatalf("unexpected bridge event: %s", event.Raw) + case "response.custom_tool_call_input.delta", "response.custom_tool_call_input.done": + id, call := event.Get("item_id").String(), event.Get("call_id").String() + if id == "" || call == "" || (s.itemID != "" && (s.itemID != id || s.callID != call)) { + t.Fatalf("unstable call identity: %s", event.Raw) + } + s.itemID, s.callID = id, call + if event.Get("type").String() == "response.custom_tool_call_input.delta" { + s.deltas.WriteString(event.Get("delta").String()) + } else { + s.inputs++ + s.inputDone = event.Get("input").String() + } + case "response.output_item.done": + item := event.Get("item") + if item.Get("type").String() == "custom_tool_call" { + if item.Get("id").String() != s.itemID || item.Get("call_id").String() != s.callID || item.Get("name").String() != "apply_patch" { + t.Fatalf("item lost identity: %s", item.Raw) + } + s.items++ + s.itemDone = item.Get("input").String() + } + case "response.completed": + s.completed++ + item := event.Get(`response.output.#(type=="custom_tool_call")`) + if item.Get("id").String() != s.itemID || item.Get("call_id").String() != s.callID { + t.Fatalf("response lost identity: %s", item.Raw) + } + s.responseDone = item.Get("input").String() + } +} + +func (s *applyPatchTestLifecycle) assert(t *testing.T) { + t.Helper() + if s.deltas.String() != applyPatchTestInput || s.inputDone != applyPatchTestInput || s.itemDone != applyPatchTestInput || s.responseDone != applyPatchTestInput || s.completed != 1 || s.inputs != 1 || s.items != 1 { + t.Fatalf("inconsistent lifecycle: delta=%q input=%q item=%q response=%q counts=%d/%d/%d", s.deltas.String(), s.inputDone, s.itemDone, s.responseDone, s.inputs, s.items, s.completed) + } +} + +func newApplyPatchCompatTestExecutor(t *testing.T, reply string) (*OpenAICompatExecutor, *cliproxyauth.Auth, <-chan []byte) { + t.Helper() + bodies := make(chan []byte, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errReadAll := io.ReadAll(r.Body) + if errReadAll != nil { + http.Error(w, "request read failed", http.StatusBadRequest) + return + } + bodies <- body + contentType := "application/json" + if strings.HasPrefix(reply, "data:") { + contentType = "text/event-stream" + } + w.Header().Set("Content-Type", contentType) + _, _ = io.WriteString(w, reply) + })) + t.Cleanup(server.Close) + return NewOpenAICompatExecutor("custom-compat", &config.Config{}), &cliproxyauth.Auth{Provider: "custom-compat", Attributes: map[string]string{"base_url": server.URL + "/v1", "api_key": "test"}}, bodies +} + +func applyPatchTestChatReply(arguments string) string { + return fmt.Sprintf(`{"id":"r1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"c1","type":"function","function":{"name":"apply_patch","arguments":%q}}]},"finish_reason":"tool_calls"}]}`, arguments) +} + +func TestApplyPatchBridgeInvalidArgumentsReturnsBadGateway(t *testing.T) { + for _, args := range []string{`{"input":7}`, `{}`, `{"input":null}`, `{"input":"x","input":"y"}`, `{"input":"x","extra":"RAW_SECRET"}`, `{"input":"RAW_SECRET"`, `{"input":"x"} trailing`} { + t.Run(args, func(t *testing.T) { + exec, auth, _ := newApplyPatchCompatTestExecutor(t, applyPatchTestChatReply(args)) + payload := applyPatchTestRequest() + response, errExecute := exec.Execute(t.Context(), auth, cliproxyexecutor.Request{Model: "test", Payload: payload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, OriginalRequest: payload}) + var status interface{ StatusCode() int } + if errExecute == nil || !errors.As(errExecute, &status) || status.StatusCode() != http.StatusBadGateway || len(response.Payload) != 0 { + t.Fatalf("expected empty gateway error, got %s %v", response.Payload, errExecute) + } + assertTask6PatchError(t, errExecute) + }) + } +} + +func TestApplyPatchBridgeHistoryRoundTrip(t *testing.T) { + exec, auth, bodies := newApplyPatchCompatTestExecutor(t, applyPatchTestChatReply(applyPatchTestPartial+applyPatchTestRemainder)) + payload := applyPatchTestRequest() + response, errExecute := exec.Execute(t.Context(), auth, cliproxyexecutor.Request{Model: "test", Payload: payload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, OriginalRequest: payload}) + if errExecute != nil { + t.Fatal(errExecute) + } + applyPatchTestDeclaration(t, "chat", <-bodies) + call := gjson.GetBytes(response.Payload, `output.#(type=="custom_tool_call")`) + if call.Get("call_id").String() != "c1" || call.Get("input").String() != applyPatchTestInput { + t.Fatalf("wrong client call: %s", response.Payload) + } + next, errSetRawBytes := sjson.SetRawBytes(payload, "input", []byte("["+call.Raw+`,{"type":"custom_tool_call_output","call_id":"c1","output":"Success"}]`)) + if errSetRawBytes != nil { + t.Fatal(errSetRawBytes) + } + _, errExecute = exec.Execute(t.Context(), auth, cliproxyexecutor.Request{Model: "test", Payload: next}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, OriginalRequest: next}) + if errExecute != nil { + t.Fatal(errExecute) + } + body := <-bodies + arguments := gjson.GetBytes(body, `messages.#(role=="assistant").tool_calls.0.function.arguments`).String() + if !gjson.Valid(arguments) || gjson.Get(arguments, "input").String() != call.Get("input").String() || gjson.GetBytes(body, `messages.#(role=="assistant").tool_calls.0.id`).String() != "c1" { + t.Fatalf("wrong replay arguments/identity: %s", body) + } + if gjson.GetBytes(body, `messages.#(role=="tool").tool_call_id`).String() != "c1" || gjson.GetBytes(body, `messages.#(role=="tool").content`).String() != "Success" { + t.Fatalf("wrong replay result identity: %s", body) + } +} + +func TestApplyPatchBridgeTruncatedStreamDoesNotComplete(t *testing.T) { + first, _ := applyPatchTestFrames("chat", "apply_patch") + exec, auth, _ := newApplyPatchCompatTestExecutor(t, first) + payload := applyPatchTestRequest() + result, errExecuteStream := exec.ExecuteStream(t.Context(), auth, cliproxyexecutor.Request{Model: "test", Payload: payload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, OriginalRequest: payload, Stream: true}) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + assertTask6FailedStream(t, result.Chunks) +} + +func TestApplyPatchBridgeOrdinaryFunctionControl(t *testing.T) { + for _, source := range []sdktranslator.Format{sdktranslator.FormatOpenAI, sdktranslator.FormatOpenAIResponse} { + t.Run(source.String(), func(t *testing.T) { + args := `{"input":"RAW_SECRET","extra":7}` + exec, auth, bodies := newApplyPatchCompatTestExecutor(t, applyPatchTestChatReply(args)) + payload := []byte(`{"model":"test","messages":[{"role":"user","content":"edit"}],"tools":[{"type":"function","function":{"name":"apply_patch","parameters":{"type":"object","properties":{"input":{"type":"string"},"extra":{"type":"integer"}}}}}]}`) + if source == sdktranslator.FormatOpenAIResponse { + payload = []byte(`{"model":"test","input":"edit","tools":[{"type":"function","name":"apply_patch","parameters":{"type":"object","properties":{"input":{"type":"string"},"extra":{"type":"integer"}}}}]}`) + } + response, errExecute := exec.Execute(t.Context(), auth, cliproxyexecutor.Request{Model: "test", Payload: payload}, cliproxyexecutor.Options{SourceFormat: source, OriginalRequest: payload}) + if errExecute != nil { + t.Fatal(errExecute) + } + body := <-bodies + if !gjson.GetBytes(body, "tools.0.function.parameters.properties.extra").Exists() || strings.Contains(gjson.GetBytes(body, "tools.0.function.description").String(), "*** Begin Patch") { + t.Fatalf("ordinary function schema changed: %s", body) + } + path := "choices.0.message.tool_calls.0.function.arguments" + if source == sdktranslator.FormatOpenAIResponse { + path = `output.#(type=="function_call").arguments` + } + arguments := gjson.GetBytes(response.Payload, path).String() + if arguments != args || !gjson.Valid(arguments) || bytes.Contains(response.Payload, []byte(`"custom_tool_call"`)) { + t.Fatalf("ordinary JSON function promoted to freeform: %s", response.Payload) + } + }) + } +} + +// Kimi selects its reused Chat and Claude executors for these ordinary clients. +// Custom Responses clients select Kimi's Responses branch, covered by the HTTP matrix. +func TestApplyPatchBridgeKimiReusedClientControls(t *testing.T) { + for _, source := range []sdktranslator.Format{sdktranslator.FormatOpenAI, sdktranslator.FormatClaude} { + t.Run(source.String(), func(t *testing.T) { + args := `{"input":"RAW_SECRET","extra":7}` + reply := applyPatchTestChatReply(args) + payload := []byte(`{"messages":[{"role":"user","content":"edit"}],"tools":[{"type":"function","function":{"name":"apply_patch","parameters":{"type":"object","properties":{"input":{"type":"string"},"extra":{"type":"integer"}}}}}]}`) + if source == sdktranslator.FormatClaude { + payload = []byte(`{"messages":[{"role":"user","content":"edit"}],"tools":[{"name":"apply_patch","input_schema":{"type":"object","properties":{"input":{"type":"string"},"extra":{"type":"integer"}}}}]}`) + reply = fmt.Sprintf(`{"id":"r1","type":"message","role":"assistant","content":[{"type":"tool_use","id":"c1","name":"apply_patch","input":%s}],"stop_reason":"tool_use"}`, args) + } + _, auth, bodies := newApplyPatchCompatTestExecutor(t, reply) + auth.Provider = "kimi" + exec := NewKimiExecutor(&config.Config{}).ForAPIKey() + response, errExecute := exec.Execute(t.Context(), auth, cliproxyexecutor.Request{Model: "kimi-k2.5", Payload: payload}, cliproxyexecutor.Options{SourceFormat: source, OriginalRequest: payload}) + if errExecute != nil { + t.Fatal(errExecute) + } + body := <-bodies + tool := gjson.GetBytes(body, "tools.0.function") + parameters := "parameters" + if source == sdktranslator.FormatClaude { + tool, parameters = gjson.GetBytes(body, "tools.0"), "input_schema" + } + if !tool.Get(parameters+".properties.extra").Exists() || strings.Contains(tool.Get("description").String(), "*** Begin Patch") || bytes.Contains(response.Payload, []byte("custom_tool_call")) || !bytes.Contains(response.Payload, []byte("RAW_SECRET")) { + t.Fatalf("ordinary reused client was promoted or restricted: request=%s response=%s", body, response.Payload) + } + arguments := gjson.GetBytes(response.Payload, "choices.0.message.tool_calls.0.function.arguments").String() + if source == sdktranslator.FormatClaude { + arguments = gjson.GetBytes(response.Payload, "content.0.input").Raw + } + if !gjson.Valid(arguments) || gjson.Get(arguments, "extra").Int() != 7 { + t.Fatalf("ordinary function lost valid JSON: %s", response.Payload) + } + }) + } +} + +func applyPatchTestPreviewChunks(t *testing.T, chunks <-chan cliproxyexecutor.StreamChunk, unblock func()) []byte { + t.Helper() + var lifecycle applyPatchTestLifecycle + var output []byte + preview := false + for chunk := range chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + output = append(output, chunk.Payload...) + for _, raw := range applyPatchTestPayloads(chunk.Payload) { + lifecycle.consume(t, gjson.ParseBytes(raw)) + if !preview && strings.Contains(lifecycle.deltas.String(), "+hello\n") { + if lifecycle.completed != 0 || lifecycle.inputDone != "" || lifecycle.itemDone != "" { + t.Fatal("preview arrived only at completion") + } + preview = true + unblock() + } + } + } + if !preview { + t.Fatal("no live line preview") + } + lifecycle.assert(t) + return output +} + +func TestApplyPatchBridgeLiveWebsocketPreview(t *testing.T) { + for _, native := range []bool{false, true} { + t.Run(fmt.Sprintf("native-codex=%v", native), func(t *testing.T) { + release := make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + bodies := make(chan []byte, 1) + first, last := applyPatchTestFrames("responses", "apply_patch") + if native { + first = "data: { \"type\":\"response.output_item.added\", \"output_index\":0,\"item\":{\"type\":\"custom_tool_call\",\"id\":\"a1\",\"call_id\":\"c1\",\"name\":\"apply_patch\",\"input\":\"\"}}\n\ndata: { \"type\":\"response.custom_tool_call_input.delta\", \"output_index\":0,\"item_id\":\"a1\",\"call_id\":\"c1\",\"delta\":\"*** Begin Patch\\n*** Add File: a.txt\\n+hello\\n\"}\n\n" + item := fmt.Sprintf(`{ "type":"custom_tool_call", "id":"a1","call_id":"c1","name":"apply_patch","input":%q}`, applyPatchTestInput) + last = fmt.Sprintf("data: { \"type\":\"response.custom_tool_call_input.delta\", \"output_index\":0,\"item_id\":\"a1\",\"call_id\":\"c1\",\"delta\":\"*** End Patch\\n\"}\n\ndata: { \"type\":\"response.custom_tool_call_input.done\", \"item_id\":\"a1\",\"call_id\":\"c1\",\"input\":%q}\n\ndata: { \"type\":\"response.output_item.done\", \"output_index\":0,\"item\":%s}\n\ndata: { \"type\":\"response.completed\", \"response\":{\"id\":\"r1\",\"status\":\"completed\",\"output\":[%s]}}\n\n", applyPatchTestInput, item, item) + } + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, errUpgrade := upgrader.Upgrade(w, r, nil) + if errUpgrade != nil { + t.Error(errUpgrade) + return + } + defer func() { + if errClose := conn.Close(); errClose != nil { + t.Error(errClose) + } + }() + _, body, errReadMessage := conn.ReadMessage() + if errReadMessage != nil { + t.Error(errReadMessage) + return + } + bodies <- body + for _, raw := range applyPatchTestPayloads([]byte(first)) { + if errWriteMessage := conn.WriteMessage(websocket.TextMessage, raw); errWriteMessage != nil { + return + } + } + select { + case <-release: + case <-r.Context().Done(): + return + } + for _, raw := range applyPatchTestPayloads([]byte(last)) { + if errWriteMessage := conn.WriteMessage(websocket.TextMessage, raw); errWriteMessage != nil { + return + } + } + })) + defer server.Close() + defer unblock() + var exec cliproxyauth.ProviderExecutor + if native { + codex := NewCodexWebsocketsExecutor(&config.Config{Codex: config.CodexConfig{DisableCodexCloaking: true}}) + codex.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)} + exec = codex + } else { + xai := NewXAIWebsocketsExecutor(&config.Config{}) + xai.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)} + exec = xai + } + ctx, cancel := context.WithCancel(cliproxyexecutor.WithDownstreamWebsocket(t.Context())) + defer cancel() + payload := applyPatchTestRequest() + auth := &cliproxyauth.Auth{ID: t.Name(), Provider: exec.Identifier(), Attributes: map[string]string{"api_key": "test", "base_url": server.URL, "websockets": "true"}} + model := "grok-4" + if native { + model = "gpt-5.6-sol" + } + result, errExecuteStream := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: model, Payload: payload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, OriginalRequest: payload, Stream: true, Headers: http.Header{codexResponsesLiteHeader: {"true"}}}) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + output := applyPatchTestPreviewChunks(t, result.Chunks, unblock) + body := <-bodies + if native { + if gjson.GetBytes(body, "tools.0").Raw != gjson.GetBytes(payload, "tools.0").Raw { + t.Fatalf("native grammar declaration altered: %s", body) + } + for _, raw := range applyPatchTestPayloads([]byte(first + last)) { + if !bytes.Contains(output, raw) { + t.Fatalf("native event bytes altered: %s", raw) + } + } + } else { + applyPatchTestDeclaration(t, "responses", body) + } + }) + } +} + +func TestApplyPatchBridgeOrdinaryFunctionStreamControl(t *testing.T) { + for _, source := range []sdktranslator.Format{sdktranslator.FormatOpenAI, sdktranslator.FormatOpenAIResponse} { + t.Run(source.String(), func(t *testing.T) { + args := `{"input":"RAW_SECRET","extra":7}` + reply := strings.ReplaceAll(task6ProviderFixture("custom-compat", "stream", "apply_patch"), fmt.Sprintf("%q", `{"input":7,"secret":"RAW_SECRET"}`), fmt.Sprintf("%q", args)) + exec, auth, bodies := newApplyPatchCompatTestExecutor(t, reply) + payload := []byte(`{"messages":[{"role":"user","content":"edit"}],"tools":[{"type":"function","function":{"name":"apply_patch","parameters":{"type":"object","properties":{"extra":{"type":"integer"}}}}}]}`) + if source == sdktranslator.FormatOpenAIResponse { + payload = []byte(`{"input":"edit","tools":[{"type":"function","name":"apply_patch","parameters":{"type":"object","properties":{"extra":{"type":"integer"}}}}]}`) + } + result, errExecuteStream := exec.ExecuteStream(t.Context(), auth, cliproxyexecutor.Request{Model: "test", Payload: payload}, cliproxyexecutor.Options{SourceFormat: source, OriginalRequest: payload, Stream: true}) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + var arguments strings.Builder + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + if bytes.Contains(chunk.Payload, []byte("custom_tool_call")) || bytes.Contains(chunk.Payload, []byte("response.failed")) { + t.Fatalf("ordinary stream was promoted or restricted: %s", chunk.Payload) + } + for _, raw := range applyPatchTestPayloads(chunk.Payload) { + event := gjson.ParseBytes(raw) + if source == sdktranslator.FormatOpenAI { + arguments.WriteString(event.Get("choices.0.delta.tool_calls.0.function.arguments").String()) + } else if event.Get("type").String() == "response.function_call_arguments.delta" { + arguments.WriteString(event.Get("delta").String()) + } + } + } + if !gjson.Valid(arguments.String()) || arguments.String() != args || !gjson.GetBytes(<-bodies, "tools.0.function.parameters.properties.extra").Exists() { + t.Fatalf("ordinary stream did not preserve JSON arguments/schema: %q", arguments.String()) + } + }) + } +} + +func TestApplyPatchBridgeInvalidStreamArguments(t *testing.T) { + // Valid decoded prefixes can precede EOF failure; raw envelope fields must not leak. + for _, args := range []string{`{"input":7}`, `{}`, `{"input":null}`, `{"input":"x","input":"y"}`, `{"input":"x","extra":"RAW_SECRET"}`, `{"input":"unfinished"`, `{"input":"x"} trailing`} { + t.Run(args, func(t *testing.T) { + reply := strings.ReplaceAll(task6ProviderFixture("custom-compat", "stream", "apply_patch"), fmt.Sprintf("%q", `{"input":7,"secret":"RAW_SECRET"}`), fmt.Sprintf("%q", args)) + exec, auth, _ := newApplyPatchCompatTestExecutor(t, reply) + payload := applyPatchTestRequest() + result, errExecuteStream := exec.ExecuteStream(t.Context(), auth, cliproxyexecutor.Request{Model: "test", Payload: payload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, OriginalRequest: payload, Stream: true}) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + assertTask6FailedStream(t, result.Chunks) + }) + } +} diff --git a/internal/runtime/executor/apply_patch_capability_test.go b/internal/runtime/executor/apply_patch_capability_test.go new file mode 100644 index 000000000..c1a524495 --- /dev/null +++ b/internal/runtime/executor/apply_patch_capability_test.go @@ -0,0 +1,35 @@ +package executor + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" +) + +func TestApplyPatchActualExecutors(t *testing.T) { + cfg := &config.Config{} + for _, exec := range []coreauth.ProviderExecutor{ + NewOpenAICompatExecutor("arbitrary-plugin-provider", cfg), NewClaudeExecutor(cfg), + NewGeminiExecutor(cfg), NewGeminiInteractionsExecutor(cfg), NewGeminiVertexExecutor(cfg), + NewAntigravityExecutor(cfg), NewAIStudioExecutor(cfg, "aistudio", nil), NewDevinExecutor(cfg), + NewKimiExecutor(cfg), NewCodexExecutor(cfg), NewCodexWebsocketsExecutor(cfg), NewCodexAutoExecutor(cfg), + NewXAIExecutor(cfg), NewXAIWebsocketsExecutor(cfg), NewXAIAutoExecutor(cfg), NewMetaExecutor(cfg), + } { + support, okSupport := exec.(coreauth.ApplyPatchSupport) + if !okSupport || !support.SupportsApplyPatch() { + t.Errorf("%T (%s) does not advertise actual support", exec, exec.Identifier()) + } + if scoped, okScoped := exec.(coreauth.APIKeyConfigExecutor); okScoped { + bound := scoped.ForAPIKey() + if bound.Identifier() != exec.Identifier() || !bound.(coreauth.ApplyPatchSupport).SupportsApplyPatch() { + t.Errorf("%T ForAPIKey lost the actual executor contract", exec) + } + } + } + for _, exec := range []coreauth.ApplyPatchSupport{&CodexAutoExecutor{}, &XAIAutoExecutor{}} { + if exec.SupportsApplyPatch() { + t.Errorf("unconfigured %T advertises support", exec) + } + } +} diff --git a/internal/runtime/executor/apply_patch_identity_test.go b/internal/runtime/executor/apply_patch_identity_test.go new file mode 100644 index 000000000..9389774c9 --- /dev/null +++ b/internal/runtime/executor/apply_patch_identity_test.go @@ -0,0 +1,372 @@ +package executor + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/websocket" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// Check every lifecycle identity, not merely the final snapshot or event counts. +func assertApplyPatchIdentityLifecycle(t *testing.T, events [][]byte, id, call, input string, index int, fragments []string) { + t.Helper() + counts := map[string]int{} + var actual []string + for _, event := range events { + root := gjson.ParseBytes(event) + kind := root.Get("type").String() + identity, idKey := root, "item_id" + switch kind { + case "response.output_item.added", "response.output_item.done": + if root.Get("item.type").String() != "custom_tool_call" { + continue + } + identity, idKey = root.Get("item"), "id" + if root.Get("output_index").Int() != int64(index) { + t.Fatalf("wrong output index: %s", event) + } + if kind == "response.output_item.added" && identity.Get("input").String() != "" { + t.Fatalf("nonempty added input: %s", event) + } + if kind == "response.output_item.done" && identity.Get("input").String() != input { + t.Fatalf("item input: %s", event) + } + case "response.custom_tool_call_input.delta", "response.custom_tool_call_input.done": + if root.Get("output_index").Int() != int64(index) { + t.Fatalf("wrong input index: %s", event) + } + if kind == "response.custom_tool_call_input.delta" { + actual = append(actual, root.Get("delta").String()) + } + if kind == "response.custom_tool_call_input.done" && root.Get("input").String() != input { + t.Fatalf("done input: %s", event) + } + case "response.completed": + identity, idKey = root.Get("response.output.0"), "id" + if identity.Get("input").String() != input { + t.Fatalf("final input: %s", event) + } + case "response.function_call_arguments.delta", "response.function_call_arguments.done", "response.failed": + t.Fatalf("unsafe patch event: %s", event) + default: + continue + } + counts[kind]++ + if identity.Get(idKey).String() != id || identity.Get("call_id").String() != call { + t.Fatalf("unstable identity: %s", event) + } + } + for _, kind := range []string{"response.output_item.added", "response.custom_tool_call_input.done", "response.output_item.done", "response.completed"} { + if counts[kind] != 1 { + t.Fatalf("missing/duplicate lifecycle: %v", counts) + } + } + if strings.Join(actual, "|") != strings.Join(fragments, "|") || len(actual) != len(fragments) { + t.Fatalf("source fragment replay: got %q want %q", actual, fragments) + } +} + +func applyPatchIdentitySource(interactions bool, first, boundary string, snapshot bool) [][]byte { + id, call := "", "" + if first == "item" { + id = "a" + } + if first == "call" { + call = "c" + } + args := `{"input":"pq"}` + var events [][]byte + if interactions { + events = append(events, []byte(fmt.Sprintf(`{"event_type":"step.start","index":0,"step":{"type":"function_call","id":%q,"call_id":%q,"name":"apply_patch"}}`, id, call))) + if !snapshot { + for _, fragment := range []string{`{"input":"p`, `q"}`} { + events = append(events, []byte(fmt.Sprintf(`{"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":%q}}`, fragment))) + } + } + if strings.HasPrefix(first, "neither-") { + key, value := "id", "a" + if first == "neither-call-first" { + key, value = "call_id", "c" + } + events = append(events, []byte(fmt.Sprintf(`{"event_type":"step.start","index":0,"step":{"type":"function_call","name":"apply_patch",%q:%q}}`, key, value))) + } + if boundary == "delta" { + events = append(events, []byte(`{"event_type":"step.delta","index":0,"step":{"id":"a","call_id":"c"},"delta":{"type":"arguments_delta","arguments":""}}`)) + } + step := fmt.Sprintf(`{"index":0,"type":"function_call","id":"a","call_id":"c","name":"apply_patch","arguments":%s}`, args) + if boundary == "item" || boundary == "delta" { + events = append(events, []byte(`{"event_type":"step.stop","index":0,"step":`+step+`}`)) + } else { + events = append(events, []byte(`{"event_type":"step.stop","index":0}`)) + } + events = append(events, []byte(`{"event_type":"interaction.completed","interaction":{"steps":[`+step+`]}}`)) + } else { + events = append(events, []byte(fmt.Sprintf(`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":%q,"call_id":%q,"name":"apply_patch","arguments":""}}`, id, call))) + if !snapshot { + for _, fragment := range []string{`{"input":"p`, `q"}`} { + events = append(events, []byte(fmt.Sprintf(`{"type":"response.function_call_arguments.delta","output_index":0,"delta":%q}`, fragment))) + } + } + if strings.HasPrefix(first, "neither-") { + key, value := "item_id", "a" + if first == "neither-call-first" { + key, value = "call_id", "c" + } + events = append(events, []byte(fmt.Sprintf(`{"type":"response.function_call_arguments.delta","output_index":0,%q:%q,"delta":""}`, key, value))) + } + if boundary == "delta" { + events = append(events, []byte(`{"type":"response.function_call_arguments.delta","output_index":0,"item_id":"a","call_id":"c","delta":""}`)) + } + events = append(events, []byte(fmt.Sprintf(`{"type":"response.function_call_arguments.done","output_index":0,"arguments":%q}`, args))) + item := fmt.Sprintf(`{"type":"function_call","id":"a","call_id":"c","name":"apply_patch","arguments":%q}`, args) + if boundary == "item" || boundary == "delta" { + events = append(events, []byte(`{"type":"response.output_item.done","output_index":0,"item":`+item+`}`)) + } + events = append(events, []byte(`{"type":"response.completed","response":{"output":[`+item+`]}}`)) + } + return events +} + +func TestApplyPatchNamedLateIdentityActualTransports(t *testing.T) { + for _, transport := range []string{"xai", "meta", "kimi", "interactions", "ws-sse", "ws-raw"} { + for _, first := range []string{"item", "call", "neither", "neither-item-first", "neither-call-first"} { + for _, boundary := range []string{"delta", "item", "terminal"} { + for _, snapshot := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/%s/snapshot=%v", transport, first, boundary, snapshot), func(t *testing.T) { + interactions := transport == "interactions" + source := applyPatchIdentitySource(interactions, first, boundary, snapshot) + exec, auth := applyPatchIdentityExecutor(t, transport, source) + ctx := t.Context() + if transport == "ws-raw" { + ctx = cliproxyexecutor.WithDownstreamWebsocket(ctx) + } + stream, errExecuteStream := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "grok-4", Payload: []byte(task6PatchRequest)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + var events [][]byte + for chunk := range stream.Chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + events = append(events, applyPatchTestPayloads(chunk.Payload)...) + } + fragments := []string{"p", "q"} + if snapshot { + fragments = nil + // Interactions retains its existing one-snapshot compatibility delta. + if interactions { + fragments = []string{"pq"} + } + } + assertApplyPatchIdentityLifecycle(t, events, "a", "c", "pq", 0, fragments) + }) + } + } + } + } +} + +func applyPatchIdentityExecutor(t *testing.T, transport string, source [][]byte) (cliproxyauth.ProviderExecutor, *cliproxyauth.Auth) { + t.Helper() + ws := strings.HasPrefix(transport, "ws-") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !ws { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, task6RepairSSE(source)) + return + } + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + conn, errUpgrade := upgrader.Upgrade(w, r, nil) + if errUpgrade != nil { + return + } + defer func() { + if errClose := conn.Close(); errClose != nil { + t.Logf("close fixture socket: %v", errClose) + } + }() + if _, _, errReadMessage := conn.ReadMessage(); errReadMessage != nil { + return + } + for _, event := range source { + if errWriteMessage := conn.WriteMessage(websocket.TextMessage, event); errWriteMessage != nil { + return + } + } + })) + t.Cleanup(server.Close) + exec := task6RepairExecutor(transport) + if transport == "interactions" { + exec = NewGeminiInteractionsExecutor(&config.Config{}) + } + if ws { + wsExec := NewXAIWebsocketsExecutor(&config.Config{}) + wsExec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)} + exec = wsExec + } + auth := &cliproxyauth.Auth{ID: t.Name(), Provider: exec.Identifier(), Attributes: map[string]string{"api_key": "test", "base_url": server.URL, "websockets": "true"}} + return exec, auth +} + +func TestApplyPatchNamedLateIdentityActualFailures(t *testing.T) { + for _, transport := range []string{"xai", "meta", "kimi", "interactions", "ws-sse", "ws-raw"} { + for _, mode := range []string{"item-conflict", "call-conflict", "partial-snapshot", "invalid-snapshot", "eof"} { + t.Run(transport+"/"+mode, func(t *testing.T) { + interactions := transport == "interactions" + first := "item" + if mode == "call-conflict" { + first = "call" + } + source := applyPatchIdentitySource(interactions, first, "item", false) + itemPath, finalPath := "item", "response.output.0" + if interactions { + itemPath, finalPath = "step", "interaction.steps.0" + } + switch mode { + case "item-conflict", "call-conflict": + key := ".id" + if mode == "call-conflict" { + key = ".call_id" + } + source[len(source)-2], _ = sjson.SetBytes(source[len(source)-2], itemPath+key, "changed") + source[len(source)-1], _ = sjson.SetBytes(source[len(source)-1], finalPath+key, "changed") + case "partial-snapshot", "invalid-snapshot": + args := `{"input":"p` + if mode == "invalid-snapshot" { + args = `{"input":"p","extra":"RAW_SECRET"}` + } + source[0], _ = sjson.SetBytes(source[0], itemPath+".arguments", args) + case "eof": + source = source[:len(source)-2] + } + exec, auth := applyPatchIdentityExecutor(t, transport, source) + checkUsage := task6CaptureFailureUsage(t, auth.ID) + defer checkUsage() + ctx := t.Context() + if transport == "ws-raw" { + ctx = cliproxyexecutor.WithDownstreamWebsocket(ctx) + } + stream, errExecuteStream := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "grok-4", Payload: []byte(task6PatchRequest)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + failed, errors := 0, 0 + for chunk := range stream.Chunks { + if chunk.Err != nil { + errors++ + assertTask6PatchError(t, chunk.Err) + } + if strings.Contains(string(chunk.Payload), "RAW_SECRET") || strings.Contains(string(chunk.Payload), "[DONE]") { + t.Fatalf("failure leaked/completed: %s", chunk.Payload) + } + for _, payload := range applyPatchTestPayloads(chunk.Payload) { + if gjson.GetBytes(payload, "type").String() == "response.failed" { + failed++ + } else { + t.Fatalf("unresolved call published: %s", payload) + } + } + } + if failed != 1 || errors != 1 { + t.Fatalf("failure count: failed=%d errors=%d", failed, errors) + } + }) + } + } +} + +func TestApplyPatchNamedLateIdentityActualInterleaved(t *testing.T) { + for _, transport := range []string{"xai", "meta", "kimi", "interactions", "ws-sse", "ws-raw"} { + t.Run(transport, func(t *testing.T) { + interactions := transport == "interactions" + first := applyPatchIdentitySource(interactions, "item", "item", false) + second := applyPatchIdentitySource(interactions, "call", "item", false) + indexKey, itemPath, finalPath := "output_index", "item", "response.output" + if interactions { + indexKey, itemPath, finalPath = "index", "step", "interaction.steps" + } + for i, event := range second { + if i == len(second)-1 { + continue + } + if gjson.GetBytes(event, indexKey).Exists() { + event, _ = sjson.SetBytes(event, indexKey, 1) + } + if gjson.GetBytes(event, itemPath+".id").String() == "a" { + event, _ = sjson.SetBytes(event, itemPath+".id", "b") + } + if gjson.GetBytes(event, itemPath+".call_id").String() == "c" { + event, _ = sjson.SetBytes(event, itemPath+".call_id", "d") + } + if interactions && gjson.GetBytes(event, "step.index").Exists() { + event, _ = sjson.SetBytes(event, "step.index", 1) + } + event = []byte(strings.ReplaceAll(string(event), "pq", "uv")) + if i == 1 || i == 2 { + path, value := "delta", `{"input":"u` + if interactions { + path = "delta.arguments" + } + if i == 2 { + value = `v"}` + } + event, _ = sjson.SetBytes(event, path, value) + } + second[i] = event + } + terminal := first[len(first)-1] + item := gjson.GetBytes(second[len(second)-2], itemPath).Raw + terminal, _ = sjson.SetRawBytes(terminal, finalPath+".1", []byte(item)) + source := [][]byte{first[0], second[0], first[1], second[1], second[2], first[2]} + source = append(source, second[3:len(second)-1]...) + source = append(source, first[3:len(first)-1]...) + source = append(source, terminal) + exec, auth := applyPatchIdentityExecutor(t, transport, source) + ctx := t.Context() + if transport == "ws-raw" { + ctx = cliproxyexecutor.WithDownstreamWebsocket(ctx) + } + stream, errExecuteStream := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "grok-4", Payload: []byte(task6PatchRequest)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + perCall := [2][][]byte{} + for chunk := range stream.Chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + for _, event := range applyPatchTestPayloads(chunk.Payload) { + if gjson.GetBytes(event, "type").String() == "response.completed" { + if len(gjson.GetBytes(event, "response.output").Array()) != 2 { + t.Fatalf("lost final call: %s", event) + } + for index := range perCall { + item := gjson.GetBytes(event, fmt.Sprintf("response.output.%d", index)) + final, _ := sjson.SetRawBytes(event, "response.output", []byte("["+item.Raw+"]")) + perCall[index] = append(perCall[index], final) + } + } else { + index := int(gjson.GetBytes(event, "output_index").Int()) + if index < 0 || index > 1 { + t.Fatalf("unknown call index: %s", event) + } + perCall[index] = append(perCall[index], event) + } + } + } + assertApplyPatchIdentityLifecycle(t, perCall[0], "a", "c", "pq", 0, []string{"p", "q"}) + assertApplyPatchIdentityLifecycle(t, perCall[1], "b", "d", "uv", 1, []string{"u", "v"}) + }) + } +} diff --git a/internal/runtime/executor/apply_patch_integration_test.go b/internal/runtime/executor/apply_patch_integration_test.go new file mode 100644 index 000000000..4140bb3a3 --- /dev/null +++ b/internal/runtime/executor/apply_patch_integration_test.go @@ -0,0 +1,731 @@ +package executor + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps" + chatresponses "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/openai/openai/responses" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers/openai" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + "github.com/tidwall/gjson" + "google.golang.org/protobuf/encoding/protowire" +) + +const task6PatchRequest = `{"input":"patch","tools":[{"type":"custom","name":"apply_patch"}]}` + +func task6ProviderFixture(provider, mode, toolName string) string { + if mode == "empty" { + return "" + } + args := `{"input":7,"secret":"RAW_SECRET"}` + if mode == "eof" { + args = `{"input":"` + } + switch provider { + case "claude", "claude-oauth": + if mode == "nonstream" { + return fmt.Sprintf(`{"id":"r","type":"message","role":"assistant","content":[{"type":"tool_use","id":"c","name":%q,"input":%s}],"usage":{"input_tokens":5,"output_tokens":3}}`, toolName, args) + } + start := fmt.Sprintf("data: {\"type\":\"message_start\",\"message\":{\"id\":\"r\",\"model\":\"claude-sonnet-4-6\",\"usage\":{\"input_tokens\":5}}}\n\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"c\",\"name\":%q,\"input\":{}}}\n\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":%q}}\n\n", toolName, args) + if mode != "eof" { + start += "data: {\"type\":\"content_block_stop\",\"index\":0}\n\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":3}}\n\ndata: {\"type\":\"message_stop\"}\n\n" + } + return start + case "gemini", "vertex", "antigravity": + args = `{"input":7,"secret":"RAW_SECRET"}` + if mode == "eof" { + args = `{"input":"unfinished"}` + } + finish := `,"finishReason":"STOP"` + if mode == "eof" { + finish = "" + } + body := fmt.Sprintf(`{"responseId":"r","candidates":[{"content":{"parts":[{"functionCall":{"name":"apply_patch","args":%s}}]}%s}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":3}}`, args, finish) + if provider == "antigravity" { + body = `{"response":` + body + `}` + } + if mode != "nonstream" { + return "data: " + body + "\n\n" + } + return body + case "gemini-interactions": + if mode == "nonstream" { + return fmt.Sprintf(`{"id":"r","steps":[{"type":"function_call","id":"c","name":"apply_patch","arguments":%q}],"usage":{"total_input_tokens":5,"total_output_tokens":3}}`, args) + } + body := fmt.Sprintf("data: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"r\"}}\n\ndata: {\"event_type\":\"step.start\",\"index\":0,\"step\":{\"type\":\"function_call\",\"id\":\"c\",\"name\":\"apply_patch\"}}\n\ndata: {\"event_type\":\"step.delta\",\"index\":0,\"delta\":{\"type\":\"function_call\",\"arguments\":%q}}\n\n", args) + if mode != "eof" { + body += "data: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"r\"}}\n\ndata: [DONE]\n\n" + } + return body + default: + if mode == "nonstream" { + return fmt.Sprintf(`{"id":"r","object":"chat.completion","choices":[{"message":{"tool_calls":[{"id":"c","type":"function","function":{"name":"apply_patch","arguments":%q}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":3}}`, args) + } + body := fmt.Sprintf("data: {\"id\":\"r\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"c\",\"type\":\"function\",\"function\":{\"name\":\"apply_patch\",\"arguments\":%q}}]}}]}\n\n", args) + if mode != "eof" { + body += "data: {\"id\":\"r\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":3}}\n\ndata: [DONE]\n\n" + } + return body + } +} + +func task6Executor(provider string) cliproxyauth.ProviderExecutor { + cfg := &config.Config{} + switch provider { + case "claude", "claude-oauth": + return NewClaudeExecutor(cfg) + case "gemini": + return NewGeminiExecutor(cfg) + case "gemini-interactions": + return NewGeminiInteractionsExecutor(cfg) + case "vertex": + return NewGeminiVertexExecutor(cfg) + case "antigravity": + return NewAntigravityExecutor(cfg) + case "kimi-chat": + return NewKimiExecutor(cfg) + default: + return NewOpenAICompatExecutor(provider, cfg) + } +} + +func assertTask6PatchError(t *testing.T, err error) { + t.Helper() + status, okStatus := err.(interface{ StatusCode() int }) + if !okStatus || status.StatusCode() != http.StatusBadGateway || err.Error() != "Invalid apply_patch tool arguments received from upstream." { + t.Fatalf("expected clean 502, got %T %v", err, err) + } +} + +func assertTask6FailedStream(t *testing.T, chunks <-chan cliproxyexecutor.StreamChunk) { + t.Helper() + var output []byte + errors := 0 + failed := 0 + for chunk := range chunks { + output = append(output, chunk.Payload...) + for _, line := range bytes.Split(chunk.Payload, []byte("\n")) { + if bytes.HasPrefix(line, []byte("data:")) && gjson.GetBytes(bytes.TrimSpace(line[5:]), "type").String() == "response.failed" { + failed++ + } + } + if chunk.Err != nil { + errors++ + assertTask6PatchError(t, chunk.Err) + } + } + if failed != 1 || errors != 1 || bytes.Contains(output, []byte(`"type":"response.completed"`)) || bytes.Contains(output, []byte("[DONE]")) || bytes.Contains(output, []byte("RAW_SECRET")) || bytes.Contains(output, []byte(`"input":7`)) { + t.Fatalf("failure contract: failed=%d errors=%d output=%s", failed, errors, output) + } +} + +func TestApplyPatchActualProviderErrorAndEOF(t *testing.T) { + for _, provider := range []string{"custom-compat", "claude", "claude-oauth", "gemini", "gemini-interactions", "vertex", "antigravity", "kimi-chat"} { + for _, mode := range []string{"nonstream", "stream", "eof", "empty", "scanner"} { + t.Run(provider+"/"+mode, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errReadAll := io.ReadAll(r.Body) + if errReadAll != nil { + t.Error(errReadAll) + return + } + name := "apply_patch" + if strings.HasPrefix(provider, "claude") { + name = gjson.GetBytes(body, "tools.0.name").String() + } + if mode != "nonstream" { + w.Header().Set("Content-Type", "text/event-stream") + } + if mode == "scanner" { + w.Header().Set("Content-Length", "999999") + } + _, _ = io.WriteString(w, task6ProviderFixture(provider, func() string { + if mode == "scanner" { + return "eof" + } + if strings.HasPrefix(provider, "claude") && mode == "nonstream" { + return "stream" + } + return mode + }(), name)) + })) + defer server.Close() + exec := task6Executor(provider) + auth := &cliproxyauth.Auth{ID: "task6", Provider: exec.Identifier(), Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + if provider == "claude-oauth" { + auth.Attributes["api_key"] = "sk-ant-oat-test" + auth.Metadata = map[string]any{"account_uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"} + } + if provider == "antigravity" { + auth.Metadata = map[string]any{"access_token": "test", "expires_in": 3600, "timestamp": "2099-01-01T00:00:00Z", "project_id": "test"} + } + checkUsage := task6CaptureFailureUsage(t, auth.ID) + defer checkUsage() + req := cliproxyexecutor.Request{Model: "gemini-3.1-pro-preview", Payload: []byte(task6PatchRequest)} + if strings.HasPrefix(provider, "claude") { + req.Model = "claude-sonnet-4-6" + } + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, OriginalRequest: req.Payload} + if provider == "kimi-chat" { + opts.SourceFormat = sdktranslator.FormatOpenAI + opts.ResponseFormat = sdktranslator.FormatOpenAIResponse + } + if mode == "nonstream" { + response, errExecute := exec.Execute(context.Background(), auth, req, opts) + if len(response.Payload) != 0 { + t.Errorf("failed response returned payload %s", response.Payload) + } + assertTask6PatchError(t, errExecute) + } else { + stream, errExecuteStream := exec.ExecuteStream(context.Background(), auth, req, opts) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + assertTask6FailedStream(t, stream.Chunks) + } + }) + } + } +} + +func task6DevinFrames(args string, legacy bool, trailer string, names ...string) []byte { + name := "apply_patch" + if len(names) > 0 { + name = names[0] + } + var tool []byte + tool = protowire.AppendTag(tool, 1, protowire.BytesType) + tool = protowire.AppendString(tool, "c") + tool = protowire.AppendTag(tool, 2, protowire.BytesType) + tool = protowire.AppendString(tool, name) + field := protowire.Number(3) + if legacy { + field = 4 + } + tool = protowire.AppendTag(tool, field, protowire.BytesType) + tool = protowire.AppendString(tool, args) + var frame []byte + frame = protowire.AppendTag(frame, 6, protowire.BytesType) + frame = protowire.AppendBytes(frame, tool) + frames := helps.WrapConnectEnvelope(frame) + if trailer != "" { + frames = append(frames, helps.WrapConnectEnvelopeWithFlag(helps.ConnectFlagEndStream, []byte(trailer))...) + } + return frames +} + +func TestApplyPatchDevinErrorAndEOF(t *testing.T) { + for _, tc := range []struct { + name, args, trailer string + legacy bool + }{ + {"invalid", `{"input":7,"secret":"RAW_SECRET"}`, `{}`, false}, + {"legacy", `{"input":"RAW_SECRET"}`, `{}`, true}, + {"missing-trailer", `{"input":"valid"}`, "", false}, + {"error-trailer", `{"input":"valid"}`, `{"error":{"code":"internal","message":"RAW_SECRET"}}`, false}, + } { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/stream=%v", tc.name, stream), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(task6DevinFrames(tc.args, tc.legacy, tc.trailer)) + })) + defer server.Close() + exec := NewDevinExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ID: "task6-devin", Provider: "devin", Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + checkUsage := task6CaptureFailureUsage(t, auth.ID) + defer checkUsage() + req := cliproxyexecutor.Request{Model: "devin/swe-2", Payload: []byte(task6PatchRequest)} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, OriginalRequest: req.Payload} + if stream { + result, errExecuteStream := exec.ExecuteStream(t.Context(), auth, req, opts) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + assertTask6FailedStream(t, result.Chunks) + } else { + result, errExecute := exec.Execute(t.Context(), auth, req, opts) + if len(result.Payload) > 0 { + t.Fatalf("failed request returned %s", result.Payload) + } + assertTask6PatchError(t, errExecute) + } + }) + } + } +} + +type task6UsageCapture struct { + id string + records chan usage.Record +} + +func (p *task6UsageCapture) HandleUsage(_ context.Context, record usage.Record) { + if record.AuthID == p.id || record.RequestID == p.id+"-barrier" { + p.records <- record + } +} +func task6CaptureFailureUsage(t *testing.T, id string) func() { + t.Helper() + capture := &task6UsageCapture{id: id, records: make(chan usage.Record, 32)} + usage.RegisterNamedPlugin("task6-patch-failure", capture) + return func() { + usage.PublishRecord(context.Background(), usage.Record{RequestID: id + "-barrier"}) + count := 0 + for { + select { + case record := <-capture.records: + if record.RequestID == id+"-barrier" { + if count != 1 { + t.Errorf("usage records=%d, want one failure", count) + } + return + } + count++ + if !record.Failed { + t.Errorf("invalid patch published success usage: %+v", record) + } + case <-time.After(3 * time.Second): + t.Fatal("usage barrier did not arrive") + } + } + } +} + +func TestApplyPatchResponsesInvalidTerminalUsage(t *testing.T) { + for _, provider := range []string{"xai", "meta", "kimi"} { + t.Run(provider, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"type\":\"response.created\",\"response\":{\"id\":\"r\"}}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"output\":[{\"type\":\"function_call\",\"id\":\"c\",\"name\":\"apply_patch\",\"arguments\":\"{}\"}],\"usage\":{\"input_tokens\":5,\"output_tokens\":3}}}\n\n") + })) + defer server.Close() + var exec cliproxyauth.ProviderExecutor + switch provider { + case "xai": + exec = NewXAIExecutor(&config.Config{}) + case "meta": + exec = NewMetaExecutor(&config.Config{}) + case "kimi": + exec = NewKimiExecutor(&config.Config{}) + } + auth := &cliproxyauth.Auth{ID: "task6-invalid-terminal", Provider: provider, Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + checkUsage := task6CaptureFailureUsage(t, auth.ID) + defer checkUsage() + req := cliproxyexecutor.Request{Model: "grok-4", Payload: []byte(task6PatchRequest)} + result, errExecuteStream := exec.ExecuteStream(t.Context(), auth, req, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, OriginalRequest: req.Payload}) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + assertTask6FailedStream(t, result.Chunks) + }) + } +} + +func task6Gateway(t *testing.T, exec cliproxyauth.ProviderExecutor, auth *cliproxyauth.Auth, stream bool) *httptest.ResponseRecorder { + t.Helper() + gin.SetMode(gin.TestMode) + manager := cliproxyauth.NewManager(nil, nil, nil) + manager.RegisterExecutor(exec) + auth.Status = cliproxyauth.StatusActive + if _, errRegister := manager.Register(t.Context(), auth); errRegister != nil { + t.Fatal(errRegister) + } + registry.GetGlobalRegistry().RegisterClient(auth.ID, exec.Identifier(), []*registry.ModelInfo{{ID: "task6-public-patch"}}) + t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient(auth.ID) }) + h := openai.NewOpenAIResponsesAPIHandler(handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)) + router := gin.New() + router.POST("/v1/responses", h.Responses) + request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(fmt.Sprintf(`{"model":"task6-public-patch","input":"patch","stream":%v,"tools":[{"type":"custom","name":"apply_patch"}]}`, stream))) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", "Codex Desktop/26.803.41515") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + return recorder +} + +func TestApplyPatchHTTPGatewayErrorMatrix(t *testing.T) { + for _, provider := range []string{"custom-compat", "claude", "claude-oauth", "gemini", "gemini-interactions", "vertex", "antigravity", "devin", "xai", "meta", "kimi"} { + for _, mode := range []string{"nonstream", "stream", "eof"} { + t.Run(provider+"/"+mode, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errReadAll := io.ReadAll(r.Body) + if errReadAll != nil { + t.Error(errReadAll) + return + } + name := "apply_patch" + actualMode := mode + if strings.HasPrefix(provider, "claude") { + name = gjson.GetBytes(body, "tools.0.name").String() + if mode == "nonstream" { + actualMode = "stream" + } + } + if provider == "devin" { + trailer := `{}` + if mode == "eof" { + trailer = "" + } + _, _ = w.Write(task6DevinFrames(`{"input":7,"secret":"RAW_SECRET"}`, false, trailer)) + return + } + if provider == "xai" || provider == "meta" || provider == "kimi" { + if provider == "kimi" && mode == "nonstream" { + _, _ = io.WriteString(w, `{"output":[{"type":"function_call","name":"apply_patch","arguments":"{}"}]}`) + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"type\":\"response.created\",\"response\":{\"id\":\"r\"}}\n\n") + if mode == "eof" { + _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"c\",\"type\":\"function_call\",\"name\":\"apply_patch\",\"arguments\":\"\"}}\n\n") + return + } + _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"output\":[{\"type\":\"function_call\",\"name\":\"apply_patch\",\"arguments\":\"{}\"}],\"usage\":{\"input_tokens\":5,\"output_tokens\":3}}}\n\n") + return + } + if actualMode != "nonstream" { + w.Header().Set("Content-Type", "text/event-stream") + } + _, _ = io.WriteString(w, task6ProviderFixture(provider, actualMode, name)) + })) + defer server.Close() + exec := task6Executor(provider) + switch provider { + case "devin": + exec = NewDevinExecutor(&config.Config{}) + case "xai": + exec = NewXAIExecutor(&config.Config{}) + case "meta": + exec = NewMetaExecutor(&config.Config{}) + case "kimi": + exec = NewKimiExecutor(&config.Config{}) + } + auth := &cliproxyauth.Auth{ID: "task6-http-" + provider + mode, Provider: exec.Identifier(), Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + if provider == "claude-oauth" { + auth.Attributes["api_key"] = "sk-ant-oat-test" + auth.Metadata = map[string]any{"account_uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"} + } + if provider == "antigravity" { + auth.Metadata = map[string]any{"access_token": "test", "expires_in": 3600, "timestamp": "2099-01-01T00:00:00Z", "project_id": "test"} + } + checkUsage := task6CaptureFailureUsage(t, auth.ID) + defer checkUsage() + result := task6Gateway(t, exec, auth, mode != "nonstream") + body := result.Body.String() + if mode == "nonstream" { + if result.Code != http.StatusBadGateway || !strings.Contains(body, "Invalid apply_patch tool arguments received from upstream.") { + t.Fatalf("nonstream did not return clean 502: %d %s", result.Code, body) + } + } else if result.Code != http.StatusOK || strings.Count(body, "event: response.failed") != 1 || strings.Contains(body, "event: response.completed") || strings.Contains(body, "data: [DONE]") || strings.Contains(body, "event: error") { + t.Fatalf("HTTP stream failure contract: status=%d body=%s", result.Code, body) + } + if strings.Contains(body, "RAW_SECRET") || strings.Contains(body, `"input":7`) { + t.Fatal("raw invalid arguments leaked over HTTP") + } + }) + } + } +} + +// task6DrainUsage synchronizes the asynchronous dispatcher before changing observers. +func task6DrainUsage(t *testing.T) { + t.Helper() + id := "task6-drain-" + t.Name() + capture := &task6UsageCapture{id: id, records: make(chan usage.Record, 1)} + usage.RegisterNamedPlugin("task6-patch-failure", capture) + usage.PublishRecord(context.Background(), usage.Record{RequestID: id + "-barrier"}) + select { + case <-capture.records: + case <-time.After(3 * time.Second): + t.Fatal("usage drain barrier did not arrive") + } +} + +func TestApplyPatchSDKOriginalRequestFallback(t *testing.T) { + for _, provider := range []string{"custom-compat", "gemini", "gemini-interactions", "vertex"} { + t.Run(provider, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, task6ProviderFixture(provider, "nonstream", "apply_patch")) + })) + defer server.Close() + exec := task6Executor(provider) + auth := &cliproxyauth.Auth{Provider: exec.Identifier(), Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + response, errExecute := exec.Execute(t.Context(), auth, cliproxyexecutor.Request{Model: "gemini-3.1-pro-preview", Payload: []byte(task6PatchRequest)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) + if len(response.Payload) > 0 { + t.Error("SDK lost original declaration and returned successful raw arguments") + } + assertTask6PatchError(t, errExecute) + }) + } +} + +func TestApplyPatchFailureStopsConsumptionAndNextAttemptIsFresh(t *testing.T) { + stopped := make(chan struct{}) + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if calls == 0 { + calls++ + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, task6ProviderFixture("custom-compat", "stream", "apply_patch")) + w.(http.Flusher).Flush() + <-r.Context().Done() + close(stopped) + return + } + _, _ = io.WriteString(w, strings.ReplaceAll(task6ProviderFixture("custom-compat", "nonstream", "apply_patch"), `\"input\":7,\"secret\":\"RAW_SECRET\"`, `\"input\":\"valid patch\"`)) + })) + defer server.Close() + exec := NewOpenAICompatExecutor("custom-compat", &config.Config{}) + auth := &cliproxyauth.Auth{Provider: "custom-compat", Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + req := cliproxyexecutor.Request{Model: "patch-model", Payload: []byte(task6PatchRequest)} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, OriginalRequest: req.Payload} + stream, errExecuteStream := exec.ExecuteStream(t.Context(), auth, req, opts) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + assertTask6FailedStream(t, stream.Chunks) + select { + case <-stopped: + case <-time.After(3 * time.Second): + t.Fatal("failed attempt kept consuming the upstream") + } + response, errExecute := exec.Execute(t.Context(), auth, req, opts) + if errExecute != nil || gjson.GetBytes(response.Payload, "output.0.input").String() != "valid patch" { + t.Fatalf("next attempt reused failed state: %s %v", response.Payload, errExecute) + } +} + +func TestApplyPatchXAIWebsocketFailureMatrix(t *testing.T) { + for _, mode := range []string{"invalid", "eof"} { + for _, raw := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/raw=%v", mode, raw), func(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, errUpgrade := upgrader.Upgrade(w, r, nil) + if errUpgrade != nil { + return + } + defer func() { + if errClose := conn.Close(); errClose != nil { + t.Log(errClose) + } + }() + if _, _, errReadMessage := conn.ReadMessage(); errReadMessage != nil { + return + } + _ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.created","response":{"id":"r"}}`)) + if mode == "eof" { + _ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"c","name":"apply_patch","arguments":""}}`)) + } else { + _ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","name":"apply_patch","arguments":"{\"input\":7,\"secret\":\"RAW_SECRET\"}"}],"usage":{"input_tokens":5,"output_tokens":3}}}`)) + } + })) + defer server.Close() + exec := NewXAIWebsocketsExecutor(&config.Config{}) + exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)} + auth := &cliproxyauth.Auth{ID: "task6-ws-" + t.Name(), Provider: "xai", Attributes: map[string]string{"api_key": "test", "base_url": server.URL, "websockets": "true"}} + checkUsage := task6CaptureFailureUsage(t, auth.ID) + defer checkUsage() + if !raw { + response := task6Gateway(t, exec, auth, true) + body := response.Body.String() + if response.Code != http.StatusOK || strings.Count(body, "event: response.failed") != 1 || strings.Contains(body, "event: response.completed") || strings.Contains(body, "RAW_SECRET") || strings.Contains(body, "data: [DONE]") { + t.Fatalf("WS to HTTP failure contract: %d %s", response.Code, body) + } + return + } + result, errExecuteStream := exec.ExecuteStream(cliproxyexecutor.WithDownstreamWebsocket(t.Context()), auth, cliproxyexecutor.Request{Model: "grok-4", Payload: []byte(task6PatchRequest)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + failed, errors := 0, 0 + for chunk := range result.Chunks { + if chunk.Err != nil { + errors++ + assertTask6PatchError(t, chunk.Err) + } + if gjson.GetBytes(chunk.Payload, "type").String() == "response.failed" { + failed++ + } + if bytes.Contains(chunk.Payload, []byte("RAW_SECRET")) || gjson.GetBytes(chunk.Payload, "type").String() == "response.completed" { + t.Fatal("raw WS failure leaked or completed") + } + } + if failed != 1 || errors != 1 { + t.Fatalf("raw WS failures=%d errors=%d", failed, errors) + } + }) + } + } +} + +func TestApplyPatchNonStreamNativeNilWithoutErrorIs502(t *testing.T) { + // Preserve the built-in request transform and restore the exact response transforms. + sdktranslator.Register(sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, nil, sdktranslator.ResponseTransform{ + Stream: chatresponses.ConvertOpenAIChatCompletionsResponseToOpenAIResponses, + NonStream: func(context.Context, string, []byte, []byte, []byte, *any) []byte { return nil }, + }) + defer sdktranslator.Register(sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, nil, sdktranslator.ResponseTransform{ + Stream: chatresponses.ConvertOpenAIChatCompletionsResponseToOpenAIResponses, + NonStream: chatresponses.ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream, + }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, task6ProviderFixture("custom-compat", "nonstream", "apply_patch")) + })) + defer server.Close() + exec := NewOpenAICompatExecutor("custom-compat", &config.Config{}) + auth := &cliproxyauth.Auth{ID: "task6-nil-translation", Provider: "custom-compat", Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + checkUsage := task6CaptureFailureUsage(t, auth.ID) + defer checkUsage() + response, errExecute := exec.Execute(t.Context(), auth, cliproxyexecutor.Request{Model: "m", Payload: []byte(task6PatchRequest)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) + if len(response.Payload) != 0 { + t.Errorf("native nil recovered via usage normalization: %s", response.Payload) + } + assertTask6PatchError(t, errExecute) +} + +func TestApplyPatchDevinLegacyOtherToolsPreserved(t *testing.T) { + for _, custom := range []bool{false, true} { + t.Run(fmt.Sprintf("custom=%v", custom), func(t *testing.T) { + name, kind := "apply_patch", "function" + if custom { + name, kind = "other_custom", "custom" + } + const input = `opaque legacy freeform` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(task6DevinFrames(input, true, `{}`, name)) + })) + defer server.Close() + exec := NewDevinExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Provider: "devin", Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + req := cliproxyexecutor.Request{Model: "devin/swe-2", Payload: []byte(fmt.Sprintf(`{"input":"hi","tools":[{"type":%q,"name":%q}]}`, kind, name))} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse} + response, errExecute := exec.Execute(t.Context(), auth, req, opts) + if errExecute != nil { + t.Fatal(errExecute) + } + key, expectedType := "arguments", "function_call" + if custom { + key, expectedType = "input", "custom_tool_call" + } + item := gjson.GetBytes(response.Payload, "output.0") + if item.Get("type").String() != expectedType || item.Get(key).String() != input { + t.Fatalf("legacy provenance changed: %s", response.Payload) + } + stream, errExecuteStream := exec.ExecuteStream(t.Context(), auth, req, opts) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + var output []byte + for chunk := range stream.Chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + output = append(output, chunk.Payload...) + } + if !bytes.Contains(output, []byte(`"type":"response.completed"`)) || bytes.Contains(output, []byte(`"type":"response.failed"`)) { + t.Fatalf("legacy stream falsely rejected: %s", output) + } + }) + } +} + +func TestApplyPatchInteractionsSourceFailureIsSealed(t *testing.T) { + for _, streaming := range []bool{false, true} { + t.Run(fmt.Sprintf("stream=%v", streaming), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !streaming { + _, _ = io.WriteString(w, `{"id":"r","status":"failed","error":{"message":"RAW_SECRET"}}`) + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"r\"}}\n\ndata: {\"event_type\":\"interaction.failed\",\"interaction\":{\"id\":\"r\"},\"error\":{\"message\":\"RAW_SECRET\"}}\n\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"r\",\"usage\":{\"total_input_tokens\":5}}}\n\ndata: [DONE]\n\n") + })) + defer server.Close() + exec := NewGeminiInteractionsExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ID: "task6-source-failed", Provider: "gemini-interactions", Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + checkUsage := task6CaptureFailureUsage(t, auth.ID) + defer checkUsage() + if !streaming { + response, errExecute := exec.Execute(t.Context(), auth, cliproxyexecutor.Request{Model: "gemini-3.1-pro-preview", Payload: []byte(task6PatchRequest)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) + if len(response.Payload) > 0 { + t.Errorf("upstream failed response returned success: %s", response.Payload) + } + assertTask6PatchError(t, errExecute) + return + } + stream, errExecuteStream := exec.ExecuteStream(t.Context(), auth, cliproxyexecutor.Request{Model: "gemini-3.1-pro-preview", Payload: []byte(task6PatchRequest)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + assertTask6FailedStream(t, stream.Chunks) + + }) + } +} + +func TestApplyPatchDevinLateFailureStopsConsumption(t *testing.T) { + stopped := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/connect+proto") + _, _ = w.Write(task6DevinFrames(`{"input":7,"secret":"RAW_SECRET"}`, false, "", "")) + _, _ = w.Write(task6DevinFrames("", false, "", "apply_patch")) + w.(http.Flusher).Flush() + <-r.Context().Done() + close(stopped) + })) + defer server.Close() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + exec := NewDevinExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ID: "task6-devin-late", Provider: "devin", Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + checkUsage := task6CaptureFailureUsage(t, auth.ID) + defer checkUsage() + stream, errExecuteStream := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "devin/swe-2", Payload: []byte(task6PatchRequest)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + consumed := make(chan []cliproxyexecutor.StreamChunk, 1) + go func() { + var chunks []cliproxyexecutor.StreamChunk + for chunk := range stream.Chunks { + chunks = append(chunks, chunk) + } + consumed <- chunks + }() + select { + case chunks := <-consumed: + buffered := make(chan cliproxyexecutor.StreamChunk, len(chunks)) + for _, chunk := range chunks { + buffered <- chunk + } + close(buffered) + assertTask6FailedStream(t, buffered) + case <-time.After(3 * time.Second): + cancel() + <-consumed + t.Fatal("late tool identity failure did not stop upstream consumption") + } + select { + case <-stopped: + case <-time.After(3 * time.Second): + t.Fatal("failed Devin source was not closed") + } +} diff --git a/internal/runtime/executor/apply_patch_repair_test.go b/internal/runtime/executor/apply_patch_repair_test.go new file mode 100644 index 000000000..8090a5f5f --- /dev/null +++ b/internal/runtime/executor/apply_patch_repair_test.go @@ -0,0 +1,623 @@ +package executor + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + codexresponses "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/codex/openai/responses" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + "github.com/tidwall/gjson" +) + +// task6RepairSource completes tool input without implicitly completing the response. +func task6RepairSource(mode string) [][]byte { + if mode == "empty" { + return nil + } + item := `{"type":"function_call","id":"a","call_id":"c","name":"apply_patch","arguments":"{\"input\":\"valid patch\"}"}` + events := [][]byte{[]byte(`{"type":"response.created","response":{"id":"r"}}`)} + if mode == "args-eof" || mode == "done" { + events = append(events, + []byte(`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":"apply_patch","arguments":""}}`), + []byte(`{"type":"response.function_call_arguments.done","output_index":0,"item_id":"a","call_id":"c","arguments":"{\"input\":\"valid patch\"}"}`)) + } else { + events = append(events, []byte(`{"type":"response.output_item.done","output_index":0,"item":`+item+`}`)) + } + if mode == "done" { + events = append(events, []byte("[DONE]")) + } else if strings.HasPrefix(mode, "response.") { + events = append(events, []byte(fmt.Sprintf(`{"type":%q,"response":{"id":"r","output":[%s],"usage":{"input_tokens":5,"output_tokens":3}}}`, mode, item))) + } + return events +} + +func task6RepairSSE(events [][]byte) string { + var body strings.Builder + for _, event := range events { + body.WriteString("data: ") + body.Write(event) + body.WriteString("\n\n") + } + return body.String() +} + +func task6RepairExecutor(provider string) cliproxyauth.ProviderExecutor { + switch provider { + case "meta": + return NewMetaExecutor(&config.Config{}) + case "kimi": + return NewKimiExecutor(&config.Config{}) + default: + return NewXAIExecutor(&config.Config{}) + } +} + +func TestApplyPatchRepairResponsesSourceTerminalHTTP(t *testing.T) { + for _, provider := range []string{"xai", "meta", "kimi"} { + for _, mode := range []string{"args-eof", "item-eof", "empty", "done", "response.completed", "response.incomplete", "response.done"} { + t.Run(provider+"/"+mode, func(t *testing.T) { + body := task6RepairSSE(task6RepairSource(mode)) + if strings.HasPrefix(mode, "response.") { + body += "data: [DONE]\n\ndata: [DONE]\n\n" + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, body) + })) + defer server.Close() + exec := task6RepairExecutor(provider) + auth := &cliproxyauth.Auth{ID: t.Name(), Provider: provider, Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + req := cliproxyexecutor.Request{Model: "grok-4", Payload: []byte(task6PatchRequest)} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse} + if !strings.HasPrefix(mode, "response.") { + checkUsage := task6CaptureFailureUsage(t, auth.ID) + stream, errExecuteStream := exec.ExecuteStream(t.Context(), auth, req, opts) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + assertTask6FailedStream(t, stream.Chunks) + checkUsage() + // Exercise the gateway too, but do not rely on its final HTTP validator. + auth.ID += "-gateway" + checkGatewayUsage := task6CaptureFailureUsage(t, auth.ID) + result := task6Gateway(t, exec, auth, true) + output := result.Body.String() + if strings.Count(output, "event: response.failed") != 1 || strings.Contains(output, "event: error") || strings.Contains(output, "response.completed") || strings.Contains(output, "[DONE]") { + t.Errorf("gateway failure contract: %d %s", result.Code, output) + } + checkGatewayUsage() + return + } + stream, errExecuteStream := exec.ExecuteStream(t.Context(), auth, req, opts) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + var output []byte + for chunk := range stream.Chunks { + if chunk.Err != nil { + t.Error(chunk.Err) + } + output = append(output, chunk.Payload...) + } + if bytes.Contains(output, []byte("response.failed")) || !bytes.Contains(output, []byte("valid patch")) || bytes.Count(output, []byte("[DONE]")) != 1 { + t.Fatalf("legal source terminal/first DONE lost: %s", output) + } + }) + } + } +} + +func task6RepairChunkType(payload []byte) string { + payload = bytes.TrimSpace(payload) + if bytes.HasPrefix(payload, []byte("data:")) { + payload = bytes.TrimSpace(payload[5:]) + } + return gjson.GetBytes(payload, "type").String() +} + +func task6RepairReadStream(t *testing.T, stream *cliproxyexecutor.StreamResult, failure bool) { + t.Helper() + failed, cleanErrors, completed := 0, 0, 0 + validPatch := false + for chunk := range stream.Chunks { + validPatch = validPatch || bytes.Contains(chunk.Payload, []byte("valid patch")) + if chunk.Err != nil { + cleanErrors++ + if failure { + assertTask6PatchError(t, chunk.Err) + } else { + t.Error(chunk.Err) + } + } + switch task6RepairChunkType(chunk.Payload) { + case "response.failed": + failed++ + case "response.completed", "response.done", "response.incomplete": + completed++ + } + if bytes.Contains(chunk.Payload, []byte("RAW_SECRET")) || (failure && bytes.Contains(chunk.Payload, []byte("[DONE]"))) { + t.Errorf("invalid source leaked: %s", chunk.Payload) + } + } + if failure && (failed != 1 || cleanErrors != 1 || completed != 0) { + t.Fatalf("failure counts: failed=%d errors=%d completed=%d", failed, cleanErrors, completed) + } + if !failure && (failed != 0 || cleanErrors != 0 || completed != 1 || !validPatch) { + t.Fatalf("success counts: failed=%d errors=%d completed=%d", failed, cleanErrors, completed) + } +} + +func TestApplyPatchRepairXAIWebsocketSourceTerminal(t *testing.T) { + for _, raw := range []bool{false, true} { + for _, mode := range []string{"args-eof", "item-eof", "empty", "done", "response.completed", "response.done", "response.incomplete"} { + t.Run(fmt.Sprintf("%s/raw=%v", mode, raw), func(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, errUpgrade := upgrader.Upgrade(w, r, nil) + if errUpgrade != nil { + return + } + defer func() { + if errClose := conn.Close(); errClose != nil { + t.Log(errClose) + } + }() + if _, _, errReadMessage := conn.ReadMessage(); errReadMessage != nil { + return + } + for _, event := range task6RepairSource(mode) { + if errWriteMessage := conn.WriteMessage(websocket.TextMessage, event); errWriteMessage != nil { + return + } + } + })) + defer server.Close() + exec := NewXAIWebsocketsExecutor(&config.Config{}) + exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)} + auth := &cliproxyauth.Auth{ID: t.Name(), Provider: "xai", Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + failure := !strings.HasPrefix(mode, "response.") + if failure { + checkUsage := task6CaptureFailureUsage(t, auth.ID) + defer checkUsage() + } + ctx := t.Context() + if raw { + ctx = cliproxyexecutor.WithDownstreamWebsocket(ctx) + } + stream, errExecuteStream := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "grok-4", Payload: []byte(task6PatchRequest)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + task6RepairReadStream(t, stream, failure) + }) + } + } +} + +type task6RepairLifecycle struct { + mu sync.Mutex + close func() error + ended chan string + once sync.Once +} + +func (l *task6RepairLifecycle) Bind(closeResource func() error) error { + l.mu.Lock() + l.close = closeResource + l.mu.Unlock() + return nil +} +func (l *task6RepairLifecycle) End(reason string) { l.once.Do(func() { l.ended <- reason }) } +func (*task6RepairLifecycle) Retain() {} +func (l *task6RepairLifecycle) detach() error { + l.mu.Lock() + closeResource := l.close + l.mu.Unlock() + if closeResource == nil { + return errors.New("lifecycle was not bound") + } + return closeResource() +} + +func task6RepairAwait(t *testing.T, ch <-chan struct{}, label string) { + t.Helper() + select { + case <-ch: + case <-time.After(3 * time.Second): + t.Fatalf("watchdog: %s", label) + } +} + +func TestApplyPatchRepairXAIWebsocketPersistentFailureRetry(t *testing.T) { + for _, branch := range []string{"bridge", "translator", "translator-terminal", "translator-warmup"} { + for _, raw := range []bool{false, true} { + if strings.HasPrefix(branch, "translator") && raw { + // Raw downstream WebSockets deliberately bypass the SDK translator. + continue + } + for _, detach := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/raw=%v/detach=%v", branch, raw, detach), func(t *testing.T) { + if strings.HasPrefix(branch, "translator") { + // Inject only the optional translator failure; retain the real executor/socket. + sdktranslator.Register(sdktranslator.FormatOpenAIResponse, sdktranslator.FormatCodex, nil, sdktranslator.ResponseTransform{Stream: func(ctx context.Context, model string, original, effective, event []byte, param *any) [][]byte { + payload := bytes.TrimSpace(bytes.TrimPrefix(event, []byte("data: "))) + if gjson.GetBytes(payload, "type").String() == "repair.invalid" || (gjson.GetBytes(payload, "type").String() == "response.completed" && gjson.GetBytes(payload, "response.id").String() == "repair-invalid") { + state := &translatorcommon.ApplyPatchErrorState{} + state.SetToolInputError(errors.New("RAW_SECRET")) + *param = state + return [][]byte{[]byte("data: " + string(translatorcommon.ApplyPatchFailure("r", 1)) + "\n\n")} + } + return codexresponses.ConvertCodexResponseToOpenAIResponses(ctx, model, original, effective, event, param) + }}) + defer sdktranslator.Register(sdktranslator.FormatOpenAIResponse, sdktranslator.FormatCodex, nil, sdktranslator.ResponseTransform{Stream: codexresponses.ConvertCodexResponseToOpenAIResponses, NonStream: codexresponses.ConvertCodexResponseToOpenAIResponsesNonStream}) + } + oldClosed := make(chan struct{}) + freshRequest := make(chan struct{}) + releaseSuccess := make(chan struct{}) + var connections atomic.Int32 + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, errUpgrade := upgrader.Upgrade(w, r, nil) + if errUpgrade != nil { + return + } + n := connections.Add(1) + defer func() { + if errClose := conn.Close(); errClose != nil { + t.Log(errClose) + } + }() + if _, _, errReadMessage := conn.ReadMessage(); errReadMessage != nil { + return + } + if n == 1 { + events := [][]byte{[]byte(`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":"apply_patch","arguments":""}}`), []byte(`{"type":"response.function_call_arguments.delta","item_id":"a","output_index":0,"delta":"{\"input\":7,\"secret\":\"RAW_SECRET\"}"}`)} + if branch == "translator" { + events = [][]byte{[]byte(`{"type":"repair.invalid"}`)} + } else if branch == "translator-terminal" { + events = [][]byte{[]byte(`{"type":"response.completed","response":{"id":"repair-invalid","output":[],"usage":{"input_tokens":5,"output_tokens":3}}}`)} + } else if branch == "translator-warmup" { + events = [][]byte{[]byte(`{"type":"response.created","response":{"id":"repair-invalid"}}`)} + } + for _, event := range events { + if errWriteMessage := conn.WriteMessage(websocket.TextMessage, event); errWriteMessage != nil { + return + } + } + // Leave the attempt open. A same-socket retry would get stale output. + if _, _, errReadMessage := conn.ReadMessage(); errReadMessage == nil { + _ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.completed","response":{"id":"stale","output":[]}}`)) + t.Error("failed socket accepted a second request") + } else { + close(oldClosed) + } + return + } + close(freshRequest) + <-releaseSuccess + for _, event := range task6RepairSource("response.completed") { + if errWriteMessage := conn.WriteMessage(websocket.TextMessage, event); errWriteMessage != nil { + return + } + } + _, _, _ = conn.ReadMessage() + })) + defer server.Close() + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseSuccess) }) } + defer release() + exec := NewXAIWebsocketsExecutor(&config.Config{}) + exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)} + exec.idStore = &xaiWebsocketIDStateStore{sessions: make(map[string]*xaiWebsocketIDState)} + sessionID := t.Name() + defer exec.CloseExecutionSession(sessionID) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if raw { + ctx = cliproxyexecutor.WithDownstreamWebsocket(ctx) + } + auth := &cliproxyauth.Auth{ID: t.Name(), Provider: "xai", Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + req := cliproxyexecutor.Request{Model: "grok-4", Payload: []byte(task6PatchRequest)} + if branch == "translator-warmup" { + req.Payload = []byte(`{"generate":false,"input":"patch","tools":[{"type":"custom","name":"apply_patch"}]}`) + } + oldLifecycle := &task6RepairLifecycle{ended: make(chan string, 1)} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: sessionID}, ExecutionLifecycle: oldLifecycle} + checkUsage := task6CaptureFailureUsage(t, auth.ID) + first, errExecuteStream := exec.ExecuteStream(ctx, auth, req, opts) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + // The failure frame is a synchronization point before the request lock releases. + failed := 0 + for chunk := range first.Chunks { + if bytes.Contains(chunk.Payload, []byte("RAW_SECRET")) || task6RepairChunkType(chunk.Payload) == "response.completed" { + t.Fatalf("failed attempt leaked/completed: %s", chunk.Payload) + } + if task6RepairChunkType(chunk.Payload) != "response.failed" { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + continue + } + failed++ + sess := exec.getOrCreateSession(sessionID) + sess.connMu.Lock() + live := sess.conn != nil + sess.connMu.Unlock() + if live { + t.Fatal("validation failure delivered before invalidating persistent connection") + } + break + } + if failed != 1 { + t.Fatal("missing clean failure frame") + } + // Retry starts while the first attempt is blocked delivering its clean error. + type attempt struct { + stream *cliproxyexecutor.StreamResult + err error + } + secondResult := make(chan attempt, 1) + req.Payload = []byte(task6PatchRequest) + opts.ExecutionLifecycle = &task6RepairLifecycle{ended: make(chan string, 1)} + go func() { + stream, errRetry := exec.ExecuteStream(ctx, auth, req, opts) + secondResult <- attempt{stream: stream, err: errRetry} + }() + errorCount := 0 + for chunk := range first.Chunks { + if chunk.Err != nil { + errorCount++ + assertTask6PatchError(t, chunk.Err) + } else { + t.Errorf("extra failure payload: %s", chunk.Payload) + } + } + if errorCount != 1 { + t.Errorf("clean errors=%d, want one", errorCount) + } + checkUsage() + task6RepairAwait(t, oldClosed, "first attempt must close open upstream") + task6RepairAwait(t, freshRequest, "retry must use a fresh connection") + select { + case reason := <-oldLifecycle.ended: + if reason != "invalid_tool_arguments" { + t.Errorf("lifecycle ended as %q", reason) + } + default: + t.Error("failed lifecycle was not ended") + } + if detach { + // A stale bound-resource callback races with the new active connection. + detached := make(chan struct{}) + go func() { + if errDetach := oldLifecycle.detach(); errDetach != nil { + t.Error(errDetach) + } + close(detached) + }() + task6RepairAwait(t, detached, "stale lifecycle detach") + } + release() + second := <-secondResult + if second.err != nil { + t.Fatal(second.err) + } + task6RepairReadStream(t, second.stream, false) + task6DrainUsage(t) + if connections.Load() != 2 { + t.Errorf("connections=%d, want two", connections.Load()) + } + }) + } + } + } +} + +func TestApplyPatchRepairGatewayInitializesGinTestMode(t *testing.T) { + // Simulate an isolated fixture starting in Gin's default mode, not suite order. + gin.SetMode(gin.DebugMode) + defer gin.SetMode(gin.TestMode) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, task6ProviderFixture("custom-compat", "nonstream", "apply_patch")) + })) + defer server.Close() + exec := NewOpenAICompatExecutor("custom-compat", &config.Config{}) + auth := &cliproxyauth.Auth{ID: t.Name(), Provider: exec.Identifier(), Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + result := task6Gateway(t, exec, auth, false) + if result.Code != http.StatusBadGateway { + t.Fatalf("expected fixture error path, got %d", result.Code) + } + if gin.Mode() != gin.TestMode { + t.Fatalf("shared gateway fixture left Gin in %q", gin.Mode()) + } +} + +func TestApplyPatchRepairResponsesSourceTerminalNonStream(t *testing.T) { + for _, provider := range []string{"xai", "meta", "kimi"} { + for _, mode := range []string{"args-eof", "item-eof", "empty", "done"} { + if provider == "kimi" && mode != "empty" { + // Kimi's non-stream endpoint returns JSON, not a buffered SSE response. + continue + } + t.Run(provider+"/"+mode, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, task6RepairSSE(task6RepairSource(mode))) + })) + defer server.Close() + exec := task6RepairExecutor(provider) + auth := &cliproxyauth.Auth{ID: t.Name(), Provider: provider, Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + checkUsage := task6CaptureFailureUsage(t, auth.ID) + response, errExecute := exec.Execute(t.Context(), auth, cliproxyexecutor.Request{Model: "grok-4", Payload: []byte(task6PatchRequest)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) + if len(response.Payload) != 0 { + t.Errorf("failed source returned payload: %s", response.Payload) + } + assertTask6PatchError(t, errExecute) + checkUsage() + auth.ID += "-gateway" + checkGatewayUsage := task6CaptureFailureUsage(t, auth.ID) + result := task6Gateway(t, exec, auth, false) + if result.Code != http.StatusBadGateway || !strings.Contains(result.Body.String(), "Invalid apply_patch tool arguments received from upstream.") { + t.Errorf("non-stream gateway did not return clean 502: %d %s", result.Code, result.Body) + } + checkGatewayUsage() + }) + } + } +} + +func TestApplyPatchRepairXAIWebsocketCompactionUsage(t *testing.T) { + const invalidState = `{"id":"resp_empty","output":[]}` + const validState = `{"id":"resp_compact","output":[{"type":"compaction","encrypted_content":"opaque-state"}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}` + for _, tc := range []struct { + name string + body string + httpStatus int + wantStatus int + wantRecords int + emptyInput bool + passthrough bool + }{ + {name: "invalid-state", body: invalidState, httpStatus: http.StatusOK, wantStatus: http.StatusBadGateway, wantRecords: 1}, + {name: "valid-state", body: validState, httpStatus: http.StatusOK, wantRecords: 1}, + {name: "http-error", body: `{"error":{"message":"compact rejected"}}`, httpStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest, wantRecords: 1}, + {name: "missing-reporter-empty-context", body: invalidState, httpStatus: http.StatusOK, wantStatus: http.StatusBadRequest, emptyInput: true}, + {name: "http-compact-passthrough", body: invalidState, httpStatus: http.StatusOK, wantRecords: 1, passthrough: true}, + } { + t.Run(tc.name, func(t *testing.T) { + requests := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/responses/compact" { + t.Errorf("unexpected compact request: %s %s", r.Method, r.URL.Path) + } + body, errReadAll := io.ReadAll(r.Body) + if errReadAll != nil { + t.Errorf("read compact request: %v", errReadAll) + } + requests <- body + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.httpStatus) + _, _ = io.WriteString(w, tc.body) + })) + defer server.Close() + exec := NewXAIWebsocketsExecutor(&config.Config{}) + exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)} + exec.idStore = &xaiWebsocketIDStateStore{sessions: make(map[string]*xaiWebsocketIDState)} + auth := &cliproxyauth.Auth{ID: t.Name(), Provider: "xai", Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + capture := &task6UsageCapture{id: auth.ID, records: make(chan usage.Record, 32)} + usage.RegisterNamedPlugin("task6-patch-failure", capture) + req := cliproxyexecutor.Request{Model: "grok-4.3", Payload: []byte(`{"model":"grok-4.3","input":[{"type":"message","role":"user","content":"history"},{"type":"compaction_trigger"}]}`)} + if tc.emptyInput { + req.Payload = []byte(`{"model":"grok-4.3","input":[{"type":"compaction_trigger"}]}`) + } + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, ResponseFormat: sdktranslator.FormatOpenAIResponse, Stream: true, Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: t.Name()}} + var errExecute error + if tc.passthrough { + opts.Alt = "responses/compact" + opts.Stream = false + var response cliproxyexecutor.Response + response, errExecute = exec.XAIExecutor.Execute(t.Context(), auth, req, opts) + if output := gjson.GetBytes(response.Payload, "output"); !output.IsArray() || len(output.Array()) != 0 { + t.Errorf("ordinary HTTP compact output changed: %s", response.Payload) + } + } else { + result, errExecuteStream := exec.ExecuteStream(cliproxyexecutor.WithDownstreamWebsocket(t.Context()), auth, req, opts) + errExecute = errExecuteStream + if result == nil && tc.wantStatus == 0 { + t.Error("valid compact response returned no stream") + } + if result != nil { + var completed []byte + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Errorf("compact stream chunk error: %v", chunk.Err) + } + if gjson.GetBytes(chunk.Payload, "type").String() == "response.completed" { + completed = chunk.Payload + } + } + if tc.wantStatus != 0 || gjson.GetBytes(completed, "response.output.0.encrypted_content").String() != "opaque-state" { + t.Errorf("unexpected compact success: %s", completed) + } + } + } + if tc.wantStatus == 0 { + if errExecute != nil { + t.Errorf("compact execution error: %v", errExecute) + } + } else if status, okStatus := errExecute.(interface{ StatusCode() int }); !okStatus || status.StatusCode() != tc.wantStatus { + t.Errorf("compact error = %v, want status %d", errExecute, tc.wantStatus) + } + select { + case body := <-requests: + if tc.emptyInput || xaiInputHasItemType(body, "compaction_trigger") { + t.Errorf("unexpected upstream compact input: %s", body) + } + default: + if !tc.emptyInput { + t.Error("compact execution made no HTTP request") + } + } + // A queued barrier observes every attempt record without timing-based sleeps. + usage.PublishRecord(t.Context(), usage.Record{RequestID: auth.ID + "-barrier"}) + count := 0 + for { + select { + case record := <-capture.records: + if record.RequestID == auth.ID+"-barrier" { + if count != tc.wantRecords { + t.Errorf("usage records=%d, want %d", count, tc.wantRecords) + } + return + } + count++ + if record.Failed != (tc.wantStatus != 0) || record.Fail.StatusCode != tc.wantStatus { + t.Errorf("unexpected compact usage outcome: %+v", record) + } + if tc.body == validState && record.Detail.TotalTokens != 3 { + t.Errorf("valid compact usage total=%d, want 3", record.Detail.TotalTokens) + } + case <-time.After(3 * time.Second): + t.Fatal("compact usage barrier did not arrive") + } + } + }) + } +} + +func TestApplyPatchRepairOrdinaryEmptyHTTPPassthrough(t *testing.T) { + for _, provider := range []string{"xai", "meta", "kimi"} { + t.Run(provider, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer server.Close() + exec := task6RepairExecutor(provider) + auth := &cliproxyauth.Auth{Provider: provider, Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + response, errExecute := exec.Execute(t.Context(), auth, cliproxyexecutor.Request{Model: "grok-4", Payload: []byte(`{"input":"ordinary","tools":[{"type":"function","name":"apply_patch"}]}`)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) + if provider == "kimi" { + if errExecute != nil || len(response.Payload) != 0 { + t.Fatalf("ordinary Kimi response changed: %s %v", response.Payload, errExecute) + } + } else if status, okStatus := errExecute.(interface{ StatusCode() int }); !okStatus || status.StatusCode() != http.StatusRequestTimeout { + t.Fatalf("ordinary disconnect changed: %v", errExecute) + } + }) + } +} diff --git a/internal/runtime/executor/apply_patch_source_stop_test.go b/internal/runtime/executor/apply_patch_source_stop_test.go new file mode 100644 index 000000000..f809613b6 --- /dev/null +++ b/internal/runtime/executor/apply_patch_source_stop_test.go @@ -0,0 +1,81 @@ +package executor + +import ( + "fmt" + "strings" + "testing" + + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + "github.com/tidwall/gjson" +) + +// Run the R1 source trace through the actual Interactions HTTP executor and usage boundary. +func TestApplyPatchInteractionsSourceStopActualFailures(t *testing.T) { + for _, first := range []string{"item", "call", "neither"} { + for _, lateName := range []bool{false, true} { + for _, input := range []string{"partial", "complete"} { + for _, discovery := range []string{"terminal", "coincident-snapshot"} { + t.Run(fmt.Sprintf("%s/late-name=%v/%s/%s", first, lateName, input, discovery), func(t *testing.T) { + id, call, name := "", "", "apply_patch" + if first == "item" { + id = "a" + } + if first == "call" { + call = "c" + } + if lateName { + name = "" + } + before, after, final := `{"input":"p`, `q"}`, "pq" + if input == "complete" { + before, after, final = `{"input":"p"}`, " \t\n", "p" + } + snapshot := fmt.Sprintf(`{"index":0,"type":"function_call","id":"a","call_id":"c","name":"apply_patch","arguments":{"input":%q},"provider_secret":"RAW_SECRET"}`, final) + post := fmt.Sprintf(`{"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":%q}}`, after) + if discovery == "coincident-snapshot" { + post = fmt.Sprintf(`{"event_type":"step.delta","index":0,"step":%s,"delta":{"type":"arguments_delta","arguments":%q}}`, snapshot, after) + } + source := [][]byte{ + []byte(fmt.Sprintf(`{"event_type":"step.start","index":0,"step":{"type":"function_call","id":%q,"call_id":%q,"name":%q}}`, id, call, name)), + []byte(fmt.Sprintf(`{"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":%q}}`, before)), + []byte(`{"event_type":"step.stop","index":0}`), + []byte(post), + []byte(`{"event_type":"interaction.completed","steps":[` + snapshot + `],"usage":{"input_tokens":5,"output_tokens":3}}`), + []byte(`[DONE]`), + } + exec, auth := applyPatchIdentityExecutor(t, "interactions", source) + checkUsage := task6CaptureFailureUsage(t, auth.ID) + defer checkUsage() + stream, errExecuteStream := exec.ExecuteStream(t.Context(), auth, cliproxyexecutor.Request{Model: "grok-4", Payload: []byte(task6PatchRequest)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + failed, errors := 0, 0 + for chunk := range stream.Chunks { + if chunk.Err != nil { + errors++ + assertTask6PatchError(t, chunk.Err) + } + if strings.Contains(string(chunk.Payload), "RAW_SECRET") || strings.Contains(string(chunk.Payload), "[DONE]") { + t.Fatalf("failure leaked/completed: %s", chunk.Payload) + } + for _, payload := range applyPatchTestPayloads(chunk.Payload) { + if gjson.GetBytes(payload, "type").String() != "response.failed" || gjson.GetBytes(payload, "response.error.code").String() != "invalid_tool_arguments" { + t.Fatalf("source-stopped unresolved patch published success/raw fallback: %s", payload) + } + failed++ + } + if len(chunk.Payload) > 0 && len(applyPatchTestPayloads(chunk.Payload)) == 0 { + t.Fatalf("raw fallback: %s", chunk.Payload) + } + } + if failed != 1 || errors != 1 { + t.Fatalf("failure count: failed=%d errors=%d", failed, errors) + } + }) + } + } + } + } +} diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go index 5d85b159c..0fc1f0acf 100644 --- a/internal/runtime/executor/claude_executor.go +++ b/internal/runtime/executor/claude_executor.go @@ -253,3 +253,6 @@ func (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Aut httpClient := helps.NewUtlsHTTPClient(ctx, e.cfg, auth, 0) return httpClient.Do(httpReq) } + +// SupportsApplyPatch reports the actual executor contract, independent of its provider name. +func (e *ClaudeExecutor) SupportsApplyPatch() bool { return e != nil } diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go index 9c302a258..5a26dabba 100644 --- a/internal/runtime/executor/claude_executor_execute.go +++ b/internal/runtime/executor/claude_executor_execute.go @@ -385,6 +385,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r return resp, wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, err) } helps.AppendAPIResponseChunk(ctx, e.cfg, data) + var streamUsage helps.StreamUsageBuffer if upstreamStream { if errValidate := validateClaudeStreamingResponse(data); errValidate != nil { helps.RecordAPIResponseError(ctx, e.cfg, errValidate) @@ -394,7 +395,6 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r commitClaudeContinuity(diagnosticsState, msgID, helps.HeaderValueCaseInsensitive(httpResp.Header, "request-id")) } lines := bytes.Split(data, []byte("\n")) - var streamUsage helps.StreamUsageBuffer for i, line := range lines { reporter.ObserveResponseModel(line) streamUsage.ObserveClaudeStream(line) @@ -407,12 +407,10 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } lines[i] = restoredLine } - streamUsage.Publish(ctx, reporter) data = bytes.Join(lines, []byte("\n")) } else { commitClaudeContinuity(diagnosticsState, claudeMessageIDFromResponse(data), helps.HeaderValueCaseInsensitive(httpResp.Header, "request-id")) reporter.ObserveResponseModel(data) - reporter.Publish(ctx, helps.ParseClaudeUsage(data)) var errRestore error data, errRestore = restoreClaudeOAuthToolNamesFromResponse(data, oauthToolNamesReverseMap) if errRestore != nil { @@ -429,11 +427,19 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r to, responseFormat, req.Model, - opts.OriginalRequest, + helps.ApplyPatchOriginalRequest(req, opts), bodyForTranslation, data, ¶m, ) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + if upstreamStream { + streamUsage.Publish(ctx, reporter) + } else { + reporter.Publish(ctx, helps.ParseClaudeUsage(data)) + } if responseFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) } diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go index 893eab942..d51f0e32f 100644 --- a/internal/runtime/executor/claude_executor_stream.go +++ b/internal/runtime/executor/claude_executor_stream.go @@ -474,6 +474,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A scanner := bufio.NewScanner(decodedBody) scanner.Buffer(nil, 52_428_800) // 50MB var param any + helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), bodyForTranslation, ¶m) var upstreamMessageID string upstreamCompleted := false for scanner.Scan() { @@ -493,16 +494,17 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A to, responseFormat, req.Model, - opts.OriginalRequest, + helps.ApplyPatchOriginalRequest(req, opts), bodyForTranslation, bytes.Clone(line), ¶m, ) - if responseFormat == sdktranslator.FormatOpenAIResponse { + if responseFormat == sdktranslator.FormatOpenAIResponse && helps.ApplyPatchTranslationError(param) == nil { for i, chunk := range chunks { chunks[i] = helps.EnsureResponsesUsageDetails(chunk) } } + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range chunks { select { case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: @@ -511,10 +513,16 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A return } } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } if upstreamCompleted { break } } + if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } if !upstreamCompleted { if emitCancellation(scanner.Err()) { return diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index cd6a733d2..aeaee31e7 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -15,3 +15,6 @@ func (e *CodexExecutor) Identifier() string { return "codex" } func (e *CodexExecutor) modelLevelCooling() bool { return e != nil && e.cfg != nil && e.cfg.Codex.ModelLevelCooling } + +// SupportsApplyPatch reports the actual executor contract, independent of its provider name. +func (e *CodexExecutor) SupportsApplyPatch() bool { return e != nil } diff --git a/internal/runtime/executor/codex_executor_execute.go b/internal/runtime/executor/codex_executor_execute.go index 5a87188e8..7fdda8b61 100644 --- a/internal/runtime/executor/codex_executor_execute.go +++ b/internal/runtime/executor/codex_executor_execute.go @@ -176,11 +176,6 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re return resp, err } - if detail, ok := helps.ParseCodexUsage(eventData); ok { - reporter.Publish(ctx, detail) - } - publishCodexImageToolUsage(ctx, reporter, body, eventData) - completedData := patchCodexCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback) if eventType == "response.completed" { cacheCodexReasoningReplayFromCompleted(replayScope, completedData) @@ -188,6 +183,13 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re var param any out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, originalPayload, body, completedData, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + publishCodexImageToolUsage(ctx, reporter, body, eventData) + if detail, ok := helps.ParseCodexUsage(eventData); ok { + reporter.Publish(ctx, detail) + } if responseFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) } @@ -297,10 +299,12 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A } helps.AppendAPIResponseChunk(ctx, e.cfg, data) upstreamData := helps.RestoreCodexMultiAgentV2Response(data, optimizeMultiAgentV2) - reporter.Publish(ctx, helps.ParseOpenAIUsage(upstreamData)) - reporter.EnsurePublished(ctx) var param any out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, originalPayload, body, upstreamData, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + reporter.Publish(ctx, helps.ParseOpenAIUsage(upstreamData)) if responseFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) } diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go index bcc630bba..e6ae2cc59 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -149,3 +149,8 @@ func codexWebsocketsEnabled(auth *cliproxyauth.Auth) bool { } return false } + +// SupportsApplyPatch requires both selectable transports to support the tool. +func (e *CodexAutoExecutor) SupportsApplyPatch() bool { + return e != nil && e.httpExec != nil && e.wsExec != nil && e.httpExec.SupportsApplyPatch() && e.wsExec.SupportsApplyPatch() +} diff --git a/internal/runtime/executor/devin_executor.go b/internal/runtime/executor/devin_executor.go index 4df18a7ec..7f39d3643 100644 --- a/internal/runtime/executor/devin_executor.go +++ b/internal/runtime/executor/devin_executor.go @@ -280,12 +280,19 @@ func (e *DevinExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re return resp, newDevinStatusError(httpResp.StatusCode, httpResp.Header, errData) } - interactionsJSON, respLog, errConsume := consumeDevinFramesToInteractions(httpResp.Body, req.Model, chatModelUID) + interactionsJSON, respLog, errConsume := consumeDevinFramesToInteractions(httpResp.Body, req.Model, chatModelUID, helps.ApplyPatchOriginalRequest(req, opts)) if respLog != nil || len(interactionsJSON) > 0 { logRespBody := helps.BuildDevinUpstreamResponseLogBody(respLog, interactionsJSON) helps.AppendAPIResponseChunk(ctx, e.cfg, logRespBody) } if errConsume != nil { + original := opts.OriginalRequest + if len(original) == 0 { + original = req.Payload + } + if helps.ApplyPatchRequested(original) { + return resp, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } if ctx.Err() == nil { helps.RecordAPIResponseError(ctx, e.cfg, errConsume) } @@ -295,11 +302,14 @@ func (e *DevinExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re if respLog != nil && respLog.Usage != nil && respLog.Usage.ModelName != "" { reporter.SetResponseModel(respLog.Usage.ModelName) } - reporter.Publish(ctx, helps.ParseInteractionsUsage(interactionsJSON)) targetFormat := cliproxyexecutor.ResponseFormatOrSource(opts) var param any - out := sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatInteractions, targetFormat, req.Model, opts.OriginalRequest, req.Payload, interactionsJSON, ¶m) + out := sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatInteractions, targetFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), req.Payload, interactionsJSON, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + reporter.Publish(ctx, helps.ParseInteractionsUsage(interactionsJSON)) if targetFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) } @@ -490,12 +500,17 @@ func (e *DevinExecutor) streamDevinFrames( claudeInputTokens := helps.NewClaudeInputTokenState(opts.SourceFormat, sdktranslator.FormatInteractions, responseFormat, opts.OriginalRequest) var translateParam any + helps.InitializeApplyPatchStream(ctx, sdktranslator.FormatInteractions, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), req.Payload, &translateParam) + translationFailed := false firstStreamEvent := true streamFrameCount := 0 createdSent := false var emitInteractionsEvent func(rawJSON []byte) bool emitInteractionsEvent = func(rawJSON []byte) bool { + if translationFailed { + return false + } if len(rawJSON) == 0 { return true } @@ -543,12 +558,13 @@ func (e *DevinExecutor) streamDevinFrames( sdktranslator.FormatInteractions, responseFormat, req.Model, - opts.OriginalRequest, + helps.ApplyPatchOriginalRequest(req, opts), req.Payload, trimmed, &translateParam, claudeInputTokens, ) + helps.RecordApplyPatchStreamFailure(ctx, translateParam, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for _, line := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: line}: @@ -556,6 +572,10 @@ func (e *DevinExecutor) streamDevinFrames( return false } } + if helps.StopApplyPatchStream(ctx, translateParam, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + translationFailed = true + return false + } return true } @@ -635,13 +655,17 @@ func (e *DevinExecutor) streamDevinFrames( emitToolCall := func(tc helps.DevinToolCallDelta) bool { if thoughtStarted { stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex) - _ = emitInteractionsEvent(stopEvent) + if !emitInteractionsEvent(stopEvent) { + return false + } thoughtStarted = false stepIndex++ } if contentStarted { stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex) - _ = emitInteractionsEvent(stopEvent) + if !emitInteractionsEvent(stopEvent) { + return false + } contentStarted = false stepIndex++ } @@ -700,13 +724,18 @@ func (e *DevinExecutor) streamDevinFrames( updateEvent, _ = sjson.SetBytes(updateEvent, "step.name", slot.name) updateEvent, _ = sjson.SetBytes(updateEvent, "step.id", slot.id) updateEvent, _ = sjson.SetBytes(updateEvent, "step.call_id", slot.id) - _ = emitInteractionsEvent(updateEvent) + if !emitInteractionsEvent(updateEvent) { + return false + } } } if argsChunk != "" { deltaEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":""}}`), "index", slot.stepIndex) deltaEvent, _ = translatorcommon.SetStringWithoutHTMLEscape(deltaEvent, "delta.arguments", argsChunk) + if tc.Arguments == "" && tc.InvalidJSONStr != "" { + deltaEvent, _ = sjson.SetBytes(deltaEvent, "delta.invalid_json_str", true) + } if !emitInteractionsEvent(deltaEvent) { return false } @@ -767,7 +796,16 @@ func (e *DevinExecutor) streamDevinFrames( if flag&helps.ConnectFlagEndStream != 0 { code, errTrailer := helps.ParseDevinTrailerError(payload) if errTrailer != nil { + if helps.EndApplyPatchStream(ctx, translateParam, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } closeOpenSteps() + if translationFailed { + return + } + if reporter != nil { + reporter.PublishFailure(ctx, errTrailer) + } log.Warnf("devin executor: trailer error (%d): %v", code, errTrailer) helps.RecordAPIResponseError(ctx, e.cfg, errTrailer) failedEvent, _ := sjson.SetBytes([]byte(`{"event_type":"response.failed","error":{"message":"","code":""}}`), "error.message", errTrailer.Error()) @@ -944,8 +982,16 @@ func (e *DevinExecutor) streamDevinFrames( } } + if (!sawEOS || streamErr != nil) && ctx.Err() == nil { + if helps.EndApplyPatchStream(ctx, translateParam, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } + } // 3. Close open steps closeOpenSteps() + if translationFailed { + return + } // If stream encountered an abnormal read error mid-flight, record failure and emit response.failed if streamErr != nil && ctx.Err() == nil { @@ -1001,16 +1047,16 @@ func (e *DevinExecutor) streamDevinFrames( completedEvent, _ = sjson.SetBytes(completedEvent, "interaction.usage.cache_write_tokens", finalUsage.CacheWriteTokens) } completedEvent, _ = sjson.SetBytes(completedEvent, "interaction.usage.total_tokens", totalTokens) - if detail, ok := helps.ParseInteractionsStreamUsage(completedEvent); ok { - if reporter != nil { - if finalUsage != nil && finalUsage.ModelName != "" { - reporter.SetResponseModel(finalUsage.ModelName) - } - reporter.Publish(ctx, detail) - } + if reporter != nil && finalUsage.ModelName != "" { + reporter.SetResponseModel(finalUsage.ModelName) } } - _ = emitInteractionsEvent(completedEvent) + if !emitInteractionsEvent(completedEvent) { + return + } + if detail, okUsage := helps.ParseInteractionsStreamUsage(completedEvent); okUsage && reporter != nil { + reporter.Publish(ctx, detail) + } if finalUsage != nil || len(accumulatedSignature) > 0 { streamSummary := &helps.DevinUpstreamResponseLog{ @@ -1039,12 +1085,13 @@ func (e *DevinExecutor) streamDevinFrames( sdktranslator.FormatInteractions, responseFormat, req.Model, - opts.OriginalRequest, + helps.ApplyPatchOriginalRequest(req, opts), req.Payload, []byte("[DONE]"), &translateParam, claudeInputTokens, ) + helps.RecordApplyPatchStreamFailure(ctx, translateParam, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for _, line := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: line}: @@ -1054,16 +1101,17 @@ func (e *DevinExecutor) streamDevinFrames( } } -func consumeDevinFramesToInteractions(body io.Reader, model, chatModelUID string) ([]byte, *helps.DevinUpstreamResponseLog, error) { +func consumeDevinFramesToInteractions(body io.Reader, model, chatModelUID string, original ...[]byte) ([]byte, *helps.DevinUpstreamResponseLog, error) { interactionID := fmt.Sprintf("interaction_%s", uuid.New().String()[:12]) var preToolTextParts []string var postToolTextParts []string var thinkingParts []string type devinToolCallBuilder struct { - id string - name string - args strings.Builder + legacy bool + id string + name string + args strings.Builder } var toolBuilders []*devinToolCallBuilder @@ -1266,6 +1314,9 @@ func consumeDevinFramesToInteractions(body io.Reader, model, chatModelUID string } } + if tc.Arguments == "" && tc.InvalidJSONStr != "" { + toolBuilders[bIdx].legacy = true + } if argsChunk != "" { toolBuilders[bIdx].args.WriteString(argsChunk) } @@ -1279,6 +1330,13 @@ func consumeDevinFramesToInteractions(body io.Reader, model, chatModelUID string } } + if len(original) > 0 { + for _, builder := range toolBuilders { + if builder.legacy && helps.IsApplyPatchUpstreamTool(original[0], builder.name) { + return nil, nil, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + } + } toolCalls := getToolCalls() if !sawEOS { @@ -2496,3 +2554,6 @@ func firstNonEmpty(values ...string) string { } return "" } + +// SupportsApplyPatch reports the actual executor contract, independent of its provider name. +func (e *DevinExecutor) SupportsApplyPatch() bool { return e != nil } diff --git a/internal/runtime/executor/devin_executor_test.go b/internal/runtime/executor/devin_executor_test.go index 13977fc0c..ec849d1ac 100644 --- a/internal/runtime/executor/devin_executor_test.go +++ b/internal/runtime/executor/devin_executor_test.go @@ -4779,3 +4779,89 @@ func TestStreamDevinFrames_MidStreamTrailerError_EmitsTranslatedFailedEventBefor t.Fatalf("expected error message in stream payloads, got:\n%s", allPayload) } } + +func TestDevinApplyPatchExecutorReuse(t *testing.T) { + wireField := func(data []byte, target protowire.Number) []byte { + for len(data) > 0 { + number, kind, n := protowire.ConsumeTag(data) + if n < 0 { + return nil + } + data = data[n:] + if kind == protowire.BytesType { + value, m := protowire.ConsumeBytes(data) + if m < 0 { + return nil + } + if number == target { + return value + } + data = data[m:] + } else { + m := protowire.ConsumeFieldValue(number, kind, data) + if m < 0 { + return nil + } + data = data[m:] + } + } + return nil + } + args := `{"input":" *** Begin Patch\n*** End Patch\n "}` + var frames bytes.Buffer + for i, fragment := range []string{args[:22], args[22:]} { + var tool []byte + tool = protowire.AppendTag(tool, 1, protowire.BytesType) + tool = protowire.AppendString(tool, "call_patch") + if i == 0 { + tool = protowire.AppendTag(tool, 2, protowire.BytesType) + tool = protowire.AppendString(tool, "functions__apply_patch") + } + tool = protowire.AppendTag(tool, 3, protowire.BytesType) + tool = protowire.AppendString(tool, fragment) + var frame []byte + frame = protowire.AppendTag(frame, 6, protowire.BytesType) + frame = protowire.AppendBytes(frame, tool) + frames.Write(helps.WrapConnectEnvelope(frame)) + } + frames.Write(helps.WrapConnectEnvelopeWithFlag(helps.ConnectFlagEndStream, []byte(`{}`))) + requests := make(chan []byte, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errReadAll := io.ReadAll(r.Body) + if errReadAll != nil { + t.Error(errReadAll) + w.WriteHeader(500) + return + } + requests <- body + w.Header().Set("Content-Type", "application/connect+proto") + _, _ = w.Write(frames.Bytes()) + })) + defer server.Close() + exec := NewDevinExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ID: "devin-patch", Provider: "devin", Attributes: map[string]string{"api_key": "test-key", "base_url": server.URL}} + req := cliproxyexecutor.Request{Model: "devin/swe-2", Payload: []byte(executorPatchRequest)} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, OriginalRequest: req.Payload} + assertWire := func(body []byte) { + if len(body) < 5 { + t.Fatal("missing envelope") + } + tool := wireField(body[5:], 10) + name, desc, params := wireField(tool, 1), wireField(tool, 2), wireField(tool, 3) + if string(name) != "functions__apply_patch" || !strings.Contains(string(desc), "*** Begin Patch") || !strings.Contains(string(desc), "@@") || !strings.Contains(string(desc), "start: patch") || gjson.GetBytes(params, "properties.input.type").String() != "string" || !gjson.GetBytes(params, "additionalProperties").Exists() || gjson.GetBytes(params, "additionalProperties").Bool() { + t.Fatalf("wire contract: name=%s desc=%s params=%s", name, desc, params) + } + } + response, errExecute := exec.Execute(context.Background(), auth, req, opts) + if errExecute != nil { + t.Fatal(errExecute) + } + assertWire(<-requests) + assertExecutorPatchOutput(t, response.Payload) + stream, errExecuteStream := exec.ExecuteStream(context.Background(), auth, req, opts) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + assertWire(<-requests) + assertExecutorPatchStream(t, stream.Chunks) +} diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go index a03889ae6..d8bcb5df4 100644 --- a/internal/runtime/executor/gemini_executor.go +++ b/internal/runtime/executor/gemini_executor.go @@ -239,9 +239,12 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r } helps.AppendAPIResponseChunk(ctx, e.cfg, data) reporter.ObserveResponseModel(data) - reporter.Publish(ctx, helps.ParseGeminiUsage(data)) var param any - out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, data, ¶m) + out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, data, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + reporter.Publish(ctx, helps.ParseGeminiUsage(data)) if responseFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) } @@ -358,7 +361,10 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A scanner := bufio.NewScanner(httpResp.Body) scanner.Buffer(nil, streamScannerBuffer) claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload) + var streamUsage helps.StreamUsageBuffer + defer streamUsage.Publish(ctx, reporter) var param any + helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, ¶m) for scanner.Scan() { line := scanner.Bytes() helps.AppendAPIResponseChunk(ctx, e.cfg, line) @@ -369,9 +375,10 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A continue } if detail, ok := helps.ParseGeminiStreamUsage(payload); ok { - reporter.Publish(ctx, detail) + streamUsage.Observe(detail, true) } - lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, bytes.Clone(payload), ¶m, claudeInputTokens) + lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, bytes.Clone(payload), ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: @@ -379,8 +386,15 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A return } } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } } - lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m, claudeInputTokens) + if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } + lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, []byte("[DONE]"), ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: @@ -388,6 +402,9 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A return } } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx, errScan) @@ -472,10 +489,13 @@ func (e *GeminiExecutor) executeInteractions(ctx context.Context, auth *cliproxy return resp, err } reporter.ObserveResponseModel(data) - reporter.Publish(ctx, helps.ParseInteractionsUsage(data)) targetFormat := cliproxyexecutor.ResponseFormatOrSource(opts) var param any - out := sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatInteractions, targetFormat, req.Model, opts.OriginalRequest, body, data, ¶m) + out := sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatInteractions, targetFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, data, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + reporter.Publish(ctx, helps.ParseInteractionsUsage(data)) if targetFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) } @@ -563,7 +583,10 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl originalRequest = req.Payload } claudeInputTokens := helps.NewClaudeInputTokenState(opts.SourceFormat, sdktranslator.FormatInteractions, responseFormat, originalRequest) + var streamUsage helps.StreamUsageBuffer + defer streamUsage.Publish(ctx, reporter) var param any + helps.InitializeApplyPatchStream(ctx, sdktranslator.FormatInteractions, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, ¶m) var frame []byte emitFrame := func() bool { rawFrame := bytes.Clone(frame) @@ -582,7 +605,7 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl if len(payload) > 0 { reporter.ObserveResponseModel(payload) if detail, ok := helps.ParseInteractionsStreamUsage(payload); ok { - reporter.Publish(ctx, detail) + streamUsage.Observe(detail, true) } } if responseFormat == sdktranslator.FormatInteractions { @@ -598,7 +621,8 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl return true } var lines [][]byte - lines = helps.TranslateStreamWithClaudeInputTokens(ctx, sdktranslator.FormatInteractions, responseFormat, req.Model, opts.OriginalRequest, body, payload, ¶m, claudeInputTokens) + lines = helps.TranslateStreamWithClaudeInputTokens(ctx, sdktranslator.FormatInteractions, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, payload, ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: @@ -606,6 +630,9 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl return false } } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return false + } return true } for scanner.Scan() { @@ -626,6 +653,9 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl if !emitFrame() { return } + if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx, errScan) @@ -1058,3 +1088,6 @@ func fixGeminiImageAspectRatio(modelName string, rawJSON []byte) []byte { } return rawJSON } + +// SupportsApplyPatch reports the actual executor contract, independent of its provider name. +func (e *GeminiExecutor) SupportsApplyPatch() bool { return e != nil } diff --git a/internal/runtime/executor/gemini_executor_test.go b/internal/runtime/executor/gemini_executor_test.go index dc444255d..6230fc19e 100644 --- a/internal/runtime/executor/gemini_executor_test.go +++ b/internal/runtime/executor/gemini_executor_test.go @@ -1325,6 +1325,8 @@ func TestGeminiExecutorNativeInteractionsResponsesStreamEmitsDone(t *testing.T) _, _ = 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")) + _, _ = w.Write([]byte("event: done\ndata: [DONE]\n\n")) + _, _ = w.Write([]byte("data: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"late\"}}\n\n")) })) defer server.Close() @@ -1349,17 +1351,24 @@ func TestGeminiExecutorNativeInteractionsResponsesStreamEmitsDone(t *testing.T) t.Fatalf("ExecuteStream() error = %v", errExecute) } - done := false + done := 0 + completed := 0 for chunk := range result.Chunks { if chunk.Err != nil { t.Fatalf("stream chunk error: %v", chunk.Err) } + if bytes.Contains(chunk.Payload, []byte(`"type":"response.completed"`)) { + completed++ + } + if bytes.Contains(chunk.Payload, []byte(`"late"`)) { + t.Fatal("post-terminal content was forwarded") + } if bytes.Equal(bytes.TrimSpace(chunk.Payload), []byte("data: [DONE]")) { - done = true + done++ } } - if !done { - t.Fatal("Responses [DONE] chunk not found") + if done != 1 || completed != 1 { + t.Fatalf("Responses terminal counts: DONE=%d completed=%d, want one each", done, completed) } } diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go index 3f128d7dd..b455ac6b1 100644 --- a/internal/runtime/executor/gemini_vertex_executor.go +++ b/internal/runtime/executor/gemini_vertex_executor.go @@ -429,7 +429,6 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au } helps.AppendAPIResponseChunk(ctx, e.cfg, data) reporter.ObserveResponseModel(data) - reporter.Publish(ctx, helps.ParseGeminiUsage(data)) // For Imagen models, convert response to Gemini format before translation // This ensures Imagen responses use the same format as gemini-3-pro-image-preview @@ -441,7 +440,11 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts) to := sdktranslator.FromString("gemini") var param any - out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, data, ¶m) + out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, data, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + reporter.Publish(ctx, helps.ParseGeminiUsage(data)) if responseFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) } @@ -563,9 +566,12 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip } helps.AppendAPIResponseChunk(ctx, e.cfg, data) reporter.ObserveResponseModel(data) - reporter.Publish(ctx, helps.ParseGeminiUsage(data)) var param any - out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, data, ¶m) + out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, data, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + reporter.Publish(ctx, helps.ParseGeminiUsage(data)) if responseFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) } @@ -686,15 +692,19 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte scanner := bufio.NewScanner(httpResp.Body) scanner.Buffer(nil, streamScannerBuffer) claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload) + var streamUsage helps.StreamUsageBuffer + defer streamUsage.Publish(ctx, reporter) var param any + helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, ¶m) for scanner.Scan() { line := scanner.Bytes() helps.AppendAPIResponseChunk(ctx, e.cfg, line) reporter.ObserveResponseModel(line) if detail, ok := helps.ParseGeminiStreamUsage(line); ok { - reporter.Publish(ctx, detail) + streamUsage.Observe(detail, true) } - lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m, claudeInputTokens) + lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, bytes.Clone(line), ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: @@ -702,8 +712,15 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte return } } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } } - lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m, claudeInputTokens) + if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } + lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, []byte("[DONE]"), ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: @@ -711,6 +728,9 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte return } } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx, errScan) @@ -836,15 +856,19 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth scanner := bufio.NewScanner(httpResp.Body) scanner.Buffer(nil, streamScannerBuffer) claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload) + var streamUsage helps.StreamUsageBuffer + defer streamUsage.Publish(ctx, reporter) var param any + helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, ¶m) for scanner.Scan() { line := scanner.Bytes() helps.AppendAPIResponseChunk(ctx, e.cfg, line) reporter.ObserveResponseModel(line) if detail, ok := helps.ParseGeminiStreamUsage(line); ok { - reporter.Publish(ctx, detail) + streamUsage.Observe(detail, true) } - lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m, claudeInputTokens) + lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, bytes.Clone(line), ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: @@ -852,8 +876,15 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth return } } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } } - lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m, claudeInputTokens) + if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } + lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, []byte("[DONE]"), ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range lines { select { case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}: @@ -861,6 +892,9 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth return } } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx, errScan) @@ -1186,3 +1220,6 @@ func (e *GeminiVertexExecutor) resolveVertexConfig(auth *cliproxyauth.Auth) *con } return nil } + +// SupportsApplyPatch reports the actual executor contract, independent of its provider name. +func (e *GeminiVertexExecutor) SupportsApplyPatch() bool { return e != nil } diff --git a/internal/runtime/executor/gemini_vertex_executor_test.go b/internal/runtime/executor/gemini_vertex_executor_test.go new file mode 100644 index 000000000..a4c0abbb3 --- /dev/null +++ b/internal/runtime/executor/gemini_vertex_executor_test.go @@ -0,0 +1,113 @@ +package executor + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + "github.com/tidwall/gjson" +) + +const executorPatchRequest = `{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch","format":{"type":"grammar","definition":"start: patch"}}]}],"input":"patch a file"}` +const executorPatchResponse = `{"responseId":"patch","candidates":[{"content":{"parts":[{"functionCall":{"name":"functions__apply_patch","args":{"input":" *** Begin Patch\n*** End Patch\n "}}}]},"finishReason":"STOP"}]}` +const executorPatchInput = " *** Begin Patch\n*** End Patch\n " + +func assertExecutorPatchDeclaration(t *testing.T, payload []byte) { + t.Helper() + declaration := gjson.GetBytes(payload, "tools.0.functionDeclarations.0") + if declaration.Get("name").String() != "functions__apply_patch" || !strings.Contains(declaration.Get("description").String(), "*** Begin Patch") || !strings.Contains(declaration.Get("description").String(), "start: patch") || declaration.Get("parametersJsonSchema.properties.input.type").String() != "string" || !declaration.Get("parametersJsonSchema.additionalProperties").Exists() || declaration.Get("parametersJsonSchema.additionalProperties").Bool() { + t.Fatalf("missing standard patch declaration: %s", payload) + } +} +func assertExecutorPatchOutput(t *testing.T, payload []byte) { + t.Helper() + item := gjson.GetBytes(payload, `output.#(type=="custom_tool_call")`) + if item.Get("type").String() != "custom_tool_call" || item.Get("input").String() != executorPatchInput || item.Get("name").String() != "apply_patch" || item.Get("namespace").String() != "functions" { + t.Fatalf("wrong patch output: %s", payload) + } +} +func assertExecutorPatchStream(t *testing.T, chunks <-chan cliproxyexecutor.StreamChunk, acknowledgeSnapshot ...func()) { + t.Helper() + acknowledged := len(acknowledgeSnapshot) == 0 + var delta strings.Builder + var done, item, final gjson.Result + for chunk := range chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + for _, line := range strings.Split(string(chunk.Payload), "\n") { + if !strings.HasPrefix(line, "data:") { + continue + } + ev := gjson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + switch ev.Get("type").String() { + case "response.output_text.delta": + if !acknowledged { + acknowledged = true + acknowledgeSnapshot[0]() + } + case "response.custom_tool_call_input.delta": + if !acknowledged { + t.Fatal("fabricated patch progress before upstream snapshot") + } + delta.WriteString(ev.Get("delta").String()) + case "response.custom_tool_call_input.done": + done = ev + case "response.output_item.done": + if ev.Get("item.type").String() == "custom_tool_call" { + item = ev.Get("item") + } + case "response.completed": + final = ev.Get("response") + } + } + } + if !acknowledged || delta.String() != executorPatchInput || done.Get("input").String() != executorPatchInput || item.Get("input").String() != executorPatchInput || done.Get("item_id").String() != item.Get("id").String() || done.Get("call_id").String() != item.Get("call_id").String() { + t.Fatalf("inconsistent stream delta=%q done=%s item=%s", delta.String(), done.Raw, item.Raw) + } + assertExecutorPatchOutput(t, []byte(final.Raw)) +} + +func TestGeminiVertexApplyPatchExecutorReuse(t *testing.T) { + requests := make(chan []byte, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errReadAll := io.ReadAll(r.Body) + if errReadAll != nil { + t.Error(errReadAll) + w.WriteHeader(500) + return + } + requests <- body + if strings.Contains(r.URL.Path, "streamGenerateContent") { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: "+executorPatchResponse+"\n\n") + } else { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, executorPatchResponse) + } + })) + defer server.Close() + exec := NewGeminiVertexExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ID: "vertex-patch", Provider: "vertex", Attributes: map[string]string{"api_key": "test-key", "base_url": server.URL}} + req := cliproxyexecutor.Request{Model: "gemini-3.1-pro-preview", Payload: []byte(executorPatchRequest)} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, OriginalRequest: req.Payload} + response, errExecute := exec.Execute(context.Background(), auth, req, opts) + if errExecute != nil { + t.Fatal(errExecute) + } + assertExecutorPatchDeclaration(t, <-requests) + assertExecutorPatchOutput(t, response.Payload) + stream, errExecuteStream := exec.ExecuteStream(context.Background(), auth, req, opts) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + assertExecutorPatchDeclaration(t, <-requests) + assertExecutorPatchStream(t, stream.Chunks) +} diff --git a/internal/runtime/executor/helps/apply_patch.go b/internal/runtime/executor/helps/apply_patch.go new file mode 100644 index 000000000..bd0c8e9e5 --- /dev/null +++ b/internal/runtime/executor/helps/apply_patch.go @@ -0,0 +1,99 @@ +package helps + +import ( + "context" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/util" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" +) + +// ApplyPatchUpstreamErrorMessage deliberately excludes upstream JSON and patch text. +const ApplyPatchUpstreamErrorMessage = "Invalid apply_patch tool arguments received from upstream." + +// ApplyPatchTranslationError consumes only the optional translator error state. +func ApplyPatchTranslationError(param any) error { + state, okState := param.(interface{ ToolInputError() error }) + if !okState { + return nil + } + return state.ToolInputError() +} + +// FinalizeApplyPatchStream notifies request-local state of a transport ending. +// It must never manufacture a successful protocol terminator. +func FinalizeApplyPatchStream(param any) [][]byte { + state, okState := param.(interface{ FinalizeToolInput() [][]byte }) + if !okState { + return nil + } + return state.FinalizeToolInput() +} + +// RecordApplyPatchStreamFailure records validation before delivery can be canceled. +func RecordApplyPatchStreamFailure(ctx context.Context, param any, reporter *UsageReporter, gatewayErr error) bool { + if ApplyPatchTranslationError(param) == nil { + return false + } + reporter.PublishFailure(ctx, gatewayErr) + return true +} + +// StopApplyPatchStream propagates a retained failure after its one translated frame. +// The caller supplies its existing sanitized gateway status error. +func StopApplyPatchStream(ctx context.Context, param any, reporter *UsageReporter, out chan<- cliproxyexecutor.StreamChunk, gatewayErr error) bool { + if !RecordApplyPatchStreamFailure(ctx, param, reporter, gatewayErr) { + return false + } + select { + case out <- cliproxyexecutor.StreamChunk{Err: gatewayErr}: + case <-ctx.Done(): + } + return true +} + +// EndApplyPatchStream checks EOF before any synthetic success or usage publication. +func EndApplyPatchStream(ctx context.Context, param any, reporter *UsageReporter, out chan<- cliproxyexecutor.StreamChunk, gatewayErr error) bool { + chunks := FinalizeApplyPatchStream(param) + RecordApplyPatchStreamFailure(ctx, param, reporter, gatewayErr) + for _, chunk := range chunks { + select { + case out <- cliproxyexecutor.StreamChunk{Payload: chunk}: + case <-ctx.Done(): + return true + } + } + return StopApplyPatchStream(ctx, param, reporter, out, gatewayErr) +} + +// ApplyPatchRequested resolves only original winning custom declarations. +func ApplyPatchRequested(original []byte) bool { + for _, identity := range util.ResponsesToolReverseIdentityMap(original) { + if identity.ApplyPatch { + return true + } + } + return false +} + +// IsApplyPatchUpstreamTool preserves the original declaration's provenance. +func IsApplyPatchUpstreamTool(original []byte, name string) bool { + return util.ResponsesToolReverseIdentityMap(original)[name].ApplyPatch +} + +// InitializeApplyPatchStream prepares optional canonical state even for an empty EOF. +// An empty source has no progress events and does not opt native Codex into bridging. +func InitializeApplyPatchStream(ctx context.Context, from, to sdktranslator.Format, model string, original, effective []byte, param *any) { + if to != sdktranslator.FormatOpenAIResponse || !ApplyPatchRequested(original) { + return + } + _ = sdktranslator.TranslateStream(ctx, from, to, model, original, effective, nil, param) +} + +// ApplyPatchOriginalRequest retains source declarations when SDK callers omit OriginalRequest. +func ApplyPatchOriginalRequest(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) []byte { + if len(opts.OriginalRequest) > 0 { + return opts.OriginalRequest + } + return req.Payload +} diff --git a/internal/runtime/executor/helps/apply_patch_responses.go b/internal/runtime/executor/helps/apply_patch_responses.go new file mode 100644 index 000000000..2b4a08c3c --- /dev/null +++ b/internal/runtime/executor/helps/apply_patch_responses.go @@ -0,0 +1,619 @@ +package helps + +import ( + "bytes" + "errors" + "fmt" + + applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" + "github.com/router-for-me/CLIProxyAPI/v8/internal/util" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// NormalizeApplyPatchResponsesRequest opts a non-Codex executor into the patch contract. +func NormalizeApplyPatchResponsesRequest(body []byte, original ...[]byte) ([]byte, error) { + if len(original) > 0 { + body = preferChatFunctionPatchTools(original[0], body) + } + return translatorcommon.NormalizeApplyPatchResponsesRequest(body) +} + +// ApplyPatchResponsesState is owned explicitly by a non-native executor, never inferred +// from the wire format or a configured function schema. Supporting state stays request-local. +type ApplyPatchResponsesState struct { + Bridge *translatorcommon.ApplyPatchResponsesBridge + tools map[string]util.ResponsesToolDescriptor + dispatchers map[string]string + byDispatcherKey map[string]*patchDispatcherCall + records []*patchDispatcherCall + upstream []byte + eventLine []byte + active, failed, closed bool + transportDone bool +} + +type patchDispatcherCall struct { + namespace string + events [][]byte + snapshots [][]byte + source string + originals [][]byte + completed bool + ordinary bool + index int + name string + arguments string +} + +// preferChatFunctionPatchTools preserves the Chat request converter's existing winner rule. +func preferChatFunctionPatchTools(original, declarations []byte) []byte { + ordinary := map[string]bool{} + for _, tool := range gjson.GetBytes(original, "tools").Array() { + if tool.Get("type").String() == "function" { + ordinary[tool.Get("function.name").String()] = true + } + } + if len(ordinary) == 0 { + return declarations + } + available := map[string]bool{} + for _, tool := range gjson.GetBytes(declarations, "tools").Array() { + if tool.Get("type").String() == "function" { + available[tool.Get("name").String()] = true + } + } + var tools [][]byte + for _, tool := range gjson.GetBytes(declarations, "tools").Array() { + name := tool.Get("name").String() + if applypatch.IsCustomTool(tool) && ordinary[name] && available[name] { + continue + } + tools = append(tools, []byte(tool.Raw)) + } + declarations, _ = sjson.SetRawBytes(declarations, "tools", translatorcommon.JoinRawArray(tools)) + return declarations +} + +func NewApplyPatchResponsesState(source sdktranslator.Format, original, declarations []byte) *ApplyPatchResponsesState { + if source == sdktranslator.FormatOpenAI { + declarations = preferChatFunctionPatchTools(original, declarations) + } + s := &ApplyPatchResponsesState{Bridge: translatorcommon.NewApplyPatchResponsesBridge(declarations), tools: util.CollectResponsesToolWinners(gjson.ParseBytes(declarations)), dispatchers: map[string]string{}, byDispatcherKey: map[string]*patchDispatcherCall{}} + for _, d := range s.tools { + if applypatch.IsCustomTool(d.Tool) { + s.active = true + } + } + return s +} + +// Active reports whether this explicitly non-native state owns a winning patch declaration. +func (s *ApplyPatchResponsesState) Active() bool { return s.active } + +// AddDispatcher marks only xAI folded namespaces that contain a winning custom patch. +func (s *ApplyPatchResponsesState) AddDispatcher(name, namespace string) { + for _, d := range s.tools { + if d.Namespace == namespace && applypatch.IsCustomTool(d.Tool) { + s.dispatchers[name] = namespace + return + } + } +} + +func dispatcherKeys(root gjson.Result) []string { + var keys []string + for _, path := range []string{"item.id", "item_id"} { + if id := root.Get(path).String(); id != "" { + keys = append(keys, "item:"+id) + } + } + for _, path := range []string{"item.call_id", "call_id"} { + if id := root.Get(path).String(); id != "" { + keys = append(keys, "call:"+id) + } + } + if index := root.Get("output_index"); index.Exists() { + keys = append(keys, fmt.Sprintf("index:%d", index.Int())) + } + return keys +} +func (s *ApplyPatchResponsesState) dispatcher(root gjson.Result) *patchDispatcherCall { + matched := map[*patchDispatcherCall]bool{} + for _, key := range dispatcherKeys(root) { + if call := s.byDispatcherKey[key]; call != nil { + matched[call] = true + } + } + for _, call := range s.records { + if matched[call] { + return call + } + } + return nil +} + +func (s *ApplyPatchResponsesState) newDispatcherCandidate(root gjson.Result) *patchDispatcherCall { + call := &patchDispatcherCall{index: -1} + s.records = append(s.records, call) + for _, key := range dispatcherKeys(root) { + if s.byDispatcherKey[key] == nil { + s.byDispatcherKey[key] = call + } + } + return call +} + +// RememberDispatcherEvent retains upstream evidence before namespace restoration. +// A wrapper alone never proves dispatcher provenance: only a declared dispatcher name does. +func (s *ApplyPatchResponsesState) RememberDispatcherEvent(event []byte) { + if s.failed || s.closed || s.transportDone || len(s.dispatchers) == 0 { + return + } + s.upstream = bytes.Clone(event) + s.RememberDispatcherArguments(event) +} + +// RememberDispatcherArguments keeps full snapshots on every matched candidate, including +// unnamed calls. The common bridge retains all identity/type contradictions until acquisition. +func (s *ApplyPatchResponsesState) RememberDispatcherArguments(event []byte) { + if s.failed || s.closed || s.transportDone || len(s.dispatchers) == 0 || gjson.GetBytes(event, "type").String() != "response.function_call_arguments.done" { + return + } + s.upstream = bytes.Clone(event) + root := gjson.ParseBytes(event) + if s.dispatcher(root) == nil { + s.newDispatcherCandidate(root) + } + seen := map[*patchDispatcherCall]bool{} + for _, key := range dispatcherKeys(root) { + if call := s.byDispatcherKey[key]; call != nil && !seen[call] { + call.snapshots = append(call.snapshots, bytes.Clone(event)) + seen[call] = true + } + } +} + +func patchDispatcherArguments(wrapper gjson.Result) string { + arguments := wrapper.Get("arguments") + if arguments.Type == gjson.String { + return arguments.String() + } + return arguments.Raw +} + +func dispatcherEventName(root gjson.Result) string { + if root.Get("item").Exists() { + return root.Get("item.name").String() + } + return root.Get("name").String() +} + +func (s *ApplyPatchResponsesState) expandDispatcher(event, original []byte) ([][]byte, error) { + root := gjson.ParseBytes(event) + raw := gjson.ParseBytes(original) + kind := root.Get("type").String() + call := s.dispatcher(root) + name := dispatcherEventName(raw) + namespace, declared := s.dispatchers[name] + if call == nil && len(s.dispatchers) > 0 { + added := kind == "response.output_item.added" && raw.Get("item.type").String() == "function_call" + if declared && raw.Get("item.type").String() == "function_call" || added || kind == "response.function_call_arguments.delta" || kind == "response.function_call_arguments.done" { + call = s.newDispatcherCandidate(root) + } + } + if call == nil { + return [][]byte{event}, nil + } + if errIdentity := s.Bridge.CheckIdentity(event); errIdentity != nil { + return nil, errIdentity + } + for _, key := range dispatcherKeys(root) { + if s.byDispatcherKey[key] == nil { + s.byDispatcherKey[key] = call + } + } + if call.index < 0 && root.Get("output_index").Exists() { + call.index = int(root.Get("output_index").Int()) + } + if call.ordinary && !declared { + return [][]byte{event}, nil + } + call.events = append(call.events, bytes.Clone(event)) + call.originals = append(call.originals, bytes.Clone(original)) + if declared { + if call.namespace != "" && call.namespace != namespace { + return nil, errors.New("conflicting apply_patch dispatcher namespace") + } + call.namespace = namespace + call.ordinary = false + } + if kind == "response.function_call_arguments.delta" { + if call.completed && root.Get("delta").String() != "" { + child := s.tools[util.QualifyResponsesNamespaceToolName(call.namespace, call.name)] + if applypatch.IsCustomTool(child.Tool) { + return nil, errors.New("apply_patch dispatcher arguments received after completion") + } + return [][]byte{event}, nil + } + call.source += root.Get("delta").String() + } + if call.namespace == "" { + if name != "" { + // A late ordinary name releases untouched arguments, even if they look like a wrapper. + call.ordinary = true + events := call.events + call.events, call.originals = nil, nil + return events, nil + } + return nil, nil + } + if kind == "response.function_call_arguments.delta" && !call.completed { + return nil, nil + } + if kind != "response.output_item.done" && !(call.completed && (kind == "response.function_call_arguments.done" || kind == "response.function_call_arguments.delta" || kind == "response.output_item.added")) { + return nil, nil + } + path := "item." + if kind == "response.function_call_arguments.done" || kind == "response.function_call_arguments.delta" { + path = "" + } + + var wrappers []string + if call.source != "" { + wrappers = append(wrappers, call.source) + } + for _, snapshot := range call.snapshots { + arguments := gjson.GetBytes(snapshot, "arguments").String() + if gjson.Get(arguments, "name").Exists() { + wrappers = append(wrappers, arguments) + } + } + for _, pending := range call.originals { + p := gjson.ParseBytes(pending) + arguments := p.Get("item.arguments").String() + if (dispatcherEventName(p) == "" || s.dispatchers[dispatcherEventName(p)] == call.namespace) && gjson.Get(arguments, "name").Exists() { + wrappers = append(wrappers, arguments) + } + } + // For callers without a pre-restoration copy, retain the original full-source contract. + if len(wrappers) == 0 { + for _, pending := range call.events { + if args := gjson.GetBytes(pending, "arguments"); gjson.Get(args.String(), "name").String() != "" { + wrappers = append(wrappers, args.String()) + } + } + } + source := gjson.Result{} + for _, wrapper := range wrappers { + if gjson.Valid(wrapper) && gjson.Get(wrapper, "name").String() != "" { + source = gjson.Parse(wrapper) + } + } + name = root.Get(path + "name").String() + if name == "" || s.dispatchers[name] == call.namespace { + name = source.Get("name").String() + if name == "" { + name = call.name + } + event, _ = sjson.SetBytes(event, path+"name", name) + event, _ = sjson.SetBytes(event, path+"namespace", call.namespace) + } + if root.Get(path+"namespace").String() == "" { + event, _ = sjson.SetBytes(event, path+"namespace", call.namespace) + } + if declared || dispatcherEventName(raw) == "" || kind == "response.function_call_arguments.done" { + if wrapper := gjson.Parse(raw.Get(path + "arguments").String()); wrapper.Get("name").String() != "" { + event, _ = sjson.SetBytes(event, path+"arguments", patchDispatcherArguments(wrapper)) + } + } + if !root.Get(path + "arguments").Exists() { + encoded := patchDispatcherArguments(source) + if encoded == "" { + encoded = call.arguments + } + if encoded != "" { + event, _ = sjson.SetBytes(event, path+"arguments", encoded) + } + } + root = gjson.ParseBytes(event) + call.events[len(call.events)-1] = event + d := s.tools[util.QualifyResponsesNamespaceToolName(call.namespace, name)] + patch := applypatch.IsCustomTool(d.Tool) + finalArguments := root.Get(path + "arguments").String() + if kind == "response.output_item.added" && finalArguments == "" { + finalArguments = call.arguments + } + if patch { + for _, snapshot := range call.snapshots { + if gjson.GetBytes(snapshot, "arguments").Type != gjson.String { + return nil, errors.New("apply_patch dispatcher arguments snapshot must be a string") + } + } + } + for _, wrapperRaw := range wrappers { + wrapper := gjson.Parse(wrapperRaw) + child := s.tools[util.QualifyResponsesNamespaceToolName(call.namespace, wrapper.Get("name").String())] + if !patch && !applypatch.IsCustomTool(child.Tool) { + continue + } + input, errUnwrap := applypatch.UnwrapInput(patchDispatcherArguments(wrapper)) + final, errFinal := applypatch.UnwrapInput(finalArguments) + if !gjson.Valid(wrapperRaw) || wrapper.Get("name").String() != name || errUnwrap != nil || errFinal != nil || input != final { + return nil, errors.New("conflicting apply_patch dispatcher arguments") + } + } + + // Retain completed aliases and source evidence until the response actually closes. + // Repeated snapshots validate only the new event, never replay completed progress. + start := 0 + if call.completed { + start = len(call.events) - 1 + } + var out [][]byte + for i := start; i < len(call.events); i++ { + pending := call.events[i] + p := gjson.ParseBytes(pending) + originalRoot := gjson.ParseBytes(call.originals[i]) + if patch { + for _, namespacePath := range []string{"namespace", "item.namespace"} { + if supplied := originalRoot.Get(namespacePath).String(); supplied != "" && supplied != call.namespace { + return nil, errors.New("conflicting apply_patch dispatcher namespace") + } + } + for _, suppliedName := range []string{dispatcherEventName(originalRoot), dispatcherEventName(p)} { + if suppliedName != "" && s.dispatchers[suppliedName] != call.namespace && util.QualifyResponsesNamespaceToolName(call.namespace, suppliedName) != d.Name { + return nil, errors.New("conflicting apply_patch dispatcher child") + } + } + } + pendingPath := "" + switch p.Get("type").String() { + case "response.function_call_arguments.delta": + if patch { + // A dispatcher envelope is not incremental child input. + continue + } + case "response.output_item.added", "response.output_item.done": + pendingPath = "item." + case "response.function_call_arguments.done": + pendingPath = "" + default: + out = append(out, pending) + continue + } + if p.Get("type").String() != "response.function_call_arguments.delta" { + pendingName := p.Get(pendingPath + "name").String() + if pendingName == "" || s.dispatchers[pendingName] == call.namespace { + pending, _ = sjson.SetBytes(pending, pendingPath+"name", name) + pending, _ = sjson.SetBytes(pending, pendingPath+"namespace", call.namespace) + } + if p.Get(pendingPath+"namespace").String() == "" { + pending, _ = sjson.SetBytes(pending, pendingPath+"namespace", call.namespace) + } + // Only actual upstream wrappers are unwrapped, not restored child contents. + args := originalRoot.Get(pendingPath + "arguments") + if patch && args.Exists() && args.Type != gjson.String { + return nil, errors.New("apply_patch dispatcher arguments snapshot must be a string") + } + if args.String() != "" && (dispatcherEventName(originalRoot) == "" || s.dispatchers[dispatcherEventName(originalRoot)] == call.namespace || pendingPath == "") { + wrapper := gjson.Parse(args.String()) + if wrapper.Get("name").String() != "" { + if patch && wrapper.Get("name").String() != name { + return nil, errors.New("conflicting apply_patch dispatcher snapshot") + } + pending, _ = sjson.SetBytes(pending, pendingPath+"arguments", patchDispatcherArguments(wrapper)) + } + } + } + out = append(out, pending) + } + call.completed = true + call.name, call.arguments = name, finalArguments + return out, nil +} + +func (s *ApplyPatchResponsesState) unfinishedDispatcher() bool { + for _, call := range s.records { + if call.namespace != "" && !call.completed { + return true + } + } + return false +} + +func (s *ApplyPatchResponsesState) fail(err error) ([][]byte, error) { + if s.failed { + return nil, err + } + s.failed = true + s.byDispatcherKey, s.records, s.upstream = nil, nil, nil + return s.Bridge.Fail(err) +} + +func (s *ApplyPatchResponsesState) Transform(event []byte) ([][]byte, error) { + if s.failed || s.transportDone { + return nil, nil + } + if s.active && bytes.Equal(bytes.TrimSpace(event), []byte("[DONE]")) { + if errFinish := s.Finish(); errFinish != nil { + return s.fail(errFinish) + } + s.transportDone = true + return [][]byte{event}, nil + } + if s.closed { + return nil, nil + } + original := s.upstream + s.upstream = nil + if original == nil { + original = event + } + var preceding [][]byte + root := gjson.ParseBytes(event) + kind := root.Get("type").String() + if kind == "response.completed" || kind == "response.incomplete" || kind == "response.done" { + originalItems := gjson.GetBytes(original, "response.output").Array() + for i, item := range root.Get("response.output").Array() { + done := []byte(`{"type":"response.output_item.done"}`) + done, _ = sjson.SetRawBytes(done, "item", []byte(item.Raw)) + // Explicit IDs take priority over array position in a sparse terminal snapshot. + call := s.dispatcher(gjson.ParseBytes(done)) + if call == nil && item.Get("id").String() == "" && item.Get("call_id").String() == "" { + done, _ = sjson.SetBytes(done, "output_index", i) + call = s.dispatcher(gjson.ParseBytes(done)) + } + if call != nil && !call.ordinary { + if call.index >= 0 { + done, _ = sjson.SetBytes(done, "output_index", call.index) + } else { + done, _ = sjson.SetBytes(done, "output_index", i) + } + originalDone := done + // Filtering may shift array positions, but restoration preserves both IDs. + matches := func(candidate gjson.Result) bool { + return candidate.Get("id").String() == item.Get("id").String() && candidate.Get("call_id").String() == item.Get("call_id").String() + } + if i < len(originalItems) && matches(originalItems[i]) { + originalDone, _ = sjson.SetRawBytes(done, "item", []byte(originalItems[i].Raw)) + } else if item.Get("id").String() != "" || item.Get("call_id").String() != "" { + for _, originalItem := range originalItems { + if matches(originalItem) { + originalDone, _ = sjson.SetRawBytes(done, "item", []byte(originalItem.Raw)) + break + } + } + } + events, errExpand := s.expandDispatcher(done, originalDone) + if errExpand != nil { + return s.fail(errExpand) + } + preceding = append(preceding, events...) + if len(events) > 0 { + event, _ = sjson.SetRawBytes(event, fmt.Sprintf("response.output.%d", i), []byte(gjson.GetBytes(events[len(events)-1], "item").Raw)) + } + } + } + if s.unfinishedDispatcher() { + return s.fail(errors.New("incomplete apply_patch namespace dispatcher received from upstream")) + } + // Unproven candidates remain ordinary; let common resolve or flush their evidence. + for _, call := range s.records { + if call.namespace == "" && !call.ordinary { + preceding = append(preceding, call.events...) + call.events = nil + } + } + } + events, errExpand := s.expandDispatcher(event, original) + events = append(preceding, events...) + if errExpand != nil { + return s.fail(errExpand) + } + var out [][]byte + for _, e := range events { + converted, errTransform := s.Bridge.Transform(e) + out = append(out, converted...) + if errTransform != nil { + s.failed = true + s.byDispatcherKey, s.records, s.upstream = nil, nil, nil + return out, errTransform + } + } + if kind == "response.completed" || kind == "response.incomplete" || kind == "response.done" || kind == "response.failed" { + s.closed = true + s.byDispatcherKey, s.records, s.upstream = nil, nil, nil + } + return out, nil +} + +// Finish validates source response closure independently of completed tool input. +// Common Finish remains argument-only because it also runs inside terminal conversion. +func (s *ApplyPatchResponsesState) Finish() error { + if errFinish := s.Bridge.Finish(); errFinish != nil { + return errFinish + } + if s.closed || !s.active { + return nil + } + if s.unfinishedDispatcher() { + return errors.New("incomplete apply_patch namespace dispatcher received from upstream") + } + return errors.New("incomplete apply_patch source response received from upstream") +} + +// Stream preserves SSE framing and updates event-name lines to match converted JSON. +// A premature [DONE] is checked before publishing any success marker. +func (s *ApplyPatchResponsesState) Stream(line []byte) ([][]byte, error) { + if !s.active { + return [][]byte{line}, nil + } + if s.failed || s.transportDone { + return nil, nil + } + if bytes.HasPrefix(line, []byte("event:")) { + s.eventLine = bytes.Clone(line) + return nil, nil + } + if !bytes.HasPrefix(line, []byte("data:")) { + return [][]byte{line}, nil + } + payload := bytes.TrimSpace(line[5:]) + var events [][]byte + var errTransform error + if bytes.Equal(payload, []byte("[DONE]")) { + if errFinish := s.Finish(); errFinish != nil { + events, errTransform = s.fail(errFinish) + } else { + // JSON completion and transport completion are separate boundaries. + s.transportDone = true + s.byDispatcherKey, s.records, s.upstream = nil, nil, nil + s.eventLine = nil + return [][]byte{line}, nil + } + } else { + events, errTransform = s.Transform(payload) + } + if len(events) == 1 && bytes.Equal(events[0], payload) && errTransform == nil { + var out [][]byte + if s.eventLine != nil { + out = append(out, s.eventLine) + s.eventLine = nil + } + return append(out, line), nil + } + var out [][]byte + for _, event := range events { + if s.eventLine != nil { + if bytes.Equal(event, payload) { + out = append(out, s.eventLine) + } else { + out = append(out, []byte("event: "+gjson.GetBytes(event, "type").String())) + } + } + data := append([]byte("data: "), event...) + // Each expanded event must be a complete SSE frame, even if the source had only one. + data = append(data, '\n', '\n') + out = append(out, data) + } + s.eventLine = nil + return out, errTransform +} + +// FinishStream emits the local failure once on EOF without a validated completion. +func (s *ApplyPatchResponsesState) FinishStream() ([][]byte, error) { + if s.failed || s.transportDone { + return nil, nil + } + if errFinish := s.Finish(); errFinish != nil { + events, errFailure := s.fail(errFinish) + for i := range events { + events[i] = append(append([]byte("data: "), events[i]...), '\n', '\n') + } + return events, errFailure + } + return nil, nil +} diff --git a/internal/runtime/executor/helps/apply_patch_responses_test.go b/internal/runtime/executor/helps/apply_patch_responses_test.go new file mode 100644 index 000000000..32df7a736 --- /dev/null +++ b/internal/runtime/executor/helps/apply_patch_responses_test.go @@ -0,0 +1,497 @@ +package helps + +import ( + "bytes" + "fmt" + "testing" + + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestApplyPatchResponsesHelperNativeSSEBytes(t *testing.T) { + request := []byte(`{"tools":[{"type":"custom","name":"apply_patch"}]}`) + s := NewApplyPatchResponsesState(sdktranslator.FormatCodex, request, request) + for _, line := range [][]byte{ + []byte("event: response.output_item.done"), + []byte(`data: { "type":"response.output_item.done", "output_index":0, "item":{"type":"custom_tool_call","id":"a","name":"apply_patch","input":"raw"}} `), + []byte(""), + []byte("event: response.completed"), + []byte(`data: { "type":"response.completed", "sequence_number":8,"response":{"output":[{"type":"custom_tool_call","id":"a","name":"apply_patch","input":"raw"}]}} `), + } { + out, errStream := s.Stream(line) + if errStream != nil { + t.Fatal(errStream) + } + if bytes.HasPrefix(line, []byte("event:")) { + continue + } + if len(out) == 0 || !bytes.Equal(out[len(out)-1], line) { + t.Fatalf("native framing changed: %q", out) + } + } +} + +func TestApplyPatchResponsesHelperDispatcherKeysAndFinal(t *testing.T) { + request := []byte(`{"tools":[{"type":"namespace","name":"n","tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"lookup"}]}]}`) + for _, key := range []string{`"output_index":0`, `"call_id":"c"`, `"item_id":"a"`} { + for _, terminalOnly := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%v", key, terminalOnly), func(t *testing.T) { + s := NewApplyPatchResponsesState(sdktranslator.FormatOpenAIResponse, request, request) + s.AddDispatcher("n", "n") + _, errAdded := s.Transform([]byte(`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":"n","namespace":"n","arguments":""}}`)) + if errAdded != nil { + t.Fatal(errAdded) + } + _, errDelta := s.Transform([]byte(`{"type":"response.function_call_arguments.delta",` + key + `,"delta":"{\"name\":\"apply_patch\",\"arguments\":{\"input\":\"p\"}}"}`)) + if errDelta != nil { + t.Fatal(errDelta) + } + item := `{"type":"function_call","id":"a","call_id":"c","name":"apply_patch","namespace":"n","arguments":"{\"input\":\"p\"}"}` + var out [][]byte + var errTransform error + if terminalOnly { + out, errTransform = s.Transform([]byte(`{"type":"response.completed","response":{"output":[` + item + `]}}`)) + } else { + out, errTransform = s.Transform([]byte(`{"type":"response.output_item.done","output_index":0,"item":` + item + `}`)) + } + if errTransform != nil { + t.Fatal(errTransform) + } + found := false + for _, e := range out { + if gjson.GetBytes(e, "type").String() == "response.custom_tool_call_input.done" { + found = true + } + if gjson.GetBytes(e, "type").String() == "response.custom_tool_call_input.delta" { + t.Fatalf("fabricated dispatcher preview: %s", out) + } + } + if !found { + t.Fatalf("dispatcher bypass: %s", out) + } + // Completed input is valid independently of response closure. + if errFinish := s.Bridge.Finish(); errFinish != nil { + t.Fatal(errFinish) + } + if !terminalOnly && s.Finish() == nil { + t.Fatal("completed dispatcher input substituted for source completion") + } + }) + } + } +} + +func TestApplyPatchResponsesHelperChatFunctionPreference(t *testing.T) { + original := []byte(`{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","function":{"name":"apply_patch"}}]}`) + declarations := []byte(`{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"apply_patch"}]}`) + s := NewApplyPatchResponsesState(sdktranslator.FormatOpenAI, original, declarations) + raw := []byte(`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","name":"apply_patch","arguments":"ordinary"}}`) + out, errTransform := s.Transform(raw) + if errTransform != nil || len(out) != 1 || !bytes.Equal(out[0], raw) { + t.Fatalf("Chat function preference: %s %v", out, errTransform) + } +} + +func TestApplyPatchResponsesHelperRequestChatPreference(t *testing.T) { + original := []byte(`{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","function":{"name":"apply_patch","parameters":{"type":"object","properties":{"x":{"type":"integer"}}}}}]}`) + body := []byte(`{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"apply_patch","parameters":{"type":"object","properties":{"x":{"type":"integer"}}}}]}`) + out, errNormalize := NormalizeApplyPatchResponsesRequest(body, original) + if errNormalize != nil || !gjson.GetBytes(out, "tools.0.parameters.properties.x").Exists() || gjson.GetBytes(out, "tools.0.parameters.properties.input").Exists() { + t.Fatalf("request ordinary preference lost: %s %v", out, errNormalize) + } +} + +func TestApplyPatchResponsesHelperDispatcherOmittedArguments(t *testing.T) { + req := []byte(`{"tools":[{"type":"namespace","name":"n","tools":[{"type":"custom","name":"apply_patch"}]}]}`) + for _, lateName := range []string{"n", "apply_patch"} { + t.Run(lateName, func(t *testing.T) { + s := NewApplyPatchResponsesState(sdktranslator.FormatOpenAIResponse, req, req) + s.AddDispatcher("n", "n") + for _, raw := range []string{ + `{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":"n","arguments":""}}`, + `{"type":"response.function_call_arguments.delta","output_index":0,"item_id":"a","delta":"{\"name\":\"apply_patch\",\"arguments\":{\"input\":\"p\"}}"}`, + } { + if _, errTransform := s.Transform([]byte(raw)); errTransform != nil { + t.Fatal(errTransform) + } + } + out, errTransform := s.Transform([]byte(fmt.Sprintf(`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":%q,"namespace":"n"}}`, lateName))) + if errTransform != nil || len(out) == 0 || gjson.GetBytes(out[len(out)-1], "item.input").String() != "p" { + t.Fatalf("sourced dispatcher completion lost: %s %v", out, errTransform) + } + }) + } +} + +func TestApplyPatchResponsesHelperClosedResponse(t *testing.T) { + req := []byte(`{"tools":[{"type":"namespace","name":"n","tools":[{"type":"custom","name":"apply_patch"}]}]}`) + s := NewApplyPatchResponsesState(sdktranslator.FormatOpenAIResponse, req, req) + s.AddDispatcher("n", "n") + if _, errTransform := s.Transform([]byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","id":"a","call_id":"c","namespace":"n","name":"apply_patch","arguments":"{\"input\":\"p\"}"}]}}`)); errTransform != nil { + t.Fatal(errTransform) + } + out, errTransform := s.Transform([]byte(`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","call_id":"changed","name":"n","arguments":""}}`)) + if len(out) != 0 || errTransform != nil { + t.Fatalf("closed response mutated: %s %v", out, errTransform) + } + if errFinish := s.Finish(); errFinish != nil { + t.Fatal(errFinish) + } +} + +func TestApplyPatchResponsesHelperTransportTerminal(t *testing.T) { + request := []byte(`{"tools":[{"type":"custom","name":"apply_patch"}]}`) + complete := []byte(`data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":"apply_patch","arguments":"{\"input\":\"p\"}"}}`) + for _, jsonTerminal := range []bool{false, true} { + t.Run(fmt.Sprint(jsonTerminal), func(t *testing.T) { + s := NewApplyPatchResponsesState(sdktranslator.FormatOpenAIResponse, request, request) + if _, errStream := s.Stream(complete); errStream != nil { + t.Fatal(errStream) + } + if jsonTerminal { + if _, errStream := s.Stream([]byte(`data: {"type":"response.completed","response":{"output":[]}}`)); errStream != nil { + t.Fatal(errStream) + } + } + marker := []byte("data: [DONE] ") + out, errStream := s.Stream(marker) + if !jsonTerminal { + if errStream == nil || len(out) != 1 || !bytes.Contains(out[0], []byte(`"type":"response.failed"`)) { + t.Fatalf("premature source sentinel accepted: %q %v", out, errStream) + } + } else if errStream != nil || len(out) != 1 || !bytes.Equal(out[0], marker) { + t.Fatalf("first legitimate source sentinel changed: %q %v", out, errStream) + } + for _, line := range [][]byte{complete, []byte("event: response.completed"), []byte(""), []byte(": keepalive"), marker} { + out, errStream = s.Stream(line) + if errStream != nil || len(out) != 0 { + t.Fatalf("post-terminal output: %q %v", out, errStream) + } + } + out, errTransform := s.Transform([]byte(`{"type":"response.completed","response":{"output":[]}}`)) + if errTransform != nil || len(out) != 0 { + t.Fatalf("transport terminal reopened by JSON: %s %v", out, errTransform) + } + }) + } +} + +func TestApplyPatchResponsesHelperFailedTransport(t *testing.T) { + req := []byte(`{"tools":[{"type":"custom","name":"apply_patch"}]}`) + s := NewApplyPatchResponsesState(sdktranslator.FormatOpenAIResponse, req, req) + if _, errStream := s.Stream([]byte(`data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","name":"apply_patch","arguments":""}}`)); errStream != nil { + t.Fatal(errStream) + } + out, errStream := s.Stream([]byte("data: [DONE]")) + if errStream == nil || len(out) != 1 || !bytes.Contains(out[0], []byte(`"type":"response.failed"`)) { + t.Fatalf("incomplete call did not fail before DONE: %s %v", out, errStream) + } + for _, line := range [][]byte{[]byte("data: [DONE]"), []byte(`data: {"type":"response.completed","response":{"output":[]}}`), []byte("event: response.completed")} { + out, errStream = s.Stream(line) + if len(out) != 0 || errStream != nil { + t.Fatalf("failure repeated or success published after failure: %s %v", out, errStream) + } + } +} + +func TestApplyPatchResponsesHelperInactiveTransportBytes(t *testing.T) { + req := []byte(`{"tools":[{"type":"function","name":"apply_patch"}]}`) + s := NewApplyPatchResponsesState(sdktranslator.FormatOpenAIResponse, req, req) + for _, line := range [][]byte{[]byte("data: [DONE]"), []byte(`data: { "type":"response.completed", "response":{"output":[]}} `), []byte("event: response.completed"), []byte(""), []byte("data: [DONE]")} { + out, errStream := s.Stream(line) + if errStream != nil || len(out) != 1 || !bytes.Equal(out[0], line) { + t.Fatalf("inactive bridge changed source bytes: %q %v", out, errStream) + } + } +} + +func TestApplyPatchResponsesHelperRetainedDispatcherProvenance(t *testing.T) { + req := []byte(`{"tools":[{"type":"namespace","name":"n","tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"lookup"}]}]}`) + patch := `{"name":"apply_patch","arguments":{"input":"p"}}` + ordinary := `{"name":"lookup","arguments":{"input":"p"}}` + for _, tc := range []struct { + name string + delta string + wrappers []string + finalName string + shouldFail bool + }{ + {"patch_then_ordinary", "", []string{patch, ordinary}, "n", true}, + {"ordinary_then_patch", "", []string{ordinary, patch}, "n", true}, + {"conflicting_inputs", "", []string{patch, `{"name":"apply_patch","arguments":{"input":"q"}}`}, "n", true}, + {"full_source_conflicts_with_snapshot", patch, []string{ordinary}, "n", true}, + {"full_source_conflicts_with_child", patch, nil, "lookup", true}, + {"ordinary_child_is_not_patch", "", []string{ordinary}, "n", false}, + {"ordinary_arguments_are_not_dispatcher_provenance", "", []string{`{"name":"lookup","arguments":{"name":"apply_patch","arguments":{"input":"not patch"}}}`}, "n", false}, + } { + t.Run(tc.name, func(t *testing.T) { + s := NewApplyPatchResponsesState(sdktranslator.FormatOpenAIResponse, req, req) + s.AddDispatcher("n", "n") + if _, errTransform := s.Transform([]byte(`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","name":"n","arguments":""}}`)); errTransform != nil { + t.Fatal(errTransform) + } + if tc.delta != "" { + if _, errTransform := s.Transform([]byte(fmt.Sprintf(`{"type":"response.function_call_arguments.delta","item_id":"a","delta":%q}`, tc.delta))); errTransform != nil { + t.Fatal(errTransform) + } + } + for _, wrapper := range tc.wrappers { + s.RememberDispatcherArguments([]byte(fmt.Sprintf(`{"type":"response.function_call_arguments.done","item_id":"a","arguments":%q}`, wrapper))) + // The real restorer removes the wrapper before this event reaches Transform. + if _, errTransform := s.Transform([]byte(fmt.Sprintf(`{"type":"response.function_call_arguments.done","item_id":"a","arguments":%q}`, gjson.Get(wrapper, "arguments").Raw))); errTransform != nil { + t.Fatal(errTransform) + } + } + final := fmt.Sprintf(`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","call_id":"late","name":%q,"namespace":"n"}}`, tc.finalName) + out, errTransform := s.Transform([]byte(final)) + if tc.shouldFail { + if errTransform == nil || len(out) != 1 || gjson.GetBytes(out[0], "type").String() != "response.failed" { + t.Fatalf("dispatcher child evidence lost: %s %v", out, errTransform) + } + } else if errTransform != nil || len(out) == 0 || gjson.GetBytes(out[len(out)-1], "item.name").String() != "lookup" || gjson.GetBytes(out[len(out)-1], "item.namespace").String() != "n" || gjson.GetBytes(out[len(out)-1], "item.arguments").String() != gjson.Get(tc.wrappers[len(tc.wrappers)-1], "arguments").Raw || bytes.Contains(bytes.Join(out, nil), []byte(`custom_tool_call`)) { + t.Fatalf("ordinary dispatcher child changed: %s %v", out, errTransform) + } + }) + } +} + +func TestApplyPatchResponsesHelperDispatcherSnapshotsAllMatched(t *testing.T) { + req := []byte(`{"tools":[{"type":"namespace","name":"n","tools":[{"type":"custom","name":"apply_patch"}]}]}`) + for discover := 0; discover < 3; discover++ { + t.Run(fmt.Sprint(discover), func(t *testing.T) { + s := NewApplyPatchResponsesState(sdktranslator.FormatOpenAIResponse, req, req) + s.AddDispatcher("n", "n") + for i := 0; i < 3; i++ { + if _, errTransform := s.Transform([]byte(fmt.Sprintf(`{"type":"response.output_item.added","output_index":%d,"item":{"type":"function_call","id":"i%d","call_id":"c%d","name":"n","arguments":""}}`, i, i, i))); errTransform != nil { + t.Fatal(errTransform) + } + } + original := []byte(`{"type":"response.function_call_arguments.done","output_index":0,"item_id":"i1","call_id":"c2","arguments":"{\"name\":\"apply_patch\",\"arguments\":{\"input\":\"p\"}}"}`) + s.RememberDispatcherArguments(original) + for _, key := range []string{"item:i0", "item:i1", "item:i2"} { + if len(s.byDispatcherKey[key].snapshots) != 1 || !bytes.Equal(s.byDispatcherKey[key].snapshots[0], original) { + t.Fatalf("original source not retained on matched record %s", key) + } + } + if _, errTransform := s.Transform([]byte(`{"type":"response.function_call_arguments.done","output_index":0,"item_id":"i1","call_id":"c2","arguments":"{\"input\":\"p\"}"}`)); errTransform != nil { + t.Fatal(errTransform) + } + out, errTransform := s.Transform([]byte(fmt.Sprintf(`{"type":"response.output_item.done","output_index":%d,"item":{"type":"function_call","id":"i%d","call_id":"c%d","name":"n"}}`, discover, discover, discover))) + if errTransform == nil || len(out) != 1 || gjson.GetBytes(out[0], "type").String() != "response.failed" { + t.Fatalf("conflicting all-key evidence lost on record %d: %s %v", discover, out, errTransform) + } + }) + } +} + +func TestApplyPatchResponsesHelperOrdinaryProgress(t *testing.T) { + request := []byte(`{"tools":[{"type":"namespace","name":"n","tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"lookup"}]}]}`) + s := NewApplyPatchResponsesState(sdktranslator.FormatOpenAIResponse, request, request) + s.AddDispatcher("n", "n") + for _, raw := range []string{ + `{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","name":"lookup","namespace":"n","arguments":""}}`, + `{"type":"response.function_call_arguments.delta","item_id":"a","delta":"{\"x\":1}"}`, + `{"type":"response.function_call_arguments.done","item_id":"a","arguments":"{\"x\":1}"}`, + `{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":"lookup","namespace":"n","arguments":"{\"x\":1}"}}`, + } { + s.RememberDispatcherEvent([]byte(raw)) + out, errTransform := s.Transform([]byte(raw)) + if errTransform != nil || len(out) != 1 || !bytes.Equal(out[0], []byte(raw)) { + t.Fatalf("ordinary progress delayed or rewritten: %s %v", out, errTransform) + } + } +} + +func TestApplyPatchResponsesHelperDispatcherLifecycle(t *testing.T) { + request := []byte(`{"tools":[{"type":"namespace","name":"n","tools":[{"type":"custom","name":"apply_patch"}]}]}`) + for _, closeAt := range []string{"response", "sentinel", "upstream_failure", "local_failure"} { + t.Run(closeAt, func(t *testing.T) { + s := NewApplyPatchResponsesState(sdktranslator.FormatOpenAIResponse, request, request) + s.AddDispatcher("n", "n") + for _, raw := range []string{ + `{"type":"response.output_item.added","output_index":2,"item":{"type":"function_call","id":"a"}}`, + `{"type":"response.function_call_arguments.done","item_id":"a","arguments":"{\"name\":\"apply_patch\",\"arguments\":{\"input\":\"p\"}}"}`, + } { + original := []byte(raw) + s.RememberDispatcherEvent(original) + out, errTransform := s.Transform(original) + if errTransform != nil || len(out) != 0 || s.byDispatcherKey["item:a"].namespace != "" { + t.Fatalf("wrapper prematurely acquired dispatcher provenance: %s %v", out, errTransform) + } + for i := range original { + original[i] = 'x' + } + } + out, errTransform := s.Transform([]byte(`{"type":"response.output_item.done","output_index":2,"item":{"type":"function_call","id":"a","call_id":"c","name":"n"}}`)) + if errTransform != nil || len(out) == 0 || gjson.GetBytes(out[len(out)-1], "item.input").String() != "p" { + t.Fatalf("retained source not acquired: %s %v", out, errTransform) + } + call := s.byDispatcherKey["item:a"] + if call == nil || !call.completed || call.namespace != "n" || call.index != 2 || s.byDispatcherKey["call:c"] != call || s.byDispatcherKey["index:2"] != call || len(call.snapshots) != 1 { + t.Fatalf("completed aliases/source evidence expired: %+v", call) + } + if errFinish := s.Bridge.Finish(); errFinish != nil || s.Finish() == nil || s.byDispatcherKey["item:a"] != call { + t.Fatalf("argument validation must not close completed provenance: %v", errFinish) + } + switch closeAt { + case "response": + out, errTransform = s.Transform([]byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","id":"a","call_id":"c","name":"n"}]}}`)) + if errTransform != nil || len(out) != 1 || gjson.GetBytes(out[0], "response.output.0.input").String() != "p" { + t.Fatalf("sparse terminal lost completed child or replayed progress: %s %v", out, errTransform) + } + case "sentinel": + out, errTransform = s.Stream([]byte("data: [DONE]")) + if errTransform == nil || len(out) != 1 || !bytes.Contains(out[0], []byte(`"type":"response.failed"`)) { + t.Fatalf("premature sentinel accepted: %s %v", out, errTransform) + } + case "upstream_failure": + if _, errTransform = s.Transform([]byte(`{"type":"response.failed","response":{"output":[]}}`)); errTransform != nil { + t.Fatal(errTransform) + } + case "local_failure": + if _, errTransform = s.Transform([]byte(`{"type":"response.output_item.done","output_index":3,"item":{"type":"function_call","id":"a","name":"n"}}`)); errTransform == nil { + t.Fatal("identity conflict did not fail") + } + } + if len(s.byDispatcherKey) != 0 || len(s.records) != 0 || s.upstream != nil { + t.Fatal("closed response retained dispatcher source/aliases") + } + out, errTransform = s.Transform([]byte(`{"type":"response.output_item.done","output_index":2,"item":{"type":"function_call","id":"a","name":"n"}}`)) + if errTransform != nil || len(out) != 0 { + t.Fatalf("closed dispatcher reopened: %s %v", out, errTransform) + } + }) + } +} + +func TestApplyPatchResponsesHelperLateChildNamespace(t *testing.T) { + request := []byte(`{"tools":[{"type":"namespace","name":"n","tools":[{"type":"custom","name":"apply_patch"}]}]}`) + s := NewApplyPatchResponsesState(sdktranslator.FormatOpenAIResponse, request, request) + s.AddDispatcher("n", "n") + var out [][]byte + for _, raw := range []string{ + `{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","name":"n","arguments":""}}`, + `{"type":"response.function_call_arguments.done","item_id":"a","arguments":"{\"name\":\"apply_patch\",\"arguments\":{\"input\":\"p\"}}"}`, + `{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":"apply_patch"}}`, + } { + s.RememberDispatcherEvent([]byte(raw)) + converted, errTransform := s.Transform([]byte(raw)) + if errTransform != nil { + t.Fatal(errTransform) + } + out = append(out, converted...) + } + if len(out) == 0 || gjson.GetBytes(out[len(out)-1], "item.namespace").String() != "n" || gjson.GetBytes(out[len(out)-1], "item.input").String() != "p" { + t.Fatalf("sourced late child lost its namespace/input: %s", out) + } +} + +func TestApplyPatchResponsesHelperTerminalSourceIdentity(t *testing.T) { + request := []byte(`{"tools":[{"type":"namespace","name":"n","tools":[{"type":"custom","name":"apply_patch"}]}]}`) + s := NewApplyPatchResponsesState(sdktranslator.FormatOpenAIResponse, request, request) + s.AddDispatcher("n", "n") + for _, raw := range []string{ + `{"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","id":"a","name":"n","arguments":""}}`, + `{"type":"response.function_call_arguments.done","item_id":"a","arguments":"{\"name\":\"apply_patch\",\"arguments\":{\"input\":\"p\"}}"}`, + `{"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","id":"a","call_id":"c","name":"n"}}`, + } { + s.RememberDispatcherEvent([]byte(raw)) + if _, errTransform := s.Transform([]byte(raw)); errTransform != nil { + t.Fatal(errTransform) + } + } + // Provider filtering can remove an earlier item after the original snapshot is copied. + s.RememberDispatcherEvent([]byte(`{"type":"response.completed","response":{"output":[{"type":"message","id":"removed"},{"type":"function_call","id":"a","call_id":"c","name":"n","namespace":"other"}]}}`)) + out, errTransform := s.Transform([]byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","id":"a","call_id":"c","name":"n","namespace":"n"}]}}`)) + if errTransform == nil || len(out) != 1 || gjson.GetBytes(out[0], "type").String() != "response.failed" { + t.Fatalf("terminal source matched by position, losing contradictory namespace: %s %v", out, errTransform) + } +} + +func TestApplyPatchResponsesHelperIndexOnlyTerminal(t *testing.T) { + request := []byte(`{"tools":[{"type":"namespace","name":"n","tools":[{"type":"custom","name":"apply_patch"}]}]}`) + s := NewApplyPatchResponsesState(sdktranslator.FormatOpenAIResponse, request, request) + s.AddDispatcher("n", "n") + for _, raw := range []string{ + `{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call"}}`, + `{"type":"response.function_call_arguments.done","output_index":0,"arguments":"{\"name\":\"apply_patch\",\"arguments\":{\"input\":\"p\"}}"}`, + // IDs are real source evidence; the later sparse terminal still matches by index. + `{"type":"response.function_call_arguments.done","output_index":0,"item_id":"a","call_id":"c","arguments":"{\"name\":\"apply_patch\",\"arguments\":{\"input\":\"p\"}}"}`, + } { + s.RememberDispatcherEvent([]byte(raw)) + if _, errTransform := s.Transform([]byte(raw)); errTransform != nil { + t.Fatal(errTransform) + } + } + terminal := []byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","name":"n"}]}}`) + s.RememberDispatcherEvent(terminal) + out, errTransform := s.Transform(terminal) + if errTransform != nil || len(out) == 0 || gjson.GetBytes(out[len(out)-1], "response.output.0.input").String() != "p" { + t.Fatalf("index-only provenance bypassed: %s %v", out, errTransform) + } +} + +func TestApplyPatchResponsesHelperCompletedOrdinaryDelta(t *testing.T) { + request := []byte(`{"tools":[{"type":"namespace","name":"n","tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"lookup"}]}]}`) + s := NewApplyPatchResponsesState(sdktranslator.FormatOpenAIResponse, request, request) + s.AddDispatcher("n", "n") + for _, raw := range []string{ + `{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","name":"n"}}`, + `{"type":"response.function_call_arguments.done","item_id":"a","arguments":"{\"name\":\"lookup\",\"arguments\":{\"x\":1}}"}`, + `{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":"n"}}`, + } { + s.RememberDispatcherEvent([]byte(raw)) + if _, errTransform := s.Transform([]byte(raw)); errTransform != nil { + t.Fatal(errTransform) + } + } + raw := []byte(`{"type":"response.function_call_arguments.delta","item_id":"a","delta":"ordinary"}`) + s.RememberDispatcherEvent(raw) + out, errTransform := s.Transform(raw) + if errTransform != nil || len(out) != 1 || !bytes.Equal(out[0], raw) { + t.Fatalf("ordinary post-completion progress acquired patch validation: %s %v", out, errTransform) + } +} + +func TestApplyPatchResponsesHelperSourceTerminalRequired(t *testing.T) { + request := []byte(`{"tools":[{"type":"custom","name":"apply_patch"}]}`) + for _, mode := range []string{"empty", "arguments", "item"} { + t.Run(mode, func(t *testing.T) { + s := NewApplyPatchResponsesState(sdktranslator.FormatCodex, request, request) + if mode != "empty" { + _, errTransform := s.Transform([]byte(`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","name":"apply_patch","arguments":""}}`)) + if errTransform != nil { + t.Fatal(errTransform) + } + event := `{"type":"response.function_call_arguments.done","item_id":"a","arguments":"{\"input\":\"p\"}"}` + if mode == "item" { + event = `{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","name":"apply_patch","arguments":"{\"input\":\"p\"}"}}` + } + if _, errTransform = s.Transform([]byte(event)); errTransform != nil { + t.Fatal(errTransform) + } + } + out, errFinishStream := s.FinishStream() + if errFinishStream == nil || len(out) != 1 || !bytes.Contains(out[0], []byte(`"type":"response.failed"`)) { + t.Fatalf("source EOF silently accepted: %s %v", out, errFinishStream) + } + if out, errFinishStream = s.FinishStream(); len(out) != 0 || errFinishStream != nil { + t.Fatalf("EOF failure repeated: %s %v", out, errFinishStream) + } + }) + } +} + +func TestApplyPatchResponsesHelperInactiveEOFAndDONE(t *testing.T) { + for _, request := range []string{`{}`, `{"tools":[{"type":"function","name":"apply_patch"}]}`} { + s := NewApplyPatchResponsesState(sdktranslator.FormatCodex, []byte(request), []byte(request)) + if out, errFinishStream := s.FinishStream(); len(out) != 0 || errFinishStream != nil { + t.Fatalf("inactive EOF changed: %s %v", out, errFinishStream) + } + line := []byte("data: [DONE]") + if out, errStream := s.Stream(line); errStream != nil || len(out) != 1 || !bytes.Equal(out[0], line) { + t.Fatalf("ordinary DONE changed: %s %v", out, errStream) + } + } +} diff --git a/internal/runtime/executor/helps/apply_patch_test.go b/internal/runtime/executor/helps/apply_patch_test.go new file mode 100644 index 000000000..4f9992a01 --- /dev/null +++ b/internal/runtime/executor/helps/apply_patch_test.go @@ -0,0 +1,126 @@ +package helps + +import ( + "bytes" + "context" + "errors" + "testing" + "time" + + _ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator" + "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" +) + +func TestApplyPatchTranslationError(t *testing.T) { + state := &common.ApplyPatchErrorState{} + want := errors.New("invalid wrapper") + state.SetToolInputError(want) + if got := ApplyPatchTranslationError(state); got != want { + t.Fatalf("translation error = %v, want %v", got, want) + } + if ApplyPatchTranslationError(struct{}{}) != nil || ApplyPatchTranslationError(nil) != nil { + t.Fatal("unrelated translator state was rejected") + } +} + +func TestApplyPatchFinalizeOptionalCanonicalState(t *testing.T) { + original := []byte(`{"tools":[{"type":"custom","name":"apply_patch"}]}`) + for _, source := range []sdktranslator.Format{sdktranslator.FormatOpenAI, sdktranslator.FormatClaude, sdktranslator.FormatGemini, sdktranslator.FormatInteractions} { + t.Run(source.String(), func(t *testing.T) { + var param any + InitializeApplyPatchStream(context.Background(), source, sdktranslator.FormatOpenAIResponse, "model", original, original, ¶m) + frames := FinalizeApplyPatchStream(param) + if len(frames) != 1 || ApplyPatchTranslationError(param) == nil || !bytes.Contains(frames[0], []byte(`"type":"response.failed"`)) { + t.Fatalf("EOF did not fail canonically: %s", frames) + } + if len(FinalizeApplyPatchStream(param)) != 0 { + t.Fatal("EOF failure emitted twice") + } + late := []byte(`data: {"type":"message_stop","event_type":"interaction.completed","candidates":[{"finishReason":"STOP"}],"choices":[{"delta":{},"finish_reason":"stop"}]}`) + if len(sdktranslator.TranslateStream(context.Background(), source, sdktranslator.FormatOpenAIResponse, "model", original, original, late, ¶m)) != 0 { + t.Fatal("EOF failure reopened into success") + } + if len(sdktranslator.TranslateStream(context.Background(), source, sdktranslator.FormatOpenAIResponse, "model", original, original, []byte("[DONE]"), ¶m)) != 0 { + t.Fatal("failure forwarded transport success") + } + }) + } + if len(FinalizeApplyPatchStream(struct{}{})) != 0 || len(FinalizeApplyPatchStream(nil)) != 0 { + t.Fatal("unrelated states received an EOF failure") + } +} + +func TestApplyPatchTokenUsageHelperRetainsFailureState(t *testing.T) { + original := []byte(`{"tools":[{"type":"custom","name":"apply_patch"}]}`) + var param any + tokens := NewClaudeInputTokenState(sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, sdktranslator.FormatOpenAIResponse, original) + chunks := TranslateStreamWithClaudeInputTokens(context.Background(), sdktranslator.FormatOpenAI, sdktranslator.FormatOpenAIResponse, "model", original, original, []byte(`data: {"id":"r","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c","function":{"name":"apply_patch","arguments":"{\"input\":7}"}}]}}]}`), ¶m, tokens) + if ApplyPatchTranslationError(param) == nil { + t.Fatal("token helper discarded the same retained error state") + } + failed := 0 + for _, chunk := range chunks { + if bytes.Contains(chunk, []byte(`"type":"response.failed"`)) { + failed++ + if bytes.Contains(chunk, []byte(`"usage"`)) { + t.Fatal("usage normalization altered a failure frame") + } + } + } + if failed != 1 { + t.Fatalf("failure frames=%d", failed) + } +} + +func TestApplyPatchInteractionsOnlyValidSourceDoneClosesTransport(t *testing.T) { + var param any + translate := func(payload string) [][]byte { + return sdktranslator.TranslateStream(context.Background(), sdktranslator.FormatInteractions, sdktranslator.FormatOpenAIResponse, "model", nil, nil, []byte(payload), ¶m) + } + _ = translate(`{"event_type":"interaction.completed","interaction":{"id":"r"}}`) + if chunks := translate(`{"event_type":"done",`); len(chunks) != 0 { + t.Fatalf("malformed post-terminal event was accepted as a sentinel: %s", chunks) + } + chunks := translate("[DONE]") + if len(chunks) != 1 || !bytes.Equal(chunks[0], []byte("data: [DONE]")) { + t.Fatalf("first real source sentinel was swallowed: %s", chunks) + } + if len(translate("[DONE]")) != 0 || len(translate(`{"event_type":"done"}`)) != 0 { + t.Fatal("duplicate source sentinel was forwarded") + } +} + +type patchCanceledEOFCapture struct{ records chan usage.Record } + +func (p *patchCanceledEOFCapture) HandleUsage(_ context.Context, record usage.Record) { + if record.Provider == "task6-canceled-eof" { + p.records <- record + } +} + +func TestApplyPatchCanceledEOFStillRecordsFailure(t *testing.T) { + capture := &patchCanceledEOFCapture{records: make(chan usage.Record, 1)} + usage.RegisterNamedPlugin("task6-canceled-eof", capture) + var param any + original := []byte(`{"tools":[{"type":"custom","name":"apply_patch"}]}`) + InitializeApplyPatchStream(context.Background(), sdktranslator.FormatGemini, sdktranslator.FormatOpenAIResponse, "m", original, original, ¶m) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + reporter := NewUsageReporter(ctx, "task6-canceled-eof", "m", nil) + out := make(chan cliproxyexecutor.StreamChunk) + if !EndApplyPatchStream(ctx, param, reporter, out, errors.New("clean failure")) { + t.Fatal("EOF failure was discarded") + } + reporter.EnsurePublished(ctx) + select { + case record := <-capture.records: + if !record.Failed { + t.Fatal("cancellation recovered a retained EOF failure as success usage") + } + case <-time.After(3 * time.Second): + t.Fatal("canceled EOF usage was not dispatched") + } +} diff --git a/internal/runtime/executor/helps/claude_input_tokens.go b/internal/runtime/executor/helps/claude_input_tokens.go index c702157ec..360ee94d3 100644 --- a/internal/runtime/executor/helps/claude_input_tokens.go +++ b/internal/runtime/executor/helps/claude_input_tokens.go @@ -63,6 +63,9 @@ func TranslateStreamWithClaudeInputTokens( rawJSON, param, ) + if param != nil && ApplyPatchTranslationError(*param) != nil { + return chunks + } if responseFormat == sdktranslator.FormatOpenAIResponse { for i, chunk := range chunks { chunks[i] = EnsureResponsesUsageDetails(chunk) diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index 909a166de..877e07ebf 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -216,11 +216,14 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req } helps.AppendAPIResponseChunk(ctx, e.cfg, data) reporter.ObserveResponseModel(data) - reporter.Publish(ctx, helps.ParseOpenAIUsage(data)) var param any // Note: TranslateNonStream uses req.Model (original with suffix) to preserve // the original model name in the response for client compatibility. - out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, data, ¶m) + out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, data, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + reporter.Publish(ctx, helps.ParseOpenAIUsage(data)) if responseFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) } @@ -354,15 +357,17 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut scanner := bufio.NewScanner(httpResp.Body) scanner.Buffer(nil, 1_048_576) // 1MB claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload) - var param any var streamUsage helps.StreamUsageBuffer defer streamUsage.Publish(ctx, reporter) + var param any + helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, ¶m) for scanner.Scan() { line := scanner.Bytes() helps.AppendAPIResponseChunk(ctx, e.cfg, line) reporter.ObserveResponseModel(line) streamUsage.ObserveOpenAIStream(line) - chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m, claudeInputTokens) + chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, bytes.Clone(line), ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range chunks { select { case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: @@ -370,8 +375,15 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut return } } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } } - doneChunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m, claudeInputTokens) + if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } + doneChunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, []byte("[DONE]"), ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range doneChunks { select { case out <- cliproxyexecutor.StreamChunk{Payload: doneChunks[i]}: @@ -423,6 +435,11 @@ func (e *KimiExecutor) executeResponses(ctx context.Context, auth *cliproxyauth. requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "openai-response", opts.SourceFormat.String(), "", body, req.Payload, requestedModel, requestPath, opts.Headers) + var errNormalizePatch error + body, errNormalizePatch = helps.NormalizeApplyPatchResponsesRequest(body) + if errNormalizePatch != nil { + return resp, errNormalizePatch + } var errNormalizeInput error body, errNormalizeInput = helps.NormalizeKimiResponsesInput(body) if errNormalizeInput != nil { @@ -492,17 +509,28 @@ func (e *KimiExecutor) executeResponses(ctx context.Context, auth *cliproxyauth. helps.AppendAPIResponseChunk(ctx, e.cfg, data) reporter.ObserveResponseModel(data) + originalRequest := req.Payload + if len(opts.OriginalRequest) > 0 { + originalRequest = opts.OriginalRequest + } + bridge := helps.NewApplyPatchResponsesState(opts.SourceFormat, originalRequest, originalRequest) + out, errTransform := bridge.Bridge.TransformNonStream(data) + if errTransform != nil || (len(out) == 0 && helps.ApplyPatchRequested(originalRequest)) { + return resp, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + if responseFormat != sdktranslator.FormatOpenAIResponse { + var param any + out = sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatOpenAIResponse, responseFormat, req.Model, originalRequest, body, out, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + } if usage, ok := helps.ParseCodexUsage(data); ok && (usage.TotalTokens > 0 || usage.InputTokens > 0) { reporter.Publish(ctx, usage) } else if usage := helps.ParseOpenAIUsage(data); usage.TotalTokens > 0 || usage.InputTokens > 0 { reporter.Publish(ctx, usage) } - out := data - if responseFormat != sdktranslator.FormatOpenAIResponse { - var param any - out = sdktranslator.TranslateNonStream(ctx, sdktranslator.FormatOpenAIResponse, responseFormat, req.Model, opts.OriginalRequest, body, data, ¶m) - } resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()} return resp, nil } @@ -539,6 +567,11 @@ func (e *KimiExecutor) executeResponsesStream(ctx context.Context, auth *cliprox requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "openai-response", opts.SourceFormat.String(), "", body, req.Payload, requestedModel, requestPath, opts.Headers) + var errNormalizePatch error + body, errNormalizePatch = helps.NormalizeApplyPatchResponsesRequest(body) + if errNormalizePatch != nil { + return nil, errNormalizePatch + } var errNormalizeInputStream error body, errNormalizeInputStream = helps.NormalizeKimiResponsesInput(body) if errNormalizeInputStream != nil { @@ -609,9 +642,25 @@ func (e *KimiExecutor) executeResponsesStream(ctx context.Context, auth *cliprox scanner := bufio.NewScanner(httpResp.Body) scanner.Buffer(nil, 52_428_800) + originalRequest := req.Payload + if len(opts.OriginalRequest) > 0 { + originalRequest = opts.OriginalRequest + } + bridge := helps.NewApplyPatchResponsesState(opts.SourceFormat, originalRequest, originalRequest) + sendFailure := func(errFailure error) { + errFailure = statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + reporter.PublishFailure(ctx, errFailure) + select { + case out <- cliproxyexecutor.StreamChunk{Err: errFailure}: + case <-ctx.Done(): + } + } + var streamUsage helps.StreamUsageBuffer + defer streamUsage.Publish(ctx, reporter) var param any emitTranslatedLine := func(line []byte) bool { + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) if responseFormat == sdktranslator.FormatOpenAIResponse { chunkPayload := make([]byte, len(line)+1) copy(chunkPayload, line) @@ -623,7 +672,8 @@ func (e *KimiExecutor) executeResponsesStream(ctx context.Context, auth *cliprox return false } } - chunks := sdktranslator.TranslateStream(ctx, sdktranslator.FormatOpenAIResponse, responseFormat, req.Model, opts.OriginalRequest, body, line, ¶m) + chunks := sdktranslator.TranslateStream(ctx, sdktranslator.FormatOpenAIResponse, responseFormat, req.Model, originalRequest, body, line, ¶m) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range chunks { select { case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: @@ -631,6 +681,9 @@ func (e *KimiExecutor) executeResponsesStream(ctx context.Context, auth *cliprox return false } } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return false + } return true } @@ -644,17 +697,40 @@ func (e *KimiExecutor) executeResponsesStream(ctx context.Context, auth *cliprox eventType := gjson.GetBytes(dataBytes, "type").String() if eventType == "response.completed" || eventType == "response.incomplete" || eventType == "response.done" { if usage, ok := helps.ParseCodexUsage(dataBytes); ok && (usage.TotalTokens > 0 || usage.InputTokens > 0) { - reporter.Publish(ctx, usage) + streamUsage.Observe(usage, true) } else if usage := helps.ParseOpenAIUsage(dataBytes); usage.TotalTokens > 0 || usage.InputTokens > 0 { - reporter.Publish(ctx, usage) + streamUsage.Observe(usage, true) } } } - if !emitTranslatedLine(line) { + lines, errTransform := bridge.Stream(bytes.Clone(line)) + if errTransform != nil { + reporter.PublishFailure(ctx, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + } + for _, converted := range lines { + if !emitTranslatedLine(converted) { + return + } + } + if errTransform != nil { + sendFailure(errTransform) return } } + finishEvents, errFinish := bridge.FinishStream() + if errFinish != nil { + reporter.PublishFailure(ctx, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + } + for _, event := range finishEvents { + if !emitTranslatedLine(event) { + return + } + } + if errFinish != nil { + sendFailure(errFinish) + return + } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) @@ -671,6 +747,14 @@ func (e *KimiExecutor) executeResponsesStream(ctx context.Context, auth *cliprox // CountTokens estimates token count for Kimi requests. func (e *KimiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + if opts.SourceFormat == sdktranslator.FormatOpenAIResponse { + var errNormalize error + req.Payload, errNormalize = helps.NormalizeApplyPatchResponsesRequest(req.Payload) + if errNormalize != nil { + return cliproxyexecutor.Response{}, errNormalize + } + } + if auth != nil { if auth.Attributes == nil { auth.Attributes = make(map[string]string) @@ -1297,3 +1381,13 @@ func normalizeKimiTemperature(body []byte) []byte { } return body } + +// SupportsApplyPatch reports the actual executor contract, independent of its provider name. +func (e *KimiExecutor) SupportsApplyPatch() bool { return e != nil } + +// ForAPIKey preserves Kimi's actual Responses/Chat routing while scoping its config. +func (e KimiExecutor) ForAPIKey() cliproxyauth.ProviderExecutor { + e.cfg = e.cfg.ForAPIKey() + e.ClaudeExecutor = *e.ClaudeExecutor.ForAPIKey().(*ClaudeExecutor) + return &e +} diff --git a/internal/runtime/executor/kimi_executor_test.go b/internal/runtime/executor/kimi_executor_test.go index e6a853c17..520fced2d 100644 --- a/internal/runtime/executor/kimi_executor_test.go +++ b/internal/runtime/executor/kimi_executor_test.go @@ -1,8 +1,10 @@ package executor import ( + "bytes" "context" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -1676,3 +1678,72 @@ func TestKimiExecutorExecuteResponsesStream_InterleavedToolOutputsReordered(t *t t.Fatalf("input[4] = %s, want deferred developer message", input[4].Raw) } } + +func TestKimiApplyPatchResponsesExecutor(t *testing.T) { + testApplyPatchResponsesExecutor(t, "kimi", NewKimiExecutor(&config.Config{}), "kimi-k2.5") +} + +func TestKimiApplyPatchSSEFramingAndIncompleteEOF(t *testing.T) { + added := "event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"id\":\"a\",\"call_id\":\"c\",\"name\":\"apply_patch\",\"arguments\":\"\"}}\n\n" + argumentsDone := "event: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"item_id\":\"a\",\"arguments\":\"{\\\"input\\\":\\\"p\\\"}\"}\n\n" + itemDone := "event: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"id\":\"a\",\"call_id\":\"c\",\"name\":\"apply_patch\",\"arguments\":\"{\\\"input\\\":\\\"p\\\"}\"}}\n\n" + completed := "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"output\":[]}}\n\n" + done := "data: [DONE]\n\n" + late := "event: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"output_index\":1,\"item\":{\"type\":\"function_call\",\"id\":\"late\",\"name\":\"apply_patch\",\"arguments\":\"{\\\"input\\\":\\\"late\\\"}\"}}\n\n" + for _, tc := range []struct { + name string + frames string + complete bool + terminals int + }{ + {"incomplete_before_done", added + done + completed + done, false, 0}, + // Completed tool arguments do not substitute for a source response terminal. + {"completed_call_then_done_then_call", added + argumentsDone + itemDone + done + late + done, false, 0}, + {"completed_call_then_response_then_done_then_call", added + argumentsDone + itemDone + completed + done + late + done, true, 1}, + {"response_completed_then_duplicate_done", added + argumentsDone + completed + done + done, true, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprint(w, tc.frames) + })) + defer server.Close() + exec := NewKimiExecutor(&config.Config{}) + result, errExecute := exec.ExecuteStream(t.Context(), &cliproxyauth.Auth{Provider: "kimi", Attributes: map[string]string{"api_key": "test", "base_url": server.URL}}, cliproxyexecutor.Request{Model: "kimi-k2.5", Payload: []byte(`{"input":[],"tools":[{"type":"custom","name":"apply_patch"}]}`)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) + if errExecute != nil { + t.Fatal(errExecute) + } + var output []byte + var streamErr error + for chunk := range result.Chunks { + output = append(output, chunk.Payload...) + if chunk.Err != nil { + streamErr = chunk.Err + } + } + if tc.complete { + if streamErr != nil { + t.Fatal(streamErr) + } + if bytes.Contains(output, []byte("event: response.function_call_arguments.done")) || !bytes.Contains(output, []byte("event: response.custom_tool_call_input.done")) { + t.Fatalf("stale event-name: %s", output) + } + marker := []byte("data: [DONE]") + if bytes.Count(output, marker) != 1 || len(bytes.TrimSpace(output[bytes.Index(output, marker)+len(marker):])) != 0 { + t.Fatalf("output after first transport terminal or duplicate sentinel: %s", output) + } + if bytes.Count(output, []byte(`"type":"response.completed"`)) != tc.terminals || bytes.Contains(output, []byte(`"response.failed"`)) { + t.Fatalf("unexpected JSON completion: %s", output) + } + } else if streamErr == nil || bytes.Contains(output, []byte("[DONE]")) || bytes.Count(output, []byte(`"type":"response.failed"`)) != 1 || bytes.Contains(output, []byte(`"type":"response.completed"`)) { + t.Fatalf("incomplete success marker: %s err=%v", output, streamErr) + } + if !tc.complete { + assertTask6PatchError(t, streamErr) + } + if bytes.Contains(output, []byte(`"late"`)) { + t.Fatalf("post-terminal tool leaked: %s", output) + } + }) + } +} diff --git a/internal/runtime/executor/meta_executor.go b/internal/runtime/executor/meta_executor.go index 47d276939..34d428d9c 100644 --- a/internal/runtime/executor/meta_executor.go +++ b/internal/runtime/executor/meta_executor.go @@ -383,3 +383,6 @@ func isMetaSubscriptionQuota(statusCode int, body []byte) bool { } return false } + +// SupportsApplyPatch reports the actual executor contract, independent of its provider name. +func (e *MetaExecutor) SupportsApplyPatch() bool { return e != nil } diff --git a/internal/runtime/executor/meta_executor_execute.go b/internal/runtime/executor/meta_executor_execute.go index 19809504c..516ce7e4c 100644 --- a/internal/runtime/executor/meta_executor_execute.go +++ b/internal/runtime/executor/meta_executor_execute.go @@ -22,6 +22,7 @@ import ( ) type metaPreparedRequest struct { + applyPatch *helps.ApplyPatchResponsesState baseModel string from sdktranslator.Format responseFormat sdktranslator.Format @@ -61,12 +62,19 @@ func (e *MetaExecutor) prepareResponsesRequest(ctx context.Context, req cliproxy body, _ = sjson.DeleteBytes(body, "safety_identifier") body, _ = sjson.DeleteBytes(body, "stream_options") body, _ = sjson.DeleteBytes(body, "client_metadata") + applyPatch := helps.NewApplyPatchResponsesState(from, originalPayload, originalTranslated) + var errNormalizePatch error + body, errNormalizePatch = helps.NormalizeApplyPatchResponsesRequest(body, originalPayload) + if errNormalizePatch != nil { + return nil, errNormalizePatch + } body = normalizeCodexInstructions(body) body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "meta executor", body) body = helps.SanitizeMetaWebSearchTools(body) body = helps.NormalizeCodexToolIntegerTypes(body, opts.Headers) return &metaPreparedRequest{ + applyPatch: applyPatch, baseModel: baseModel, from: from, responseFormat: responseFormat, @@ -170,25 +178,47 @@ func (e *MetaExecutor) translateMetaCompleted(ctx context.Context, req cliproxye if errEvent := metaStreamEventError(eventData); errEvent != nil { return metaCompletedTranslation{}, errEvent } - eventType := gjson.GetBytes(eventData, "type").String() - switch eventType { - case "response.output_item.done": - xaiCollectOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback) - case "response.completed", "response.incomplete": - completedData := patchCodexCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback) - var param any - out := sdktranslator.TranslateNonStream(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, completedData, ¶m) - return metaCompletedTranslation{payload: out, sourceEvent: completedData}, nil + events, errBridge := prepared.applyPatch.Transform(eventData) + if errBridge != nil { + errBridge = statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + return metaCompletedTranslation{}, errBridge + } + for _, eventData := range events { + eventType := gjson.GetBytes(eventData, "type").String() + switch eventType { + case "response.output_item.done": + xaiCollectOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback) + case "response.completed", "response.incomplete": + completedData := patchCodexCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback) + var param any + out := sdktranslator.TranslateNonStream(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, completedData, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return metaCompletedTranslation{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + return metaCompletedTranslation{payload: out, sourceEvent: completedData}, nil + } } } if completedData, ok := metaAsCompletedEvent(data); ok { completedData = patchCodexCompletedOutput(completedData, outputItemsByIndex, outputItemsFallback) + var errBridge error + completedData, errBridge = prepared.applyPatch.Bridge.TransformNonStream(completedData) + if errBridge != nil { + errBridge = statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + return metaCompletedTranslation{}, errBridge + } var param any out := sdktranslator.TranslateNonStream(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, completedData, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return metaCompletedTranslation{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } return metaCompletedTranslation{payload: out, sourceEvent: completedData}, nil } + if errFinish := prepared.applyPatch.Finish(); errFinish != nil { + return metaCompletedTranslation{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } return metaCompletedTranslation{}, statusErr{code: http.StatusRequestTimeout, msg: "meta stream error: stream disconnected before response.completed or response.incomplete"} } diff --git a/internal/runtime/executor/meta_executor_stream.go b/internal/runtime/executor/meta_executor_stream.go index d2212d8cb..8d5f54626 100644 --- a/internal/runtime/executor/meta_executor_stream.go +++ b/internal/runtime/executor/meta_executor_stream.go @@ -81,11 +81,21 @@ func (e *MetaExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut scanner := bufio.NewScanner(httpResp.Body) scanner.Buffer(nil, 52_428_800) claudeInputTokens := helps.NewClaudeInputTokenState(prepared.from, prepared.to, prepared.responseFormat, prepared.originalPayload) + var streamUsage helps.StreamUsageBuffer + defer streamUsage.Publish(ctx, reporter) var param any outputItemsByIndex := make(map[int64][]byte) var outputItemsFallback [][]byte emitTranslatedLine := func(translatedLine []byte) bool { - chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, translatedLine, ¶m, claudeInputTokens) + lines, errBridge := prepared.applyPatch.Stream(translatedLine) + if errBridge != nil { + reporter.PublishFailure(ctx, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + } + var chunks [][]byte + for _, line := range lines { + chunks = append(chunks, helps.TranslateStreamWithClaudeInputTokens(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, line, ¶m, claudeInputTokens)...) + } + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range chunks { select { case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: @@ -93,6 +103,18 @@ func (e *MetaExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut return false } } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return false + } + if errBridge != nil { + errBridge = statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + reporter.PublishFailure(ctx, errBridge) + select { + case out <- cliproxyexecutor.StreamChunk{Err: errBridge}: + case <-ctx.Done(): + } + return false + } return true } for scanner.Scan() { @@ -121,7 +143,7 @@ func (e *MetaExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut xaiCollectOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback) case "response.completed", "response.incomplete": if detail, ok := helps.ParseCodexUsage(eventData); ok { - reporter.Publish(ctx, detail) + streamUsage.Observe(detail, true) } eventData = patchCodexCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback) } @@ -129,6 +151,28 @@ func (e *MetaExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut return } } + finishEvents, errFinish := prepared.applyPatch.FinishStream() + if errFinish != nil { + reporter.PublishFailure(ctx, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + } + for _, event := range finishEvents { + for _, chunk := range helps.TranslateStreamWithClaudeInputTokens(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, event, ¶m, claudeInputTokens) { + select { + case out <- cliproxyexecutor.StreamChunk{Payload: chunk}: + case <-ctx.Done(): + return + } + } + } + if errFinish != nil { + errFinish = statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + reporter.PublishFailure(ctx, errFinish) + select { + case out <- cliproxyexecutor.StreamChunk{Err: errFinish}: + case <-ctx.Done(): + } + return + } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx, errScan) diff --git a/internal/runtime/executor/meta_executor_test.go b/internal/runtime/executor/meta_executor_test.go index e857f7319..e58126216 100644 --- a/internal/runtime/executor/meta_executor_test.go +++ b/internal/runtime/executor/meta_executor_test.go @@ -1582,3 +1582,7 @@ func TestMetaExecutor_NormalizesToolFieldsForCodexUserAgent(t *testing.T) { t.Fatalf("Meta stream additional_tools yield_time_ms type = %q, want integer", gotType) } } + +func TestMetaApplyPatchResponsesExecutor(t *testing.T) { + testApplyPatchResponsesExecutor(t, "meta", NewMetaExecutor(&config.Config{}), "muse-spark-1.3") +} diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index 3c79ab90c..c213d41f0 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -205,12 +205,14 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A } helps.AppendAPIResponseChunk(ctx, e.cfg, body) reporter.ObserveResponseModel(body) - reporter.Publish(ctx, helps.ParseOpenAIUsage(body)) // Ensure we at least record the request even if upstream doesn't return usage - reporter.EnsurePublished(ctx) // Translate response back to source format when needed var param any - out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, body, ¶m) + out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, body, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + reporter.Publish(ctx, helps.ParseOpenAIUsage(body)) if responseFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) } @@ -426,6 +428,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy scanner.Buffer(nil, 52_428_800) // 50MB claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload) var param any + helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, ¶m) var streamUsage helps.StreamUsageBuffer var seenDone bool var streamFailed bool @@ -487,7 +490,8 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy } streamLine := append([]byte("data: "), dataPayload...) - chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, streamLine, ¶m, claudeInputTokens) + chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, streamLine, ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range chunks { select { case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: @@ -496,6 +500,10 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy return true } } + if helps.ApplyPatchTranslationError(param) != nil { + publishStreamError(statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}, false) + return true + } if isDone { seenDone = true return true @@ -536,6 +544,9 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy if errScan == nil && !seenDone && !streamFailed && !streamAborted && len(frameData) > 0 { _ = processFrame() } + if !streamFailed && !streamAborted && helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return + } if streamFailed || streamAborted { return } @@ -561,7 +572,8 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy } // Other protocols retain compatibility with providers that omit [DONE]. - chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, translated, []byte("data: [DONE]"), ¶m, claudeInputTokens) + chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, []byte("data: [DONE]"), ¶m, claudeInputTokens) + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range chunks { select { case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: @@ -1109,3 +1121,6 @@ func openAICompatRetryAfter(status int, headers http.Header, body []byte, now ti } return nil } + +// SupportsApplyPatch reports the actual executor contract, independent of its provider name. +func (e *OpenAICompatExecutor) SupportsApplyPatch() bool { return e != nil } diff --git a/internal/runtime/executor/xai_executor.go b/internal/runtime/executor/xai_executor.go index 4991a62a0..d39097fb1 100644 --- a/internal/runtime/executor/xai_executor.go +++ b/internal/runtime/executor/xai_executor.go @@ -113,3 +113,6 @@ func (e *XAIExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) return httpClient.Do(httpReq) } + +// SupportsApplyPatch reports the actual executor contract, independent of its provider name. +func (e *XAIExecutor) SupportsApplyPatch() bool { return e != nil } diff --git a/internal/runtime/executor/xai_executor_execute.go b/internal/runtime/executor/xai_executor_execute.go index 5242e3636..bd08febc5 100644 --- a/internal/runtime/executor/xai_executor_execute.go +++ b/internal/runtime/executor/xai_executor_execute.go @@ -91,6 +91,7 @@ func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req continue } eventData := xaiNormalizeReasoningSummaryData(bytes.TrimSpace(line[len(xaiDataTag):])) + prepared.applyPatch.RememberDispatcherEvent(eventData) eventData = namespaceRestorer.restore(eventData) if prepared.webSearchAlias != "" { eventData = restoreXAIClientWebSearchName(eventData, prepared.webSearchAlias) @@ -99,49 +100,72 @@ func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req if len(eventData) == 0 { continue } - reporter.ObserveResponseModel(eventData) - eventType := gjson.GetBytes(eventData, "type").String() - switch eventType { - case "response.output_item.done": - xaiCollectOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback) - case "response.completed", "response.incomplete": - if detail, ok := helps.ParseCodexUsage(eventData); ok { - reporter.Publish(ctx, detail) + events, errBridge := prepared.applyPatch.Transform(eventData) + if errBridge != nil { + errBridge = statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + return resp, errBridge + } + for _, eventData := range events { + reporter.ObserveResponseModel(eventData) + eventType := gjson.GetBytes(eventData, "type").String() + switch eventType { + case "response.output_item.done": + xaiCollectOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback) + case "response.completed", "response.incomplete": + completedData := xaiPatchCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback) + completedData = xaiNormalizeReasoningSummaryData(completedData) + if eventType == "response.completed" { + // A truncated turn carries no replayable terminal state, so only a + // completed response may refresh the reasoning replay cache. + cacheXAIReasoningReplayFromCompleted(ctx, prepared.replayScope, completedData) + } + var param any + out := sdktranslator.TranslateNonStream(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, completedData, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + if detail, ok := helps.ParseCodexUsage(eventData); ok { + reporter.Publish(ctx, detail) + } + if prepared.responseFormat == sdktranslator.FormatOpenAIResponse { + out = helps.EnsureResponsesUsageDetails(out) + } + return cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}, nil } - completedData := xaiPatchCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback) - completedData = xaiNormalizeReasoningSummaryData(completedData) - if eventType == "response.completed" { - // A truncated turn carries no replayable terminal state, so only a - // completed response may refresh the reasoning replay cache. - cacheXAIReasoningReplayFromCompleted(ctx, prepared.replayScope, completedData) - } - var param any - out := sdktranslator.TranslateNonStream(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, completedData, ¶m) - if prepared.responseFormat == sdktranslator.FormatOpenAIResponse { - out = helps.EnsureResponsesUsageDetails(out) - } - return cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}, nil } } + if errFinish := prepared.applyPatch.Finish(); errFinish != nil { + return resp, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } return resp, statusErr{code: http.StatusRequestTimeout, msg: "xai stream error: stream disconnected before response.completed or response.incomplete"} } func (e *XAIExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { - prepared, data, headers, errCompact := e.executeCompactRequest(ctx, auth, req, opts) - if errCompact != nil { - return resp, errCompact + prepared, data, headers, reporter, errCompactRequest := e.executeCompactRequest(ctx, auth, req, opts) + if errCompactRequest != nil { + return resp, errCompactRequest } + defer reporter.TrackFailure(ctx, &err) + converted, errBridge := prepared.applyPatch.Bridge.TransformNonStream(data) + if errBridge != nil { + errBridge = statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + return resp, errBridge + } var param any - out := sdktranslator.TranslateNonStream(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, data, ¶m) + out := sdktranslator.TranslateNonStream(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, converted, ¶m) + if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 { + return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + } + reporter.Publish(ctx, helps.ParseOpenAIUsage(data)) if prepared.responseFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) } return cliproxyexecutor.Response{Payload: out, Headers: headers}, nil } -func (e *XAIExecutor) executeCompactRequest(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*xaiPreparedRequest, []byte, http.Header, error) { +func (e *XAIExecutor) executeCompactRequest(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*xaiPreparedRequest, []byte, http.Header, *helps.UsageReporter, error) { token, _ := xaiCreds(auth) // Compact must not use xaiChatBaseURL: CLI chat-proxy returns 404 for // /responses/compact and a 404 cools down the whole xAI auth pool. @@ -150,7 +174,7 @@ func (e *XAIExecutor) executeCompactRequest(ctx context.Context, auth *cliproxya prepared, err := e.prepareResponsesRequestTo(ctx, req, opts, false, sdktranslator.FormatOpenAIResponse) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } prepared.body, _ = sjson.DeleteBytes(prepared.body, "stream") prepared.body, _ = sjson.DeleteBytes(prepared.body, "tools") @@ -173,7 +197,7 @@ func (e *XAIExecutor) executeCompactRequest(ctx context.Context, auth *cliproxya requestURL := strings.TrimSuffix(baseURL, "/") + "/responses/compact" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewReader(prepared.body)) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } // Official API / custom compact endpoints use standard API headers, not CLI // chat-proxy identity headers (which applyXAIChatHeaders may still attach for OAuth chat). @@ -185,7 +209,7 @@ func (e *XAIExecutor) executeCompactRequest(ctx context.Context, auth *cliproxya httpResp, err := httpClient.Do(httpReq) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) - return nil, nil, nil, err + return nil, nil, nil, nil, err } defer func() { if errClose := httpResp.Body.Close(); errClose != nil { @@ -197,29 +221,28 @@ func (e *XAIExecutor) executeCompactRequest(ctx context.Context, auth *cliproxya data, err := io.ReadAll(httpResp.Body) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) - return nil, nil, nil, err + return nil, nil, nil, nil, err } 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 = xaiStatusErr(httpResp.StatusCode, data) - return nil, nil, nil, err + return nil, nil, nil, nil, err } reporter.ObserveResponseModel(data) - reporter.Publish(ctx, helps.ParseOpenAIUsage(data)) - reporter.EnsurePublished(ctx) clearXAIReasoningReplayAfterCompaction(ctx, prepared.replayScope) - return prepared, data, httpResp.Header.Clone(), nil + return prepared, data, httpResp.Header.Clone(), reporter, nil } func (e *XAIExecutor) executeCompactionTriggerStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { - prepared, data, headers, err := e.executeCompactRequest(ctx, auth, req, opts) - if err != nil { - return nil, err + prepared, data, headers, reporter, errCompactRequest := e.executeCompactRequest(ctx, auth, req, opts) + if errCompactRequest != nil { + return nil, errCompactRequest } + reporter.Publish(ctx, helps.ParseOpenAIUsage(data)) headers = headers.Clone() if headers == nil { headers = make(http.Header) diff --git a/internal/runtime/executor/xai_executor_request.go b/internal/runtime/executor/xai_executor_request.go index fb1ad2824..131338c31 100644 --- a/internal/runtime/executor/xai_executor_request.go +++ b/internal/runtime/executor/xai_executor_request.go @@ -23,6 +23,7 @@ import ( ) type xaiPreparedRequest struct { + applyPatch *helps.ApplyPatchResponsesState baseModel string from sdktranslator.Format responseFormat sdktranslator.Format @@ -90,9 +91,20 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox body, _ = sjson.DeleteBytes(body, "safety_identifier") body, _ = sjson.DeleteBytes(body, "stream_options") body = helps.RewriteCodexMultiAgentV2Input(ctx, opts.Headers, body, e.cfg) + applyPatch := helps.NewApplyPatchResponsesState(from, originalPayload, originalTranslated) + var errNormalizePatch error + body, errNormalizePatch = helps.NormalizeApplyPatchResponsesRequest(body, originalPayload) + if errNormalizePatch != nil { + return nil, errNormalizePatch + } willInjectXSearch := e.cfg != nil && e.cfg.XAI.InjectXSearch shouldFold := xaiShouldFoldNamespaceTools(body, willInjectXSearch) namespaceTools := collectXAINamespaceToolRefsWithFold(body, shouldFold) + for name, ref := range namespaceTools { + if ref.isDispatcher { + applyPatch.AddDispatcher(name, ref.namespace) + } + } // Collect before normalizeXAITools flattens namespace wrappers so keys match // the post-restore (namespace, short-name) shape used by the response filter. clientDeclaredTools := collectXAIClientDeclaredToolKeys(body) @@ -146,6 +158,7 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox } return &xaiPreparedRequest{ + applyPatch: applyPatch, baseModel: baseModel, from: from, responseFormat: responseFormat, @@ -1554,9 +1567,6 @@ func normalizeXAITool(tool gjson.Result, namespaceName string, keepImageGenerati if toolType == xaiImageGenerationToolType && !keepImageGeneration { return nil, true, true } - if toolType == xaiCustomToolType && tool.Get("name").String() == "apply_patch" { - return nil, true, true - } raw := []byte(tool.Raw) schemaTool := tool diff --git a/internal/runtime/executor/xai_executor_stream.go b/internal/runtime/executor/xai_executor_stream.go index cf469d2cf..8ec809d14 100644 --- a/internal/runtime/executor/xai_executor_stream.go +++ b/internal/runtime/executor/xai_executor_stream.go @@ -78,6 +78,8 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth scanner := bufio.NewScanner(httpResp.Body) scanner.Buffer(nil, 52_428_800) claudeInputTokens := helps.NewClaudeInputTokenState(prepared.from, prepared.to, prepared.responseFormat, prepared.originalPayload) + var streamUsage helps.StreamUsageBuffer + defer streamUsage.Publish(ctx, reporter) var param any outputItemsByIndex := make(map[int64][]byte) var outputItemsFallback [][]byte @@ -85,7 +87,32 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth namespaceRestorer := newXAINamespaceRestorer(prepared.namespaceTools) var pendingEventLine []byte emitTranslatedLine := func(translatedLine []byte) bool { - chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, translatedLine, ¶m, claudeInputTokens) + lines, errBridge := prepared.applyPatch.Stream(translatedLine) + if errBridge != nil { + reporter.PublishFailure(ctx, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + } + var chunks [][]byte + for _, line := range lines { + if bytes.HasPrefix(line, xaiDataTag) { + eventData := bytes.TrimSpace(line[len(xaiDataTag):]) + switch gjson.GetBytes(eventData, "type").String() { + case "response.output_item.done": + xaiCollectOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback) + case "response.completed", "response.incomplete": + // Reconstruct only after the bridge has restored dispatcher children. + eventData = xaiPatchCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback) + eventData = xaiNormalizeReasoningSummaryData(eventData) + if gjson.GetBytes(eventData, "type").String() == "response.completed" { + // Only completed responses carry replayable terminal state. + cacheXAIReasoningReplayFromCompleted(ctx, prepared.replayScope, eventData) + } + ending := line[len(bytes.TrimRight(line, "\r\n")):] + line = append(append([]byte("data: "), eventData...), ending...) + } + } + chunks = append(chunks, helps.TranslateStreamWithClaudeInputTokens(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, line, ¶m, claudeInputTokens)...) + } + helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) for i := range chunks { select { case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: @@ -93,6 +120,18 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth return false } } + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + return false + } + if errBridge != nil { + errBridge = statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + reporter.PublishFailure(ctx, errBridge) + select { + case out <- cliproxyexecutor.StreamChunk{Err: errBridge}: + case <-ctx.Done(): + } + return false + } return true } for scanner.Scan() { @@ -111,6 +150,7 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth eventDataList := xaiNormalizeReasoningSummaryDataEvents(bytes.TrimSpace(line[len(xaiDataTag):])) hasPendingEventLine := pendingEventLine != nil for i, eventData := range eventDataList { + prepared.applyPatch.RememberDispatcherEvent(eventData) eventData = namespaceRestorer.restore(eventData) if prepared.webSearchAlias != "" { eventData = restoreXAIClientWebSearchName(eventData, prepared.webSearchAlias) @@ -124,21 +164,10 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth } reporter.ObserveResponseModel(eventData) normalizedEventName := gjson.GetBytes(eventData, "type").String() - switch normalizedEventName { - case "response.output_item.done": - xaiCollectOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback) - case "response.completed", "response.incomplete": + if normalizedEventName == "response.completed" || normalizedEventName == "response.incomplete" { if detail, ok := helps.ParseCodexUsage(eventData); ok { - reporter.Publish(ctx, detail) + streamUsage.Observe(detail, true) } - eventData = xaiPatchCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback) - eventData = xaiNormalizeReasoningSummaryData(eventData) - if normalizedEventName == "response.completed" { - // A truncated turn carries no replayable terminal state, so only a - // completed response may refresh the reasoning replay cache. - cacheXAIReasoningReplayFromCompleted(ctx, prepared.replayScope, eventData) - } - normalizedEventName = gjson.GetBytes(eventData, "type").String() } if hasPendingEventLine { @@ -171,6 +200,28 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth if pendingEventLine != nil { emitTranslatedLine(xaiNormalizeReasoningSummaryEventLine(pendingEventLine, "")) } + finishEvents, errFinish := prepared.applyPatch.FinishStream() + if errFinish != nil { + reporter.PublishFailure(ctx, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + } + for _, event := range finishEvents { + for _, chunk := range helps.TranslateStreamWithClaudeInputTokens(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, event, ¶m, claudeInputTokens) { + select { + case out <- cliproxyexecutor.StreamChunk{Payload: chunk}: + case <-ctx.Done(): + return + } + } + } + if errFinish != nil { + errFinish = statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + reporter.PublishFailure(ctx, errFinish) + select { + case out <- cliproxyexecutor.StreamChunk{Err: errFinish}: + case <-ctx.Done(): + } + return + } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx, errScan) diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go index b9f1910d3..86232c52f 100644 --- a/internal/runtime/executor/xai_executor_test.go +++ b/internal/runtime/executor/xai_executor_test.go @@ -17,6 +17,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/gorilla/websocket" xaiauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/xai" internalcache "github.com/router-for-me/CLIProxyAPI/v8/internal/cache" "github.com/router-for-me/CLIProxyAPI/v8/internal/config" @@ -172,7 +173,7 @@ func TestXAIExecutorExecuteShapesResponsesRequest(t *testing.T) { t.Fatalf("read body: %v", errRead) } w.Header().Set("Content-Type", "text/event-stream") - _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"model\":\"grok-4.3\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"model\":\"grok-4.3\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]},{\"type\":\"function_call\",\"id\":\"patch_item\",\"call_id\":\"cp\",\"name\":\"apply_patch\",\"arguments\":\"{\\\"input\\\":\\\"p\\\"}\"}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n")) })) defer server.Close() @@ -190,7 +191,7 @@ func TestXAIExecutorExecuteShapesResponsesRequest(t *testing.T) { }, } - _, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ + result, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ Model: "grok-4.3", Payload: []byte(`{"model":"grok-4.3","input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"test"}],"content":null,"encrypted_content":null},{"type":"reasoning","summary":[{"type":"summary_text","text":"second"}]},{"role":"user","content":"hello"}],"include":["reasoning.encrypted_content"],"reasoning":{"effort":"high"},"tools":[{"type":"tool_search"},{"type":"image_generation"},{"type":"custom","name":"apply_patch"},{"type":"custom","name":"custom_lookup"},{"type":"function","name":"lookup"},{"type":"web_search","external_web_access":true,"search_content_types":["text","image"]},{"type":"namespace","name":"codex_app","description":"Tools in the codex_app namespace.","tools":[{"type":"function","name":"automation_update"},{"type":"custom","name":"namespace_custom"},{"type":"tool_search"}]}],"tool_choice":{"type":"allowed_tools","tools":[{"type":"function","name":"automation_update","namespace":"codex_app"},{"type":"function","name":"lookup"},{"type":"web_search"}]}}`), }, cliproxyexecutor.Options{ @@ -204,6 +205,9 @@ func TestXAIExecutorExecuteShapesResponsesRequest(t *testing.T) { t.Fatalf("Execute() error = %v", err) } + if gjson.GetBytes(result.Payload, "output.1.type").String() != "custom_tool_call" || gjson.GetBytes(result.Payload, "output.1.input").String() != "p" { + t.Fatalf("retained patch was not actually bridged: %s", result.Payload) + } if gotPath != "/responses" { t.Fatalf("path = %q, want /responses", gotPath) } @@ -247,8 +251,8 @@ func TestXAIExecutorExecuteShapesResponsesRequest(t *testing.T) { t.Fatalf("input.2 exists, want consecutive reasoning item merged; body=%s", string(gotBody)) } tools := gjson.GetBytes(gotBody, "tools").Array() - if len(tools) != 6 { - t.Fatalf("tools length = %d, want 6; body=%s", len(tools), string(gotBody)) + if len(tools) != 7 { + t.Fatalf("tools length = %d, want 7; body=%s", len(tools), string(gotBody)) } foundAutomationUpdate := false foundNamespaceCustom := false @@ -268,7 +272,9 @@ func TestXAIExecutorExecuteShapesResponsesRequest(t *testing.T) { t.Fatalf("tools.%d.parameters missing for xAI function tool; body=%s", i, string(gotBody)) } if got := tool.Get("name").String(); got == "apply_patch" { - t.Fatalf("tools.%d.name = apply_patch, want removed; body=%s", i, string(gotBody)) + if tool.Get("type").String() != "function" || tool.Get("parameters.required.0").String() != "input" || tool.Get("parameters.additionalProperties").Bool() || tool.Get("parameters.properties.input.type").String() != "string" { + t.Fatalf("tools.%d invalid patch contract: %s", i, tool.Raw) + } } switch tool.Get("name").String() { case "codex_app__automation_update": @@ -3235,7 +3241,7 @@ func TestXAIExecutorExecuteStreamFiltersToolSearchTool(t *testing.T) { t.Fatalf("read body: %v", errRead) } w.Header().Set("Content-Type", "text/event-stream") - _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"model\":\"grok-4.3\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]}]}}\n\n")) + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"created_at\":0,\"status\":\"completed\",\"model\":\"grok-4.3\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]},{\"type\":\"function_call\",\"id\":\"patch_item\",\"call_id\":\"cp\",\"name\":\"apply_patch\",\"arguments\":\"{\\\"input\\\":\\\"p\\\"}\"}]}}\n\n")) })) defer server.Close() @@ -3256,15 +3262,20 @@ func TestXAIExecutorExecuteStreamFiltersToolSearchTool(t *testing.T) { if err != nil { t.Fatalf("ExecuteStream() error = %v", err) } + var output []byte for chunk := range result.Chunks { + output = append(output, chunk.Payload...) if chunk.Err != nil { t.Fatalf("stream chunk error = %v", chunk.Err) } } + if !bytes.Contains(output, []byte(`"type":"custom_tool_call"`)) || !bytes.Contains(output, []byte(`"input":"p"`)) || !bytes.Contains(output, []byte("response.custom_tool_call_input.done")) { + t.Fatalf("retained patch was not actually bridged: %s", output) + } tools := gjson.GetBytes(gotBody, "tools").Array() - if len(tools) != 6 { - t.Fatalf("tools length = %d, want 6; body=%s", len(tools), string(gotBody)) + if len(tools) != 7 { + t.Fatalf("tools length = %d, want 7; body=%s", len(tools), string(gotBody)) } if gjson.GetBytes(gotBody, "input.0.content").Exists() { t.Fatalf("input.0.content exists, want removed; body=%s", string(gotBody)) @@ -3299,7 +3310,9 @@ func TestXAIExecutorExecuteStreamFiltersToolSearchTool(t *testing.T) { t.Fatalf("tools.%d.parameters missing for xAI function tool; body=%s", i, string(gotBody)) } if got := tool.Get("name").String(); got == "apply_patch" { - t.Fatalf("tools.%d.name = apply_patch, want removed; body=%s", i, string(gotBody)) + if tool.Get("type").String() != "function" || tool.Get("parameters.required.0").String() != "input" || tool.Get("parameters.additionalProperties").Bool() || tool.Get("parameters.properties.input.type").String() != "string" { + t.Fatalf("tools.%d invalid patch contract: %s", i, tool.Raw) + } } switch tool.Get("name").String() { case "codex_app__automation_update": @@ -7055,3 +7068,438 @@ func TestXAIExecutorFoldsNamespaceNamedWebSearchWithoutAliasing(t *testing.T) { t.Fatalf("output.0.namespace = %q, want web_search; payload=%s", got, resp.Payload) } } + +// Removing request normalization or restoring the same-format bypass breaks these wire assertions. +func testApplyPatchResponsesExecutor(t *testing.T, provider string, exec interface { + Execute(context.Context, *cliproxyauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) + ExecuteStream(context.Context, *cliproxyauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) +}, model string) { + t.Helper() + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("stream=%v", stream), func(t *testing.T) { + request := []byte(`{"input":[{"type":"custom_tool_call","call_id":"old","name":"apply_patch","input":"old\n"},{"type":"custom_tool_call_output","call_id":"old","output":"ok"}],"tools":[{"type":"custom","name":"apply_patch"}],"tool_choice":{"type":"custom","name":"apply_patch"}}`) + item := `{"type":"function_call","id":"fc1","call_id":"c1","name":"apply_patch","arguments":"{\"input\":\"*** Begin Patch\\n+中😀\\n*** End Patch\\n\"}"}` + completed := `{"type":"response.completed","response":{"id":"r1","status":"completed","output":[` + item + `]}}` + bodies := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bodies <- body + if provider == "kimi" && !stream { + _, _ = w.Write([]byte(completed)) + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = fmt.Fprintf(w, "data: %s\n\ndata: %s\n\n", `{"type":"response.output_item.done","output_index":0,"item":`+item+`}`, completed) + })) + defer server.Close() + auth := &cliproxyauth.Auth{Provider: provider, Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + req := cliproxyexecutor.Request{Model: model, Payload: request} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, Stream: stream} + var output []byte + if stream { + result, errExecute := exec.ExecuteStream(t.Context(), auth, req, opts) + if errExecute != nil { + t.Fatal(errExecute) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + output = append(output, chunk.Payload...) + } + if !bytes.Contains(output, []byte(`"custom_tool_call"`)) || !bytes.Contains(output, []byte(`"response.custom_tool_call_input.done"`)) { + t.Fatalf("bridge bypass: %s", output) + } + } else { + result, errExecute := exec.Execute(t.Context(), auth, req, opts) + if errExecute != nil { + t.Fatal(errExecute) + } + output = result.Payload + root := gjson.ParseBytes(output) + if root.Get("response").Exists() { + root = root.Get("response") + } + if root.Get("output.0.type").String() != "custom_tool_call" || root.Get("output.0.input").String() != "*** Begin Patch\n+中😀\n*** End Patch\n" { + t.Fatalf("bridge bypass: %s", output) + } + } + body := <-bodies + if gjson.GetBytes(body, "tools.0.type").String() != "function" || !gjson.GetBytes(body, "tools.0.parameters.properties.input").Exists() || gjson.GetBytes(body, "tool_choice.type").String() != "function" { + t.Fatalf("patch declaration missing: %s", body) + } + if gjson.GetBytes(body, "input.0.arguments").String() != `{"input":"old\n"}` || gjson.GetBytes(body, "input.1.type").String() != "function_call_output" { + t.Fatalf("history lost: %s", body) + } + }) + } +} + +func TestXAIApplyPatchResponsesExecutor(t *testing.T) { + testApplyPatchResponsesExecutor(t, "xai", NewXAIExecutor(&config.Config{}), "grok-4") +} + +func TestApplyPatchActualCodexExecutorNativePassthrough(t *testing.T) { + for _, responseFormat := range []sdktranslator.Format{sdktranslator.FormatCodex, sdktranslator.FormatOpenAIResponse} { + t.Run(responseFormat.String(), func(t *testing.T) { + tool := `{ "type":"custom", "name":"apply_patch", "format":{"type":"grammar","syntax":"lark","definition":"start: patch"}}` + history := `{ "type":"custom_tool_call", "call_id":"old", "name":"apply_patch", "input":"{\"input\":\"raw\"}"}` + events := []string{ + `{ "type":"response.output_item.added", "output_index":0,"item":{"type":"custom_tool_call","id":"a","call_id":"c","name":"apply_patch","input":""}}`, + `{ "type":"response.custom_tool_call_input.delta", "item_id":"a", "delta":"p"}`, + `{ "type":"response.custom_tool_call_input.done", "item_id":"a", "input":"p"}`, + `{ "type":"response.output_item.done", "output_index":0,"item":{"type":"custom_tool_call","id":"a","call_id":"c","name":"apply_patch","input":"p"}}`, + `{ "type":"response.completed", "sequence_number":80, "response":{"id":"r","status":"completed","output":[{"type":"custom_tool_call","id":"a","call_id":"c","name":"apply_patch","input":"p"}]}}`, + } + bodies := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bodies <- body + w.Header().Set("Content-Type", "text/event-stream") + for _, event := range events { + _, _ = fmt.Fprintf(w, "data: %s\n\n", event) + } + })) + defer server.Close() + exec := NewCodexExecutor(&config.Config{Codex: config.CodexConfig{DisableCodexCloaking: true}}) + req := cliproxyexecutor.Request{Model: "gpt-5.6-sol", Payload: []byte(`{"input":[` + history + `],"tools":[` + tool + `]}`)} + result, errExecute := exec.ExecuteStream(t.Context(), codexTestAuth(server.URL), req, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, ResponseFormat: responseFormat, Headers: http.Header{codexResponsesLiteHeader: {"true"}}}) + if errExecute != nil { + t.Fatal(errExecute) + } + var output []byte + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + output = append(output, chunk.Payload...) + } + body := <-bodies + if gjson.GetBytes(body, "tools.0").Raw != tool || gjson.GetBytes(body, "input.0").Raw != history { + t.Fatalf("native declarations/history altered: %s", body) + } + for _, event := range events { + if !bytes.Contains(output, []byte(event)) { + t.Fatalf("native event bytes altered: want=%s got=%s", event, output) + } + } + }) + } +} + +func TestXAIApplyPatchFoldedNamespaceHTTP(t *testing.T) { + for _, tc := range []struct{ stream, snapshotOnly, sparseTerminal, lateName bool }{ + {false, false, false, false}, {true, false, false, false}, + {false, true, false, false}, {true, true, false, false}, + {false, true, true, false}, {true, true, true, false}, + {false, true, false, true}, {true, true, false, true}, + {false, true, true, true}, {true, true, true, true}, + } { + stream, snapshotOnly := tc.stream, tc.snapshotOnly + t.Run(fmt.Sprintf("stream=%v/snapshotOnly=%v/sparseTerminal=%v/lateName=%v", stream, snapshotOnly, tc.sparseTerminal, tc.lateName), func(t *testing.T) { + var declarations []string + declarations = append(declarations, `{"type":"custom","name":"apply_patch"}`) + for i := 0; i < 205; i++ { + declarations = append(declarations, fmt.Sprintf(`{"type":"function","name":"lookup%d","parameters":{"type":"object"}}`, i)) + } + tools := `[{"type":"namespace","name":"n","tools":[` + strings.Join(declarations, ",") + `]}]` + request := []byte(`{"tools":` + tools + `,"input":[{"type":"custom_tool_call","call_id":"old","name":"apply_patch","namespace":"n","input":"old"},{"type":"custom_tool_call_output","call_id":"old","output":"ok"}]}`) + bodies := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bodies <- body + w.Header().Set("Content-Type", "text/event-stream") + patch := `{"type":"function_call","id":"a","call_id":"c","name":"n","arguments":"{\"name\":\"apply_patch\",\"arguments\":{\"input\":\"p\"}}"}` + ordinary := `{"type":"function_call","id":"b","call_id":"d","name":"n","arguments":"{\"name\":\"lookup0\",\"arguments\":{\"x\":1}}"}` + events := []string{`{"type":"response.output_item.done","output_index":0,"item":` + patch + `}`} + if snapshotOnly { + events = []string{ + `{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","name":"n","arguments":""}}`, + `{"type":"response.function_call_arguments.done","item_id":"a","arguments":"{\"name\":\"apply_patch\",\"arguments\":{\"input\":\"p\"}}"}`, + `{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":"n"}}`, + } + } + if snapshotOnly { + events = append(events, + `{"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","id":"b","name":"n","arguments":""}}`, + `{"type":"response.function_call_arguments.done","item_id":"b","arguments":"{\"name\":\"lookup0\",\"arguments\":{\"x\":1}}"}`, + `{"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","id":"b","call_id":"d","name":"n"}}`, + `{"type":"response.completed","response":{"output":[]}}`, + ) + } else { + events = append(events, `{"type":"response.output_item.done","output_index":1,"item":`+ordinary+`}`, `{"type":"response.completed","response":{"output":[`+ordinary+`]}}`) + } + if tc.lateName { + events[0] = `{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a"}}` + events[3] = `{"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","id":"b"}}` + } + if tc.sparseTerminal { + events[len(events)-1] = `{"type":"response.completed","response":{"output":[{"type":"function_call","id":"a","call_id":"c","name":"n"},{"type":"function_call","id":"b","call_id":"d","name":"n"}]}}` + } + for _, e := range events { + _, _ = fmt.Fprintf(w, "data: %s\n\n", e) + } + })) + defer server.Close() + exec := NewXAIExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Provider: "xai", Attributes: map[string]string{"base_url": server.URL, "api_key": "test"}} + req := cliproxyexecutor.Request{Model: "grok-4", Payload: request} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse} + var final, output []byte + if stream { + result, errExecute := exec.ExecuteStream(t.Context(), auth, req, opts) + if errExecute != nil { + t.Fatal(errExecute) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + output = append(output, chunk.Payload...) + for _, line := range bytes.Split(chunk.Payload, []byte("\n")) { + payload := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:"))) + if gjson.GetBytes(payload, "type").String() == "response.completed" { + final = []byte(gjson.GetBytes(payload, "response").Raw) + } + } + } + } else { + result, errExecute := exec.Execute(t.Context(), auth, req, opts) + if errExecute != nil { + t.Fatal(errExecute) + } + final = result.Payload + } + if gjson.GetBytes(final, "output.0.type").String() != "custom_tool_call" || gjson.GetBytes(final, "output.0.namespace").String() != "n" || gjson.GetBytes(final, "output.0.input").String() != "p" || gjson.GetBytes(final, "output.1.name").String() != "lookup0" || gjson.GetBytes(final, "output.1.arguments").String() != `{"x":1}` { + t.Fatalf("mixed namespace restoration: %s", final) + } + if stream && snapshotOnly && (bytes.Contains(output, []byte(`"response.custom_tool_call_input.delta"`)) || bytes.Count(output, []byte(`"type":"response.custom_tool_call_input.done"`)) != 1) { + t.Fatalf("snapshot-only completion invented progress or lost input.done: %s", output) + } + body := <-bodies + if gjson.GetBytes(body, "tools.0.name").String() != "n" || gjson.GetBytes(body, "input.0.name").String() != "n" || gjson.GetBytes(body, "input.0.arguments").String() != `{"arguments":{"input":"old"},"name":"apply_patch"}` || gjson.GetBytes(body, "input.1.type").String() != "function_call_output" { + t.Fatalf("dispatcher declaration/history: %s", body) + } + }) + } +} + +func TestXAIApplyPatchDispatcherEvidenceLifecycle(t *testing.T) { + patch := `{"name":"apply_patch","arguments":{"input":"p"}}` + ordinary := `{"name":"lookup0","arguments":{"name":"apply_patch","arguments":{"input":"ordinary"}}}` + added := `{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a"}}` + argsDone := fmt.Sprintf(`{"type":"response.function_call_arguments.done","item_id":"a","arguments":%q}`, patch) + done := `{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":"n"}}` + sparse := `{"type":"function_call","id":"a","call_id":"c","name":"n"}` + terminal := `{"type":"response.completed","response":{"output":[]}}` + type evidenceCase struct { + name string + events []string + wantName string + wantArgs string + fail bool + } + cases := []evidenceCase{ + {"unnamed_added_full_wrapper", []string{fmt.Sprintf(`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","arguments":%q}}`, patch), done, terminal}, "apply_patch", "p", false}, + {"index_acquired_after_snapshot", []string{argsDone, `{"type":"response.output_item.added","output_index":2,"item":{"type":"function_call","id":"a"}}`, `{"type":"response.output_item.done","output_index":2,"item":{"type":"function_call","id":"a","call_id":"c","name":"n"}}`, `{"type":"response.completed","response":{"output":[` + sparse + `]}}`}, "apply_patch", "p", false}, + {"completed_namespace_conflict", []string{added, argsDone, done, `{"type":"response.completed","response":{"output":[{"type":"function_call","id":"a","call_id":"c","name":"n","namespace":"other"}]}}`}, "", "", true}, + {"candidate_dual_call_conflict", []string{`{"type":"response.output_item.added","output_index":0,"call_id":"x","item":{"type":"function_call","id":"a","call_id":"c"}}`, argsDone, done, terminal}, "", "", true}, + {"candidate_dual_item_conflict", []string{`{"type":"response.output_item.added","output_index":0,"item_id":"x","item":{"type":"function_call","id":"a"}}`, argsDone, done, terminal}, "", "", true}, + {"terminal_first_dispatcher_name", []string{added, argsDone, `{"type":"response.completed","response":{"output":[` + sparse + `]}}`}, "apply_patch", "p", false}, + {"late_ordinary_dispatcher_child", []string{added, fmt.Sprintf(`{"type":"response.function_call_arguments.done","item_id":"a","arguments":%q}`, ordinary), done, `{"type":"response.completed","response":{"output":[` + sparse + `]}}`}, "lookup0", `{"name":"apply_patch","arguments":{"input":"ordinary"}}`, false}, + {"ordinary_wrapper_is_literal", []string{added, argsDone, fmt.Sprintf(`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":"plain","arguments":%q}}`, patch), terminal}, "plain", patch, false}, + {"dispatcher_delta_child_conflict", []string{`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","name":"n"}}`, `{"type":"response.function_call_arguments.delta","item_id":"a","name":"lookup0","namespace":"n","delta":""}`, argsDone, done, terminal}, "", "", true}, + {"completed_delta_child_conflict", []string{added, argsDone, done, `{"type":"response.function_call_arguments.delta","item_id":"a","name":"lookup0","namespace":"n","delta":""}`, terminal}, "", "", true}, + {"completed_added_child_conflict", []string{added, argsDone, done, `{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","name":"lookup0","namespace":"n","arguments":""}}`, terminal}, "", "", true}, + {"completed_added_repeated", []string{added, argsDone, done, `{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","name":"n","arguments":""}}`, terminal}, "apply_patch", "p", false}, + {"folded_child_snapshot_is_valid", []string{`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","name":"n"}}`, fmt.Sprintf(`{"type":"response.function_call_arguments.delta","item_id":"a","delta":%q}`, patch), `{"type":"response.function_call_arguments.done","item_id":"a","arguments":"{\"input\":\"p\"}"}`, done, terminal}, "apply_patch", "p", false}, + {"completed_child_snapshot_consistent", []string{added, argsDone, done, `{"type":"response.function_call_arguments.done","item_id":"a","arguments":"{\"input\":\"p\"}"}`, terminal}, "apply_patch", "p", false}, + {"completed_child_snapshot_conflicting", []string{added, argsDone, done, `{"type":"response.function_call_arguments.done","item_id":"a","arguments":"{\"input\":\"q\"}"}`, terminal}, "", "", true}, + {"completed_arguments_done_conflict", []string{added, argsDone, done, `{"type":"response.function_call_arguments.done","item_id":"a","arguments":"{\"name\":\"apply_patch\",\"arguments\":{\"input\":\"q\"}}"}`, terminal}, "", "", true}, + {"completed_arguments_done_repeated", []string{added, argsDone, done, argsDone, terminal}, "apply_patch", "p", false}, + {"completed_child_conflict", []string{added, argsDone, done, `{"type":"response.completed","response":{"output":[{"type":"function_call","id":"a","call_id":"c","name":"n","arguments":"{\"name\":\"lookup0\",\"arguments\":{\"input\":\"p\"}}"}]}}`}, "", "", true}, + {"completed_snapshot_conflict", []string{added, argsDone, done, `{"type":"response.completed","response":{"output":[{"type":"function_call","id":"a","call_id":"c","name":"n","arguments":"{\"name\":\"apply_patch\",\"arguments\":{\"input\":\"q\"}}"}]}}`}, "", "", true}, + {"completed_call_conflict", []string{added, argsDone, done, `{"type":"response.completed","response":{"output":[{"type":"function_call","id":"a","call_id":"different","name":"n"}]}}`}, "", "", true}, + {"completed_type_conflict", []string{added, argsDone, done, `{"type":"response.completed","response":{"output":[{"type":"message","id":"a","call_id":"c","name":"n"}]}}`}, "", "", true}, + {"completed_item_conflict", []string{added, argsDone, done, `{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"different","call_id":"c","name":"n"}}`, terminal}, "", "", true}, + {"completed_index_conflict", []string{added, argsDone, done, `{"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","id":"a","call_id":"c","name":"n"}}`, terminal}, "", "", true}, + {"candidate_item_conflict", []string{added, `{"type":"response.function_call_arguments.done","output_index":0,"item_id":"different","arguments":"{\"name\":\"apply_patch\",\"arguments\":{\"input\":\"p\"}}"}`, done, terminal}, "", "", true}, + {"candidate_type_conflict", []string{`{"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"a"}}`, argsDone, done, terminal}, "", "", true}, + {"candidate_name_conflict", []string{`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","name":"plain"}}`, argsDone, done, terminal}, "", "", true}, + {"candidate_snapshot_type_conflict", []string{added, `{"type":"response.function_call_arguments.done","item_id":"a","arguments":{"name":"apply_patch","arguments":{"input":"p"}}}`, done, terminal}, "", "", true}, + } + for discover := 0; discover < 3; discover++ { + events := []string{} + for i := 0; i < 3; i++ { + events = append(events, fmt.Sprintf(`{"type":"response.output_item.added","output_index":%d,"item":{"type":"function_call","id":"i%d","call_id":"c%d"}}`, i, i, i)) + } + events = append(events, fmt.Sprintf(`{"type":"response.function_call_arguments.done","output_index":0,"item_id":"i1","call_id":"c2","arguments":%q}`, patch), fmt.Sprintf(`{"type":"response.output_item.done","output_index":%d,"item":{"type":"function_call","id":"i%d","call_id":"c%d","name":"n"}}`, discover, discover, discover), terminal) + cases = append(cases, evidenceCase{fmt.Sprintf("candidate_all_keys_discover_%d", discover), events, "", "", true}) + } + for _, mode := range []string{"http", "http_stream", "ws_sse", "ws_raw"} { + for _, tc := range cases { + t.Run(mode+"/"+tc.name, func(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(mode, "ws_") { + conn, errUpgrade := upgrader.Upgrade(w, r, nil) + if errUpgrade != nil { + return + } + defer func() { + if errClose := conn.Close(); errClose != nil { + t.Logf("close test websocket: %v", errClose) + } + }() + if _, _, errReadMessage := conn.ReadMessage(); errReadMessage != nil { + return + } + for _, event := range tc.events { + if errWriteMessage := conn.WriteMessage(websocket.TextMessage, []byte(event)); errWriteMessage != nil { + return + } + } + return + } + w.Header().Set("Content-Type", "text/event-stream") + for _, event := range tc.events { + _, _ = fmt.Fprintf(w, "data: %s\n\n", event) + } + })) + defer server.Close() + declarations := []string{`{"type":"custom","name":"apply_patch"}`} + for i := 0; i < 205; i++ { + declarations = append(declarations, fmt.Sprintf(`{"type":"function","name":"lookup%d","parameters":{"type":"object"}}`, i)) + } + request := []byte(`{"input":[],"tools":[{"type":"function","name":"plain","parameters":{"type":"object"}},{"type":"namespace","name":"n","tools":[` + strings.Join(declarations, ",") + `]}]}`) + auth := &cliproxyauth.Auth{Provider: "xai", Attributes: map[string]string{"base_url": server.URL, "api_key": "test", "websockets": "true"}} + req := cliproxyexecutor.Request{Model: "grok-4", Payload: request} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse} + var output, final []byte + var resultErr error + if mode == "http" { + result, errExecute := NewXAIExecutor(&config.Config{}).Execute(t.Context(), auth, req, opts) + final, resultErr = result.Payload, errExecute + } else { + var exec interface { + ExecuteStream(context.Context, *cliproxyauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) + } = NewXAIExecutor(&config.Config{}) + ctx := t.Context() + if strings.HasPrefix(mode, "ws_") { + wsExec := NewXAIWebsocketsExecutor(&config.Config{}) + wsExec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)} + exec = wsExec + } + if mode == "ws_raw" { + ctx = cliproxyexecutor.WithDownstreamWebsocket(ctx) + } + result, errExecuteStream := exec.ExecuteStream(ctx, auth, req, opts) + if errExecuteStream != nil { + t.Fatal(errExecuteStream) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + resultErr = chunk.Err + } + output = append(output, chunk.Payload...) + for _, line := range bytes.Split(chunk.Payload, []byte("\n")) { + payload := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:"))) + if gjson.GetBytes(payload, "type").String() == "response.completed" { + final = []byte(gjson.GetBytes(payload, "response").Raw) + } + } + } + } + if tc.fail { + if resultErr == nil || !strings.Contains(resultErr.Error(), "apply_patch") || len(final) != 0 || (mode != "http" && bytes.Count(output, []byte(`"type":"response.failed"`)) != 1) { + t.Fatalf("conflict escaped: final=%s output=%s err=%v", final, output, resultErr) + } + return + } + if resultErr != nil { + t.Fatal(resultErr) + } + if mode != "http" && bytes.Count(output, []byte(`"type":"response.completed"`)) != 1 { + t.Fatalf("missing or repeated response completion: %s", output) + } + item := gjson.GetBytes(final, "output.0") + if item.Get("name").String() != tc.wantName || item.Get("call_id").String() != "c" { + t.Fatalf("child identity lost: %s", final) + } + if tc.wantName == "apply_patch" { + if item.Get("type").String() != "custom_tool_call" || item.Get("namespace").String() != "n" || item.Get("input").String() != tc.wantArgs || bytes.Contains(output, []byte(`"response.custom_tool_call_input.delta"`)) || (mode != "http" && bytes.Count(output, []byte(`"type":"response.custom_tool_call_input.done"`)) != 1) { + t.Fatalf("snapshot patch lost or progress fabricated: %s %s", final, output) + } + } else if item.Get("type").String() != "function_call" || item.Get("arguments").String() != tc.wantArgs || (tc.wantName == "lookup0" && item.Get("namespace").String() != "n") || bytes.Contains(output, []byte(`custom_tool_call`)) || (mode != "http" && bytes.Count(output, []byte(`"type":"response.function_call_arguments.done"`)) != 1) { + t.Fatalf("ordinary wrapper reclassified or replayed: %s %s", final, output) + } + }) + } + } +} + +func TestApplyPatchResponsesExecutorLocalFailures(t *testing.T) { + for _, provider := range []string{"xai", "meta", "kimi"} { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/stream=%v", provider, stream), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if provider == "kimi" && !stream { + _, _ = fmt.Fprint(w, `{"output":[{"type":"function_call","name":"apply_patch","arguments":"{}"}]}`) + return + } + w.Header().Set("Content-Type", "text/event-stream") + if stream { + _, _ = fmt.Fprint(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"id\":\"a\",\"name\":\"apply_patch\",\"arguments\":\"\"}}\n\n") + return + } + _, _ = fmt.Fprint(w, "data: {\"type\":\"response.completed\",\"response\":{\"output\":[{\"type\":\"function_call\",\"name\":\"apply_patch\",\"arguments\":\"{}\"}]}}\n\n") + })) + defer server.Close() + var exec interface { + Execute(context.Context, *cliproxyauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) + ExecuteStream(context.Context, *cliproxyauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) + } + model := "grok-4" + switch provider { + case "xai": + exec = NewXAIExecutor(&config.Config{}) + case "meta": + exec = NewMetaExecutor(&config.Config{}) + model = "muse-spark-1.3" + case "kimi": + exec = NewKimiExecutor(&config.Config{}) + model = "kimi-k2.5" + } + auth := &cliproxyauth.Auth{ID: "task6-owned", Provider: provider, Attributes: map[string]string{"api_key": "test", "base_url": server.URL}} + checkUsage := task6CaptureFailureUsage(t, auth.ID) + defer checkUsage() + req := cliproxyexecutor.Request{Model: model, Payload: []byte(`{"input":[],"tools":[{"type":"custom","name":"apply_patch"}]}`)} + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse} + if stream { + result, errExecute := exec.ExecuteStream(t.Context(), auth, req, opts) + if errExecute != nil { + t.Fatal(errExecute) + } + var output []byte + var streamErr error + for chunk := range result.Chunks { + output = append(output, chunk.Payload...) + if chunk.Err != nil { + streamErr = chunk.Err + } + } + assertTask6PatchError(t, streamErr) + if streamErr == nil || !bytes.Contains(output, []byte(`"type":"response.failed"`)) || bytes.Contains(output, []byte(`"type":"response.completed"`)) { + t.Fatalf("EOF falsely succeeded: %s %v", output, streamErr) + } + } else { + result, errExecute := exec.Execute(t.Context(), auth, req, opts) + assertTask6PatchError(t, errExecute) + if errExecute == nil || len(result.Payload) > 0 { + t.Fatalf("malformed nonstream succeeded: %s %v", result.Payload, errExecute) + } + } + }) + } + } +} diff --git a/internal/runtime/executor/xai_websockets_executor.go b/internal/runtime/executor/xai_websockets_executor.go index 5368aa570..28402efff 100644 --- a/internal/runtime/executor/xai_websockets_executor.go +++ b/internal/runtime/executor/xai_websockets_executor.go @@ -710,6 +710,19 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox } } + var streamUsage helps.StreamUsageBuffer + defer streamUsage.Publish(ctx, reporter) + + // Validation ends the upstream attempt before downstream delivery or session reuse. + invalidatePatchAttempt := func() { + terminateReason = "invalid_tool_arguments" + terminateErr = statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + if sess != nil { + e.invalidateUpstreamConnWithoutDisconnectNotify(sess, conn, terminateReason, terminateErr) + } + reporter.PublishFailure(ctx, terminateErr) + } + claudeInputTokens := helps.NewClaudeInputTokenState(prepared.from, prepared.to, prepared.responseFormat, prepared.originalPayload) var param any outputItemsByIndex := make(map[int64][]byte) @@ -732,6 +745,22 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox _ = send(cliproxyexecutor.StreamChunk{Err: ctx.Err()}) return } + if ctx == nil || ctx.Err() == nil { + if errFinish := prepared.applyPatch.Finish(); errFinish != nil { + events, errBridge := prepared.applyPatch.Bridge.Fail(errFinish) + errBridge = statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + invalidatePatchAttempt() + for _, event := range events { + if cliproxyexecutor.DownstreamWebsocket(ctx) { + _ = send(cliproxyexecutor.StreamChunk{Payload: event}) + } else { + _ = send(cliproxyexecutor.StreamChunk{Payload: encodeCodexWebsocketAsSSE(event)}) + } + } + _ = send(cliproxyexecutor.StreamChunk{Err: errBridge}) + return + } + } mappedErr := mapXAIWebsocketReadError(errRead) terminateReason = "read_error" terminateErr = mappedErr @@ -777,6 +806,7 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox } for _, payload := range xaiNormalizeReasoningSummaryDataEvents(payload) { + prepared.applyPatch.RememberDispatcherEvent(payload) payload = namespaceRestorer.restore(payload) if prepared.webSearchAlias != "" { payload = restoreXAIClientWebSearchName(payload, prepared.webSearchAlias) @@ -785,86 +815,96 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox if len(payload) == 0 { continue } - eventType := gjson.GetBytes(payload, "type").String() - isTerminalEvent := eventType == "response.completed" || eventType == "response.done" || eventType == "error" - reporter.ObserveResponseModel(payload) - warmupCompletedPayload := []byte(nil) - switch eventType { - case "response.created": - if warmupRequest { - warmupCompletedPayload = buildXAIWebsocketWarmupCompletedPayload(payload) - if idMapper != nil && idMapper.state != nil && !recordedTranscript { - idMapper.state.recordTranscriptTurn(wsReqBody, warmupCompletedPayload, transcriptReset) + events, errBridge := prepared.applyPatch.Transform(payload) + if errBridge != nil { + errBridge = statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + invalidatePatchAttempt() + for _, event := range events { + if cliproxyexecutor.DownstreamWebsocket(ctx) { + _ = send(cliproxyexecutor.StreamChunk{Payload: event}) + } else { + _ = send(cliproxyexecutor.StreamChunk{Payload: encodeCodexWebsocketAsSSE(event)}) + } + } + reporter.PublishFailure(ctx, errBridge) + _ = send(cliproxyexecutor.StreamChunk{Err: errBridge}) + return + } + for _, payload := range events { + eventType := gjson.GetBytes(payload, "type").String() + patchTerminal := prepared.applyPatch.Active() && (eventType == "response.incomplete" || eventType == "response.failed") + isTerminalEvent := eventType == "response.completed" || eventType == "response.done" || eventType == "error" || patchTerminal + reporter.ObserveResponseModel(payload) + warmupCompletedPayload := []byte(nil) + switch eventType { + case "response.created": + if warmupRequest { + warmupCompletedPayload = buildXAIWebsocketWarmupCompletedPayload(payload) + if idMapper != nil && idMapper.state != nil && !recordedTranscript { + idMapper.state.recordTranscriptTurn(wsReqBody, warmupCompletedPayload, transcriptReset) + recordedTranscript = true + } + logXAIWebsocketWarmupCompleted(executionSessionID, authID, wsURL, payload) + } + case "response.output_item.done": + xaiCollectOutputItemDone(payload, outputItemsByIndex, &outputItemsFallback) + case "response.completed": + logXAIWebsocketTerminalResponse(executionSessionID, authID, wsURL, eventType, payload) + if detail, ok := helps.ParseCodexUsage(payload); ok { + streamUsage.Observe(detail, true) + } + payload = xaiPatchCompletedOutput(payload, outputItemsByIndex, outputItemsFallback) + payload = xaiNormalizeReasoningSummaryData(payload) + cacheXAIReasoningReplayFromCompleted(ctx, prepared.replayScope, payload) + if !warmupRequest && idMapper != nil && idMapper.state != nil && !recordedTranscript { + idMapper.state.recordTranscriptTurn(wsReqBody, payload, transcriptReset) recordedTranscript = true } - logXAIWebsocketWarmupCompleted(executionSessionID, authID, wsURL, payload) - } - case "response.output_item.done": - xaiCollectOutputItemDone(payload, outputItemsByIndex, &outputItemsFallback) - case "response.completed": - logXAIWebsocketTerminalResponse(executionSessionID, authID, wsURL, eventType, payload) - if detail, ok := helps.ParseCodexUsage(payload); ok { - reporter.Publish(ctx, detail) - } - payload = xaiPatchCompletedOutput(payload, outputItemsByIndex, outputItemsFallback) - payload = xaiNormalizeReasoningSummaryData(payload) - cacheXAIReasoningReplayFromCompleted(ctx, prepared.replayScope, payload) - if !warmupRequest && idMapper != nil && idMapper.state != nil && !recordedTranscript { - idMapper.state.recordTranscriptTurn(wsReqBody, payload, transcriptReset) - recordedTranscript = true - } - case "response.done": - logXAIWebsocketTerminalResponse(executionSessionID, authID, wsURL, eventType, payload) - if detail, ok := helps.ParseCodexUsage(payload); ok { - reporter.Publish(ctx, detail) - } - if !warmupRequest && idMapper != nil && idMapper.state != nil && !recordedTranscript { - idMapper.state.recordTranscriptTurn(wsReqBody, payload, transcriptReset) - recordedTranscript = true - } - } - - if cliproxyexecutor.DownstreamWebsocket(ctx) { - downstreamPayload := helps.EnsureResponsesUsageDetails(payload) - downstreamWarmupCompletedPayload := helps.EnsureResponsesUsageDetails(warmupCompletedPayload) - if idMapper != nil { - downstreamPayload = idMapper.downstreamResponsePayload(downstreamPayload) - if len(warmupCompletedPayload) > 0 { - downstreamWarmupCompletedPayload = idMapper.downstreamResponsePayload(downstreamWarmupCompletedPayload) + case "response.done": + logXAIWebsocketTerminalResponse(executionSessionID, authID, wsURL, eventType, payload) + if detail, ok := helps.ParseCodexUsage(payload); ok { + streamUsage.Observe(detail, true) + } + if !warmupRequest && idMapper != nil && idMapper.state != nil && !recordedTranscript { + idMapper.state.recordTranscriptTurn(wsReqBody, payload, transcriptReset) + recordedTranscript = true } } - if !send(cliproxyexecutor.StreamChunk{Payload: downstreamPayload}) { - terminateReason = "context_done" - terminateErr = ctx.Err() - return - } - if len(downstreamWarmupCompletedPayload) > 0 { - if !send(cliproxyexecutor.StreamChunk{Payload: downstreamWarmupCompletedPayload}) { + + if cliproxyexecutor.DownstreamWebsocket(ctx) { + downstreamPayload := helps.EnsureResponsesUsageDetails(payload) + downstreamWarmupCompletedPayload := helps.EnsureResponsesUsageDetails(warmupCompletedPayload) + if idMapper != nil { + downstreamPayload = idMapper.downstreamResponsePayload(downstreamPayload) + if len(warmupCompletedPayload) > 0 { + downstreamWarmupCompletedPayload = idMapper.downstreamResponsePayload(downstreamWarmupCompletedPayload) + } + } + if !send(cliproxyexecutor.StreamChunk{Payload: downstreamPayload}) { terminateReason = "context_done" terminateErr = ctx.Err() return } - return + if len(downstreamWarmupCompletedPayload) > 0 { + if !send(cliproxyexecutor.StreamChunk{Payload: downstreamWarmupCompletedPayload}) { + terminateReason = "context_done" + terminateErr = ctx.Err() + return + } + return + } + if isTerminalEvent { + return + } + continue } - if isTerminalEvent { - return - } - continue - } - payload = normalizeCodexWebsocketCompletion(payload) - line := encodeCodexWebsocketAsSSE(payload) - chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, line, ¶m, claudeInputTokens) - for i := range chunks { - if !send(cliproxyexecutor.StreamChunk{Payload: chunks[i]}) { - terminateReason = "context_done" - terminateErr = ctx.Err() - return + payload = normalizeCodexWebsocketCompletion(payload) + line := encodeCodexWebsocketAsSSE(payload) + chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, line, ¶m, claudeInputTokens) + if helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + invalidatePatchAttempt() } - } - if len(warmupCompletedPayload) > 0 { - line = encodeCodexWebsocketAsSSE(warmupCompletedPayload) - chunks = helps.TranslateStreamWithClaudeInputTokens(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, line, ¶m, claudeInputTokens) for i := range chunks { if !send(cliproxyexecutor.StreamChunk{Payload: chunks[i]}) { terminateReason = "context_done" @@ -872,10 +912,30 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox return } } - return - } - if eventType == "response.completed" || eventType == "response.done" { - return + if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + terminateReason = "invalid_tool_arguments" + terminateErr = statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage} + return + } + if len(warmupCompletedPayload) > 0 { + line = encodeCodexWebsocketAsSSE(warmupCompletedPayload) + chunks = helps.TranslateStreamWithClaudeInputTokens(ctx, prepared.to, prepared.responseFormat, req.Model, prepared.originalPayload, prepared.body, line, ¶m, claudeInputTokens) + if helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) { + invalidatePatchAttempt() + } + for i := range chunks { + if !send(cliproxyexecutor.StreamChunk{Payload: chunks[i]}) { + terminateReason = "context_done" + terminateErr = ctx.Err() + return + } + } + helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) + return + } + if eventType == "response.completed" || eventType == "response.done" || patchTerminal { + return + } } } } @@ -937,15 +997,17 @@ func (e *XAIWebsocketsExecutor) executeCompactionTriggerFromWebsocketContext(ctx compactReq := req compactReq.Payload = compactPayload - prepared, data, headers, err := e.XAIExecutor.executeCompactRequest(ctx, auth, compactReq, opts) - if err != nil { - return nil, err + prepared, data, headers, reporter, errCompactRequest := e.XAIExecutor.executeCompactRequest(ctx, auth, compactReq, opts) + if errCompactRequest != nil { + return nil, errCompactRequest } responseID, compactionItem, errValidate := validateXAIWebsocketCompactionResponse(data) if errValidate != nil { + reporter.PublishFailure(ctx, errValidate) return nil, errValidate } + reporter.Publish(ctx, helps.ParseOpenAIUsage(data)) idMapper.state.replaceTranscriptWithItems(compactionItem) idMapper.state.mapDownstreamToUpstream(responseID, "") @@ -1811,3 +1873,8 @@ func xaiWebsocketsEnabled(auth *cliproxyauth.Auth) bool { } return false } + +// SupportsApplyPatch requires both selectable transports to support the tool. +func (e *XAIAutoExecutor) SupportsApplyPatch() bool { + return e != nil && e.httpExec != nil && e.wsExec != nil && e.httpExec.SupportsApplyPatch() && e.wsExec.SupportsApplyPatch() +} diff --git a/internal/runtime/executor/xai_websockets_executor_test.go b/internal/runtime/executor/xai_websockets_executor_test.go index c90faabfe..ebf42156f 100644 --- a/internal/runtime/executor/xai_websockets_executor_test.go +++ b/internal/runtime/executor/xai_websockets_executor_test.go @@ -2373,3 +2373,163 @@ func TestXAIWebsockets_KeepalivePingDuringUpload_Sessionless(t *testing.T) { } } } + +func TestXAIWebsocketApplyPatchTransport(t *testing.T) { + for _, fold := range []bool{false, true} { + for _, tc := range []struct{ downstreamWS, snapshotOnly, sparseTerminal, lateName bool }{ + {false, false, false, false}, {true, false, false, false}, + {false, true, false, false}, {true, true, false, false}, + {false, true, true, false}, {true, true, true, false}, + {false, true, false, true}, {true, true, false, true}, + {false, true, true, true}, {true, true, true, true}, + } { + downstreamWS, snapshotOnly := tc.downstreamWS, tc.snapshotOnly + t.Run(fmt.Sprintf("fold=%v/ws=%v/snapshotOnly=%v/sparseTerminal=%v/lateName=%v", fold, downstreamWS, snapshotOnly, tc.sparseTerminal, tc.lateName), func(t *testing.T) { + bodies := make(chan []byte, 1) + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, errUpgrade := upgrader.Upgrade(w, r, nil) + if errUpgrade != nil { + return + } + defer func() { + if errClose := conn.Close(); errClose != nil { + t.Logf("close test websocket: %v", errClose) + } + }() + _, body, errRead := conn.ReadMessage() + if errRead != nil { + return + } + bodies <- body + name, args := "apply_patch", `{"input":"p\n中😀"}` + if fold { + name = "n" + args = `{"name":"apply_patch","arguments":{"input":"p\n中😀"}}` + } + added := fmt.Sprintf(`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":%q,"arguments":""}}`, name) + done := fmt.Sprintf(`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":%q,"arguments":%q}}`, name, args) + events := []string{added} + if !snapshotOnly { + events = append(events, fmt.Sprintf(`{"type":"response.function_call_arguments.delta","output_index":0,"item_id":"a","delta":%q}`, args)) + } else { + // Call identity may first arrive with the omitted-arguments item completion. + added = fmt.Sprintf(`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","name":%q,"arguments":""}}`, name) + events[0] = added + done = fmt.Sprintf(`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":%q}}`, name) + } + events = append(events, fmt.Sprintf(`{"type":"response.function_call_arguments.done","item_id":"a","arguments":%q}`, args), done, `{"type":"response.completed","response":{"id":"r","output":[]}}`) + if tc.lateName { + events[0] = `{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a"}}` + } + if tc.sparseTerminal { + events[len(events)-1] = fmt.Sprintf(`{"type":"response.completed","response":{"id":"r","output":[{"type":"function_call","id":"a","call_id":"c","name":%q}]}}`, name) + } + for _, e := range events { + if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(e)); errWrite != nil { + return + } + } + })) + defer server.Close() + tools := `[{"type":"custom","name":"apply_patch"}]` + cfg := &config.Config{} + if fold { + var declarations []string + declarations = append(declarations, `{"type":"custom","name":"apply_patch"}`) + for i := 0; i < 205; i++ { + declarations = append(declarations, fmt.Sprintf(`{"type":"function","name":"lookup%d","parameters":{"type":"object"}}`, i)) + } + tools = `[{"type":"namespace","name":"n","tools":[` + strings.Join(declarations, ",") + `]}]` + } + exec := NewXAIWebsocketsExecutor(cfg) + exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)} + auth := &cliproxyauth.Auth{ID: "patch-ws", Provider: "xai", Attributes: map[string]string{"base_url": server.URL, "api_key": "test", "websockets": "true"}} + ctx := t.Context() + if downstreamWS { + ctx = cliproxyexecutor.WithDownstreamWebsocket(ctx) + } + result, errExecute := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "grok-4", Payload: []byte(`{"input":[],"tools":` + tools + `}`)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) + if errExecute != nil { + t.Fatal(errExecute) + } + var output, final []byte + var lifecycle [][]byte + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + output = append(output, chunk.Payload...) + lifecycle = append(lifecycle, applyPatchTestPayloads(chunk.Payload)...) + payload := bytes.TrimSpace(bytes.TrimPrefix(chunk.Payload, []byte("data:"))) + if gjson.GetBytes(payload, "type").String() == "response.completed" { + final = []byte(gjson.GetBytes(payload, "response").Raw) + } + } + if !bytes.Contains(output, []byte(`"custom_tool_call"`)) || !bytes.Contains(output, []byte(`"response.custom_tool_call_input.done"`)) || !bytes.Contains(output, []byte(`"input":"p\n中😀"`)) { + t.Fatalf("actual websocket bridge bypass: %s", output) + } + if snapshotOnly && bytes.Contains(output, []byte(`"response.custom_tool_call_input.delta"`)) { + t.Fatalf("snapshot-only completion invented progress: %s", output) + } + var fragments []string + if !snapshotOnly && !fold { + fragments = []string{"p\n中😀"} + } + assertApplyPatchIdentityLifecycle(t, lifecycle, "a", "c", "p\n中😀", 0, fragments) + if gjson.GetBytes(final, "output.0.type").String() != "custom_tool_call" || gjson.GetBytes(final, "output.0.input").String() != "p\n中😀" || gjson.GetBytes(final, "output.0.call_id").String() != "c" || (fold && gjson.GetBytes(final, "output.0.namespace").String() != "n") { + t.Fatalf("terminal lost child input/identity: %s", final) + } + if bytes.Count(output, []byte(`"type":"response.custom_tool_call_input.done"`)) != 1 || bytes.Count(output, []byte(`"type":"response.completed"`)) != 1 { + t.Fatalf("duplicate completion: %s", output) + } + body := <-bodies + if fold { + if gjson.GetBytes(body, "tools.0.name").String() != "n" || !strings.Contains(gjson.GetBytes(body, "tools.0.description").String(), "apply_patch") { + t.Fatalf("dispatcher lost patch: %s", body) + } + } else if !gjson.GetBytes(body, "tools.0.parameters.properties.input").Exists() { + t.Fatalf("missing patch schema: %s", body) + } + }) + } + } +} + +func TestXAIWebsocketApplyPatchIncompleteEOF(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, errUpgrade := upgrader.Upgrade(w, r, nil) + if errUpgrade != nil { + return + } + defer func() { + if errClose := conn.Close(); errClose != nil { + t.Logf("close test websocket: %v", errClose) + } + }() + if _, _, errRead := conn.ReadMessage(); errRead != nil { + return + } + _ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a","name":"apply_patch","arguments":""}}`)) + })) + defer server.Close() + exec := NewXAIWebsocketsExecutor(&config.Config{}) + exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)} + result, errExecute := exec.ExecuteStream(cliproxyexecutor.WithDownstreamWebsocket(t.Context()), &cliproxyauth.Auth{Provider: "xai", Attributes: map[string]string{"base_url": server.URL, "api_key": "test", "websockets": "true"}}, cliproxyexecutor.Request{Model: "grok-4", Payload: []byte(`{"input":[],"tools":[{"type":"custom","name":"apply_patch"}]}`)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}) + if errExecute != nil { + t.Fatal(errExecute) + } + var output []byte + var streamErr error + for chunk := range result.Chunks { + output = append(output, chunk.Payload...) + if chunk.Err != nil { + streamErr = chunk.Err + } + } + assertTask6PatchError(t, streamErr) + if streamErr == nil || !bytes.Contains(output, []byte(`"type":"response.failed"`)) || bytes.Count(output, []byte(`"type":"response.failed"`)) != 1 || bytes.Contains(output, []byte(`"type":"response.completed"`)) { + t.Fatalf("bridge EOF was not finished: %s err=%v", output, streamErr) + } +} diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response_test.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response_test.go index 3db2f193a..766bc7d0f 100644 --- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response_test.go +++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response_test.go @@ -2,6 +2,7 @@ package responses import ( "context" + "strings" "testing" "github.com/tidwall/gjson" @@ -222,3 +223,42 @@ func TestConvertAntigravityResponseToOpenAIResponsesNonStream_WebSearch(t *testi t.Fatalf("expected tool_usage.web_search.num_requests = 1, got %d", parsed.Get("tool_usage.web_search.num_requests").Int()) } } + +func TestAntigravityApplyPatchReuse(t *testing.T) { + request := []byte(`{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch","format":{"type":"grammar","definition":"start: patch"}}]}],"input":"patch"}`) + translated := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.1-pro-preview", request, false) + declaration := gjson.GetBytes(translated, "request.tools.0.functionDeclarations.0") + if declaration.Get("name").String() != "functions__apply_patch" || !strings.Contains(declaration.Get("description").String(), "*** Begin Patch") || declaration.Get("parametersJsonSchema.properties.input.type").String() != "string" || !declaration.Get("parametersJsonSchema.additionalProperties").Exists() || declaration.Get("parametersJsonSchema.additionalProperties").Bool() { + t.Fatalf("missing declaration: %s", translated) + } + raw := []byte(`{"response":{"responseId":"patch","candidates":[{"content":{"parts":[{"functionCall":{"name":"functions__apply_patch","args":{"input":" *** Begin Patch\n*** End Patch\n "}}}]},"finishReason":"STOP"}]}}`) + want := " *** Begin Patch\n*** End Patch\n " + nonStream := ConvertAntigravityResponseToOpenAIResponsesNonStream(context.Background(), "gemini", request, translated, raw, nil) + if gjson.GetBytes(nonStream, "output.0.input").String() != want || gjson.GetBytes(nonStream, "output.0.namespace").String() != "functions" { + t.Fatalf("wrong input: %s", nonStream) + } + var param any + var delta strings.Builder + var done, item, final string + for _, chunk := range ConvertAntigravityResponseToOpenAIResponses(context.Background(), "gemini", request, translated, raw, ¶m) { + for _, line := range strings.Split(string(chunk), "\n") { + if !strings.HasPrefix(line, "data:") { + continue + } + ev := gjson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + switch ev.Get("type").String() { + case "response.custom_tool_call_input.delta": + delta.WriteString(ev.Get("delta").String()) + case "response.custom_tool_call_input.done": + done = ev.Get("input").String() + case "response.output_item.done": + item = ev.Get("item.input").String() + case "response.completed": + final = ev.Get("response.output.0.input").String() + } + } + } + if delta.String() != want || done != want || item != want || final != want { + t.Fatalf("inconsistent input: delta=%q done=%q item=%q final=%q", delta.String(), done, item, final) + } +} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go index 1ad73f0a6..9c880b7ca 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go @@ -7,6 +7,7 @@ import ( log "github.com/sirupsen/logrus" + applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch" "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature" "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking" @@ -1255,10 +1256,6 @@ func convertResponsesContentPartToClaude(part gjson.Result) []byte { return nil } -func isOpenAIResponsesApplyPatchCustomTool(toolType string, tool gjson.Result) bool { - return toolType == "custom" && strings.TrimSpace(tool.Get("name").String()) == "apply_patch" -} - func convertResponsesToolDescriptorToClaude(descriptor responsesToolDescriptor, claudeName string) ([]byte, bool) { overrideName := claudeName if overrideName == "" && !descriptor.direct { @@ -1351,9 +1348,7 @@ func responsesToolDescriptors(root gjson.Result) []responsesToolDescriptor { case "", "function": appendDescriptor(child, qualifiedName, childName, namespaceName, "function", sourcePriority, false) case "custom": - if !isOpenAIResponsesApplyPatchCustomTool("custom", child) { - appendDescriptor(child, qualifiedName, childName, namespaceName, "custom", sourcePriority, false) - } + appendDescriptor(child, qualifiedName, childName, namespaceName, "custom", sourcePriority, false) } return true }) @@ -1365,9 +1360,7 @@ func responsesToolDescriptors(root gjson.Result) []responsesToolDescriptor { case "", "function": appendDescriptor(tool, responsesToolName(tool), "", "", "function", source.priority, true) case "custom": - if !isOpenAIResponsesApplyPatchCustomTool("custom", tool) { - appendDescriptor(tool, responsesToolName(tool), "", "", "custom", source.priority, true) - } + appendDescriptor(tool, responsesToolName(tool), "", "", "custom", source.priority, true) case "namespace": appendNamespaceChildren(tool, source.priority) case "web_search": @@ -1577,6 +1570,10 @@ func convertResponsesCustomToolToClaude(tool gjson.Result, overrideName string) if description := responsesToolDescription(tool); description != "" { tJSON, _ = sjson.SetBytes(tJSON, "description", description) } + if applypatch.IsCustomTool(tool) { + tJSON, _ = sjson.SetBytes(tJSON, "description", applypatch.Description(tool)) + tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", applypatch.Parameters()) + } tJSON = common.AttachCacheControl(tJSON, tool) return tJSON, true } diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go index fa9e5057f..655adac67 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go @@ -1090,7 +1090,7 @@ func TestConvertOpenAIResponsesRequestToClaude_KeepsToolUseAdjacentToToolResult( } } -func TestConvertOpenAIResponsesRequestToClaude_DropsApplyPatchCustomTool(t *testing.T) { +func TestConvertOpenAIResponsesRequestToClaude_KeepsApplyPatchCustomTool(t *testing.T) { raw := []byte(`{ "model":"claude-test", "input":[{"role":"user","content":[{"type":"input_text","text":"hi"}]}], @@ -1113,14 +1113,17 @@ func TestConvertOpenAIResponsesRequestToClaude_DropsApplyPatchCustomTool(t *test out := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false) root := gjson.ParseBytes(out) - if got := root.Get("tools.#").Int(); got != 1 { - t.Fatalf("tools count = %d, want 1. Output: %s", got, string(out)) + if got := root.Get("tools.#").Int(); got != 2 { + t.Fatalf("tools count = %d, want 2. Output: %s", got, out) } - if got := root.Get("tools.0.name").String(); got != "exec_command" { - t.Fatalf("tools.0.name = %q, want exec_command. Output: %s", got, string(out)) + tool := root.Get(`tools.#(name=="apply_patch")`) + for _, instruction := range []string{"*** Begin Patch", "*** End Patch", "*** Add File:", "*** Delete File:", "*** Update File:", "*** Move to:", "*** End of File", "@@", "start: patch", "JSON object"} { + if !strings.Contains(tool.Get("description").String(), instruction) { + t.Errorf("missing patch instruction %q", instruction) + } } - if got := root.Get("tools.#(name==\"apply_patch\")").Raw; got != "" { - t.Fatalf("apply_patch custom tool should be dropped. Output: %s", string(out)) + if tool.Get("input_schema.additionalProperties").Bool() || !tool.Get("input_schema.additionalProperties").Exists() || tool.Get("input_schema.required.0").String() != "input" { + t.Fatalf("patch schema is not strict: %s", tool.Raw) } } @@ -2798,3 +2801,29 @@ func TestConvertOpenAIResponsesRequestToClaude_LongToolChoiceAndHistory(t *testi t.Fatalf("history tool_use.name = %q, want %q", foundHistoryToolUseName, declaredPricesName) } } + +func TestApplyPatchClaudeRequestContractAndHistory(t *testing.T) { + request := []byte(`{"tools":[{"type":"namespace","name":"editor","tools":[{"type":"custom","name":"apply_patch","description":"Edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.","format":{"type":"grammar","syntax":"lark","definition":"start: patch"},"cache_control":{"type":"ephemeral"}}]}],"input":[{"type":"custom_tool_call","namespace":"editor","name":"apply_patch","call_id":"c1","input":"*** Begin Patch\n*** Add File: a.txt\n+hello\n*** End Patch"},{"type":"custom_tool_call_output","call_id":"c1","output":"done"}]}`) + result := gjson.ParseBytes(ConvertOpenAIResponsesRequestToClaude("test", request, false)) + tool := result.Get("tools.0") + description := tool.Get("description").String() + schema := tool.Get("input_schema") + for _, instruction := range []string{"*** Begin Patch", "*** End Patch", "*** Add File:", "*** Delete File:", "*** Update File:", "*** Move to:", "*** End of File", "@@", "start: patch", "JSON object"} { + if !strings.Contains(description, instruction) { + t.Errorf("missing instruction %q", instruction) + } + } + if strings.Contains(description, "do not wrap the patch in JSON") { + t.Fatal("contradictory freeform instructions") + } + if schema.Get("additionalProperties").Bool() || !schema.Get("additionalProperties").Exists() || schema.Get("required.0").String() != "input" { + t.Fatalf("not strict schema: %s", schema.Raw) + } + call := result.Get("messages.0.content.0") + if tool.Get("cache_control.type").String() != "ephemeral" { + t.Fatalf("cache control lost: %s", tool.Raw) + } + if call.Get("name").String() != "editor__apply_patch" || call.Get("input.input").String() != "*** Begin Patch\n*** Add File: a.txt\n+hello\n*** End Patch" || call.Get("id").String() != "c1" || result.Get("messages.1.content.0.tool_use_id").String() != "c1" || result.Get("messages.1.content.0.content").String() != "done" { + t.Fatalf("history mismatch: %s", result.Raw) + } +} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go index f8c14c3f2..8fe68caf5 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go @@ -9,27 +9,39 @@ import ( log "github.com/sirupsen/logrus" + applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch" translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) type claudeToResponsesState struct { - Seq int - ResponseID string - CreatedAt int64 - NextOutputIndex int - CurrentMsgID string - CurrentFCID string - InTextBlock bool - InFuncBlock bool - MessageOpen bool - ContentPartOpen bool - MessageOutputIndex int - FuncArgsBuf map[int]*strings.Builder // index -> args - FuncArgsDone map[int]bool - FuncItemDone map[int]bool - FuncItemStatus map[int]string + translatorcommon.ApplyPatchErrorState + ApplyPatchCalls map[string]*translatorcommon.ApplyPatchCallState + ToolWinners map[string]responsesToolDescriptor + ToolNames claudeToolNames + FuncItemAdded map[int]bool + FuncArgsSent map[int]int + FuncBlockStopped map[int]bool + FuncInputSnapshot map[int]string + FuncInputSnapshotErrors map[int]error + FuncIdentityConflicts map[int]bool + CompletedEmitted bool + Seq int + ResponseID string + CreatedAt int64 + NextOutputIndex int + CurrentMsgID string + CurrentFCID string + InTextBlock bool + InFuncBlock bool + MessageOpen bool + ContentPartOpen bool + MessageOutputIndex int + FuncArgsBuf map[int]*strings.Builder // index -> args + FuncArgsDone map[int]bool + FuncItemDone map[int]bool + FuncItemStatus map[int]string // function call bookkeeping for output aggregation FuncNames map[int]string // Claude block index -> function name FuncCallIDs map[int]string // Claude block index -> call id @@ -255,10 +267,143 @@ func (st *claudeToResponsesState) finalizeWebSearchWithStatus(item *claudeRespon return [][]byte{emitEvent("response.output_item.done", done)} } -func (st *claudeToResponsesState) finalizeFuncItem(idx int, requestForToolMetadata []byte, status string, nextSeq func() int) [][]byte { - if st.FuncItemDone[idx] { +// isApplyPatch consults the original request's winning declaration, not the +// sanitized upstream name or a discarded declaration with the same name. +func (st *claudeToResponsesState) isApplyPatch(name string) bool { + d, ok := st.ToolWinners[st.ToolNames.identity(name)] + return ok && d.toolType == "custom" && applypatch.IsCustomTool(d.tool) +} + +// validateApplyPatchSnapshots permits equivalent JSON spellings but never +// silently replaces one complete patch input with another. +func validateApplyPatchSnapshots(previous, current string) error { + var call translatorcommon.ApplyPatchCallState + if previous != "" { + if _, _, errFinishArguments := call.FinishArguments(previous); errFinishArguments != nil { + return errFinishArguments + } + } + _, _, errFinishArguments := call.FinishArguments(current) + return errFinishArguments +} + +// finishClaudeApplyPatchArguments treats complete streamed JSON as a complete +// snapshot, not a prefix that a later snapshot may silently extend. A partial +// source can still be completed by a consistent full snapshot. +func finishClaudeApplyPatchArguments(call *translatorcommon.ApplyPatchCallState, arguments, snapshot string) (string, string, error) { + if snapshot == "" { + return call.FinishArguments(arguments) + } + if gjson.Valid(arguments) { + if _, _, errFinishArguments := call.FinishArguments(arguments); errFinishArguments != nil { + return "", "", errFinishArguments + } + } + return call.FinishArguments(snapshot) +} + +func (st *claudeToResponsesState) failToolInput(err error, nextSeq func() int) [][]byte { + if st.ToolInputError() != nil { return nil } + st.SetToolInputError(err) + return [][]byte{emitEvent("response.failed", translatorcommon.ApplyPatchFailure(st.ResponseID, nextSeq()))} +} + +func (st *claudeToResponsesState) emitFuncItem(idx int, requestJSON []byte, force bool, nextSeq func() int) [][]byte { + if st.FuncItemAdded[idx] || st.ToolInputError() != nil { + return nil + } + name, callID := st.FuncNames[idx], st.FuncCallIDs[idx] + if force && name == "" && len(st.ToolWinners) == 1 { + for identity := range st.ToolWinners { + if st.isApplyPatch(identity) { + name = st.ToolNames.claudeName(identity) + st.FuncNames[idx] = name + } + } + } + if st.isApplyPatch(name) { + if st.FuncIdentityConflicts[idx] { + return st.failToolInput(fmt.Errorf("conflicting apply_patch call identity"), nextSeq) + } + if errSnapshot := st.FuncInputSnapshotErrors[idx]; errSnapshot != nil { + return st.failToolInput(errSnapshot, nextSeq) + } + } + if !force && (name == "" || callID == "") { + return nil + } + if callID == "" { + callID = fmt.Sprintf("call_%s_%d", st.ResponseID, idx) + st.FuncCallIDs[idx] = callID + } + d := st.ToolWinners[st.ToolNames.identity(name)] + st.FuncCustom[idx] = d.toolType == "custom" + outputIndex := st.functionOutputIndex(idx) + item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`) + itemID := fmt.Sprintf("fc_%s", callID) + if st.FuncCustom[idx] { + item = []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`) + itemID = fmt.Sprintf("ctc_%s", callID) + if st.isApplyPatch(name) { + localName := d.childName + if d.direct { + localName = d.name + } + st.ApplyPatchCalls[fmt.Sprint(idx)] = &translatorcommon.ApplyPatchCallState{ + ItemID: itemID, CallID: callID, Name: localName, Namespace: d.namespace, OutputIndex: outputIndex, + } + } + } + item, _ = sjson.SetBytes(item, "item.id", itemID) + item, _ = sjson.SetBytes(item, "item.call_id", callID) + item = applyResponsesFunctionCallNamespaceFields(item, requestJSON, name, "item") + item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) + item, _ = sjson.SetBytes(item, "output_index", outputIndex) + st.FuncItemAdded[idx] = true + return [][]byte{emitEvent("response.output_item.added", item)} +} + +func (st *claudeToResponsesState) emitPendingFuncArgs(idx int, nextSeq func() int) [][]byte { + if !st.FuncItemAdded[idx] || st.ToolInputError() != nil { + return nil + } + buf := st.FuncArgsBuf[idx] + if buf == nil || buf.Len() <= st.FuncArgsSent[idx] { + return nil + } + fragment := buf.String()[st.FuncArgsSent[idx]:] + st.FuncArgsSent[idx] = buf.Len() + if st.FuncCustom[idx] { + if patchCall := st.ApplyPatchCalls[fmt.Sprint(idx)]; patchCall != nil { + delta, errPushArguments := patchCall.PushArguments(fragment) + if errPushArguments != nil { + return st.failToolInput(errPushArguments, nextSeq) + } + if delta != "" { + return [][]byte{emitEvent("response.custom_tool_call_input.delta", translatorcommon.ApplyPatchInputDelta(patchCall, delta, nextSeq()))} + } + } + return nil + } + msg := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`) + msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq()) + msg, _ = sjson.SetBytes(msg, "item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx])) + msg, _ = sjson.SetBytes(msg, "output_index", st.functionOutputIndex(idx)) + msg, _ = translatorcommon.SetStringWithoutHTMLEscape(msg, "delta", fragment) + return [][]byte{emitEvent("response.function_call_arguments.delta", msg)} +} + +func (st *claudeToResponsesState) finalizeFuncItem(idx int, requestForToolMetadata []byte, status string, nextSeq func() int) [][]byte { + if st.FuncItemDone[idx] || st.ToolInputError() != nil { + return nil + } + out := st.emitFuncItem(idx, requestForToolMetadata, true, nextSeq) + out = append(out, st.emitPendingFuncArgs(idx, nextSeq)...) + if st.ToolInputError() != nil { + return out + } if st.FuncItemDone == nil { st.FuncItemDone = make(map[int]bool) } @@ -284,20 +429,36 @@ func (st *claudeToResponsesState) finalizeFuncItem(idx int, requestForToolMetada } name := st.FuncNames[idx] - var out [][]byte if st.FuncCustom[idx] { - input := unwrapCustomToolInput(args) + input := "" + patchCall := st.ApplyPatchCalls[fmt.Sprint(idx)] + if patchCall != nil { + tail, fullInput, errFinishArguments := finishClaudeApplyPatchArguments(patchCall, args, st.FuncInputSnapshot[idx]) + if errFinishArguments != nil { + return append(out, st.failToolInput(errFinishArguments, nextSeq)...) + } + input = fullInput + if tail != "" { + out = append(out, emitEvent("response.custom_tool_call_input.delta", translatorcommon.ApplyPatchInputDelta(patchCall, tail, nextSeq()))) + } + } else { + input = unwrapCustomToolInput(args) + } if !st.FuncArgsDone[idx] { if st.FuncArgsDone == nil { st.FuncArgsDone = make(map[int]bool) } st.FuncArgsDone[idx] = true - inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`) - inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq()) - inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", callID)) - inputDone, _ = sjson.SetBytes(inputDone, "output_index", outputIndex) - inputDone, _ = sjson.SetBytes(inputDone, "input", input) - out = append(out, emitEvent("response.custom_tool_call_input.done", inputDone)) + if patchCall != nil { + out = append(out, emitEvent("response.custom_tool_call_input.done", translatorcommon.ApplyPatchInputDone(patchCall, input, nextSeq()))) + } else { + inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`) + inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq()) + inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", callID)) + inputDone, _ = sjson.SetBytes(inputDone, "output_index", outputIndex) + inputDone, _ = sjson.SetBytes(inputDone, "input", input) + out = append(out, emitEvent("response.custom_tool_call_input.done", inputDone)) + } } itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`) @@ -446,22 +607,40 @@ func (st *claudeToResponsesState) finalizeAssistantMessage(nextSeq func() int) [ return out } +func newClaudeToResponsesState(requestJSON []byte) *claudeToResponsesState { + root := gjson.ParseBytes(requestJSON) + winners := responsesToolWinners(root) + return &claudeToResponsesState{ + ToolWinners: winners, + ToolNames: buildClaudeToolNamesWithWinners(root, winners), + ApplyPatchCalls: make(map[string]*translatorcommon.ApplyPatchCallState), + FuncItemAdded: make(map[int]bool), + FuncArgsSent: make(map[int]int), + FuncBlockStopped: make(map[int]bool), + FuncInputSnapshot: make(map[int]string), + FuncInputSnapshotErrors: make(map[int]error), + FuncIdentityConflicts: make(map[int]bool), + MessageOutputIndex: -1, + ReasoningIndex: -1, + FuncArgsBuf: make(map[int]*strings.Builder), + FuncNames: make(map[int]string), + FuncCallIDs: make(map[int]string), + FuncCustom: make(map[int]bool), + FuncOutputIndices: make(map[int]int), + WebSearchByBlock: make(map[int]*claudeResponsesWebSearchItem), + WebSearchByToolID: make(map[string]*claudeResponsesWebSearchItem), + } +} + // ConvertClaudeResponseToOpenAIResponses converts Claude SSE to OpenAI Responses SSE events. func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { if *param == nil { - *param = &claudeToResponsesState{ - MessageOutputIndex: -1, - ReasoningIndex: -1, - FuncArgsBuf: make(map[int]*strings.Builder), - FuncNames: make(map[int]string), - FuncCallIDs: make(map[int]string), - FuncCustom: make(map[int]bool), - FuncOutputIndices: make(map[int]int), - WebSearchByBlock: make(map[int]*claudeResponsesWebSearchItem), - WebSearchByToolID: make(map[string]*claudeResponsesWebSearchItem), - } + *param = newClaudeToResponsesState(pickRequestJSON(originalRequestRawJSON, requestRawJSON)) } st := (*param).(*claudeToResponsesState) + if st.ToolInputError() != nil || st.CompletedEmitted { + return nil + } // Expect `data: {..}` from Claude clients if !bytes.HasPrefix(rawJSON, dataTag) { @@ -470,7 +649,6 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin rawJSON = bytes.TrimSpace(rawJSON[5:]) root := gjson.ParseBytes(rawJSON) requestForToolMetadata := pickRequestJSON(originalRequestRawJSON, requestRawJSON) - customToolNames := responsesCustomToolNames(requestForToolMetadata) ev := root.Get("type").String() var out [][]byte @@ -502,6 +680,13 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin st.ReasoningIndex = -1 st.ReasoningItems = nil st.StopReason = "" + st.ApplyPatchCalls = make(map[string]*translatorcommon.ApplyPatchCallState) + st.FuncItemAdded = make(map[int]bool) + st.FuncArgsSent = make(map[int]int) + st.FuncBlockStopped = make(map[int]bool) + st.FuncInputSnapshot = make(map[int]string) + st.FuncInputSnapshotErrors = make(map[int]error) + st.FuncIdentityConflicts = make(map[int]bool) st.FuncArgsBuf = make(map[int]*strings.Builder) st.FuncArgsDone = make(map[int]bool) st.FuncItemDone = make(map[int]bool) @@ -554,7 +739,15 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin // Finalize any previous completed function calls for prevIdx := range st.FuncCallIDs { if !st.FuncItemDone[prevIdx] && prevIdx != idx { + // Patch calls may interleave; a new block is not a completion + // snapshot for a still-open (or not-yet-named) call. + if (st.isApplyPatch(st.FuncNames[prevIdx]) || st.FuncNames[prevIdx] == "") && !st.FuncBlockStopped[prevIdx] { + continue + } out = append(out, st.finalizeFuncItem(prevIdx, requestForToolMetadata, "completed", nextSeq)...) + if st.ToolInputError() != nil { + return out + } } } // Finalize any previous web search items @@ -588,35 +781,58 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin } } else if typ == "tool_use" { st.InFuncBlock = true - st.CurrentFCID = cb.Get("id").String() - name := cb.Get("name").String() - _, isCustomTool := customToolNames[name] - if st.FuncCustom == nil { - st.FuncCustom = make(map[int]bool) + callID, name := cb.Get("id").String(), cb.Get("name").String() + oldID, oldName := st.FuncCallIDs[idx], st.FuncNames[idx] + // Pending identity evidence must survive later matching updates. + if callID != "" && oldID != "" && callID != oldID { + st.FuncIdentityConflicts[idx] = true } - st.FuncCustom[idx] = isCustomTool - outputIndex := st.functionOutputIndex(idx) - var item []byte - if isCustomTool { - item = []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`) - item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("ctc_%s", st.CurrentFCID)) - item, _ = sjson.SetBytes(item, "item.call_id", st.CurrentFCID) - item = applyResponsesFunctionCallNamespaceFields(item, requestForToolMetadata, name, "item") - } else { - item = []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`) - item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("fc_%s", st.CurrentFCID)) - item, _ = sjson.SetBytes(item, "item.call_id", st.CurrentFCID) - item = applyResponsesFunctionCallNamespaceFields(item, requestForToolMetadata, name, "item") + if st.isApplyPatch(oldName) || st.isApplyPatch(name) { + if st.FuncIdentityConflicts[idx] || (name != "" && oldName != "" && st.ToolNames.identity(name) != st.ToolNames.identity(oldName)) { + return append(out, st.failToolInput(fmt.Errorf("conflicting apply_patch call identity"), nextSeq)...) + } } - item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) - item, _ = sjson.SetBytes(item, "output_index", outputIndex) - out = append(out, emitEvent("response.output_item.added", item)) + if !st.FuncItemAdded[idx] { + // Keep the block key even before its ID arrives so terminal + // validation cannot overlook an unnamed or ID-less call. + if callID != "" || oldID == "" { + st.FuncCallIDs[idx] = callID + } + } + if name != "" && !st.FuncItemAdded[idx] { + st.FuncNames[idx] = name + } + st.CurrentFCID = st.FuncCallIDs[idx] + st.functionOutputIndex(idx) if st.FuncArgsBuf[idx] == nil { st.FuncArgsBuf[idx] = &strings.Builder{} } - // Record function metadata for aggregation. - st.FuncCallIDs[idx] = st.CurrentFCID - st.FuncNames[idx] = name + // Empty start input is a Claude placeholder, not an arguments fragment. + // Retain populated snapshot evidence without fabricating progress. + if input := cb.Get("input"); input.Exists() && (!input.IsObject() || len(input.Map()) > 0) { + // Validate without emitting or finishing the live decoder; defer + // failures until classification selects the patch bridge. + if errValidateApplyPatchSnapshots := validateApplyPatchSnapshots(st.FuncInputSnapshot[idx], input.Raw); errValidateApplyPatchSnapshots != nil && st.FuncInputSnapshotErrors[idx] == nil { + st.FuncInputSnapshotErrors[idx] = errValidateApplyPatchSnapshots + } + // Item completion does not seal the response. Compare late snapshots + // against this call's finished decoder without emitting more input. + if st.FuncItemDone[idx] { + if patchCall := st.ApplyPatchCalls[fmt.Sprint(idx)]; patchCall != nil { + if _, _, errFinishArguments := patchCall.FinishArguments(input.Raw); errFinishArguments != nil { + return append(out, st.failToolInput(errFinishArguments, nextSeq)...) + } + } + } + st.FuncInputSnapshot[idx] = input.Raw + } + if st.isApplyPatch(st.FuncNames[idx]) { + if errSnapshot := st.FuncInputSnapshotErrors[idx]; errSnapshot != nil { + return append(out, st.failToolInput(errSnapshot, nextSeq)...) + } + } + out = append(out, st.emitFuncItem(idx, requestForToolMetadata, false, nextSeq)...) + out = append(out, st.emitPendingFuncArgs(idx, nextSeq)...) } else if typ == "server_tool_use" { if name := cb.Get("name").String(); name != claudeWebSearchToolName { // Reachability guard: only web_search can be enabled on Claude by @@ -685,25 +901,13 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin } return [][]byte{} } - if !st.InFuncBlock || st.CurrentFCID == "" { - return [][]byte{} - } idx := int(root.Get("index").Int()) if pj := d.Get("partial_json"); pj.Exists() { if st.FuncArgsBuf[idx] == nil { st.FuncArgsBuf[idx] = &strings.Builder{} } st.FuncArgsBuf[idx].WriteString(pj.String()) - if st.FuncCustom[idx] { - return [][]byte{} - } - outputIndex := st.functionOutputIndex(idx) - msg := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`) - msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq()) - msg, _ = sjson.SetBytes(msg, "item_id", fmt.Sprintf("fc_%s", st.CurrentFCID)) - msg, _ = sjson.SetBytes(msg, "output_index", outputIndex) - msg, _ = translatorcommon.SetStringWithoutHTMLEscape(msg, "delta", pj.String()) - out = append(out, emitEvent("response.function_call_arguments.delta", msg)) + out = append(out, st.emitPendingFuncArgs(idx, nextSeq)...) } } else if dt == "thinking_delta" { if st.ReasoningActive { @@ -731,6 +935,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin return [][]byte{} } case "content_block_stop": + st.FuncBlockStopped[int(root.Get("index").Int())] = true if st.InTextBlock { st.InTextBlock = false } else if st.InFuncBlock { @@ -754,6 +959,9 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin for idx := range st.FuncCallIDs { if !st.FuncItemDone[idx] { out = append(out, st.finalizeFuncItem(idx, requestForToolMetadata, toolStatus, nextSeq)...) + if st.ToolInputError() != nil { + return out + } } } for _, item := range st.WebSearchItems { @@ -913,7 +1121,13 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID)) item, _ = sjson.SetBytes(item, "status", status) - item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(args)) + input := "" + if patchCall := st.ApplyPatchCalls[fmt.Sprint(idx)]; patchCall != nil { + input = patchCall.Decoder.Input() + } else { + input = unwrapCustomToolInput(args) + } + item, _ = sjson.SetBytes(item, "input", input) item, _ = sjson.SetBytes(item, "call_id", callID) item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, name, "") outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), item) @@ -948,6 +1162,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin completed, _ = sjson.SetBytes(completed, "response.usage.total_tokens", totalTokens) } } + st.CompletedEmitted = true out = append(out, emitEvent(eventType, completed)) } @@ -955,7 +1170,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin } // ConvertClaudeResponseToOpenAIResponsesNonStream aggregates Claude SSE into a single OpenAI Responses JSON. -func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { +func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte { // Aggregate Claude SSE lines into a single OpenAI Responses JSON (non-stream) // We follow the same aggregation logic as the streaming variant but produce // one final object matching docs/out.json structure. @@ -981,7 +1196,10 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string } reqBytes := pickRequestJSON(originalRequestRawJSON, requestRawJSON) - customToolNames := responsesCustomToolNames(reqBytes) + st := newClaudeToResponsesState(reqBytes) + if param != nil { + *param = st + } // Base OpenAI Responses (non-stream) object out := []byte(`{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"incomplete_details":null,"output":[],"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{},"total_tokens":0}}`) @@ -995,16 +1213,17 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string ) type nonStreamOutputItem struct { - outputIndex int - itemType string - id string - callID string - name string - text strings.Builder - signature string - annotations []any - args strings.Builder - results []byte + outputIndex int + itemType string + id string + callID string + name string + text strings.Builder + signature string + annotations []any + args strings.Builder + inputSnapshot string + results []byte } blockToItem := make(map[int]*nonStreamOutputItem) @@ -1034,6 +1253,10 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string for _, ch := range chunks { root := gjson.ParseBytes(ch) ev := root.Get("type").String() + // Match the streaming path: no input after the protocol terminator. + if ev == "message_stop" { + break + } switch ev { case "message_start": @@ -1070,17 +1293,47 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string activeMessageItem = item case "tool_use": itemType := "function_call" - if _, isCustomTool := customToolNames[cb.Get("name").String()]; isCustomTool { + if d := st.ToolWinners[st.ToolNames.identity(cb.Get("name").String())]; d.toolType == "custom" { itemType = "custom_tool_call" } - item := newOutputItem(itemType, idx) - item.callID = cb.Get("id").String() - if itemType == "custom_tool_call" { + item := blockToItem[idx] + if item == nil { + item = newOutputItem(itemType, idx) + } + name, callID := cb.Get("name").String(), cb.Get("id").String() + if callID != "" && item.callID != "" && callID != item.callID { + st.FuncIdentityConflicts[idx] = true + } + if st.isApplyPatch(item.name) || st.isApplyPatch(name) { + if st.FuncIdentityConflicts[idx] || (name != "" && item.name != "" && st.ToolNames.identity(name) != st.ToolNames.identity(item.name)) { + st.SetToolInputError(fmt.Errorf("conflicting apply_patch call identity")) + return []byte(gjson.GetBytes(translatorcommon.ApplyPatchFailure(responseID, 0), "response").Raw) + } + } + if name != "" { + item.name = name + item.itemType = itemType + } + if callID != "" { + item.callID = callID + } + if input := cb.Get("input"); input.Exists() && (!input.IsObject() || len(input.Map()) > 0) { + if errValidateApplyPatchSnapshots := validateApplyPatchSnapshots(item.inputSnapshot, input.Raw); errValidateApplyPatchSnapshots != nil && st.FuncInputSnapshotErrors[idx] == nil { + st.FuncInputSnapshotErrors[idx] = errValidateApplyPatchSnapshots + } + item.inputSnapshot = input.Raw + } + if st.isApplyPatch(item.name) { + if errSnapshot := st.FuncInputSnapshotErrors[idx]; errSnapshot != nil { + st.SetToolInputError(errSnapshot) + return []byte(gjson.GetBytes(translatorcommon.ApplyPatchFailure(responseID, 0), "response").Raw) + } + } + if item.itemType == "custom_tool_call" { item.id = fmt.Sprintf("ctc_%s", item.callID) } else { item.id = fmt.Sprintf("fc_%s", item.callID) } - item.name = cb.Get("name").String() case "server_tool_use": name := cb.Get("name").String() if name != claudeWebSearchToolName { @@ -1272,7 +1525,23 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string item = []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", outputItem.id) item, _ = sjson.SetBytes(item, "status", itemStatus) - item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(outputItem.args.String())) + input := "" + if st.isApplyPatch(outputItem.name) { + patchCall := &translatorcommon.ApplyPatchCallState{} + if _, errPushArguments := patchCall.PushArguments(outputItem.args.String()); errPushArguments != nil { + st.SetToolInputError(errPushArguments) + return []byte(gjson.GetBytes(translatorcommon.ApplyPatchFailure(responseID, 0), "response").Raw) + } + _, fullInput, errFinishArguments := finishClaudeApplyPatchArguments(patchCall, outputItem.args.String(), outputItem.inputSnapshot) + if errFinishArguments != nil { + st.SetToolInputError(errFinishArguments) + return []byte(gjson.GetBytes(translatorcommon.ApplyPatchFailure(responseID, 0), "response").Raw) + } + input = fullInput + } else { + input = unwrapCustomToolInput(outputItem.args.String()) + } + item, _ = sjson.SetBytes(item, "input", input) item, _ = sjson.SetBytes(item, "call_id", outputItem.callID) item = applyResponsesFunctionCallNamespaceFields(item, reqBytes, outputItem.name, "") break @@ -1326,3 +1595,23 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string return out } + +// FinalizeToolInput rejects a patch-enabled stream lacking its source terminator. +func (st *claudeToResponsesState) FinalizeToolInput() [][]byte { + if st.ToolInputError() != nil || st.CompletedEmitted { + return nil + } + enabled := false + for name := range st.ToolWinners { + if st.isApplyPatch(name) { + enabled = true + break + } + } + if !enabled { + return nil + } + st.SetToolInputError(fmt.Errorf("upstream apply_patch stream ended before protocol completion")) + st.Seq++ + return [][]byte{emitEvent("response.failed", translatorcommon.ApplyPatchFailure(st.ResponseID, st.Seq))} +} diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go index 219a09a6e..96a171c3d 100644 --- a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go +++ b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go @@ -1,6 +1,7 @@ package responses import ( + "bytes" "context" "fmt" "strings" @@ -1609,3 +1610,818 @@ func TestConvertClaudeResponseToOpenAIResponses_EmptyFunctionArgsConsistentOnTru t.Fatalf("response.output.0.arguments = %q, want empty string", got) } } + +func applyPatchClaudeStart(index int, id, name string) []byte { + return []byte(fmt.Sprintf(`data: {"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":%q,"name":%q,"input":{}}}`, index, id, name)) +} +func applyPatchClaudeFragment(index int, fragment string) []byte { + return []byte(fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":%q}}`, index, fragment)) +} +func applyPatchClaudeEnd() [][]byte { + return [][]byte{[]byte(`data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}`), []byte(`data: {"type":"message_stop"}`)} +} +func applyPatchClaudeEvents(t *testing.T, chunks [][]byte) []gjson.Result { + t.Helper() + var events []gjson.Result + for _, chunk := range chunks { + _, data := parseClaudeResponsesSSEEvent(t, chunk) + events = append(events, data) + } + return events +} + +const applyPatchClaudeRequest = `{"tools":[{"type":"custom","name":"apply_patch","format":{"type":"grammar","syntax":"lark","definition":"start: patch"}}]}` + +func TestApplyPatchClaudePreviewBeforeDone(t *testing.T) { + request := []byte(applyPatchClaudeRequest) + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, ¶m)) + } + feed(applyPatchClaudeStart(0, "c1", "apply_patch")) + preview := feed(applyPatchClaudeFragment(0, `{"input":"*** Begin Patch\n*** Add File: a.txt\n+hello\n`)) + if len(preview) != 1 || preview[0].Get("type").String() != "response.custom_tool_call_input.delta" || preview[0].Get("delta").String() != "*** Begin Patch\n*** Add File: a.txt\n+hello\n" { + t.Fatalf("missing real decoded preview: %v", preview) + } + if preview[0].Get("call_id").String() != "c1" || preview[0].Get("item_id").String() != "ctc_c1" { + t.Fatalf("preview identity: %s", preview[0].Raw) + } + var events []gjson.Result + events = append(events, preview...) + events = append(events, feed(applyPatchClaudeFragment(0, `*** End Patch"}`))...) + for _, chunk := range applyPatchClaudeEnd() { + events = append(events, feed(chunk)...) + } + want := "*** Begin Patch\n*** Add File: a.txt\n+hello\n*** End Patch" + var deltas strings.Builder + seenDone, seenItem, seenCompleted := false, false, false + lastSequence := 0 + for _, event := range events { + if sequence := int(event.Get("sequence_number").Int()); sequence <= lastSequence { + t.Fatalf("non-monotonic sequence: %s", event.Raw) + } else { + lastSequence = sequence + } + switch event.Get("type").String() { + case "response.custom_tool_call_input.delta": + deltas.WriteString(event.Get("delta").String()) + case "response.custom_tool_call_input.done": + seenDone = true + if deltas.String() != want || event.Get("input").String() != want || event.Get("call_id").String() != "c1" { + t.Fatalf("input.done mismatch: %s", event.Raw) + } + case "response.output_item.done": + seenItem = true + if !seenDone || event.Get("item.input").String() != want { + t.Fatalf("item.done mismatch: %s", event.Raw) + } + case "response.completed": + seenCompleted = true + if !seenItem || event.Get("response.output.0.input").String() != want { + t.Fatalf("completed mismatch: %s", event.Raw) + } + } + } + if !seenDone || !seenItem || !seenCompleted { + t.Fatalf("missing terminal events: %v", events) + } +} + +func TestApplyPatchClaudeLateIdentityAndInterleavedCalls(t *testing.T) { + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m)) + } + feed(applyPatchClaudeStart(0, "", "")) + for _, event := range feed(applyPatchClaudeFragment(0, `{"input":"first\n`)) { + if strings.Contains(event.Get("type").String(), "arguments.delta") || event.Get("type").String() == "response.custom_tool_call_input.delta" { + t.Fatalf("emitted before identity: %s", event.Raw) + } + } + var events []gjson.Result + events = append(events, feed(applyPatchClaudeStart(0, "c1", "apply_patch"))...) + events = append(events, feed(applyPatchClaudeStart(1, "c2", "apply_patch"))...) + events = append(events, feed(applyPatchClaudeFragment(1, `{"input":"second`))...) + events = append(events, feed(applyPatchClaudeFragment(0, `tail"}`))...) + events = append(events, feed(applyPatchClaudeFragment(1, ` tail"}`))...) + for _, chunk := range applyPatchClaudeEnd() { + events = append(events, feed(chunk)...) + } + inputs := map[string]string{} + indices := map[string]int64{} + done := map[string]string{} + for _, event := range events { + switch event.Get("type").String() { + case "response.custom_tool_call_input.delta": + id := event.Get("call_id").String() + inputs[id] += event.Get("delta").String() + indices[id] = event.Get("output_index").Int() + case "response.custom_tool_call_input.done": + done[event.Get("call_id").String()] = event.Get("input").String() + case "response.failed": + t.Fatalf("interleaved calls failed: %s", event.Raw) + } + } + if inputs["c1"] != "first\ntail" || inputs["c2"] != "second tail" || done["c1"] != inputs["c1"] || done["c2"] != inputs["c2"] || indices["c1"] == indices["c2"] { + t.Fatalf("mixed calls: inputs=%v done=%v indices=%v", inputs, done, indices) + } +} + +func TestApplyPatchClaudeInvalidArgumentsFailOnce(t *testing.T) { + for _, arguments := range []string{`plain patch`, `{}`, `{"input":42}`, `{"input":"x","extra":1}`, `{"input":"x","input":"y"}`, `{"input":"x"} {}`, `{"input":"unfinished`, `{"input":"bad\q"}`, `{"input":"\ud800"}`} { + t.Run(arguments, func(t *testing.T) { + var param any + var events []gjson.Result + feed := func(chunk []byte) { + events = append(events, applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m))...) + } + feed(applyPatchClaudeStart(0, "c1", "apply_patch")) + feed(applyPatchClaudeFragment(0, arguments)) + for _, chunk := range applyPatchClaudeEnd() { + feed(chunk) + } + for _, chunk := range applyPatchClaudeEnd() { + feed(chunk) + } + failureCount := 0 + for _, event := range events { + switch event.Get("type").String() { + case "response.failed": + failureCount++ + if event.Get("response.error.code").String() != "invalid_tool_arguments" { + t.Fatalf("wrong failure: %s", event.Raw) + } + case "response.completed", "response.incomplete", "response.custom_tool_call_input.done", "response.output_item.done": + t.Fatalf("invalid arguments succeeded: %s", event.Raw) + } + } + state, ok := param.(interface{ ToolInputError() error }) + if failureCount != 1 || !ok || state.ToolInputError() == nil { + t.Fatalf("missing retained failure: count=%d state=%T", failureCount, param) + } + }) + } +} + +func TestApplyPatchClaudeWinnerAndNamespace(t *testing.T) { + for _, tc := range []struct{ name, request, upstream, wantType, wantName, namespace string }{ + {"function", `{"tools":[{"type":"function","name":"apply_patch"}]}`, "apply_patch", "function_call", "apply_patch", ""}, + {"function wins", `{"tools":[{"type":"function","name":"apply_patch"}],"input":[{"type":"additional_tools","tools":[{"type":"custom","name":"apply_patch"}]}]}`, "apply_patch", "function_call", "apply_patch", ""}, + {"custom wins", `{"tools":[{"type":"custom","name":"apply_patch"}],"input":[{"type":"additional_tools","tools":[{"type":"function","name":"apply_patch"}]}]}`, "apply_patch", "custom_tool_call", "apply_patch", ""}, + {"namespace", `{"tools":[{"type":"namespace","name":"editor","tools":[{"type":"custom","name":"apply_patch"}]}]}`, "editor__apply_patch", "custom_tool_call", "apply_patch", "editor"}, + {"flat collision", `{"tools":[{"type":"function","name":"editor__apply_patch"},{"type":"namespace","name":"editor","tools":[{"type":"custom","name":"apply_patch"}]}]}`, "editor__apply_patch", "function_call", "editor__apply_patch", ""}, + {"same source custom first", `{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"apply_patch"}]}`, "apply_patch", "custom_tool_call", "apply_patch", ""}, + {"same source function first", `{"tools":[{"type":"function","name":"apply_patch"},{"type":"custom","name":"apply_patch"}]}`, "apply_patch", "function_call", "apply_patch", ""}, + {"namespace before flat", `{"tools":[{"type":"namespace","name":"editor","tools":[{"type":"custom","name":"apply_patch"}]},{"type":"function","name":"editor__apply_patch"}]}`, "editor__apply_patch", "function_call", "editor__apply_patch", ""}, + {"sanitized namespace", `{"tools":[{"type":"namespace","name":"mcp.editor","tools":[{"type":"custom","name":"apply_patch"}]}]}`, "mcp_editor__apply_patch", "custom_tool_call", "apply_patch", "mcp.editor"}, + {"other custom", `{"tools":[{"type":"custom","name":"edit"}]}`, "edit", "custom_tool_call", "edit", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + var param any + request := []byte(tc.request) + feed := func(chunk []byte) []gjson.Result { + return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, ¶m)) + } + feed(applyPatchClaudeStart(0, "c1", tc.upstream)) + args := `{"input":"x"}` + if tc.wantType == "function_call" || tc.name == "other custom" { + args = `{"not_input":42}` + } + events := feed(applyPatchClaudeFragment(0, args)) + for _, chunk := range applyPatchClaudeEnd() { + events = append(events, feed(chunk)...) + } + var item gjson.Result + for _, event := range events { + if event.Get("type").String() == "response.completed" { + item = event.Get("response.output.0") + } + } + if item.Get("type").String() != tc.wantType || item.Get("name").String() != tc.wantName || item.Get("namespace").String() != tc.namespace { + t.Fatalf("wrong winning identity: %s", item.Raw) + } + if tc.wantType == "function_call" && item.Get("arguments").String() != args { + t.Fatalf("ordinary function was unwrapped: %s", item.Raw) + } + if tc.name == "other custom" && item.Get("input").String() != args { + t.Fatalf("other custom changed: %s", item.Raw) + } + }) + } +} + +func TestApplyPatchClaudeNonStreamStrictInput(t *testing.T) { + for _, tc := range []struct { + arguments, want string + invalid bool + }{ + {`{"input":"*** Begin Patch\n*** End Patch"}`, "*** Begin Patch\n*** End Patch", false}, + {`{"input":12}`, "", true}, {`{"input":"truncated`, "", true}, {`{"input":"x","extra":true}`, "", true}, {`{"input":"\ud800"}`, "", true}, + } { + t.Run(tc.arguments, func(t *testing.T) { + var param any + raw := []byte(string(applyPatchClaudeStart(0, "c1", "apply_patch")) + "\n" + string(applyPatchClaudeFragment(0, tc.arguments)) + "\n" + `data: {"type":"message_stop"}`) + result := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, raw, ¶m)) + if tc.invalid { + state, ok := param.(interface{ ToolInputError() error }) + if result.Get("status").String() != "failed" || result.Get("error.code").String() != "invalid_tool_arguments" || !ok || state.ToolInputError() == nil { + t.Fatalf("invalid non-stream input succeeded: %s state=%T", result.Raw, param) + } + } else if result.Get("output.0.input").String() != tc.want { + t.Fatalf("non-stream input mismatch: %s", result.Raw) + } + }) + } +} + +func TestApplyPatchClaudeTerminalValidatesTruncatedCall(t *testing.T) { + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m)) + } + feed(applyPatchClaudeStart(0, "c1", "apply_patch")) + feed(applyPatchClaudeFragment(0, `{"input":"unfinished`)) + chunks := applyPatchClaudeEnd() + events := feed(chunks[len(chunks)-1]) + if len(events) != 1 || events[0].Get("type").String() != "response.failed" { + t.Fatalf("truncated terminal did not fail: %v", events) + } +} + +func TestApplyPatchClaudeUnicodeFragments(t *testing.T) { + arguments := `{"input":"line\n\u4f60\u597d \ud83d\ude00 \" \\ \u96ea"}` + for split := 1; split < len(arguments); split++ { + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m)) + } + feed(applyPatchClaudeStart(0, "c1", "apply_patch")) + events := feed(applyPatchClaudeFragment(0, arguments[:split])) + events = append(events, feed(applyPatchClaudeFragment(0, arguments[split:]))...) + for _, chunk := range applyPatchClaudeEnd() { + events = append(events, feed(chunk)...) + } + var input strings.Builder + for _, event := range events { + if event.Get("type").String() == "response.custom_tool_call_input.delta" { + input.WriteString(event.Get("delta").String()) + } + } + if input.String() != "line\n你好 😀 \" \\ 雪" { + t.Fatalf("split %d: input = %q", split, input.String()) + } + } +} + +func TestApplyPatchClaudeIdentityFieldsArriveSeparately(t *testing.T) { + for _, tc := range []struct{ id, name string }{{"c1", ""}, {"", "apply_patch"}} { + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m)) + } + feed(applyPatchClaudeStart(0, tc.id, tc.name)) + events := feed(applyPatchClaudeFragment(0, `{"input":"preview`)) + if len(events) != 0 { + t.Fatalf("preview without complete identity: %v", events) + } + events = feed(applyPatchClaudeStart(0, "c1", "apply_patch")) + found := false + for _, event := range events { + if event.Get("type").String() == "response.custom_tool_call_input.delta" { + found = event.Get("delta").String() == "preview" && event.Get("call_id").String() == "c1" + } + } + if !found { + t.Fatalf("buffer not released after identity: %v", events) + } + } +} + +func TestApplyPatchClaudeConflictingIdentityFails(t *testing.T) { + for _, tc := range []struct{ id, name string }{{"other", "apply_patch"}, {"c1", "other"}} { + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m)) + } + feed(applyPatchClaudeStart(0, "c1", "apply_patch")) + feed(applyPatchClaudeFragment(0, `{"input":"preview`)) + events := feed(applyPatchClaudeStart(0, tc.id, tc.name)) + for _, chunk := range applyPatchClaudeEnd() { + events = append(events, feed(chunk)...) + } + if len(events) != 1 || events[0].Get("type").String() != "response.failed" { + t.Fatalf("conflicting identity did not fail once: %v", events) + } + } +} + +func TestApplyPatchClaudeNonStreamOriginalDeclarationWins(t *testing.T) { + original := []byte(`{"tools":[{"type":"function","name":"apply_patch"}]}`) + var param any + raw := []byte(string(applyPatchClaudeStart(0, "c1", "apply_patch")) + "\n" + string(applyPatchClaudeFragment(0, `{"not_input":42}`)) + "\n" + `data: {"type":"message_stop"}`) + result := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", original, []byte(applyPatchClaudeRequest), raw, ¶m)) + if result.Get("output.0.type").String() != "function_call" || result.Get("output.0.arguments").String() != `{"not_input":42}` { + t.Fatalf("converted declaration overrode original: %s", result.Raw) + } +} + +func TestApplyPatchClaudeSnapshotsValidateAndDoNotPreview(t *testing.T) { + for _, tc := range []struct { + name, fragment, snapshot, want string + invalid bool + }{ + {"snapshot only", "", `{"input":"whole"}`, "whole", false}, + {"matching suffix", `{"input":"pre`, `{"input":"prefix"}`, "prefix", false}, + {"conflict", `{"input":"wrong`, `{"input":"prefix"}`, "", true}, + {"completed conflict", `{"input":"pre"}`, `{"input":"prefix"}`, "", true}, + {"invalid snapshot", `{"input":"pre`, `{"input":7}`, "", true}, + } { + t.Run(tc.name, func(t *testing.T) { + var param any + request := []byte(applyPatchClaudeRequest) + feed := func(chunk []byte) []gjson.Result { + return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, ¶m)) + } + start := applyPatchClaudeStart(0, "c1", "apply_patch") + events := feed(start) + var raw []byte + raw = append(raw, start...) + raw = append(raw, '\n') + if tc.fragment != "" { + chunk := applyPatchClaudeFragment(0, tc.fragment) + events = append(events, feed(chunk)...) + raw = append(raw, chunk...) + raw = append(raw, '\n') + } + snapshot := []byte(fmt.Sprintf(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"c1","name":"apply_patch","input":%s}}`, tc.snapshot)) + snapshotEvents := feed(snapshot) + for _, event := range snapshotEvents { + if event.Get("type").String() == "response.custom_tool_call_input.delta" { + t.Fatalf("snapshot fabricated a preview: %s", event.Raw) + } + } + events = append(events, snapshotEvents...) + raw = append(raw, snapshot...) + raw = append(raw, '\n') + for _, chunk := range applyPatchClaudeEnd() { + events = append(events, feed(chunk)...) + raw = append(raw, chunk...) + raw = append(raw, '\n') + } + var terminal gjson.Result + var input strings.Builder + for _, event := range events { + if event.Get("type").String() == "response.custom_tool_call_input.delta" { + input.WriteString(event.Get("delta").String()) + } + if event.Get("type").String() == "response.failed" || event.Get("type").String() == "response.completed" { + terminal = event + } + } + var nonStreamParam any + nonStream := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", request, nil, raw, &nonStreamParam)) + if tc.invalid { + if terminal.Get("type").String() != "response.failed" || nonStream.Get("status").String() != "failed" { + t.Fatalf("conflicting/invalid snapshot succeeded: stream=%s nonstream=%s", terminal.Raw, nonStream.Raw) + } + } else if input.String() != tc.want || terminal.Get("response.output.0.input").String() != tc.want || nonStream.Get("output.0.input").String() != tc.want { + t.Fatalf("snapshot mismatch: deltas=%q stream=%s nonstream=%s", input.String(), terminal.Raw, nonStream.Raw) + } + }) + } +} + +func TestApplyPatchClaudeNonStreamConflictingIdentityFails(t *testing.T) { + var param any + raw := []byte(string(applyPatchClaudeStart(0, "c1", "apply_patch")) + "\n" + string(applyPatchClaudeFragment(0, `{"input":"x"}`)) + "\n" + string(applyPatchClaudeStart(0, "c2", "apply_patch")) + "\n" + `data: {"type":"message_stop"}`) + result := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, raw, ¶m)) + state, ok := param.(interface{ ToolInputError() error }) + if result.Get("status").String() != "failed" || !ok || state.ToolInputError() == nil { + t.Fatalf("conflicting non-stream identity succeeded: %s", result.Raw) + } +} + +func TestApplyPatchClaudeConflictingSnapshotsFail(t *testing.T) { + var param any + request := []byte(applyPatchClaudeRequest) + var raw []byte + var events []gjson.Result + for _, input := range []string{`{"input":"first"}`, `{"input":"replacement"}`} { + chunk := []byte(fmt.Sprintf(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"c1","name":"apply_patch","input":%s}}`, input)) + raw = append(raw, chunk...) + raw = append(raw, '\n') + events = append(events, applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, ¶m))...) + } + for _, chunk := range applyPatchClaudeEnd() { + raw = append(raw, chunk...) + raw = append(raw, '\n') + events = append(events, applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, ¶m))...) + } + failures := 0 + for _, event := range events { + if event.Get("type").String() == "response.failed" { + failures++ + } + if event.Get("type").String() == "response.completed" || event.Get("type").String() == "response.custom_tool_call_input.done" { + t.Fatalf("silently replaced snapshot: %s", event.Raw) + } + } + var nonStreamParam any + result := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", request, nil, raw, &nonStreamParam)) + if failures != 1 || result.Get("status").String() != "failed" { + t.Fatalf("snapshot replacement did not fail: stream failures=%d nonstream=%s", failures, result.Raw) + } +} + +func TestApplyPatchClaudePendingIdentityConflictFails(t *testing.T) { + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m)) + } + feed(applyPatchClaudeStart(0, "c1", "")) + feed(applyPatchClaudeFragment(0, `{"input":"x"}`)) + events := feed(applyPatchClaudeStart(0, "c2", "apply_patch")) + if len(events) != 1 || events[0].Get("type").String() != "response.failed" { + t.Fatalf("pending ID silently replaced: %v", events) + } +} + +func TestApplyPatchClaudeMissingIDSynthesizedAtTerminal(t *testing.T) { + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m)) + } + feed(applyPatchClaudeStart(0, "", "apply_patch")) + feed(applyPatchClaudeFragment(0, `{"input":"x"}`)) + var events []gjson.Result + for _, chunk := range applyPatchClaudeEnd() { + events = append(events, feed(chunk)...) + } + var completed gjson.Result + for _, event := range events { + if event.Get("type").String() == "response.completed" { + completed = event + } + } + item := completed.Get("response.output.0") + if item.Get("type").String() != "custom_tool_call" || item.Get("input").String() != "x" || item.Get("call_id").String() == "" { + t.Fatalf("missing ID lost patch: %s", completed.Raw) + } +} + +func TestApplyPatchClaudeNonStreamLateIdentity(t *testing.T) { + var param any + raw := []byte(string(applyPatchClaudeStart(0, "", "apply_patch")) + "\n" + string(applyPatchClaudeFragment(0, `{"input":"x"}`)) + "\n" + string(applyPatchClaudeStart(0, "c1", "")) + "\n" + `data: {"type":"message_stop"}`) + result := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, raw, ¶m)) + item := result.Get("output.0") + if item.Get("type").String() != "custom_tool_call" || item.Get("input").String() != "x" || item.Get("id").String() != "ctc_c1" { + t.Fatalf("late ID/name lost: %s", result.Raw) + } +} + +func assertApplyPatchClaudeDeferredFailure(t *testing.T, events []gjson.Result, param any) { + t.Helper() + failures := 0 + for _, event := range events { + switch event.Get("type").String() { + case "response.failed": + failures++ + if event.Get("response.error.code").String() != "invalid_tool_arguments" { + t.Fatalf("wrong failure: %s", event.Raw) + } + case "response.completed", "response.incomplete", "response.custom_tool_call_input.done", "response.output_item.done": + t.Fatalf("invalid pending evidence succeeded: %s", event.Raw) + } + } + state := param.(interface{ ToolInputError() error }) + if failures != 1 || state.ToolInputError() == nil { + t.Fatalf("missing retained failure: count=%d error=%v", failures, state.ToolInputError()) + } +} + +func TestApplyPatchClaudeDeferredIdentityConflict(t *testing.T) { + for _, tc := range []struct { + name, request, resolvedID, resolvedName, wantType string + invalid bool + }{ + {"keep second ID", applyPatchClaudeRequest, "c2", "apply_patch", "", true}, + {"return to first ID", applyPatchClaudeRequest, "c1", "apply_patch", "", true}, + {"omit final ID", applyPatchClaudeRequest, "", "apply_patch", "", true}, + {"ordinary function", `{"tools":[{"type":"function","name":"apply_patch"}]}`, "c2", "apply_patch", "function_call", false}, + {"function winner", `{"tools":[{"type":"function","name":"apply_patch"},{"type":"custom","name":"apply_patch"}]}`, "c2", "apply_patch", "function_call", false}, + {"other custom", `{"tools":[{"type":"custom","name":"edit"}]}`, "c2", "edit", "custom_tool_call", false}, + } { + chunks := [][]byte{applyPatchClaudeStart(0, "c1", ""), applyPatchClaudeStart(0, "c2", ""), applyPatchClaudeStart(0, tc.resolvedID, tc.resolvedName), applyPatchClaudeFragment(0, `{"input":"x"}`)} + chunks = append(chunks, applyPatchClaudeEnd()...) + for _, stream := range []bool{true, false} { + mode := "nonstream" + if stream { + mode = "stream" + } + t.Run(tc.name+"/"+mode, func(t *testing.T) { + var param any + var result gjson.Result + if stream { + var events []gjson.Result + for i, chunk := range chunks { + current := applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(tc.request), nil, chunk, ¶m)) + if i < 2 && len(current) != 0 { + t.Fatalf("unclassified identity emitted events: %v", current) + } + events = append(events, current...) + } + if tc.invalid { + assertApplyPatchClaudeDeferredFailure(t, events, param) + return + } + for _, event := range events { + if event.Get("type").String() == "response.completed" { + result = event.Get("response") + } + } + } else { + result = gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(tc.request), nil, bytes.Join(chunks, []byte("\n")), ¶m)) + if tc.invalid { + if result.Get("status").String() != "failed" || result.Get("error.code").String() != "invalid_tool_arguments" || param.(interface{ ToolInputError() error }).ToolInputError() == nil { + t.Fatalf("lost pending conflict: %s", result.Raw) + } + return + } + } + item := result.Get("output.0") + if result.Get("status").String() != "completed" || item.Get("type").String() != tc.wantType || item.Get("call_id").String() != "c2" || param.(interface{ ToolInputError() error }).ToolInputError() != nil { + t.Fatalf("unrelated tool changed: %s", result.Raw) + } + if tc.wantType == "function_call" && item.Get("arguments").String() != `{"input":"x"}` { + t.Fatalf("ordinary arguments changed: %s", item.Raw) + } + if tc.wantType == "custom_tool_call" && item.Get("input").String() != "x" { + t.Fatalf("other custom input changed: %s", item.Raw) + } + }) + } + } +} + +func TestApplyPatchClaudeDeferredSnapshots(t *testing.T) { + for _, tc := range []struct { + name, first, second, fragment, toolName, request, want string + invalid bool + }{ + {name: "different complete snapshots", first: `{"input":"first"}`, second: `{"input":"replacement"}`, invalid: true}, + {name: "complete snapshot cannot extend", first: `{"input":"pre"}`, second: `{"input":"prefix"}`, invalid: true}, + {name: "early wrong type", first: `{"input":42}`, second: `{"input":"replacement"}`, invalid: true}, + {name: "early extra field", first: `{"input":"first","extra":1}`, second: `{"input":"replacement"}`, invalid: true}, + {name: "early duplicate key", first: `{"input":"first","input":"replacement"}`, second: `{"input":"replacement"}`, invalid: true}, + {name: "early invalid surrogate", first: `{"input":"\ud800"}`, second: `{"input":"replacement"}`, invalid: true}, + {name: "early invalid then placeholder", first: `{"input":null}`, second: `{}`, fragment: `{"input":"replacement"}`, invalid: true}, + {name: "early invalid then equal decoded input", first: `{"input":"first","extra":1}`, second: `{"input":"first"}`, invalid: true}, + {name: "equivalent complete snapshots", first: `{"input":"\u0078\n"}`, second: ` { "input" : "x\n" } `, want: "x\n"}, + {name: "single snapshot and placeholders", first: `{"input":"whole"}`, second: `{}`, want: "whole"}, + {name: "partial source extension", first: `{}`, second: `{"input":"prefix"}`, fragment: `{"input":"pre`, want: "prefix"}, + {name: "complete source conflict", first: `{}`, second: `{"input":"prefix"}`, fragment: `{"input":"pre"}`, invalid: true}, + {name: "ordinary placeholders", first: `{}`, second: `{}`, fragment: `{"input":"x"}`, want: "x"}, + {name: "ordinary function ignores patch evidence", first: `{"input":42}`, second: `{"input":"replacement"}`, fragment: `{"other":42}`, request: `{"tools":[{"type":"function","name":"apply_patch"}]}`, want: `{"other":42}`}, + {name: "other custom ignores patch evidence", first: `{"input":42}`, second: `{"input":"replacement"}`, fragment: `{"other":42}`, toolName: "edit", request: `{"tools":[{"type":"custom","name":"edit"}]}`, want: `{"other":42}`}, + } { + request, toolName := tc.request, tc.toolName + if request == "" { + request = applyPatchClaudeRequest + } + if toolName == "" { + toolName = "apply_patch" + } + snapshot := func(input string) []byte { + return []byte(fmt.Sprintf(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"c1","name":"","input":%s}}`, input)) + } + chunks := [][]byte{snapshot(tc.first)} + if tc.fragment != "" { + chunks = append(chunks, applyPatchClaudeFragment(0, tc.fragment)) + } + chunks = append(chunks, snapshot(tc.second), applyPatchClaudeStart(0, "c1", toolName)) + chunks = append(chunks, applyPatchClaudeEnd()...) + for _, stream := range []bool{true, false} { + mode := "nonstream" + if stream { + mode = "stream" + } + t.Run(tc.name+"/"+mode, func(t *testing.T) { + var param any + var result gjson.Result + if stream { + var events []gjson.Result + var deltas strings.Builder + for i, chunk := range chunks { + current := applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(request), nil, chunk, ¶m)) + if i < len(chunks)-3 && len(current) != 0 { + t.Fatalf("unclassified snapshots fabricated output: %v", current) + } + events = append(events, current...) + } + if tc.invalid { + assertApplyPatchClaudeDeferredFailure(t, events, param) + return + } + for _, event := range events { + if event.Get("type").String() == "response.custom_tool_call_input.delta" { + deltas.WriteString(event.Get("delta").String()) + } + if event.Get("type").String() == "response.completed" { + result = event.Get("response") + } + } + if tc.request == "" && deltas.String() != tc.want { + t.Fatalf("snapshot/partial input mismatch: delta=%q want=%q", deltas.String(), tc.want) + } + } else { + result = gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(request), nil, bytes.Join(chunks, []byte("\n")), ¶m)) + if tc.invalid { + if result.Get("status").String() != "failed" || result.Get("error.code").String() != "invalid_tool_arguments" || param.(interface{ ToolInputError() error }).ToolInputError() == nil { + t.Fatalf("lost pending snapshot evidence: %s", result.Raw) + } + return + } + } + field := "output.0.input" + if tc.request != "" && toolName == "apply_patch" { + field = "output.0.arguments" + } + if result.Get("status").String() != "completed" || result.Get(field).String() != tc.want || param.(interface{ ToolInputError() error }).ToolInputError() != nil { + t.Fatalf("pending snapshots changed valid/unrelated input: %s", result.Raw) + } + }) + } + } +} + +func TestApplyPatchClaudeSnapshotAfterItemCompletion(t *testing.T) { + for _, tc := range []struct { + name, snapshot string + invalid bool + }{ + {"conflicting complete snapshot", `{"input":"y"}`, true}, + {"complete snapshot cannot extend finished input", `{"input":"xy"}`, true}, + {"equivalent reencoded snapshot", ` { "input" : "\u0078" } `, false}, + {"placeholder after item completion", `{}`, false}, + } { + // Reproduce the six-step sequence with a second, still-open patch call. + chunks := [][]byte{ + applyPatchClaudeStart(0, "c1", "apply_patch"), + applyPatchClaudeFragment(0, `{"input":"x"}`), + []byte(`data: {"type":"content_block_stop","index":0}`), + applyPatchClaudeStart(1, "c2", "apply_patch"), + applyPatchClaudeFragment(1, `{"input":"ok"}`), + []byte(fmt.Sprintf(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"c1","name":"apply_patch","input":%s}}`, tc.snapshot)), + } + chunks = append(chunks, applyPatchClaudeEnd()...) + for _, stream := range []bool{true, false} { + mode := "nonstream" + if stream { + mode = "stream" + } + t.Run(tc.name+"/"+mode, func(t *testing.T) { + request := []byte(applyPatchClaudeRequest) + var param any + var result gjson.Result + if stream { + feed := func(chunk []byte) []gjson.Result { + return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, ¶m)) + } + var events []gjson.Result + for _, chunk := range chunks[:5] { + events = append(events, feed(chunk)...) + } + inputDone, itemDone := 0, 0 + var preview strings.Builder + for _, event := range events { + switch event.Get("type").String() { + case "response.custom_tool_call_input.delta": + if event.Get("call_id").String() == "c1" { + preview.WriteString(event.Get("delta").String()) + } + case "response.custom_tool_call_input.done": + inputDone++ + if event.Get("call_id").String() != "c1" || event.Get("input").String() != "x" { + t.Fatalf("wrong early input completion: %s", event.Raw) + } + case "response.output_item.done": + itemDone++ + if event.Get("item.call_id").String() != "c1" || event.Get("item.input").String() != "x" { + t.Fatalf("wrong early item completion: %s", event.Raw) + } + case "response.completed", "response.incomplete", "response.failed": + t.Fatalf("response ended before the late snapshot: %s", event.Raw) + } + } + st := param.(*claudeToResponsesState) + if inputDone != 1 || itemDone != 1 || preview.String() != "x" || !st.FuncItemDone[0] || st.FuncInputSnapshot[0] != "" { + t.Fatalf("first call was not finalized from real arguments without a snapshot: inputDone=%d itemDone=%d preview=%q", inputDone, itemDone, preview.String()) + } + snapshotEvents := feed(chunks[5]) + if !tc.invalid && len(snapshotEvents) != 0 { + t.Fatalf("late snapshot fabricated events: %v", snapshotEvents) + } + after := append([]gjson.Result(nil), snapshotEvents...) + for _, chunk := range chunks[6:] { + after = append(after, feed(chunk)...) + } + if tc.invalid { + assertApplyPatchClaudeDeferredFailure(t, after, param) + if len(snapshotEvents) != 1 || snapshotEvents[0].Get("type").String() != "response.failed" { + t.Fatalf("late conflicting snapshot was not rejected immediately: %v", snapshotEvents) + } + return + } + completions := 0 + for _, event := range after { + switch event.Get("type").String() { + case "response.custom_tool_call_input.delta", "response.custom_tool_call_input.done": + if event.Get("call_id").String() != "c2" { + t.Fatalf("finalized first call emitted duplicate input events: %s", event.Raw) + } + case "response.output_item.done": + if event.Get("item.call_id").String() != "c2" { + t.Fatalf("finalized first call emitted duplicate item completion: %s", event.Raw) + } + case "response.completed": + completions++ + result = event.Get("response") + } + } + if completions != 1 { + t.Fatalf("expected one successful response completion, got %d", completions) + } + } else { + result = gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", request, nil, bytes.Join(chunks, []byte("\n")), ¶m)) + if tc.invalid { + if result.Get("status").String() != "failed" || result.Get("error.code").String() != "invalid_tool_arguments" || param.(interface{ ToolInputError() error }).ToolInputError() == nil { + t.Fatalf("late conflicting snapshot succeeded: %s", result.Raw) + } + return + } + } + if result.Get("status").String() != "completed" || result.Get("output.0.call_id").String() != "c1" || result.Get("output.0.input").String() != "x" || result.Get("output.1.call_id").String() != "c2" || result.Get("output.1.input").String() != "ok" || param.(interface{ ToolInputError() error }).ToolInputError() != nil { + t.Fatalf("late matching snapshot changed successful input: %s", result.Raw) + } + }) + } + } +} + +func TestApplyPatchClaudeSuccessfulTerminalSealsState(t *testing.T) { + for _, tc := range []struct { + name string + late []byte + }{ + {"duplicate message_stop", []byte(`data: {"type":"message_stop"}`)}, + {"post-terminal fragment", applyPatchClaudeFragment(0, `{"input":"late"}`)}, + {"post-terminal conflicting snapshot", []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"c1","name":"apply_patch","input":{"input":"y"}}}`)}, + } { + chunks := [][]byte{applyPatchClaudeStart(0, "c1", "apply_patch"), applyPatchClaudeFragment(0, `{"input":"x"}`)} + chunks = append(chunks, applyPatchClaudeEnd()...) + for _, stream := range []bool{true, false} { + mode := "nonstream" + if stream { + mode = "stream" + } + t.Run(tc.name+"/"+mode, func(t *testing.T) { + var param any + if stream { + feed := func(chunk []byte) []gjson.Result { + return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m)) + } + completions := 0 + for _, chunk := range chunks { + for _, event := range feed(chunk) { + if event.Get("type").String() == "response.completed" { + completions++ + if event.Get("response.output.0.input").String() != "x" { + t.Fatalf("incorrect completion: %s", event.Raw) + } + } + } + } + if completions != 1 { + t.Fatalf("expected one successful terminal: count=%d", completions) + } + for _, chunk := range [][]byte{tc.late, []byte(`data: {"type":"message_stop"}`)} { + if late := feed(chunk); len(late) != 0 { + t.Fatalf("sealed response emitted more events: %v", late) + } + } + } else { + raw := bytes.Join(chunks, []byte("\n")) + raw = append(raw, '\n') + raw = append(raw, tc.late...) + result := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, raw, ¶m)) + if result.Get("status").String() != "completed" || result.Get("output.0.input").String() != "x" { + t.Fatalf("successful terminal became a failure: %s", result.Raw) + } + } + if errToolInputError := param.(interface{ ToolInputError() error }).ToolInputError(); errToolInputError != nil { + t.Fatalf("successful terminal retained a failure: %v", errToolInputError) + } + }) + } + } +} diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_request.go b/internal/translator/codex/openai/chat-completions/codex_openai_request.go index 9b5e9a4ef..3f855f994 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_request.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_request.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch" translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -111,7 +112,14 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b if _, custom := customToolNames[name]; custom { callType = "custom" } - return callType, name, toolCall.Get("function.arguments").String(), true + input = toolCall.Get("function.arguments").String() + if callType == "custom" && strings.TrimSpace(name) == "apply_patch" { + // Only normalized function history carries the JSON envelope. Explicit custom input is raw. + if unwrapped, errUnwrap := applypatch.UnwrapInput(input); errUnwrap == nil { + input = unwrapped + } + } + return callType, name, input, true default: return "", "", "", false } diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_request_test.go b/internal/translator/codex/openai/chat-completions/codex_openai_request_test.go index c26c816bc..f1bab25ff 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_request_test.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_request_test.go @@ -1141,7 +1141,7 @@ func TestCustomToolCallResponseFollowUpRoundTrip(t *testing.T) { if got := assistantMessage.Get("tool_calls.0.type").String(); got != "function" { t.Fatalf("expected response to normalize custom call as function, got %s", assistantMessage.Raw) } - if got := assistantMessage.Get("tool_calls.0.function.arguments").String(); got != "patch" { + if got := assistantMessage.Get("tool_calls.0.function.arguments").String(); got != `{"input":"patch"}` { t.Fatalf("expected normalized custom input, got %s", assistantMessage.Raw) } @@ -1164,6 +1164,9 @@ func TestCustomToolCallResponseFollowUpRoundTrip(t *testing.T) { if got := items[2].Get("type").String(); got != "custom_tool_call_output" { t.Fatalf("expected custom_tool_call_output after response round trip, got %s", items[2].Raw) } + if got := items[1].Get("input").String(); got != "patch" { + t.Fatalf("raw follow-up input = %q", got) + } } func TestMixedToolCallHistoryPreservesCallFamilies(t *testing.T) { @@ -1716,3 +1719,27 @@ func TestConvertOpenAIRequestToCodexServiceTier(t *testing.T) { }) } } + +func TestApplyPatchChatHistoryBoundary(t *testing.T) { + for _, tc := range []struct{ name, tools, call, wantType, want string }{ + {"normalized", `[{"type":"custom","name":"apply_patch"}]`, `{"type":"function","function":{"name":"apply_patch","arguments":"{\"input\":\"p\"}"}}`, "custom_tool_call", "p"}, + {"legacy", `[{"type":"custom","name":"apply_patch"}]`, `{"type":"function","function":{"name":"apply_patch","arguments":"raw patch"}}`, "custom_tool_call", "raw patch"}, + {"explicit", `[{"type":"custom","name":"apply_patch"}]`, `{"type":"custom","custom":{"name":"apply_patch","input":"{\"input\":\"p\"}"}}`, "custom_tool_call", `{"input":"p"}`}, + {"invalid-wrapper", `[{"type":"custom","name":"apply_patch"}]`, `{"type":"function","function":{"name":"apply_patch","arguments":"{\"input\":\"p\",\"extra\":1}"}}`, "custom_tool_call", `{"input":"p","extra":1}`}, + {"function-preference", `[{"type":"custom","name":"apply_patch"},{"type":"function","function":{"name":"apply_patch","parameters":{}}}]`, `{"type":"function","function":{"name":"apply_patch","arguments":"{\"input\":\"p\"}"}}`, "function_call", `{"input":"p"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + raw := []byte(`{"tools":` + tc.tools + `,"messages":[{"role":"assistant","tool_calls":[` + tc.call + `]}]}`) + out := ConvertOpenAIRequestToCodex("m", raw, true) + items := gjson.GetBytes(out, "input").Array() + item := items[len(items)-1] + field := "arguments" + if tc.wantType == "custom_tool_call" { + field = "input" + } + if item.Get("type").String() != tc.wantType || item.Get(field).String() != tc.want { + t.Fatalf("history boundary: %s", out) + } + }) + } +} diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response.go b/internal/translator/codex/openai/chat-completions/codex_openai_response.go index c77633023..435abad56 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_response.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_response.go @@ -12,7 +12,9 @@ import ( "strings" "time" + applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch" translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" + "github.com/router-for-me/CLIProxyAPI/v8/internal/util" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -25,6 +27,9 @@ var ( type toolCallStreamState struct { Index int ArgumentsEmitted bool + Patch bool + InputStarted bool + InputClosed bool Done bool } @@ -205,7 +210,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR // Increment index for this new tool call item. p := (*param).(*ConvertCliToOpenAIParams) p.FunctionCallIndex++ - state := &toolCallStreamState{Index: p.FunctionCallIndex} + state := &toolCallStreamState{Index: p.FunctionCallIndex, Patch: isOriginalCustomPatch(originalRequestRawJSON, itemResult)} registerToolCallState(p, rootResult, itemResult, state) functionCallItemTemplate := []byte(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`) @@ -233,6 +238,13 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR return [][]byte{} } state.ArgumentsEmitted = true + if state.Patch { + deltaValue = applypatch.EscapeInputFragment(deltaValue) + if !state.InputStarted { + deltaValue = `{"input":"` + deltaValue + state.InputStarted = true + } + } functionCallItemTemplate := []byte(`{"index":0,"function":{"arguments":""}}`) functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", state.Index) @@ -244,8 +256,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR } else if dataType == "response.function_call_arguments.done" || dataType == "response.custom_tool_call_input.done" { p := (*param).(*ConvertCliToOpenAIParams) state := findToolCallState(p, rootResult, gjson.Result{}) - if state == nil || state.Done || state.ArgumentsEmitted { - // Arguments were already streamed via delta events; nothing to emit. + if state == nil || state.Done || state.InputClosed || (state.ArgumentsEmitted && !state.Patch) { return [][]byte{} } @@ -256,6 +267,9 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR } state.ArgumentsEmitted = true fullArgs := rootResult.Get(fullArgsField).String() + if state.Patch { + fullArgs = finishPatchChatArguments(state, fullArgs) + } if fullArgs == "" { return [][]byte{} } @@ -318,7 +332,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR return [][]byte{} } state.Done = true - if state.ArgumentsEmitted { + if state.ArgumentsEmitted && (!state.Patch || state.InputClosed) { return [][]byte{} } @@ -326,6 +340,9 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR // completed arguments so the id and name are not duplicated. state.ArgumentsEmitted = true fullArgs := codexToolCallArguments(itemResult) + if state.Patch { + fullArgs = finishPatchChatArguments(state, fullArgs) + } if fullArgs == "" { return [][]byte{} } @@ -339,7 +356,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR // Fallback path: model skipped output_item.added, so emit the complete tool call now. p.FunctionCallIndex++ - state = &toolCallStreamState{Index: p.FunctionCallIndex, ArgumentsEmitted: true, Done: true} + state = &toolCallStreamState{Index: p.FunctionCallIndex, ArgumentsEmitted: true, Done: true, Patch: isOriginalCustomPatch(originalRequestRawJSON, itemResult)} registerToolCallState(p, rootResult, itemResult, state) functionCallItemTemplate := []byte(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`) @@ -356,7 +373,11 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR } functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.name", name) - functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", codexToolCallArguments(itemResult)) + fullArgs := codexToolCallArguments(itemResult) + if state.Patch { + fullArgs = finishPatchChatArguments(state, fullArgs) + } + functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", fullArgs) template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant") template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate) @@ -504,7 +525,11 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", n) } - functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.arguments", codexToolCallArguments(outputItem)) + fullArgs := codexToolCallArguments(outputItem) + if isOriginalCustomPatch(originalRequestRawJSON, outputItem) { + fullArgs = applypatch.WrapInput(fullArgs) + } + functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.arguments", fullArgs) toolCalls = append(toolCalls, functionCallTemplate) case "image_generation_call": @@ -687,3 +712,30 @@ func setCodexCacheWriteTokens(template []byte, usage gjson.Result) []byte { template, _ = sjson.SetRawBytes(template, "usage.prompt_tokens_details.cached_creation_tokens", []byte(value.Raw)) return template } + +// isOriginalCustomPatch never promotes an ordinary same-name function to custom. +func isOriginalCustomPatch(original []byte, item gjson.Result) bool { + if item.Get("type").String() != "custom_tool_call" { + return false + } + name := util.QualifyResponsesNamespaceToolName(item.Get("namespace").String(), item.Get("name").String()) + // Chat Completions prefers ordinary functions for ambiguous names, regardless of order. + for _, tool := range gjson.GetBytes(original, "tools").Array() { + if tool.Get("type").String() == "function" && tool.Get("function.name").String() == name { + return false + } + } + winner, ok := util.CollectResponsesToolWinners(gjson.ParseBytes(original))[name] + return ok && applypatch.IsCustomTool(winner.Tool) +} + +func finishPatchChatArguments(state *toolCallStreamState, input string) string { + if state.InputClosed { + return "" + } + state.InputClosed = true + if state.InputStarted { + return `"}` + } + return applypatch.WrapInput(input) +} diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go b/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go index 26b00c92b..fcc96d9bc 100644 --- a/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go +++ b/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go @@ -3,6 +3,7 @@ package chat_completions import ( "context" "encoding/json" + "strings" "testing" "github.com/tidwall/gjson" @@ -883,3 +884,74 @@ func TestConvertCodexResponseToOpenAI_RestoresNormalizedToolNames(t *testing.T) t.Fatalf("stream expected restored name %q, got %q", originalName, gotNameStream) } } + +// Only the original winning custom patch declaration allows the JSON wrapper. +func TestApplyPatchCustomChatCompletionsWrapper(t *testing.T) { + for _, toolType := range []string{"custom", "function"} { + t.Run(toolType, func(t *testing.T) { + request := []byte(`{"tools":[{"type":"` + toolType + `","name":"apply_patch"}]}`) + var param any + var arguments strings.Builder + send := func(event string) { + for _, out := range ConvertCodexResponseToOpenAI(t.Context(), "model", request, request, []byte("data: "+event), ¶m) { + arguments.WriteString(gjson.GetBytes(out, "choices.0.delta.tool_calls.0.function.arguments").String()) + } + } + send(`{"type":"response.output_item.added","output_index":0,"item":{"type":"custom_tool_call","id":"a","call_id":"c","name":"apply_patch","input":""}}`) + for _, delta := range []string{"*** Begin Patch\n", "+中文😀 \"\n", "*** End Patch\n"} { + send(`{"type":"response.custom_tool_call_input.delta","item_id":"a","output_index":0,"delta":` + string(mustJSONMarshal(t, delta)) + `}`) + } + patch := "*** Begin Patch\n+中文😀 \"\n*** End Patch\n" + send(`{"type":"response.custom_tool_call_input.done","item_id":"a","output_index":0,"input":` + string(mustJSONMarshal(t, patch)) + `}`) + send(`{"type":"response.output_item.done","output_index":0,"item":{"type":"custom_tool_call","id":"a","call_id":"c","name":"apply_patch","input":` + string(mustJSONMarshal(t, patch)) + `}}`) + want := patch + if toolType == "custom" { + want = `{"input":` + string(mustJSONMarshal(t, patch)) + `}` + } + if arguments.String() != want { + t.Fatalf("arguments=%q want=%q", arguments.String(), want) + } + var nonStream any + out := ConvertCodexResponseToOpenAINonStream(t.Context(), "model", request, request, []byte(`{"type":"response.completed","response":{"output":[{"type":"custom_tool_call","name":"apply_patch","call_id":"c","input":`+string(mustJSONMarshal(t, patch))+`}]}}`), &nonStream) + if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.function.arguments").String(); got != want { + t.Fatalf("nonstream arguments=%q want=%q", got, want) + } + }) + } +} + +func TestApplyPatchCustomChatCompletionsDoneFallback(t *testing.T) { + request := []byte(`{"tools":[{"type":"custom","name":"apply_patch"}]}`) + for _, added := range []bool{false, true} { + var param any + if added { + _ = ConvertCodexResponseToOpenAI(t.Context(), "m", request, request, []byte(`data: {"type":"response.output_item.added","output_index":0,"item":{"type":"custom_tool_call","id":"a","call_id":"c","name":"apply_patch","input":""}}`), ¶m) + } + out := ConvertCodexResponseToOpenAI(t.Context(), "m", request, request, []byte(`data: {"type":"response.output_item.done","output_index":0,"item":{"type":"custom_tool_call","id":"a","call_id":"c","name":"apply_patch","input":"p"}}`), ¶m) + if len(out) != 1 || gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").String() != `{"input":"p"}` { + t.Fatalf("fallback: %s", out) + } + } +} + +// This round trip requires the request converter to unwrap only a winning custom +// patch's normalized function envelope, while explicit custom inputs remain raw. +func TestApplyPatchChatCompletionNativeHistoryRoundTrip(t *testing.T) { + original := []byte(`{"messages":[{"role":"user","content":"patch"}],"tools":[{"type":"custom","name":"apply_patch"}]}`) + response := []byte(`{"type":"response.completed","response":{"output":[{"type":"custom_tool_call","call_id":"c","name":"apply_patch","input":"p"}]}}`) + out := ConvertCodexResponseToOpenAINonStream(t.Context(), "m", original, original, response, nil) + message := gjson.GetBytes(out, "choices.0.message") + followup := []byte(`{"messages":[` + message.Raw + `,{"role":"tool","tool_call_id":"c","content":"ok"}],"tools":[{"type":"custom","name":"apply_patch"}]}`) + request := ConvertOpenAIRequestToCodex("m", followup, true) + if got := gjson.GetBytes(request, "input.0.input").String(); got != "p" { + t.Fatalf("normalized function history must restore raw patch before native Codex: got %q, request=%s", got, request) + } +} + +func TestApplyPatchChatResponseOrdinaryFunctionPreference(t *testing.T) { + original := []byte(`{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","function":{"name":"apply_patch","parameters":{}}}]}`) + out := ConvertCodexResponseToOpenAINonStream(t.Context(), "m", original, nil, []byte(`{"type":"response.completed","response":{"output":[{"type":"custom_tool_call","call_id":"c","name":"apply_patch","input":"raw"}]}}`), nil) + if gjson.GetBytes(out, "choices.0.message.tool_calls.0.function.arguments").String() != "raw" { + t.Fatalf("ordinary preference stolen: %s", out) + } +} diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_response.go b/internal/translator/codex/openai/responses/codex_openai-responses_response.go index 7ec443c29..0d1ca19aa 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_response.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_response.go @@ -12,16 +12,40 @@ import ( // ConvertCodexResponseToOpenAIResponses converts OpenAI Chat Completions streaming chunks // to OpenAI Responses SSE events (response.*). -func ConvertCodexResponseToOpenAIResponses(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) [][]byte { - if bytes.HasPrefix(rawJSON, []byte("data:")) { +func ConvertCodexResponseToOpenAIResponses(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { + originalEvent := rawJSON + sse := bytes.HasPrefix(rawJSON, []byte("data:")) + if sse { rawJSON = bytes.TrimSpace(rawJSON[5:]) - rawJSON = setResponsesModel(rawJSON, modelName, originalRequestRawJSON, requestRawJSON) - out := make([]byte, 0, len(rawJSON)+len("data: ")) - out = append(out, []byte("data: ")...) - out = append(out, rawJSON...) - return [][]byte{out} } - return [][]byte{setResponsesModel(rawJSON, modelName, originalRequestRawJSON, requestRawJSON)} + updated := setResponsesModel(rawJSON, modelName, originalRequestRawJSON, requestRawJSON) + bridge := responsesBridge(param) + if bridge == nil { + // Native Codex never opts in, even when configuration supplies the bridge schema. + if bytes.Equal(updated, rawJSON) { + return [][]byte{originalEvent} + } + if sse { + updated = append([]byte("data: "), updated...) + } + return [][]byte{updated} + } + outputs, _ := bridge.Transform(updated) + if sse { + for i := range outputs { + outputs[i] = append([]byte("data: "), outputs[i]...) + } + } + return outputs +} + +// Only an executor-owned param can enable bridging on this shared translator. +func responsesBridge(param *any) *translatorcommon.ApplyPatchResponsesBridge { + if param != nil { + bridge, _ := (*param).(*translatorcommon.ApplyPatchResponsesBridge) + return bridge + } + return nil } func setResponsesModel(rawJSON []byte, modelName string, originalRequestRawJSON, requestRawJSON []byte) []byte { @@ -50,10 +74,22 @@ func setResponsesModel(rawJSON []byte, modelName string, originalRequestRawJSON, // ConvertCodexResponseToOpenAIResponsesNonStream builds a single Responses JSON // from a non-streaming OpenAI Chat Completions response. -func ConvertCodexResponseToOpenAIResponsesNonStream(_ context.Context, _ string, _, _, rawJSON []byte, _ *any) []byte { - rootResult := gjson.ParseBytes(rawJSON) +func ConvertCodexResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte { + bridge := responsesBridge(param) + converted := rawJSON + if bridge != nil { + var errTransform error + converted, errTransform = bridge.TransformNonStream(rawJSON) + if errTransform != nil { + return nil + } + } + rootResult := gjson.ParseBytes(converted) // Verify this is a terminal response event. responseType := rootResult.Get("type").String() + if responseType == "" && rootResult.Get("output").IsArray() { + return converted + } if responseType != "response.completed" && responseType != "response.incomplete" { return []byte{} } diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_response_test.go b/internal/translator/codex/openai/responses/codex_openai-responses_response_test.go index 61382f0c2..8295ba142 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_response_test.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_response_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" "github.com/tidwall/gjson" ) @@ -36,3 +37,32 @@ func TestConvertCodexResponseToOpenAIResponsesNonStreamIncomplete(t *testing.T) t.Fatalf("incomplete reason = %q, want max_output_tokens; payload=%s", got, out) } } + +func TestApplyPatchResponsesActualRequestGatesNativeCodex(t *testing.T) { + original := []byte(`{"tools":[{"type":"custom","name":"apply_patch"}]}`) + bridged, errNormalize := translatorcommon.NormalizeApplyPatchResponsesRequest(original) + if errNormalize != nil { + t.Fatal(errNormalize) + } + raw := []byte(`data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":"apply_patch","arguments":"{\"input\":\"p\"}"}}`) + var native any + out := ConvertCodexResponseToOpenAIResponses(t.Context(), "m", original, original, raw, &native) + if len(out) != 1 || string(out[0]) != string(raw) { + t.Fatalf("native Codex changed: %s", out) + } + var configuredNative any + out = ConvertCodexResponseToOpenAIResponses(t.Context(), "m", original, bridged, raw, &configuredNative) + if len(out) != 1 || string(out[0]) != string(raw) { + t.Fatalf("configuration enabled native bridging: %s", out) + } + var xai any = translatorcommon.NewApplyPatchResponsesBridge(original) + out = ConvertCodexResponseToOpenAIResponses(t.Context(), "m", original, bridged, raw, &xai) + if len(out) != 3 || gjson.GetBytes(out[2][6:], "item.input").String() != "p" { + t.Fatalf("same Codex wire format bypassed bridge: %s", out) + } + var failed any = translatorcommon.NewApplyPatchResponsesBridge(original) + out = ConvertCodexResponseToOpenAIResponses(t.Context(), "m", original, bridged, []byte(`data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","name":"apply_patch","arguments":"{}"}}`), &failed) + if len(out) != 1 || gjson.GetBytes(out[0][6:], "type").String() != "response.failed" || failed.(interface{ ToolInputError() error }).ToolInputError() == nil { + t.Fatalf("failure: %s", out) + } +} diff --git a/internal/translator/common/apply_patch_events.go b/internal/translator/common/apply_patch_events.go new file mode 100644 index 000000000..45e3c9177 --- /dev/null +++ b/internal/translator/common/apply_patch_events.go @@ -0,0 +1,74 @@ +package common + +import "github.com/tidwall/sjson" + +// ApplyPatchCallState owns the input decoder and identity of one tool call. +type ApplyPatchCallState struct { + ItemID string + CallID string + Name string + Namespace string + OutputIndex int + Decoder ApplyPatchInputDecoder +} + +// PushArguments decodes the next arguments fragment for this call. +func (s *ApplyPatchCallState) PushArguments(fragment string) (string, error) { + return s.Decoder.Push(fragment) +} + +// FinishArguments returns any unsent suffix and the complete decoded input. +func (s *ApplyPatchCallState) FinishArguments(arguments string) (string, string, error) { + tail, errFinish := s.Decoder.Finish(arguments) + if errFinish != nil { + return "", "", errFinish + } + return tail, s.Decoder.Input(), nil +} + +// ApplyPatchInputDelta builds a Responses custom-tool input delta without SSE framing. +func ApplyPatchInputDelta(s *ApplyPatchCallState, delta string, sequence int) []byte { + payload := []byte(`{"type":"response.custom_tool_call_input.delta","item_id":"","call_id":"","output_index":0,"sequence_number":0,"delta":""}`) + payload = applyPatchEventIdentity(payload, s, sequence) + payload, _ = sjson.SetBytes(payload, "delta", delta) + return payload +} + +// ApplyPatchInputDone builds a Responses custom-tool input completion without SSE framing. +func ApplyPatchInputDone(s *ApplyPatchCallState, input string, sequence int) []byte { + payload := []byte(`{"type":"response.custom_tool_call_input.done","item_id":"","call_id":"","output_index":0,"sequence_number":0,"input":""}`) + payload = applyPatchEventIdentity(payload, s, sequence) + payload, _ = sjson.SetBytes(payload, "input", input) + return payload +} + +func applyPatchEventIdentity(payload []byte, s *ApplyPatchCallState, sequence int) []byte { + payload, _ = sjson.SetBytes(payload, "item_id", s.ItemID) + payload, _ = sjson.SetBytes(payload, "call_id", s.CallID) + payload, _ = sjson.SetBytes(payload, "output_index", s.OutputIndex) + payload, _ = sjson.SetBytes(payload, "sequence_number", sequence) + return payload +} + +// ApplyPatchFailure builds a terminal Responses failure without exposing upstream arguments. +func ApplyPatchFailure(responseID string, sequence int) []byte { + payload := []byte(`{"type":"response.failed","sequence_number":0,"response":{"id":"","object":"response","status":"failed","error":{"type":"server_error","code":"invalid_tool_arguments","message":"Invalid apply_patch tool arguments received from upstream.","param":null}}}`) + payload, _ = sjson.SetBytes(payload, "response.id", responseID) + payload, _ = sjson.SetBytes(payload, "sequence_number", sequence) + return payload +} + +// ApplyPatchErrorState retains a conversion error for the caller. +type ApplyPatchErrorState struct { + err error +} + +// SetToolInputError stores the original conversion error for executor handling. +func (s *ApplyPatchErrorState) SetToolInputError(err error) { + s.err = err +} + +// ToolInputError returns the original conversion error, if any. +func (s *ApplyPatchErrorState) ToolInputError() error { + return s.err +} diff --git a/internal/translator/common/apply_patch_events_test.go b/internal/translator/common/apply_patch_events_test.go new file mode 100644 index 000000000..2ec03d7d0 --- /dev/null +++ b/internal/translator/common/apply_patch_events_test.go @@ -0,0 +1,174 @@ +package common + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch" + + "github.com/tidwall/gjson" +) + +func TestApplyPatchCallStateInterleavedCalls(t *testing.T) { + first := ApplyPatchCallState{ItemID: "item_1", CallID: "call_1", Name: "apply_patch", Namespace: "tools", OutputIndex: 2} + second := ApplyPatchCallState{ItemID: "item_2", CallID: "call_2", Name: "apply_patch", OutputIndex: 4} + if delta, errPushArguments := first.PushArguments(`{"input":"first\uD83D`); errPushArguments != nil || delta != "first" { + t.Fatalf("first push = %q, %v", delta, errPushArguments) + } + if delta, errPushArguments := second.PushArguments("{\"input\":\"second\xe4"); errPushArguments != nil || delta != "second" { + t.Fatalf("second push = %q, %v", delta, errPushArguments) + } + if delta, errPushArguments := first.PushArguments(`\uDE00\n`); errPushArguments != nil || delta != "😀\n" { + t.Fatalf("first continuation = %q, %v", delta, errPushArguments) + } + if delta, errPushArguments := second.PushArguments("\xb8\xad"); errPushArguments != nil || delta != "中" { + t.Fatalf("second continuation = %q, %v", delta, errPushArguments) + } + tail, input, errFinishArguments := first.FinishArguments(applypatch.WrapInput("first😀\nlast")) + if errFinishArguments != nil || tail != "last" || input != "first😀\nlast" { + t.Fatalf("first finish = %q, %q, %v", tail, input, errFinishArguments) + } + tail, input, errFinishArguments = second.FinishArguments(applypatch.WrapInput("second中")) + if errFinishArguments != nil || tail != "" || input != "second中" { + t.Fatalf("second finish = %q, %q, %v", tail, input, errFinishArguments) + } + if first.ItemID != "item_1" || first.CallID != "call_1" || first.Namespace != "tools" || first.OutputIndex != 2 { + t.Fatal("decoding changed call identity") + } +} + +func TestApplyPatchCallStateFailureIsolation(t *testing.T) { + var bad, good ApplyPatchCallState + if _, errPushArguments := bad.PushArguments(`{"input":null`); errPushArguments == nil { + t.Fatal("bad call did not fail") + } + if delta, errPushArguments := good.PushArguments(`{"input":"good`); errPushArguments != nil || delta != "good" { + t.Fatalf("good call push = %q, %v", delta, errPushArguments) + } + if tail, input, errFinishArguments := bad.FinishArguments(`{"input":"bad"}`); errFinishArguments == nil || tail != "" || input != "" { + t.Fatalf("bad finish = %q, %q, %v", tail, input, errFinishArguments) + } + if tail, input, errFinishArguments := good.FinishArguments(`{"input":"good tail"}`); errFinishArguments != nil || tail != " tail" || input != "good tail" { + t.Fatalf("good finish = %q, %q, %v", tail, input, errFinishArguments) + } + if tail, input, errFinishArguments := good.FinishArguments(`{"input":"good tail"}`); errFinishArguments != nil || tail != "" || input != "good tail" { + t.Fatalf("duplicate good finish = %q, %q, %v", tail, input, errFinishArguments) + } +} + +func TestApplyPatchCallStateConcurrentIndependentCalls(t *testing.T) { + start := make(chan struct{}) + results := make(chan error, 16) + for i := 0; i < cap(results); i++ { + go func(index int) { + <-start + state := ApplyPatchCallState{ItemID: fmt.Sprintf("item_%d", index), CallID: fmt.Sprintf("call_%d", index), OutputIndex: index} + want := fmt.Sprintf("*** Begin Patch\n+ call %d 中文😀 \n*** End Patch\n", index) + arguments := applypatch.WrapInput(want) + var output strings.Builder + for j := 0; j < len(arguments); j++ { + delta, errPushArguments := state.PushArguments(arguments[j : j+1]) + if errPushArguments != nil { + results <- errPushArguments + return + } + output.WriteString(delta) + } + tail, input, errFinishArguments := state.FinishArguments(arguments) + output.WriteString(tail) + if errFinishArguments != nil { + results <- errFinishArguments + return + } + if output.String() != want || input != want { + results <- fmt.Errorf("call %d: output %q, input %q", index, output.String(), input) + return + } + payload := ApplyPatchInputDone(&state, input, index) + if !json.Valid(payload) || gjson.GetBytes(payload, "call_id").String() != state.CallID { + results <- fmt.Errorf("call %d: invalid done identity", index) + return + } + results <- nil + }(i) + } + close(start) + for i := 0; i < cap(results); i++ { + if errResult := <-results; errResult != nil { + t.Error(errResult) + } + } +} + +func TestApplyPatchEventsPayloads(t *testing.T) { + state := ApplyPatchCallState{ItemID: "item_\"中", CallID: "call_\\1", Name: "apply_patch", Namespace: "tools", OutputIndex: 3} + text := "*** Begin Patch\n+ 中文 \\\" \n*** End Patch\n" + for _, test := range []struct { + name string + payload []byte + kind string + textKey string + sequence int + }{ + {name: "delta", payload: ApplyPatchInputDelta(&state, text, 11), kind: "response.custom_tool_call_input.delta", textKey: "delta", sequence: 11}, + {name: "done", payload: ApplyPatchInputDone(&state, text, 12), kind: "response.custom_tool_call_input.done", textKey: "input", sequence: 12}, + } { + t.Run(test.name, func(t *testing.T) { + if !json.Valid(test.payload) { + t.Fatalf("payload is not plain JSON: %q", test.payload) + } + root := gjson.ParseBytes(test.payload) + for key, want := range map[string]string{"type": test.kind, "item_id": state.ItemID, "call_id": state.CallID, test.textKey: text} { + if got := root.Get(key).String(); got != want { + t.Fatalf("%s = %q, want %q", key, got, want) + } + } + if root.Get("output_index").Int() != 3 || root.Get("sequence_number").Int() != int64(test.sequence) { + t.Fatalf("invalid ordering metadata: %s", test.payload) + } + }) + } + zero := ApplyPatchInputDelta(&ApplyPatchCallState{}, "", 0) + for _, key := range []string{"item_id", "call_id", "output_index", "sequence_number", "delta"} { + if !gjson.GetBytes(zero, key).Exists() { + t.Fatalf("zero-valued payload omitted %s", key) + } + } +} + +func TestApplyPatchFailureSanitizesClientError(t *testing.T) { + secret := "*** Begin Patch\n+secret-token-value\n*** End Patch\n" + original := errors.New("invalid input: " + secret) + var state ApplyPatchErrorState + if state.ToolInputError() != nil { + t.Fatal("new state contains an error") + } + state.SetToolInputError(nil) + state.SetToolInputError(original) + if state.ToolInputError() != original { + t.Fatal("original conversion error was not preserved") + } + payload := ApplyPatchFailure("resp_\"1", 17) + if !json.Valid(payload) || strings.Contains(string(payload), "secret-token-value") || strings.Contains(string(payload), "Begin Patch") { + t.Fatalf("invalid or unsanitized failure payload: %q", payload) + } + root := gjson.ParseBytes(payload) + for key, want := range map[string]string{ + "type": "response.failed", + "response.id": "resp_\"1", + "response.object": "response", + "response.status": "failed", + "response.error.code": "invalid_tool_arguments", + "response.error.message": "Invalid apply_patch tool arguments received from upstream.", + } { + if got := root.Get(key).String(); got != want { + t.Fatalf("%s = %q, want %q", key, got, want) + } + } + if root.Get("sequence_number").Int() != 17 { + t.Fatalf("sequence = %s", root.Get("sequence_number")) + } +} diff --git a/internal/translator/common/apply_patch_identity_test.go b/internal/translator/common/apply_patch_identity_test.go new file mode 100644 index 000000000..fcdc667e0 --- /dev/null +++ b/internal/translator/common/apply_patch_identity_test.go @@ -0,0 +1,164 @@ +package common + +import ( + "fmt" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +// A known name does not make either missing downstream ID safe to publish. +func TestApplyPatchResponsesNamedLateIdentity(t *testing.T) { + for _, first := range []string{"item", "call", "neither", "neither-item-first", "neither-call-first"} { + for _, boundary := range []string{"item", "terminal"} { + t.Run(first+"-"+boundary, func(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + id, call := "", "" + if first == "item" { + id = "a" + } + if first == "call" { + call = "c" + } + pending := [][]byte{patchEvent("response.output_item.added", 0, patchItem("function_call", id, call, "apply_patch", ""))} + for _, fragment := range []string{`{"input":"p`, `q"}`} { + pending = append(pending, []byte(fmt.Sprintf(`{"type":"response.function_call_arguments.delta","output_index":0,"delta":%s}`, patchJSON(fragment)))) + } + pending = append(pending, []byte(`{"type":"response.function_call_arguments.done","output_index":0,"arguments":"{\"input\":\"pq\"}"}`)) + for _, event := range pending { + if out := patchSend(t, b, event); len(out) != 0 { + t.Fatalf("published unresolved identity: %s", out) + } + } + if strings.HasPrefix(first, "neither-") { + id, call = "a", "" + if first == "neither-call-first" { + id, call = "", "c" + } + if out := patchSend(t, b, patchEvent("response.output_item.added", 0, patchItem("function_call", id, call, "apply_patch", ""))); len(out) != 0 { + t.Fatalf("one late ID is still unresolved: %s", out) + } + } + item := patchItem("function_call", "a", "c", "apply_patch", `{"input":"pq"}`) + var events [][]byte + if boundary == "item" { + events = append(events, patchSend(t, b, patchEvent("response.output_item.done", 0, item))...) + } + events = append(events, patchSend(t, b, []byte(`{"type":"response.completed","response":{"output":[`+item+`]}}`))...) + counts := map[string]int{} + var fragments []string + for _, event := range events { + root := gjson.ParseBytes(event) + kind := root.Get("type").String() + counts[kind]++ + identity := root + idKey := "item_id" + switch kind { + case "response.output_item.added", "response.output_item.done": + identity, idKey = root.Get("item"), "id" + wantInput := "" + if kind == "response.output_item.done" { + wantInput = "pq" + } + if identity.Get("input").String() != wantInput { + t.Fatalf("item input: %s", event) + } + case "response.completed": + identity, idKey = root.Get("response.output.0"), "id" + if identity.Get("input").String() != "pq" { + t.Fatalf("final input: %s", event) + } + case "response.custom_tool_call_input.done": + if root.Get("input").String() != "pq" { + t.Fatalf("done input: %s", event) + } + } + if kind != "response.completed" && (!root.Get("output_index").Exists() || root.Get("output_index").Int() != 0) { + t.Fatalf("unstable index: %s", event) + } + if identity.Get(idKey).String() != "a" || identity.Get("call_id").String() != "c" { + t.Fatalf("changed lifecycle identity: %s", event) + } + if kind == "response.custom_tool_call_input.delta" { + fragments = append(fragments, root.Get("delta").String()) + } + } + if strings.Join(fragments, "|") != "p|q" { + t.Fatalf("not real buffered fragments: %q", fragments) + } + for _, kind := range []string{"response.output_item.added", "response.custom_tool_call_input.done", "response.output_item.done", "response.completed"} { + if counts[kind] != 1 { + t.Fatalf("lifecycle counts: %v", counts) + } + } + }) + } + } +} + +func TestApplyPatchResponsesNamedLateIdentityEvidence(t *testing.T) { + for _, tc := range []struct { + name string + before, after []byte + }{ + {"seen-item-change", patchEvent("response.output_item.added", 0, patchItem("function_call", "a", "", "apply_patch", "")), patchEvent("response.output_item.done", 0, patchItem("function_call", "changed", "c", "apply_patch", `{"input":"pq"}`))}, + {"seen-call-change", patchEvent("response.output_item.added", 0, patchItem("function_call", "", "c", "apply_patch", "")), patchEvent("response.output_item.done", 0, patchItem("function_call", "a", "changed", "apply_patch", `{"input":"pq"}`))}, + {"partial-snapshot", patchEvent("response.output_item.added", 0, patchItem("function_call", "", "", "apply_patch", `{"input":"p`)), patchEvent("response.output_item.done", 0, patchItem("function_call", "a", "c", "apply_patch", `{"input":"pq"}`))}, + {"invalid-snapshot", patchEvent("response.output_item.added", 0, patchItem("function_call", "a", "", "apply_patch", `{"input":"pq","extra":1}`)), patchEvent("response.output_item.done", 0, patchItem("function_call", "a", "c", "apply_patch", `{"input":"pq"}`))}, + {"unresolved-terminal", patchEvent("response.output_item.done", 0, patchItem("function_call", "a", "", "apply_patch", `{"input":"pq"}`)), []byte(`{"type":"response.completed","response":{"output":[]}}`)}, + } { + t.Run(tc.name, func(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + out, errTransform := b.Transform(tc.before) + if errTransform == nil { + if len(out) != 0 { + t.Fatalf("unresolved output: %s", out) + } + out, errTransform = b.Transform(tc.after) + } + if errTransform == nil || len(out) != 1 || gjson.GetBytes(out[0], "type").String() != "response.failed" { + t.Fatalf("lost evidence: %s %v", out, errTransform) + } + if out, errTransform = b.Transform(tc.after); len(out) != 0 || errTransform != nil { + t.Fatalf("failure reopened: %s %v", out, errTransform) + } + }) + } +} + +func TestApplyPatchResponsesNamedLateIdentityInterleaved(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + for i, item := range []string{patchItem("function_call", "a0", "", "apply_patch", ""), patchItem("function_call", "", "c1", "apply_patch", "")} { + if out := patchSend(t, b, patchEvent("response.output_item.added", i, item)); len(out) != 0 { + t.Fatalf("early added: %s", out) + } + if out := patchSend(t, b, []byte(fmt.Sprintf(`{"type":"response.function_call_arguments.delta","output_index":%d,"delta":%s}`, i, patchJSON(`{"input":"`+fmt.Sprint(i)+`"}`)))); len(out) != 0 { + t.Fatalf("early delta: %s", out) + } + } + var events [][]byte + for _, i := range []int{1, 0} { + events = append(events, patchSend(t, b, patchEvent("response.output_item.done", i, patchItem("function_call", fmt.Sprint("a", i), fmt.Sprint("c", i), "apply_patch", `{"input":"`+fmt.Sprint(i)+`"}`)))...) + } + events = append(events, patchSend(t, b, []byte(`{"type":"response.completed","response":{"output":[]}}`))...) + for _, event := range events { + root := gjson.ParseBytes(event) + if root.Get("type").String() == "response.completed" { + for i, item := range root.Get("response.output").Array() { + if item.Get("id").String() != fmt.Sprint("a", i) || item.Get("call_id").String() != fmt.Sprint("c", i) || item.Get("input").String() != fmt.Sprint(i) { + t.Fatalf("interleaved final: %s", event) + } + } + continue + } + i := int(root.Get("output_index").Int()) + identity, key := root, "item_id" + if root.Get("item").Exists() { + identity, key = root.Get("item"), "id" + } + if identity.Get(key).String() != fmt.Sprint("a", i) || identity.Get("call_id").String() != fmt.Sprint("c", i) { + t.Fatalf("crossed calls: %s", event) + } + } +} diff --git a/internal/translator/common/apply_patch_input.go b/internal/translator/common/apply_patch_input.go new file mode 100644 index 000000000..f92989e3e --- /dev/null +++ b/internal/translator/common/apply_patch_input.go @@ -0,0 +1,284 @@ +package common + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "unicode/utf16" + "unicode/utf8" + + applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch" +) + +type applyPatchJSONPhase uint8 + +const ( + patchBeforeObject applyPatchJSONPhase = iota + patchBeforeKey + patchInKey + patchBeforeColon + patchBeforeValue + patchInValue + patchAfterValue + patchComplete +) + +// ApplyPatchInputDecoder decodes the input string from streamed function arguments. +// A decoder belongs to one call and must not be copied after use. +type ApplyPatchInputDecoder struct { + phase applyPatchJSONPhase + keyRaw []byte + escapeRaw []byte + utf8Pending []byte + highSurrogate uint16 + input strings.Builder + finished bool + err error +} + +// Push scans each fragment once, emitting only complete, validated characters. +func (d *ApplyPatchInputDecoder) Push(fragment string) (string, error) { + if d.err != nil { + return "", d.err + } + if d.finished { + if fragment == "" { + return "", nil + } + return "", d.fail(errors.New("apply_patch arguments received after completion")) + } + start := d.input.Len() + for i := 0; i < len(fragment); i++ { + c := fragment[i] + switch d.phase { + case patchBeforeObject: + if applyPatchJSONSpace(c) { + continue + } + if c != '{' { + return "", d.fail(errors.New("apply_patch arguments must be a JSON object")) + } + d.phase = patchBeforeKey + case patchBeforeKey: + if applyPatchJSONSpace(c) { + continue + } + if c != '"' { + return "", d.fail(errors.New("apply_patch arguments must contain the input field")) + } + d.keyRaw = append(d.keyRaw, c) + d.phase = patchInKey + case patchInKey: + d.keyRaw = append(d.keyRaw, c) + if len(d.escapeRaw) != 0 { + d.escapeRaw = d.escapeRaw[:0] + continue + } + if c == '\\' { + d.escapeRaw = append(d.escapeRaw, c) + continue + } + if c < 0x20 { + return "", d.fail(errors.New("invalid control character in apply_patch input key")) + } + if c == '"' { + var key string + if errUnmarshal := json.Unmarshal(d.keyRaw, &key); errUnmarshal != nil { + return "", d.fail(fmt.Errorf("decode apply_patch input key: %w", errUnmarshal)) + } + if key != "input" { + return "", d.fail(errors.New("apply_patch arguments must contain the input field")) + } + d.keyRaw = nil + d.phase = patchBeforeColon + } + case patchBeforeColon: + if applyPatchJSONSpace(c) { + continue + } + if c != ':' { + return "", d.fail(errors.New("apply_patch input key must be followed by a colon")) + } + d.phase = patchBeforeValue + case patchBeforeValue: + if applyPatchJSONSpace(c) { + continue + } + if c != '"' { + return "", d.fail(errors.New("apply_patch input must be a string")) + } + d.phase = patchInValue + case patchInValue: + if errConsumeValue := d.consumeValue(c); errConsumeValue != nil { + return "", d.fail(errConsumeValue) + } + case patchAfterValue: + if applyPatchJSONSpace(c) { + continue + } + if c != '}' { + return "", d.fail(errors.New("apply_patch arguments must contain only one input field")) + } + d.phase = patchComplete + case patchComplete: + if !applyPatchJSONSpace(c) { + return "", d.fail(errors.New("apply_patch arguments must not contain trailing JSON")) + } + } + } + return d.input.String()[start:], nil +} + +func (d *ApplyPatchInputDecoder) consumeValue(c byte) error { + if len(d.utf8Pending) != 0 || c >= utf8.RuneSelf { + // Pending escapes and surrogate pairs cannot consume raw UTF-8. + if len(d.escapeRaw) != 0 || d.highSurrogate != 0 { + return errors.New("invalid Unicode escape in apply_patch input") + } + d.utf8Pending = append(d.utf8Pending, c) + if !utf8.FullRune(d.utf8Pending) { + return nil + } + r, size := utf8.DecodeRune(d.utf8Pending) + if (r == utf8.RuneError && size == 1) || size != len(d.utf8Pending) { + return errors.New("invalid UTF-8 in apply_patch input") + } + d.input.Write(d.utf8Pending) + d.utf8Pending = d.utf8Pending[:0] + return nil + } + if len(d.escapeRaw) != 0 { + d.escapeRaw = append(d.escapeRaw, c) + if len(d.escapeRaw) == 2 { + if d.highSurrogate != 0 && c != 'u' { + return errors.New("apply_patch input high surrogate requires a low surrogate") + } + var decoded byte + switch c { + case 'u': + return nil + case '"', '\\', '/': + decoded = c + case 'b': + decoded = '\b' + case 'f': + decoded = '\f' + case 'n': + decoded = '\n' + case 'r': + decoded = '\r' + case 't': + decoded = '\t' + default: + return errors.New("invalid escape in apply_patch input") + } + d.input.WriteByte(decoded) + d.escapeRaw = d.escapeRaw[:0] + return nil + } + if _, ok := applyPatchHex(c); !ok { + return errors.New("invalid Unicode escape in apply_patch input") + } + if len(d.escapeRaw) < 6 { + return nil + } + var code uint16 + for _, digit := range d.escapeRaw[2:] { + nibble, _ := applyPatchHex(digit) + code = code<<4 | nibble + } + d.escapeRaw = d.escapeRaw[:0] + switch { + case d.highSurrogate != 0: + if code < 0xdc00 || code > 0xdfff { + return errors.New("apply_patch input high surrogate requires a low surrogate") + } + d.input.WriteRune(utf16.DecodeRune(rune(d.highSurrogate), rune(code))) + d.highSurrogate = 0 + case code >= 0xd800 && code <= 0xdbff: + d.highSurrogate = code + case code >= 0xdc00 && code <= 0xdfff: + return errors.New("unpaired low surrogate in apply_patch input") + default: + d.input.WriteRune(rune(code)) + } + return nil + } + if d.highSurrogate != 0 && c != '\\' { + return errors.New("apply_patch input high surrogate requires a low surrogate") + } + switch { + case c == '\\': + d.escapeRaw = append(d.escapeRaw, c) + case c == '"': + d.phase = patchAfterValue + case c < 0x20: + return errors.New("invalid control character in apply_patch input") + default: + d.input.WriteByte(c) + } + return nil +} + +// Finish validates the final wrapper and emits only the previously unsent suffix. +func (d *ApplyPatchInputDecoder) Finish(arguments string) (string, error) { + if d.err != nil { + return "", d.err + } + input, errUnwrapInput := applypatch.UnwrapInput(arguments) + if errUnwrapInput != nil { + return "", d.fail(errUnwrapInput) + } + // encoding/json replaces invalid UTF-8 and unpaired surrogates. The final + // snapshot needs the same strict character validation as streamed fragments. + var final ApplyPatchInputDecoder + if _, errPush := final.Push(arguments); errPush != nil { + return "", d.fail(errPush) + } + if d.finished { + if input != d.Input() { + return "", d.fail(errors.New("conflicting apply_patch arguments completion")) + } + return "", nil + } + if !strings.HasPrefix(input, d.Input()) { + return "", d.fail(errors.New("final apply_patch input conflicts with streamed input")) + } + tail := input[d.input.Len():] + d.input.WriteString(tail) + d.finished = true + d.phase = patchComplete + d.keyRaw = nil + d.escapeRaw = nil + d.utf8Pending = nil + d.highSurrogate = 0 + return tail, nil +} + +// Input returns the decoded input, preserving its original whitespace. +func (d *ApplyPatchInputDecoder) Input() string { + return d.input.String() +} + +func (d *ApplyPatchInputDecoder) fail(err error) error { + d.err = err + return err +} + +func applyPatchJSONSpace(c byte) bool { + return c == ' ' || c == '\t' || c == '\r' || c == '\n' +} + +func applyPatchHex(c byte) (uint16, bool) { + switch { + case c >= '0' && c <= '9': + return uint16(c - '0'), true + case c >= 'a' && c <= 'f': + return uint16(c-'a') + 10, true + case c >= 'A' && c <= 'F': + return uint16(c-'A') + 10, true + default: + return 0, false + } +} diff --git a/internal/translator/common/apply_patch_input_test.go b/internal/translator/common/apply_patch_input_test.go new file mode 100644 index 000000000..4d89d35f3 --- /dev/null +++ b/internal/translator/common/apply_patch_input_test.go @@ -0,0 +1,337 @@ +package common + +import ( + "strings" + "testing" + "unicode/utf8" + + applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch" +) + +func TestApplyPatchInputDecoderEverySplit(t *testing.T) { + patch := "*** Begin Patch\n*** Add File: a.txt\n+中文 \\\"\n*** End Patch\n" + arguments := applypatch.WrapInput(patch) + for split := 0; split <= len(arguments); split++ { + var decoder ApplyPatchInputDecoder + var output strings.Builder + for _, fragment := range []string{arguments[:split], arguments[split:]} { + delta, errPush := decoder.Push(fragment) + if errPush != nil { + t.Fatalf("split %d: %v", split, errPush) + } + output.WriteString(delta) + } + tail, errFinish := decoder.Finish(arguments) + if errFinish != nil { + t.Fatalf("split %d: %v", split, errFinish) + } + output.WriteString(tail) + if output.String() != patch || decoder.Input() != patch { + t.Fatalf("split %d: output %q, input %q", split, output.String(), decoder.Input()) + } + } +} + +func TestApplyPatchInputDecoderStringFragments(t *testing.T) { + tests := []struct { + name string + arguments string + want string + }{ + {name: "empty", arguments: `{"input":""}`}, + {name: "whitespace", arguments: " \t\r\n{ \n\"input\" \t: \" line \\n\\t next\\r\\n\" \r}\n\t", want: " line \n\t next\r\n"}, + {name: "escapes", arguments: `{"input":"\"\\\/\b\f\n\r\t\u0000\u0041\u4e2d\u6587"}`, want: "\"\\/\b\f\n\r\t\x00A中文"}, + {name: "escaped key", arguments: `{"in\u0070ut":"patch"}`, want: "patch"}, + {name: "surrogate pair", arguments: `{"input":"before\uD83D\uDE00after"}`, want: "before😀after"}, + {name: "surrogate boundaries", arguments: `{"input":"\ud800\udc00\uDBFF\uDFFF\uD7FF\uE000"}`, want: "\U00010000\U0010ffff\uD7FF\uE000"}, + {name: "utf8", arguments: `{"input":"¢中文😀�"}`, want: "¢中文😀�"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + for split := 0; split <= len(test.arguments); split++ { + checkApplyPatchFragments(t, []string{test.arguments[:split], test.arguments[split:]}, test.arguments, test.want) + // A final snapshot can complete a pending escape or UTF-8 character. + checkApplyPatchFragments(t, []string{test.arguments[:split]}, test.arguments, test.want) + } + fragments := make([]string, len(test.arguments)) + for i := 0; i < len(test.arguments); i++ { + fragments[i] = test.arguments[i : i+1] + } + checkApplyPatchFragments(t, fragments, test.arguments, test.want) + }) + } +} + +func checkApplyPatchFragments(t *testing.T, fragments []string, arguments, want string) { + t.Helper() + var decoder ApplyPatchInputDecoder + var output strings.Builder + for i, fragment := range fragments { + delta, errPush := decoder.Push(fragment) + if errPush != nil { + t.Fatalf("fragment %d: %v", i, errPush) + } + if !utf8.ValidString(delta) { + t.Fatalf("fragment %d returned incomplete UTF-8: %q", i, delta) + } + output.WriteString(delta) + if decoder.Input() != output.String() { + t.Fatalf("fragment %d: input %q, emitted %q", i, decoder.Input(), output.String()) + } + } + tail, errFinish := decoder.Finish(arguments) + if errFinish != nil { + t.Fatal(errFinish) + } + output.WriteString(tail) + if output.String() != want || decoder.Input() != want { + t.Fatalf("output %q, input %q, want %q", output.String(), decoder.Input(), want) + } +} + +func TestApplyPatchInputDecoderPreviewBeforeClosingJSON(t *testing.T) { + fragments := []struct { + fragment string + want string + }{ + {fragment: `{"input":"*** Begin Patch\n*** Add File: a.txt\n+ `, want: "*** Begin Patch\n*** Add File: a.txt\n+ "}, + {fragment: string([]byte{0xe4}), want: ""}, + {fragment: string([]byte{0xb8}), want: ""}, + {fragment: string([]byte{0xad}) + `\`, want: "中"}, + {fragment: `uD8`, want: ""}, + {fragment: `3D`, want: ""}, + {fragment: `\uDE`, want: ""}, + {fragment: `00\n*** End Patch\n`, want: "😀\n*** End Patch\n"}, + } + var decoder ApplyPatchInputDecoder + for i, fragment := range fragments { + delta, errPush := decoder.Push(fragment.fragment) + if errPush != nil || delta != fragment.want { + t.Fatalf("fragment %d: delta %q, error %v, want %q", i, delta, errPush, fragment.want) + } + } + want := "*** Begin Patch\n*** Add File: a.txt\n+ 中😀\n*** End Patch\n" + if decoder.Input() != want { + t.Fatalf("open JSON input = %q, want %q", decoder.Input(), want) + } + tail, errFinish := decoder.Finish(applypatch.WrapInput(want)) + if errFinish != nil || tail != "" { + t.Fatalf("finish = %q, %v", tail, errFinish) + } +} + +func TestApplyPatchInputDecoderRejectsInvalidArguments(t *testing.T) { + tests := []struct { + name string + arguments string + }{ + {name: "empty"}, + {name: "array", arguments: `[]`}, + {name: "empty object", arguments: `{}`}, + {name: "wrong key", arguments: `{"patch":"x"}`}, + {name: "unquoted key", arguments: `{input:"x"}`}, + {name: "invalid key escape", arguments: `{"in\qput":"x"}`}, + {name: "missing colon", arguments: `{"input" "x"}`}, + {name: "number value", arguments: `{"input":42}`}, + {name: "null value", arguments: `{"input":null}`}, + {name: "boolean value", arguments: `{"input":true}`}, + {name: "object value", arguments: `{"input":{}}`}, + {name: "array value", arguments: `{"input":[]}`}, + {name: "duplicate key", arguments: `{"input":"x","input":"y"}`}, + {name: "extra key", arguments: `{"input":"x","extra":"y"}`}, + {name: "trailing comma", arguments: `{"input":"x",}`}, + {name: "trailing json", arguments: `{"input":"x"}{}`}, + {name: "non json whitespace", arguments: "\v{\"input\":\"x\"}"}, + {name: "invalid escape", arguments: `{"input":"\q"}`}, + {name: "invalid hex", arguments: `{"input":"\u12G4"}`}, + {name: "short unicode", arguments: `{"input":"\u123"}`}, + {name: "dangling escape", arguments: `{"input":"x\`}, + {name: "raw newline", arguments: "{\"input\":\"x\ny\"}"}, + {name: "raw nul", arguments: "{\"input\":\"x\x00y\"}"}, + {name: "low surrogate alone", arguments: `{"input":"\uDE00"}`}, + {name: "high surrogate alone", arguments: `{"input":"\uD83D"}`}, + {name: "high followed by text", arguments: `{"input":"\uD83Dx"}`}, + {name: "high followed by newline escape", arguments: `{"input":"\uD83D\n"}`}, + {name: "high followed by bmp", arguments: `{"input":"\uD83D\u0041"}`}, + {name: "two high surrogates", arguments: `{"input":"\uD83D\uD83D"}`}, + {name: "invalid utf8 continuation", arguments: "{\"input\":\"\xe4A\"}"}, + {name: "utf8 truncated by quote", arguments: "{\"input\":\"\xe4\xb8\"}"}, + {name: "overlong utf8", arguments: "{\"input\":\"\xc0\xaf\"}"}, + {name: "utf8 encoded surrogate", arguments: "{\"input\":\"\xed\xa0\x80\"}"}, + {name: "utf8 beyond maximum", arguments: "{\"input\":\"\xf4\x90\x80\x80\"}"}, + {name: "unclosed value", arguments: `{"input":"x`}, + {name: "unclosed object", arguments: `{"input":"x"`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + for split := 0; split <= len(test.arguments); split++ { + var decoder ApplyPatchInputDecoder + for _, fragment := range []string{test.arguments[:split], test.arguments[split:]} { + _, errPush := decoder.Push(fragment) + if errPush != nil { + break + } + } + if _, errFinish := decoder.Finish(test.arguments); errFinish == nil { + t.Fatalf("split %d accepted %q", split, test.arguments) + } + if !utf8.ValidString(decoder.Input()) { + t.Fatalf("split %d emitted invalid UTF-8", split) + } + } + var byteDecoder ApplyPatchInputDecoder + for i := 0; i < len(test.arguments); i++ { + if _, errPush := byteDecoder.Push(test.arguments[i : i+1]); errPush != nil { + break + } + } + if _, errFinish := byteDecoder.Finish(test.arguments); errFinish == nil { + t.Fatalf("single-byte fragments accepted %q", test.arguments) + } + var decoder ApplyPatchInputDecoder + if _, errFinish := decoder.Finish(test.arguments); errFinish == nil { + t.Fatalf("finish without deltas accepted %q", test.arguments) + } + }) + } +} + +func TestApplyPatchInputDecoderInvalidValueFailsImmediately(t *testing.T) { + for _, fragment := range []string{`{"input":4`, `{"input":n`, `{"input":t`, `{"input":[`, `{"input":{`, `{"input":"\q`, `{"input":"\u12G`, `{"input":"\uDE00`, `{"input":"\uD83Dx`} { + var decoder ApplyPatchInputDecoder + if _, errPush := decoder.Push(fragment); errPush == nil { + t.Fatalf("did not immediately reject %q", fragment) + } + } +} + +func TestApplyPatchInputDecoderInvalidPendingCharactersDoNotEmitReplacement(t *testing.T) { + for _, test := range []struct { + name string + pending string + continuation string + }{ + {name: "surrogate closed", pending: `\uD83D`, continuation: `"}`}, + {name: "surrogate text", pending: `\uD83D`, continuation: "x"}, + {name: "surrogate escape", pending: `\uD83D\`, continuation: "n"}, + {name: "invalid low surrogate", pending: `\uD83D\u00`, continuation: "41"}, + {name: "invalid escape", pending: `\`, continuation: "q"}, + {name: "invalid hex", pending: `\u12`, continuation: "G"}, + {name: "invalid utf8", pending: "\xe4", continuation: "x"}, + {name: "truncated utf8", pending: "\xe4\xb8", continuation: `"}`}, + } { + t.Run(test.name, func(t *testing.T) { + var decoder ApplyPatchInputDecoder + if delta, errPush := decoder.Push(`{"input":"safe` + test.pending); errPush != nil || delta != "safe" { + t.Fatalf("pending push = %q, %v", delta, errPush) + } + if delta, errPush := decoder.Push(test.continuation); errPush == nil || delta != "" { + t.Fatalf("invalid continuation = %q, %v", delta, errPush) + } + if decoder.Input() != "safe" { + t.Fatalf("invalid character changed confirmed input to %q", decoder.Input()) + } + }) + } +} + +func TestApplyPatchInputDecoderFinishAndRepeatedTermination(t *testing.T) { + arguments := applypatch.WrapInput(" first\nsecond \n") + for _, test := range []struct { + name string + push string + want string + }{ + {name: "no deltas", want: " first\nsecond \n"}, + {name: "partial deltas", push: `{"input":" first\n`, want: "second \n"}, + {name: "complete deltas", push: arguments}, + } { + t.Run(test.name, func(t *testing.T) { + var decoder ApplyPatchInputDecoder + if _, errPush := decoder.Push(test.push); errPush != nil { + t.Fatal(errPush) + } + tail, errFinish := decoder.Finish(arguments) + if errFinish != nil || tail != test.want || decoder.Input() != " first\nsecond \n" { + t.Fatalf("finish: tail %q, input %q, error %v", tail, decoder.Input(), errFinish) + } + for i := 0; i < 3; i++ { + tail, errFinish = decoder.Finish(arguments) + if errFinish != nil || tail != "" { + t.Fatalf("repeat %d: %q, %v", i, tail, errFinish) + } + } + if _, errFinish = decoder.Finish(applypatch.WrapInput(" first\nsecond \nmore")); errFinish == nil { + t.Fatal("accepted conflicting repeat with the same prefix") + } + }) + } +} + +func TestApplyPatchInputDecoderRejectsFinalPrefixConflict(t *testing.T) { + for _, final := range []string{"other", "pre", "prefix changed"} { + var decoder ApplyPatchInputDecoder + if delta, errPush := decoder.Push(`{"input":"prefix original`); errPush != nil || delta != "prefix original" { + t.Fatalf("push = %q, %v", delta, errPush) + } + if tail, errFinish := decoder.Finish(applypatch.WrapInput(final)); errFinish == nil || tail != "" { + t.Fatalf("accepted final conflict %q: %q, %v", final, tail, errFinish) + } + if decoder.Input() != "prefix original" { + t.Fatal("conflict changed the emitted prefix") + } + } +} + +func TestApplyPatchInputDecoderErrorIsTerminal(t *testing.T) { + var decoder ApplyPatchInputDecoder + _, errInitialPush := decoder.Push(`{"input":"safe\uD83D\u0041`) + if errInitialPush == nil || decoder.Input() != "safe" { + t.Fatalf("invalid surrogate: input %q, error %v", decoder.Input(), errInitialPush) + } + if delta, errPush := decoder.Push(`suffix"}`); errPush != errInitialPush || delta != "" { + t.Fatalf("push after error = %q, %v", delta, errPush) + } + if tail, errFinish := decoder.Finish(applypatch.WrapInput("safe")); errFinish != errInitialPush || tail != "" { + t.Fatalf("finish after error = %q, %v", tail, errFinish) + } +} + +func TestApplyPatchInputDecoderFinishErrorIsTerminal(t *testing.T) { + for _, arguments := range []string{`{"input":null}`, `{"input":"wrong prefix"}`, `{"input":"safe\uD83D"}`} { + var decoder ApplyPatchInputDecoder + if delta, errPush := decoder.Push(`{"input":"safe`); errPush != nil || delta != "safe" { + t.Fatalf("push = %q, %v", delta, errPush) + } + _, errInitialFinish := decoder.Finish(arguments) + if errInitialFinish == nil { + t.Fatalf("accepted invalid final %q", arguments) + } + if tail, errFinish := decoder.Finish(`{"input":"safe"}`); errFinish != errInitialFinish || tail != "" || decoder.Input() != "safe" { + t.Fatalf("finish after error = %q, %v, input %q", tail, errFinish, decoder.Input()) + } + } +} + +func TestApplyPatchInputDecoderEquivalentFinalEncoding(t *testing.T) { + var decoder ApplyPatchInputDecoder + if delta, errPush := decoder.Push(`{"input":"中\n`); errPush != nil || delta != "中\n" { + t.Fatalf("push = %q, %v", delta, errPush) + } + if tail, errFinish := decoder.Finish(`{"in\u0070ut":"\u4e2d\n😀"}`); errFinish != nil || tail != "😀" { + t.Fatalf("finish = %q, %v", tail, errFinish) + } + if tail, errFinish := decoder.Finish(" \n{\"input\":\"中\\n\\uD83D\\uDE00\"}\t"); errFinish != nil || tail != "" { + t.Fatalf("equivalent repeat = %q, %v", tail, errFinish) + } +} + +func TestApplyPatchInputDecoderPushAfterFinish(t *testing.T) { + var decoder ApplyPatchInputDecoder + if _, errFinish := decoder.Finish(`{"input":"patch"}`); errFinish != nil { + t.Fatal(errFinish) + } + if delta, errPush := decoder.Push("more"); errPush == nil || delta != "" || decoder.Input() != "patch" { + t.Fatalf("push after finish = %q, %v, input %q", delta, errPush, decoder.Input()) + } +} diff --git a/internal/translator/common/apply_patch_responses.go b/internal/translator/common/apply_patch_responses.go new file mode 100644 index 000000000..5229ada36 --- /dev/null +++ b/internal/translator/common/apply_patch_responses.go @@ -0,0 +1,716 @@ +package common + +import ( + "errors" + "fmt" + "strings" + + applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch" + "github.com/router-for-me/CLIProxyAPI/v8/internal/util" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// NormalizeApplyPatchResponsesRequest adapts declarations and explicit custom patch history. +// Winners are collected before rewriting: normalization must not change declaration precedence. +func NormalizeApplyPatchResponsesRequest(raw []byte) ([]byte, error) { + if !gjson.ValidBytes(raw) { + return raw, errors.New("invalid Responses request JSON") + } + root := gjson.ParseBytes(raw) + winners := util.CollectResponsesToolWinners(root) + affected := map[string]bool{} + for _, d := range util.CollectResponsesToolDescriptors(root) { + if applypatch.IsCustomTool(d.Tool) { + affected[d.Name] = true + } + } + var normalizeTools func(gjson.Result, string) []byte + normalizeTools = func(tools gjson.Result, namespace string) []byte { + var items [][]byte + for _, tool := range tools.Array() { + item := []byte(tool.Raw) + if tool.Get("type").String() == "namespace" { + for _, key := range []string{"tools", "children"} { + if children := tool.Get(key); children.IsArray() { + item, _ = sjson.SetRawBytes(item, key, normalizeTools(children, tool.Get("name").String())) + break + } + } + } else { + name := tool.Get("name").String() + if name == "" { + name = tool.Get("function.name").String() + } + qualified := util.QualifyResponsesNamespaceToolName(namespace, name) + winner, ok := winners[qualified] + if ok && affected[qualified] { + if winner.Tool.Index != tool.Index { + continue + } + if applypatch.IsCustomTool(tool) { + item, _ = sjson.SetBytes(item, "type", "function") + item, _ = sjson.SetBytes(item, "description", applypatch.Description(tool)) + item, _ = sjson.SetRawBytes(item, "parameters", applypatch.Parameters()) + item, _ = sjson.DeleteBytes(item, "format") + } + } + } + items = append(items, item) + } + return JoinRawArray(items) + } + if tools := root.Get("tools"); tools.IsArray() { + raw, _ = sjson.SetRawBytes(raw, "tools", normalizeTools(tools, "")) + } + patchHistory := map[string]bool{} + for _, item := range root.Get("input").Array() { + if item.Get("type").String() == "custom_tool_call" && strings.TrimSpace(item.Get("name").String()) == "apply_patch" { + patchHistory[item.Get("call_id").String()] = true + } + } + for i, item := range root.Get("input").Array() { + path := fmt.Sprintf("input.%d", i) + switch item.Get("type").String() { + case "additional_tools": + if tools := item.Get("tools"); tools.IsArray() { + raw, _ = sjson.SetRawBytes(raw, path+".tools", normalizeTools(tools, "")) + } + case "custom_tool_call": + if strings.TrimSpace(item.Get("name").String()) != "apply_patch" { + continue + } + if item.Get("input").Type != gjson.String { + return nil, errors.New("apply_patch history input must be a string") + } + raw, _ = sjson.SetBytes(raw, path+".type", "function_call") + raw, _ = sjson.SetBytes(raw, path+".arguments", applypatch.WrapInput(item.Get("input").String())) + raw, _ = sjson.DeleteBytes(raw, path+".input") + case "custom_tool_call_output": + if patchHistory[item.Get("call_id").String()] { + raw, _ = sjson.SetBytes(raw, path+".type", "function_call_output") + } + } + } + var normalizeChoice func(gjson.Result) []byte + normalizeChoice = func(choice gjson.Result) []byte { + out := []byte(choice.Raw) + name := choice.Get("name").String() + namespace := choice.Get("namespace").String() + if d, ok := winners[util.QualifyResponsesNamespaceToolName(namespace, name)]; ok && applypatch.IsCustomTool(d.Tool) && choice.Get("type").String() == "custom" { + out, _ = sjson.SetBytes(out, "type", "function") + } + for i, child := range choice.Get("tools").Array() { + out, _ = sjson.SetRawBytes(out, fmt.Sprintf("tools.%d", i), normalizeChoice(child)) + } + return out + } + if choice := root.Get("tool_choice"); choice.IsObject() { + raw, _ = sjson.SetRawBytes(raw, "tool_choice", normalizeChoice(choice)) + } + return raw, nil +} + +type responsesPatchRecord struct { + state ApplyPatchCallState + kind, qualified, source string + patch, named, added, inputDone, itemDone bool + snapshot, completedItem string + hasSnapshot bool + pending [][]byte + evidence error +} + +// ApplyPatchResponsesBridge handles JSON event payloads, without SSE framing. +// Identity evidence is retained even before a call receives its name. +// A bridge is local to one response and must not be shared between goroutines. +type ApplyPatchResponsesBridge struct { + ApplyPatchErrorState + tools map[string]util.ResponsesToolDescriptor + records []*responsesPatchRecord + byItemID map[string]*responsesPatchRecord + byCallID map[string]*responsesPatchRecord + byOutputIndex map[int]*responsesPatchRecord + sequence, lastSequence int + responseID string + failed, terminal, active, converted bool +} + +// NewApplyPatchResponsesBridge resolves original declarations before any normalization. +func NewApplyPatchResponsesBridge(originalRequest []byte) *ApplyPatchResponsesBridge { + b := &ApplyPatchResponsesBridge{tools: util.CollectResponsesToolWinners(gjson.ParseBytes(originalRequest)), byItemID: map[string]*responsesPatchRecord{}, byCallID: map[string]*responsesPatchRecord{}, byOutputIndex: map[int]*responsesPatchRecord{}} + for _, d := range b.tools { + if applypatch.IsCustomTool(d.Tool) { + b.active = true + } + } + return b +} + +func (b *ApplyPatchResponsesBridge) next() int { b.sequence++; return b.sequence } +func (b *ApplyPatchResponsesBridge) failure(err error) ([][]byte, error) { + if b.failed || b.terminal { + return nil, nil + } + b.failed = true + b.SetToolInputError(err) + return [][]byte{ApplyPatchFailure(b.responseID, b.next())}, err +} + +// Fail terminates an executor-owned bridge with the same one-shot failure contract. +func (b *ApplyPatchResponsesBridge) Fail(err error) ([][]byte, error) { return b.failure(err) } + +func (b *ApplyPatchResponsesBridge) descriptor(item gjson.Result) (util.ResponsesToolDescriptor, bool) { + name := util.QualifyResponsesNamespaceToolName(item.Get("namespace").String(), item.Get("name").String()) + d, ok := b.tools[name] + return d, ok +} + +// resolve checks every supplied identity, not just the first usable key. Conflicting +// unmatched keys and multiple matched records retain evidence until patch provenance is known. +func (b *ApplyPatchResponsesBridge) resolve(event, item gjson.Result) (*responsesPatchRecord, error) { + ids := []string{event.Get("item_id").String(), item.Get("id").String()} + calls := []string{event.Get("call_id").String(), item.Get("call_id").String()} + index := event.Get("output_index") + matched := map[*responsesPatchRecord]bool{} + for _, id := range ids { + if r := b.byItemID[id]; id != "" && r != nil { + matched[r] = true + } + } + for _, id := range calls { + if r := b.byCallID[id]; id != "" && r != nil { + matched[r] = true + } + } + if index.Exists() { + if r := b.byOutputIndex[int(index.Int())]; r != nil { + matched[r] = true + } + } + var r *responsesPatchRecord + for _, candidate := range b.records { + if matched[candidate] { + r = candidate + break + } + } + if r == nil { + r = &responsesPatchRecord{state: ApplyPatchCallState{OutputIndex: -1}} + b.records = append(b.records, r) + } + bad := len(matched) > 1 + for _, id := range ids { + if id != "" && r.state.ItemID != "" && r.state.ItemID != id { + bad = true + } + } + for _, id := range calls { + if id != "" && r.state.CallID != "" && r.state.CallID != id { + bad = true + } + } + if ids[0] != "" && ids[1] != "" && ids[0] != ids[1] { + bad = true + } + if calls[0] != "" && calls[1] != "" && calls[0] != calls[1] { + bad = true + } + if index.Exists() && r.state.OutputIndex >= 0 && r.state.OutputIndex != int(index.Int()) { + bad = true + } + d, known := b.descriptor(item) + incomingPatch := known && applypatch.IsCustomTool(d.Tool) && item.Get("type").String() != "custom_tool_call" + if bad { + errIdentity := errors.New("conflicting apply_patch call identity") + r.evidence = errIdentity + for candidate := range matched { + candidate.evidence = errIdentity + if candidate.patch { + incomingPatch = true + } + } + // Keep aliases for unmatched conflicting keys, too. Their later patch provenance + // must not create a fresh record and erase the earlier contradiction. + for _, id := range ids { + if id != "" && b.byItemID[id] == nil { + b.byItemID[id] = r + } + } + for _, id := range calls { + if id != "" && b.byCallID[id] == nil { + b.byCallID[id] = r + } + } + if index.Exists() && b.byOutputIndex[int(index.Int())] == nil { + b.byOutputIndex[int(index.Int())] = r + } + if r.patch || incomingPatch { + return nil, errIdentity + } + } else { + for _, id := range ids { + if id != "" { + r.state.ItemID = id + b.byItemID[id] = r + } + } + for _, id := range calls { + if id != "" { + r.state.CallID = id + b.byCallID[id] = r + } + } + if index.Exists() { + r.state.OutputIndex = int(index.Int()) + b.byOutputIndex[r.state.OutputIndex] = r + } + } + kind := item.Get("type").String() + if kind != "" { + if r.kind != "" && r.kind != kind { + r.evidence = errors.New("conflicting apply_patch call type") + } + if r.kind == "" { + r.kind = kind + } + } + name := item.Get("name").String() + if name != "" { + qualified := util.QualifyResponsesNamespaceToolName(item.Get("namespace").String(), name) + if known { + qualified = d.Name + } + if r.named && r.qualified != qualified { + r.evidence = errors.New("conflicting apply_patch call name") + } + r.named = true + r.qualified = qualified + r.state.Name = name + r.state.Namespace = item.Get("namespace").String() + if known { + r.state.Name = d.LocalName + r.state.Namespace = d.Namespace + } + } + if incomingPatch { + r.patch = true + } + if r.patch && r.evidence != nil { + return nil, r.evidence + } + return r, nil +} + +// CheckIdentity retains every supplied key before a folded dispatcher reveals its child. +// Names are deliberately withheld: a dispatcher name is not the selected child name. +func (b *ApplyPatchResponsesBridge) CheckIdentity(event []byte) error { + root := gjson.ParseBytes(event) + item := root.Get("item") + if item.Exists() { + raw := []byte(item.Raw) + raw, _ = sjson.DeleteBytes(raw, "name") + raw, _ = sjson.DeleteBytes(raw, "namespace") + item = gjson.ParseBytes(raw) + } + _, errResolve := b.resolve(root, item) + return errResolve +} + +func (b *ApplyPatchResponsesBridge) restoreItem(item []byte, r *responsesPatchRecord, input string, added bool) []byte { + if r.patch { + item, _ = sjson.SetBytes(item, "type", "custom_tool_call") + item, _ = sjson.DeleteBytes(item, "arguments") + item, _ = sjson.SetBytes(item, "input", input) + } + if d, ok := b.tools[r.qualified]; ok && d.Namespace != "" { + item, _ = sjson.SetBytes(item, "name", d.LocalName) + item, _ = sjson.SetBytes(item, "namespace", d.Namespace) + } + if r.patch && !added { + if r.state.ItemID != "" { + item, _ = sjson.SetBytes(item, "id", r.state.ItemID) + } + if r.state.CallID != "" { + item, _ = sjson.SetBytes(item, "call_id", r.state.CallID) + } + item, _ = sjson.SetBytes(item, "name", r.state.Name) + } + return item +} + +func (b *ApplyPatchResponsesBridge) itemEvent(kind string, item []byte, r *responsesPatchRecord) []byte { + out := []byte(`{"type":"","output_index":0,"sequence_number":0,"item":{}}`) + out, _ = sjson.SetBytes(out, "type", kind) + out, _ = sjson.SetBytes(out, "output_index", r.state.OutputIndex) + out, _ = sjson.SetBytes(out, "sequence_number", b.next()) + out, _ = sjson.SetRawBytes(out, "item", item) + return out +} + +func (b *ApplyPatchResponsesBridge) snapshot(r *responsesPatchRecord, arguments gjson.Result, final bool) error { + if !arguments.Exists() { + return nil + } + if arguments.Type != gjson.String { + return errors.New("apply_patch arguments snapshot must be a string") + } + if arguments.String() == "" && !final { + return nil + } + var decoder ApplyPatchInputDecoder + if _, errFinish := decoder.Finish(arguments.String()); errFinish != nil { + return errFinish + } + if r.hasSnapshot { + var previous ApplyPatchInputDecoder + _, _ = previous.Finish(r.snapshot) + if previous.Input() != decoder.Input() { + return errors.New("conflicting apply_patch arguments snapshot") + } + } + if !strings.HasPrefix(decoder.Input(), r.state.Decoder.Input()) { + return errors.New("apply_patch snapshot conflicts with streamed input") + } + r.snapshot = arguments.String() + r.hasSnapshot = true + return nil +} + +func (r *responsesPatchRecord) identityReady() bool { + return r.state.ItemID != "" && r.state.CallID != "" && r.state.OutputIndex >= 0 +} + +func (b *ApplyPatchResponsesBridge) patchEvent(raw []byte, r *responsesPatchRecord) ([][]byte, error) { + if !r.identityReady() { + return nil, errors.New("unresolved apply_patch call identity") + } + root := gjson.ParseBytes(raw) + kind := root.Get("type").String() + item := root.Get("item") + b.converted = true + var out [][]byte + if item.Exists() { + if item.Get("type").String() != "function_call" { + return nil, errors.New("conflicting apply_patch call type") + } + if errSnapshot := b.snapshot(r, item.Get("arguments"), kind == "response.output_item.done"); errSnapshot != nil { + return nil, errSnapshot + } + } + if !r.added { + added := []byte(`{"type":"function_call","name":"","arguments":""}`) + if item.Exists() { + added = []byte(item.Raw) + } + added, _ = sjson.SetBytes(added, "name", r.state.Name) + if r.state.ItemID != "" { + added, _ = sjson.SetBytes(added, "id", r.state.ItemID) + } + if r.state.CallID != "" { + added, _ = sjson.SetBytes(added, "call_id", r.state.CallID) + } + if r.state.Namespace != "" { + added, _ = sjson.SetBytes(added, "namespace", r.state.Namespace) + } + out = append(out, b.itemEvent("response.output_item.added", b.restoreItem(added, r, "", true), r)) + r.added = true + } + switch kind { + case "response.function_call_arguments.delta": + fragment := root.Get("delta").String() + if r.inputDone { + if fragment != "" { + return nil, errors.New("apply_patch arguments received after completion") + } + return out, nil + } + r.source += fragment + delta, errPush := r.state.PushArguments(fragment) + if errPush != nil { + return nil, errPush + } + if r.hasSnapshot { + var snapshot ApplyPatchInputDecoder + _, _ = snapshot.Finish(r.snapshot) + if !strings.HasPrefix(snapshot.Input(), r.state.Decoder.Input()) { + return nil, errors.New("apply_patch stream conflicts with snapshot") + } + } + if delta != "" { + out = append(out, ApplyPatchInputDelta(&r.state, delta, b.next())) + } + case "response.function_call_arguments.done", "response.output_item.done": + arguments := root.Get("arguments") + if item.Exists() { + arguments = item.Get("arguments") + } + if arguments.Exists() { + if errSnapshot := b.snapshot(r, arguments, true); errSnapshot != nil { + return nil, errSnapshot + } + } + final := r.source + if r.hasSnapshot { + final = r.snapshot + } + tail, input, errFinish := r.state.FinishArguments(final) + if errFinish != nil { + return nil, errFinish + } + if !r.inputDone { + if tail != "" && r.source != "" { + out = append(out, ApplyPatchInputDelta(&r.state, tail, b.next())) + } + out = append(out, ApplyPatchInputDone(&r.state, input, b.next())) + r.inputDone = true + } + if kind == "response.output_item.done" && !r.itemDone { + r.completedItem = string(b.restoreItem([]byte(item.Raw), r, input, false)) + out = append(out, b.itemEvent(kind, []byte(r.completedItem), r)) + r.itemDone = true + } + } + return out, nil +} + +func (b *ApplyPatchResponsesBridge) transformItemEvent(raw []byte) ([][]byte, error) { + root := gjson.ParseBytes(raw) + item := root.Get("item") + if !item.Exists() && root.Get("name").Exists() { + // Arguments events can supply late names and identities at the root. + identity := []byte(`{"type":"function_call"}`) + for _, key := range []string{"name", "namespace", "call_id"} { + if value := root.Get(key); value.Exists() { + identity, _ = sjson.SetBytes(identity, key, value.Value()) + } + } + item = gjson.ParseBytes(identity) + } + r, errResolve := b.resolve(root, item) + if errResolve != nil { + return nil, errResolve + } + // A known name is provenance, not readiness. Retain the real source events + // until both upstream IDs and the output index can identify every emitted event. + if (!r.named && (r.kind == "" || r.kind == "function_call")) || (r.patch && !r.identityReady()) { + if r.patch { + arguments := root.Get("arguments") + if root.Get("item").Exists() { + arguments = item.Get("arguments") + } + kind := root.Get("type").String() + if errSnapshot := b.snapshot(r, arguments, kind == "response.output_item.done" || kind == "response.function_call_arguments.done"); errSnapshot != nil { + return nil, errSnapshot + } + } + r.pending = append(r.pending, append([]byte(nil), raw...)) + return nil, nil + } + var out [][]byte + pending := r.pending + r.pending = nil + if r.patch { + for _, event := range append(pending, raw) { + converted, errEvent := b.patchEvent(event, r) + if errEvent != nil { + return nil, errEvent + } + out = append(out, converted...) + } + return out, nil + } + out = append(out, pending...) + if root.Get("item").Exists() && r.kind == "function_call" && b.tools[r.qualified].Namespace != "" { + raw, _ = sjson.SetRawBytes(raw, "item", b.restoreItem([]byte(item.Raw), r, "", false)) + } + if root.Get("type").String() == "response.output_item.done" && item.Exists() { + r.itemDone = true + r.completedItem = gjson.GetBytes(raw, "item").Raw + } + return append(out, raw), nil +} + +func (b *ApplyPatchResponsesBridge) envelope(raw []byte, stream bool) ([]byte, [][]byte, error) { + root := gjson.ParseBytes(raw) + path := "output" + response := root + if root.Get("response").Exists() { + path = "response.output" + response = root.Get("response") + } + var preceding [][]byte + seen := map[*responsesPatchRecord]bool{} + var items [][]byte + for i, item := range response.Get("output").Array() { + index := i + // A terminal snapshot may omit earlier completed items: array position is not identity. + known := b.byItemID[item.Get("id").String()] + if known == nil { + known = b.byCallID[item.Get("call_id").String()] + } + if known != nil && known.state.OutputIndex >= 0 { + index = known.state.OutputIndex + } else if known == nil && (item.Get("id").String() != "" || item.Get("call_id").String() != "") { + if previous := b.byOutputIndex[index]; previous != nil && (previous.state.ItemID != "" || previous.state.CallID != "") { + for _, r := range b.records { + if r.state.OutputIndex >= index { + index = r.state.OutputIndex + 1 + } + } + } + } + event := gjson.ParseBytes(patchEnvelopeItem(index, item)) + r, errResolve := b.resolve(event, item) + if errResolve != nil { + return nil, nil, errResolve + } + seen[r] = true + if r.patch { + pending := r.pending + r.pending = nil + for _, source := range pending { + events, errEvent := b.patchEvent(source, r) + if errEvent != nil { + return nil, nil, errEvent + } + preceding = append(preceding, events...) + } + events, errEvent := b.patchEvent([]byte(event.Raw), r) + if errEvent != nil { + return nil, nil, errEvent + } + preceding = append(preceding, events...) + raw, _ = sjson.SetRawBytes(raw, fmt.Sprintf("%s.%d", path, i), b.restoreItem([]byte(item.Raw), r, r.state.Decoder.Input(), false)) + } else if item.Get("type").String() == "function_call" && b.tools[r.qualified].Namespace != "" { + raw, _ = sjson.SetRawBytes(raw, fmt.Sprintf("%s.%d", path, i), b.restoreItem([]byte(item.Raw), r, "", false)) + } + items = append(items, []byte(gjson.GetBytes(raw, fmt.Sprintf("%s.%d", path, i)).Raw)) + } + for _, r := range b.records { + if seen[r] || (!r.patch && !b.converted) { + continue + } + if r.inputDone && !r.itemDone { + item := []byte(`{"type":"function_call","status":"completed"}`) + r.completedItem = string(b.restoreItem(item, r, r.state.Decoder.Input(), false)) + preceding = append(preceding, b.itemEvent("response.output_item.done", []byte(r.completedItem), r)) + r.itemDone = true + } + if r.itemDone { + index := r.state.OutputIndex + if index < 0 || index > len(items) { + index = len(items) + } + items = append(items, nil) + copy(items[index+1:], items[index:]) + items[index] = []byte(r.completedItem) + } + } + if len(items) != len(response.Get("output").Array()) { + raw, _ = sjson.SetRawBytes(raw, path, JoinRawArray(items)) + } + if stream { + if errFinish := b.Finish(); errFinish != nil { + return nil, nil, errFinish + } + for _, r := range b.records { + preceding = append(preceding, r.pending...) + r.pending = nil + } + if b.converted { + raw, _ = sjson.SetBytes(raw, "sequence_number", b.next()) + } + } + return raw, preceding, nil +} + +func patchEnvelopeItem(index int, item gjson.Result) []byte { + out := []byte(`{"type":"response.output_item.done","output_index":0,"item":{}}`) + out, _ = sjson.SetBytes(out, "output_index", index) + out, _ = sjson.SetRawBytes(out, "item", []byte(item.Raw)) + return out +} + +// Transform converts one payload. A failure is terminal and is emitted only once. +func (b *ApplyPatchResponsesBridge) Transform(event []byte) ([][]byte, error) { + if b.failed || b.terminal { + return nil, nil + } + if !b.active { + return [][]byte{event}, nil + } + root := gjson.ParseBytes(event) + if id := root.Get("response.id").String(); id != "" { + b.responseID = id + } + if seq := int(root.Get("sequence_number").Int()); seq > b.sequence { + b.sequence = seq + } + kind := root.Get("type").String() + var out [][]byte + var errTransform error + switch kind { + case "response.output_item.added", "response.output_item.done", "response.function_call_arguments.delta", "response.function_call_arguments.done": + out, errTransform = b.transformItemEvent(event) + case "response.completed", "response.incomplete", "response.done": + var final []byte + final, out, errTransform = b.envelope(event, true) + if errTransform == nil { + out = append(out, final) + b.terminal = true + } + case "response.failed": + b.terminal = true + out = [][]byte{event} + default: + out = [][]byte{event} + } + if errTransform != nil { + return b.failure(errTransform) + } + for i, event := range out { + seq := int(gjson.GetBytes(event, "sequence_number").Int()) + // Native custom payloads are opaque. Only a stream that acquired a function + // patch call needs resequencing of its other compatibility events. + nativeCustom := strings.HasPrefix(kind, "response.custom_tool_call_input.") || root.Get("item.type").String() == "custom_tool_call" + if b.converted && !nativeCustom && seq <= b.lastSequence { + seq = b.next() + out[i], _ = sjson.SetBytes(event, "sequence_number", seq) + } + if seq > b.lastSequence { + b.lastSequence = seq + } + } + return out, nil +} + +// TransformNonStream accepts either a bare response or a terminal event envelope. +func (b *ApplyPatchResponsesBridge) TransformNonStream(response []byte) ([]byte, error) { + if !b.active { + return response, nil + } + out, _, errEnvelope := b.envelope(response, false) + if errEnvelope != nil { + b.failed = true + b.SetToolInputError(errEnvelope) + return nil, errEnvelope + } + return out, nil +} + +// Finish rejects acquired calls whose final arguments have not been validated. +func (b *ApplyPatchResponsesBridge) Finish() error { + if b.ToolInputError() != nil { + return b.ToolInputError() + } + if b.terminal { + return nil + } + for _, r := range b.records { + if r.patch && !r.inputDone { + return errors.New("incomplete apply_patch tool arguments received from upstream") + } + } + return nil +} diff --git a/internal/translator/common/apply_patch_responses_test.go b/internal/translator/common/apply_patch_responses_test.go new file mode 100644 index 000000000..54b47f5f9 --- /dev/null +++ b/internal/translator/common/apply_patch_responses_test.go @@ -0,0 +1,410 @@ +package common + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch" + "github.com/tidwall/gjson" +) + +var patchResponsesRequest = []byte(`{"tools":[{"type":"custom","name":"apply_patch"}]}`) + +func patchJSON(value any) string { raw, _ := json.Marshal(value); return string(raw) } +func patchItem(kind, id, call, name, args string) string { + return fmt.Sprintf(`{"type":%s,"id":%s,"call_id":%s,"name":%s,"arguments":%s}`, patchJSON(kind), patchJSON(id), patchJSON(call), patchJSON(name), patchJSON(args)) +} +func patchEvent(kind string, index int, item string) []byte { + return []byte(fmt.Sprintf(`{"type":%s,"output_index":%d,"item":%s}`, patchJSON(kind), index, item)) +} +func patchSend(t *testing.T, b *ApplyPatchResponsesBridge, event []byte) [][]byte { + t.Helper() + out, errTransform := b.Transform(event) + if errTransform != nil { + t.Fatalf("Transform(%s): %v", event, errTransform) + } + return out +} + +// Raw source fragments must survive decoding without inventing a final snapshot. +func TestApplyPatchResponsesBridgeDeltaAndFourCompletions(t *testing.T) { + for _, late := range []bool{false, true} { + t.Run(fmt.Sprintf("late=%v", late), func(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + name := "apply_patch" + if late { + name = "" + } + var events [][]byte + events = append(events, patchSend(t, b, patchEvent("response.output_item.added", 0, patchItem("function_call", "", "", name, "")))...) + args := `{"input":"*** Begin Patch\n+中文\uD83D\uDE00 \"\\\n*** End Patch\n"}` + for _, part := range []string{args[:18], args[18:35], args[35:41], args[41:]} { + events = append(events, patchSend(t, b, []byte(fmt.Sprintf(`{"type":"response.function_call_arguments.delta","output_index":0,"delta":%s}`, patchJSON(part))))...) + } + item := patchItem("function_call", "fc1", "c1", "apply_patch", args) + events = append(events, patchSend(t, b, patchEvent("response.output_item.done", 0, item))...) + events = append(events, patchSend(t, b, []byte(`{"type":"response.completed","response":{"id":"r1","output":[`+item+`]}}`))...) + want, _ := applypatch.UnwrapInput(args) + var delta strings.Builder + counts := map[string]int{} + lastSequence := int64(-1) + for _, event := range events { + root := gjson.ParseBytes(event) + kind := root.Get("type").String() + counts[kind]++ + seq := root.Get("sequence_number").Int() + if seq <= lastSequence { + t.Fatalf("sequence not increasing: %s", event) + } + lastSequence = seq + switch kind { + case "response.custom_tool_call_input.delta": + delta.WriteString(root.Get("delta").String()) + case "response.custom_tool_call_input.done": + if root.Get("input").String() != want || root.Get("item_id").String() != "fc1" || root.Get("call_id").String() != "c1" { + t.Fatalf("input done: %s", event) + } + case "response.output_item.done": + if root.Get("item.input").String() != want || root.Get("item.arguments").Exists() { + t.Fatalf("item done: %s", event) + } + case "response.completed": + if root.Get("response.output.0.input").String() != want { + t.Fatalf("envelope: %s", event) + } + } + } + if delta.String() != want || counts["response.custom_tool_call_input.done"] != 1 || counts["response.output_item.done"] != 1 || counts["response.completed"] != 1 { + t.Fatalf("counts=%v delta=%q want=%q", counts, delta.String(), want) + } + if errFinish := b.Finish(); errFinish != nil { + t.Fatal(errFinish) + } + if more := patchSend(t, b, []byte(`{"type":"response.completed","response":{"output":[]}}`)); len(more) != 0 { + t.Fatalf("duplicate terminal: %s", more) + } + }) + } +} + +func TestApplyPatchResponsesBridgePassthrough(t *testing.T) { + for _, req := range [][]byte{patchResponsesRequest, []byte(`{"tools":[{"type":"function","name":"apply_patch"}]}`), []byte(`{}`)} { + b := NewApplyPatchResponsesBridge(req) + for _, event := range [][]byte{ + []byte(`{ "type":"response.output_item.added", "output_index":0,"item":{"type":"custom_tool_call","id":"native","name":"apply_patch","input":""}}`), + []byte(`{ "type":"response.custom_tool_call_input.delta", "item_id":"native","delta":"raw patch"}`), + patchEvent("response.output_item.done", 1, patchItem("function_call", "ordinary", "ordinary", "lookup", `{"x":1}`)), + } { + out := patchSend(t, b, event) + if len(out) != 1 || string(out[0]) != string(event) { + t.Fatalf("changed native/ordinary: %s", out) + } + } + } +} + +func TestApplyPatchResponsesBridgeIdentityAndSnapshotEvidence(t *testing.T) { + cases := []struct { + name string + before [][]byte + after []byte + }{ + {"index-item", [][]byte{patchEvent("response.output_item.added", 0, patchItem("function_call", "a", "ca", "apply_patch", "")), patchEvent("response.output_item.added", 1, patchItem("function_call", "b", "cb", "lookup", ""))}, []byte(`{"type":"response.function_call_arguments.delta","output_index":1,"item_id":"a","delta":"{}"}`)}, + {"call-item", [][]byte{patchEvent("response.output_item.added", 0, patchItem("function_call", "a", "ca", "apply_patch", ""))}, []byte(`{"type":"response.function_call_arguments.done","output_index":0,"item_id":"a","call_id":"other","arguments":"{\"input\":\"p\"}"}`)}, + {"type-before-name", [][]byte{patchEvent("response.output_item.added", 0, patchItem("message", "a", "ca", "", ""))}, patchEvent("response.output_item.done", 0, patchItem("function_call", "a", "ca", "apply_patch", `{"input":"p"}`))}, + {"invalid-before-name", [][]byte{patchEvent("response.output_item.added", 0, patchItem("function_call", "a", "ca", "", `{"input":"p","extra":1}`))}, patchEvent("response.output_item.done", 0, patchItem("function_call", "a", "ca", "apply_patch", `{"input":"p"}`))}, + {"partial-snapshot", nil, patchEvent("response.output_item.added", 0, patchItem("function_call", "a", "ca", "apply_patch", `{"input":"p`))}, + {"invalid-final-only", nil, []byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","name":"apply_patch","arguments":"{}"}]}}`)}, + {"old-patch-new-type", [][]byte{patchEvent("response.output_item.added", 0, patchItem("function_call", "a", "ca", "apply_patch", ""))}, []byte(`{"type":"response.completed","response":{"output":[{"type":"message","id":"a","content":[]}]}}`)}, + {"pending-id-evidence", [][]byte{patchEvent("response.output_item.added", 0, patchItem("function_call", "a", "ca", "", ""))}, patchEvent("response.output_item.done", 0, patchItem("function_call", "a", "changed", "apply_patch", `{"input":"p"}`))}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + for _, e := range tc.before { + patchSend(t, b, e) + } + out, errTransform := b.Transform(tc.after) + if errTransform == nil || len(out) != 1 || gjson.GetBytes(out[0], "type").String() != "response.failed" || b.ToolInputError() == nil { + t.Fatalf("unsafe evidence accepted: %s %v", out, errTransform) + } + if more, errMore := b.Transform([]byte(`{"type":"response.completed","response":{"output":[]}}`)); len(more) != 0 || errMore != nil { + t.Fatalf("post-failure output: %s %v", more, errMore) + } + }) + } +} + +func TestApplyPatchResponsesBridgeStagesAndFinish(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + patchSend(t, b, patchEvent("response.output_item.added", 0, patchItem("function_call", "a", "ca", "apply_patch", ""))) + if errFinish := b.Finish(); errFinish == nil { + t.Fatal("incomplete call accepted") + } + b = NewApplyPatchResponsesBridge(patchResponsesRequest) + for i := 0; i < 2; i++ { + patchSend(t, b, patchEvent("response.output_item.added", i, patchItem("function_call", fmt.Sprint("a", i), fmt.Sprint("c", i), "apply_patch", ""))) + args := applypatch.WrapInput(fmt.Sprint("patch", i)) + out := patchSend(t, b, []byte(fmt.Sprintf(`{"type":"response.function_call_arguments.done","output_index":%d,"arguments":%s}`, i, patchJSON(args)))) + if len(out) != 1 { + t.Fatalf("missing input.done: %s", out) + } + out = patchSend(t, b, patchEvent("response.output_item.done", i, patchItem("function_call", fmt.Sprint("a", i), fmt.Sprint("c", i), "apply_patch", args))) + if len(out) != 1 || gjson.GetBytes(out[0], "item.input").String() != fmt.Sprint("patch", i) { + t.Fatalf("duplicate arguments: %s", out) + } + if out = patchSend(t, b, patchEvent("response.output_item.done", i, patchItem("function_call", fmt.Sprint("a", i), fmt.Sprint("c", i), "apply_patch", args))); len(out) != 0 { + t.Fatalf("duplicate done: %s", out) + } + } + if errFinish := b.Finish(); errFinish != nil { + t.Fatal(errFinish) + } +} + +func TestApplyPatchResponsesRequestHistoryAndWinners(t *testing.T) { + for _, req := range []string{ + `{"tools":[{"type":"custom","name":"apply_patch"}],"input":[]}`, + `{"tools":[{"type":"function","name":"apply_patch"}],"input":[]}`, + `{"input":[]}`, + } { + root := gjson.Parse(req) + _ = root + raw := strings.Replace(req, `"input":[]`, `"input":[{"type":"custom_tool_call","call_id":"old","name":"apply_patch","input":"{\"input\":\"raw\"}"},{"type":"custom_tool_call_output","call_id":"old","output":"ok"},{"type":"function_call","call_id":"fn","name":"apply_patch","arguments":"{\"input\":\"existing\"}"}]`, 1) + out, errNormalize := NormalizeApplyPatchResponsesRequest([]byte(raw)) + if errNormalize != nil { + t.Fatal(errNormalize) + } + if gjson.GetBytes(out, "input.0.arguments").String() != applypatch.WrapInput(`{"input":"raw"}`) || gjson.GetBytes(out, "input.1.type").String() != "function_call_output" || gjson.GetBytes(out, "input.2.arguments").String() != `{"input":"existing"}` { + t.Fatalf("history: %s", out) + } + if strings.Contains(req, `"type":"function"`) && gjson.GetBytes(out, "tools.0.parameters").Exists() { + t.Fatalf("ordinary declaration changed: %s", out) + } + } + for _, req := range []string{ + `{"tools":[{"type":"function","name":"apply_patch","description":"ordinary"}],"input":[{"type":"additional_tools","tools":[{"type":"custom","name":"apply_patch"}]}]}`, + `{"tools":[{"type":"namespace","name":"n","tools":[{"type":"custom","name":"apply_patch"}]},{"type":"function","name":"n__apply_patch","description":"ordinary"}]}`, + } { + out, errNormalize := NormalizeApplyPatchResponsesRequest([]byte(req)) + if errNormalize != nil { + t.Fatal(errNormalize) + } + winners := NewApplyPatchResponsesBridge([]byte(req)) + item := patchItem("function_call", "a", "c", "apply_patch", `{"x":1}`) + if strings.Contains(req, `"namespace"`) { + item = patchItem("function_call", "a", "c", "n__apply_patch", `{"x":1}`) + } + got := patchSend(t, winners, patchEvent("response.output_item.done", 0, item)) + if len(got) != 1 || gjson.GetBytes(got[0], "item.type").String() != "function_call" { + t.Fatalf("loser stole ordinary identity: %s normalized=%s", got, out) + } + } +} + +func TestApplyPatchResponsesNamespaceMixedNonStream(t *testing.T) { + req := []byte(`{"tools":[{"type":"namespace","name":"n","tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"lookup"}]}]}`) + b := NewApplyPatchResponsesBridge(req) + raw := []byte(`{"id":"r","output":[` + patchItem("function_call", "a", "c", "n__apply_patch", applypatch.WrapInput("p")) + `,` + patchItem("function_call", "b", "d", "n__lookup", `{"x":1}`) + `]}`) + out, errTransform := b.TransformNonStream(raw) + if errTransform != nil { + t.Fatal(errTransform) + } + if gjson.GetBytes(out, "output.0.type").String() != "custom_tool_call" || gjson.GetBytes(out, "output.0.name").String() != "apply_patch" || gjson.GetBytes(out, "output.0.namespace").String() != "n" || gjson.GetBytes(out, "output.1.name").String() != "lookup" || gjson.GetBytes(out, "output.1.namespace").String() != "n" || gjson.GetBytes(out, "output.1.arguments").String() != `{"x":1}` { + t.Fatalf("namespace routing: %s", out) + } +} + +func TestApplyPatchResponsesContinuationMissingSnapshots(t *testing.T) { + for _, source := range []string{"deltas", "arguments.done"} { + t.Run(source, func(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + patchSend(t, b, patchEvent("response.output_item.added", 0, patchItem("function_call", "a", "c", "apply_patch", ""))) + kind, field := "response.function_call_arguments.done", "arguments" + if source == "deltas" { + kind, field = "response.function_call_arguments.delta", "delta" + } + patchSend(t, b, []byte(fmt.Sprintf(`{"type":%s,"item_id":"a",%s:%s}`, patchJSON(kind), patchJSON(field), patchJSON(applypatch.WrapInput("p"))))) + out := patchSend(t, b, []byte(`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":"apply_patch"}}`)) + if gjson.GetBytes(out[len(out)-1], "item.input").String() != "p" { + t.Fatalf("missing snapshot: %s", out) + } + out = patchSend(t, b, []byte(`{"type":"response.completed","response":{"output":[]}}`)) + if gjson.GetBytes(out[len(out)-1], "response.output.0.input").String() != "p" { + t.Fatalf("omitted completed item: %s", out) + } + }) + } +} + +func TestApplyPatchResponsesContinuationNativeTerminalBytes(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + for _, e := range [][]byte{ + []byte(`{ "type":"response.output_item.done", "output_index":0, "item":{"type":"custom_tool_call","id":"n","name":"apply_patch","input":"raw"}}`), + []byte(`{ "type":"response.completed", "sequence_number":71, "response": {"output":[{"type":"custom_tool_call","id":"n","name":"apply_patch","input":"raw"}]}}`), + } { + out := patchSend(t, b, e) + if len(out) != 1 || string(out[0]) != string(e) { + t.Fatalf("native bytes changed: %s", out) + } + } +} + +func TestApplyPatchResponsesContinuationRootLateName(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + patchSend(t, b, []byte(`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a"}}`)) + out := patchSend(t, b, []byte(`{"type":"response.function_call_arguments.done","output_index":0,"item_id":"a","call_id":"c","name":"apply_patch","arguments":"{\"input\":\"p\"}"}`)) + if len(out) == 0 || gjson.GetBytes(out[len(out)-1], "type").String() != "response.custom_tool_call_input.done" { + t.Fatalf("late root name: %s", out) + } + if errFinish := b.Finish(); errFinish != nil { + t.Fatal(errFinish) + } +} + +func TestApplyPatchResponsesContinuationNoInventedPreview(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + out := patchSend(t, b, patchEvent("response.output_item.done", 0, patchItem("function_call", "a", "c", "apply_patch", applypatch.WrapInput("p")))) + for _, e := range out { + if gjson.GetBytes(e, "type").String() == "response.custom_tool_call_input.delta" { + t.Fatalf("invented progress: %s", out) + } + } +} + +func TestApplyPatchResponsesContinuationAllMatchedProvenance(t *testing.T) { + // Every key can select patch provenance, including a record other than the first match. + keys := []string{"output_index", "item_id", "call_id"} + for _, patchKey := range keys { + for _, firstKey := range keys { + if firstKey == patchKey { + continue + } + t.Run(patchKey+"-"+firstKey, func(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + patchSend(t, b, patchEvent("response.output_item.added", 0, patchItem("function_call", "a", "ca", "lookup", ""))) + patchSend(t, b, patchEvent("response.output_item.added", 1, patchItem("function_call", "b", "cb", "apply_patch", ""))) + vals := map[string]string{"output_index": "0", "item_id": `"a"`, "call_id": `"ca"`} + vals[patchKey] = map[string]string{"output_index": "1", "item_id": `"b"`, "call_id": `"cb"`}[patchKey] + e := []byte(fmt.Sprintf(`{"type":"response.function_call_arguments.delta","output_index":%s,"item_id":%s,"call_id":%s,"delta":"{}"}`, vals["output_index"], vals["item_id"], vals["call_id"])) + out, errTransform := b.Transform(e) + if errTransform == nil || len(out) != 1 || gjson.GetBytes(out[0], "type").String() != "response.failed" { + t.Fatalf("lost matched provenance: %s %v", out, errTransform) + } + }) + } + } + for _, discover := range []int{0, 1, 2} { + t.Run(fmt.Sprint("pending-", discover), func(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + for i := 0; i < 3; i++ { + patchSend(t, b, patchEvent("response.output_item.added", i, patchItem("function_call", fmt.Sprint("i", i), fmt.Sprint("c", i), "", ""))) + } + patchSend(t, b, []byte(`{"type":"response.function_call_arguments.delta","output_index":0,"item_id":"i1","call_id":"c2","delta":""}`)) + out, errTransform := b.Transform(patchEvent("response.output_item.done", discover, patchItem("function_call", fmt.Sprint("i", discover), fmt.Sprint("c", discover), "apply_patch", applypatch.WrapInput("p")))) + if errTransform == nil || len(out) != 1 { + t.Fatalf("pending evidence lost: %s %v", out, errTransform) + } + }) + } +} + +func TestApplyPatchResponsesContinuationCompletedWindow(t *testing.T) { + for _, terminal := range []bool{false, true} { + t.Run(fmt.Sprint(terminal), func(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + item := patchItem("function_call", "a", "c", "apply_patch", applypatch.WrapInput("p")) + patchSend(t, b, patchEvent("response.output_item.done", 0, item)) + if terminal { + patchSend(t, b, []byte(`{"type":"response.completed","response":{"output":[`+item+`]}}`)) + } + out, errTransform := b.Transform(patchEvent("response.output_item.done", 0, patchItem("function_call", "a", "c", "apply_patch", applypatch.WrapInput("different")))) + if terminal { + if len(out) != 0 || errTransform != nil { + t.Fatalf("closed response: %s %v", out, errTransform) + } + } else if errTransform == nil { + t.Fatalf("contradiction after item.done: %s", out) + } + }) + } +} + +func TestApplyPatchResponsesContinuationOmittedMixedCompletedItems(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + patchSend(t, b, patchEvent("response.output_item.done", 0, patchItem("function_call", "p", "cp", "apply_patch", applypatch.WrapInput("p")))) + ordinary := `{"type":"message","id":"m","content":[{"type":"output_text","text":"ok"}]}` + patchSend(t, b, patchEvent("response.output_item.done", 1, ordinary)) + out := patchSend(t, b, []byte(`{"type":"response.completed","response":{"output":[]}}`)) + if gjson.GetBytes(out[len(out)-1], "response.output.0.input").String() != "p" || gjson.GetBytes(out[len(out)-1], "response.output.1.id").String() != "m" { + t.Fatalf("completed output lost: %s", out) + } + b = NewApplyPatchResponsesBridge(patchResponsesRequest) + patchSend(t, b, patchEvent("response.output_item.done", 0, patchItem("function_call", "p", "cp", "apply_patch", applypatch.WrapInput("p")))) + out = patchSend(t, b, []byte(`{"type":"response.completed","response":{"output":[`+ordinary+`]}}`)) + if gjson.GetBytes(out[len(out)-1], "response.output.0.input").String() != "p" || gjson.GetBytes(out[len(out)-1], "response.output.1.id").String() != "m" { + t.Fatalf("new final output collided: %s", out) + } +} + +func TestApplyPatchResponsesContinuationUnmatchedIdentityEvidence(t *testing.T) { + for _, known := range []bool{false, true} { + for _, key := range []string{`"item_id":"b"`, `"call_id":"cb"`, `"output_index":1`} { + t.Run(fmt.Sprintf("known=%v/%s", known, key), func(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + if known { + patchSend(t, b, patchEvent("response.output_item.added", 0, patchItem("function_call", "a", "ca", "lookup", ""))) + } + patchSend(t, b, []byte(`{"type":"response.output_item.added","output_index":1,"item_id":"a","call_id":"ca","item":{"type":"function_call","id":"b","call_id":"cb","name":""}}`)) + out, errTransform := b.Transform([]byte(`{"type":"response.function_call_arguments.done",` + key + `,"name":"apply_patch","arguments":"{\"input\":\"p\"}"}`)) + if errTransform == nil || len(out) != 1 || gjson.GetBytes(out[0], "type").String() != "response.failed" { + t.Fatalf("unmatched-key evidence lost: %s %v", out, errTransform) + } + }) + } + } +} + +func TestApplyPatchResponsesContinuationMixedSequence(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + var events [][]byte + for _, raw := range []string{ + `{"type":"response.output_item.added","sequence_number":1,"output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":"apply_patch","arguments":""}}`, + `{"type":"response.output_item.added","sequence_number":2,"output_index":1,"item":{"type":"function_call","id":"b","name":"lookup","arguments":""}}`, + `{"type":"response.function_call_arguments.done","sequence_number":3,"output_index":0,"item_id":"a","arguments":"{\"input\":\"p\"}"}`, + `{"type":"response.output_item.done","sequence_number":4,"output_index":1,"item":{"type":"function_call","id":"b","name":"lookup","arguments":"{\"x\":1}"}}`, + `{"type":"response.completed","sequence_number":5,"response":{"output":[]}}`, + } { + events = append(events, patchSend(t, b, []byte(raw))...) + } + last := int64(-1) + for _, event := range events { + seq := gjson.GetBytes(event, "sequence_number").Int() + if seq <= last { + t.Fatalf("nonmonotonic mixed sequence: %s", events) + } + last = seq + } +} + +func TestApplyPatchResponsesContinuationOrdinaryRootNamePassthrough(t *testing.T) { + req := []byte(`{"tools":[{"type":"namespace","name":"n","tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"lookup"}]}]}`) + b := NewApplyPatchResponsesBridge(req) + raw := []byte(`{ "type":"response.function_call_arguments.done", "output_index":0,"name":"lookup","namespace":"n","arguments":"{}"}`) + out := patchSend(t, b, raw) + if len(out) != 1 || string(out[0]) != string(raw) { + t.Fatalf("ordinary root name changed: %s", out) + } +} + +func TestApplyPatchResponsesContinuationMixedNativeBytes(t *testing.T) { + b := NewApplyPatchResponsesBridge(patchResponsesRequest) + patchSend(t, b, []byte(`{"type":"response.output_item.added","sequence_number":1,"output_index":0,"item":{"type":"function_call","id":"a","name":"apply_patch","arguments":""}}`)) + native := []byte(` { "type":"response.output_item.added", "sequence_number":2, "output_index":1,"item":{"type":"custom_tool_call","id":"n","name":"apply_patch","input":""}} `) + out := patchSend(t, b, native) + if len(out) != 1 || string(out[0]) != string(native) { + t.Fatalf("native custom became bridge-owned: %s", out) + } +} diff --git a/internal/translator/gemini/openai/responses/apply_patch_review_test.go b/internal/translator/gemini/openai/responses/apply_patch_review_test.go new file mode 100644 index 000000000..c4e198e60 --- /dev/null +++ b/internal/translator/gemini/openai/responses/apply_patch_review_test.go @@ -0,0 +1,181 @@ +package responses + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +const geminiMixedPatchReviewRequest = `{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"lookup"}]}` + +func geminiPatchReviewPart(indexKey string, index int, id, name, args string) string { + return fmt.Sprintf(`{"%s":%d,"functionCall":{"id":%q,"name":%q,"args":%s}}`, indexKey, index, id, name, args) +} + +func geminiPatchReviewFrame(parts []string, terminal bool) []byte { + finish := "" + if terminal { + finish = `,"finishReason":"STOP"` + } + return []byte(`{"candidates":[{"content":{"parts":[` + strings.Join(parts, ",") + `]}` + finish + `}]}`) +} + +func geminiPatchReviewEvents(t *testing.T, chunks [][]byte) []gjson.Result { + t.Helper() + var events []gjson.Result + for _, chunk := range chunks { + _, event := parseSSEEvent(t, chunk) + events = append(events, event) + } + return events +} + +func TestGeminiApplyPatchCrossKeyEvidenceConflict(t *testing.T) { + for _, indexKey := range []string{"partIndex", "index"} { + for _, order := range []string{"ordinary first", "patch first"} { + for _, direction := range []string{"ordinary index patch ID", "patch index ordinary ID"} { + for _, name := range []string{"lookup", "apply_patch", ""} { + for _, mode := range []string{"stream separate frames", "stream single frame", "non-stream"} { + t.Run(strings.Join([]string{indexKey, order, direction, name, mode}, "/"), func(t *testing.T) { + ordinary := geminiPatchReviewPart(indexKey, 3, "ordinary", "lookup", `{"x":1}`) + patch := geminiPatchReviewPart(indexKey, 2, "patch", "apply_patch", `{"input":"p"}`) + parts := []string{ordinary, patch} + if order == "patch first" { + parts = []string{patch, ordinary} + } + index, id := 3, "patch" + if direction == "patch index ordinary ID" { + index, id = 2, "ordinary" + } + args := `{"x":1}` + if name == "apply_patch" { + args = `{"input":"secret"}` + } + conflict := geminiPatchReviewPart(indexKey, index, id, name, args) + parts = append(parts, conflict) + request := []byte(geminiMixedPatchReviewRequest) + var param any + if mode == "non-stream" { + out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini", request, nil, geminiPatchReviewFrame(parts, true), ¶m) + if len(out) != 0 { + t.Fatalf("patch provenance discarded: %s", out) + } + if state, ok := param.(interface{ ToolInputError() error }); !ok || state.ToolInputError() == nil { + t.Fatal("missing non-stream tool input error") + } + return + } + send := func(raw []byte) []gjson.Result { + return geminiPatchReviewEvents(t, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", request, nil, raw, ¶m)) + } + var events []gjson.Result + if mode == "stream single frame" { + events = send(geminiPatchReviewFrame(parts, true)) + } else { + for _, part := range parts[:2] { + events = append(events, send(geminiPatchReviewFrame([]string{part}, false))...) + } + events = append(events, send(geminiPatchReviewFrame([]string{conflict}, true))...) + } + counts := map[string]int{} + for _, event := range events { + kind := event.Get("type").String() + counts[kind]++ + if kind == "response.failed" && (event.Get("response.error.code").String() != "invalid_tool_arguments" || strings.Contains(event.Raw, "secret") || strings.Contains(event.Raw, `\"input\"`)) { + t.Fatalf("unsanitized failure: %s", event.Raw) + } + if kind == "response.custom_tool_call_input.delta" && event.Get("delta").String() != "p" || kind == "response.custom_tool_call_input.done" && event.Get("input").String() != "p" { + t.Fatalf("collision leaked or replayed patch input: %s", event.Raw) + } + } + if counts["response.failed"] != 1 || counts["response.completed"] != 0 || counts["response.output_item.added"] != 2 || counts["response.output_item.done"] != 2 || counts["response.custom_tool_call_input.delta"] != 1 || counts["response.custom_tool_call_input.done"] != 1 || counts["response.function_call_arguments.done"] != 1 { + t.Fatalf("collision added a call or completed response: counts=%v events=%v", counts, events) + } + state := param.(*geminiToResponsesState) + patchEvidence := state.FunctionEvidence["part:2"] + ordinaryEvidence := state.FunctionEvidence["part:3"] + if state.NextIndex != 2 || len(state.FunctionEvidence) != 4 || patchEvidence == ordinaryEvidence || state.FunctionEvidence["id:patch"] != patchEvidence || state.FunctionEvidence["id:ordinary"] != ordinaryEvidence || !patchEvidence.ApplyPatch || patchEvidence.PatchCall == nil || patchEvidence.RawName != "apply_patch" || ordinaryEvidence.ApplyPatch || ordinaryEvidence.RawName != "lookup" { + t.Fatalf("collision changed established aliases/provenance: %+v", state.FunctionEvidence) + } + errInput := state.ToolInputError() + if errInput == nil || !strings.Contains(errInput.Error(), "indexes") { + t.Fatalf("missing cross-key tool input error: %v", errInput) + } + for _, raw := range [][]byte{[]byte(`[DONE]`), geminiPatchReviewFrame(nil, true), geminiPatchReviewFrame(parts, true), geminiPatchReviewFrame([]string{patch}, false)} { + if more := send(raw); len(more) != 0 || state.ToolInputError() != errInput { + t.Fatalf("failed response reopened or lost error: %v", more) + } + } + }) + } + } + } + } + } +} + +func TestGeminiApplyPatchCrossKeyOrdinaryLegacy(t *testing.T) { + requests := []struct{ name, request, second string }{ + {"ordinary only in patch-enabled request", `{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"lookup"},{"type":"function","name":"second"}]}`, "second"}, + {"same-name function winner", `{"tools":[{"type":"function","name":"apply_patch"},{"type":"function","name":"lookup"},{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch"}]}],"input":[{"type":"additional_tools","tools":[{"type":"custom","name":"apply_patch"}]}]}`, "apply_patch"}, + } + for _, request := range requests { + for _, indexKey := range []string{"partIndex", "index"} { + for _, order := range []string{"first second", "second first"} { + for _, direction := range []string{"first index second ID", "second index first ID"} { + t.Run(strings.Join([]string{request.name, indexKey, order, direction}, "/"), func(t *testing.T) { + parts := []string{geminiPatchReviewPart(indexKey, 3, "first", "lookup", `{"x":1}`), geminiPatchReviewPart(indexKey, 2, "second", request.second, `{"x":2}`)} + names := []string{"lookup", request.second} + arguments := []string{`{"x":1}`, `{"x":2}`} + if order == "second first" { + parts[0], parts[1] = parts[1], parts[0] + names[0], names[1] = names[1], names[0] + arguments[0], arguments[1] = arguments[1], arguments[0] + } + index, id, name := 3, "second", "lookup" + if direction == "second index first ID" { + index, id, name = 2, "first", request.second + } + parts = append(parts, geminiPatchReviewPart(indexKey, index, id, name, `{"x":3}`)) + names = append(names, name) + arguments = append(arguments, `{"x":3}`) + var param any + var events []gjson.Result + for i, part := range parts { + events = append(events, geminiPatchReviewEvents(t, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", []byte(request.request), nil, geminiPatchReviewFrame([]string{part}, i == len(parts)-1), ¶m))...) + } + counts := map[string]int{} + var final gjson.Result + for _, event := range events { + counts[event.Get("type").String()]++ + if event.Get("type").String() == "response.completed" { + final = event.Get("response") + } + } + if counts["response.failed"] != 0 || counts["response.completed"] != 1 || counts["response.output_item.done"] != 3 || counts["response.custom_tool_call_input.done"] != 0 || param.(interface{ ToolInputError() error }).ToolInputError() != nil { + t.Fatalf("ordinary cross-key legacy behavior changed: %v", events) + } + var nonStream any + out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini", []byte(request.request), nil, geminiPatchReviewFrame(parts, true), &nonStream) + for _, output := range []gjson.Result{final, gjson.ParseBytes(out)} { + if output.Get("output.#").Int() != 3 { + t.Fatalf("ordinary calls consolidated or rejected: %s", output.Raw) + } + for i, item := range output.Get("output").Array() { + if item.Get("type").String() != "function_call" || item.Get("name").String() != names[i] || item.Get("arguments").String() != arguments[i] { + t.Fatalf("ordinary winner changed: %s", item.Raw) + } + } + } + if state, ok := nonStream.(interface{ ToolInputError() error }); ok && state.ToolInputError() != nil { + t.Fatal("ordinary non-stream call received patch error") + } + }) + } + } + } + } +} diff --git a/internal/translator/gemini/openai/responses/apply_patch_test.go b/internal/translator/gemini/openai/responses/apply_patch_test.go new file mode 100644 index 000000000..8a4739207 --- /dev/null +++ b/internal/translator/gemini/openai/responses/apply_patch_test.go @@ -0,0 +1,281 @@ +package responses + +import ( + "context" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +const geminiPatchRequest = `{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch","format":{"type":"grammar","definition":"start: patch"}}]}]}` + +func TestGeminiApplyPatchCompleteArgumentsLifecycle(t *testing.T) { + var param any + raw := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"functions__apply_patch","args":{"input":" *** Begin Patch\n*** End Patch\n "}}}]}}]}`) + if out := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.1-pro", []byte(geminiPatchRequest), nil, []byte(`{"responseId":"r"}`), ¶m); len(out) != 2 { + t.Fatalf("initial events=%d", len(out)) + } + out := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.1-pro", []byte(geminiPatchRequest), nil, raw, ¶m) + out = append(out, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.1-pro", []byte(geminiPatchRequest), nil, []byte(`[DONE]`), ¶m)...) + var delta strings.Builder + var done, item, final gjson.Result + for _, chunk := range out { + kind, ev := parseSSEEvent(t, chunk) + switch kind { + case "response.custom_tool_call_input.delta": + delta.WriteString(ev.Get("delta").String()) + case "response.custom_tool_call_input.done": + done = ev + case "response.output_item.done": + item = ev.Get("item") + case "response.completed": + final = ev.Get("response.output.0") + } + } + want := " *** Begin Patch\n*** End Patch\n " + if delta.String() != want || done.Get("input").String() != want || item.Get("input").String() != want || final.Get("input").String() != want { + t.Fatalf("inconsistent patch input: delta=%q done=%s item=%s final=%s", delta.String(), done.Raw, item.Raw, final.Raw) + } + if done.Get("call_id").String() != item.Get("call_id").String() || done.Get("item_id").String() != item.Get("id").String() || item.Get("namespace").String() != "functions" || final.Get("id").String() != item.Get("id").String() { + t.Fatal("patch identity changed") + } +} + +func TestGeminiApplyPatchRejectInvalidCompleteArguments(t *testing.T) { + for _, args := range []string{`{}`, `{"input":1}`, `{"input":"a","input":"b"}`, `{"input":"secret"`, `{"input":"x","extra":1}`, `{"input":"\ud800"}`} { + raw := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"functions__apply_patch","args":` + args + `}}]},"finishReason":"STOP"}]}`) + var nonStream any + out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini", []byte(geminiPatchRequest), nil, raw, &nonStream) + if len(out) != 0 { + t.Fatalf("invalid input returned: %s", out) + } + if state, ok := nonStream.(interface{ ToolInputError() error }); !ok || state.ToolInputError() == nil { + t.Fatal("missing non-stream error") + } + var param any + chunks := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", []byte(geminiPatchRequest), nil, raw, ¶m) + failures := 0 + for _, chunk := range chunks { + kind, ev := parseSSEEvent(t, chunk) + if kind == "response.failed" { + failures++ + if ev.Get("response.error.code").String() != "invalid_tool_arguments" { + t.Fatal("wrong error") + } + } + if kind == "response.completed" || kind == "response.custom_tool_call_input.done" { + t.Fatalf("invalid success: %s", chunk) + } + } + if failures != 1 { + t.Fatalf("failures=%d", failures) + } + if more := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", []byte(geminiPatchRequest), nil, []byte(`[DONE]`), ¶m); len(more) != 0 { + t.Fatal("failure reopened") + } + } +} + +func TestGeminiApplyPatchCompleteSnapshotMatrix(t *testing.T) { + frame := func(name, id, args string) []byte { + return []byte(`{"candidates":[{"content":{"parts":[{"partIndex":2,"functionCall":{"id":"` + id + `","name":"` + name + `","args":` + args + `}}]}}]}`) + } + cases := []struct { + name string + inputs [][]byte + fail bool + }{ + {"equivalent encoding after item done", [][]byte{frame("functions__apply_patch", "c1", `{"input":"a"}`), frame("functions__apply_patch", "c1", `{ "input" : "\u0061" }`)}, false}, + {"complete input cannot extend", [][]byte{frame("functions__apply_patch", "c1", `{"input":"a"}`), frame("functions__apply_patch", "c1", `{"input":"ab"}`)}, true}, + {"ID conflict after item done", [][]byte{frame("functions__apply_patch", "c1", `{"input":"a"}`), frame("functions__apply_patch", "c2", `{"input":"a"}`)}, true}, + {"illegal snapshot before name", [][]byte{frame("", "c1", `{"input":1}`), frame("functions__apply_patch", "c1", `{"input":"a"}`)}, true}, + {"truncated snapshot before name", [][]byte{frame("", "c1", `{"input":"a`), frame("functions__apply_patch", "c1", `{"input":"a"}`)}, true}, + {"ID conflict before name", [][]byte{frame("", "c1", `{"input":"a"}`), frame("", "c2", `{"input":"a"}`), frame("functions__apply_patch", "c1", `{"input":"a"}`)}, true}, + {"full snapshots before name cannot extend", [][]byte{frame("", "c1", `{"input":"a"}`), frame("functions__apply_patch", "c1", `{"input":"ab"}`)}, true}, + {"valid name arrives later", [][]byte{frame("", "c1", `{"input":"a"}`), frame("functions__apply_patch", "c1", `{"input":"a"}`)}, false}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + var param any + var events []gjson.Result + for _, input := range test.inputs { + for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", []byte(geminiPatchRequest), nil, input, ¶m) { + _, ev := parseSSEEvent(t, chunk) + events = append(events, ev) + } + } + failures, done := 0, 0 + for _, ev := range events { + if ev.Get("type").String() == "response.failed" { + failures++ + } + if ev.Get("type").String() == "response.custom_tool_call_input.done" { + done++ + } + } + if test.fail { + if failures != 1 { + t.Fatalf("failures=%d events=%v", failures, events) + } + } else { + if failures != 0 || done != 1 { + t.Fatalf("failures=%d done=%d", failures, done) + } + chunks := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", []byte(geminiPatchRequest), nil, []byte(`[DONE]`), ¶m) + _, ev := parseSSEEvent(t, chunks[len(chunks)-1]) + if ev.Get("response.output.#").Int() != 1 || ev.Get("response.output.0.input").String() != "a" { + t.Fatalf("final: %s", ev.Raw) + } + } + for _, input := range [][]byte{test.inputs[0], []byte(`[DONE]`)} { + if chunks := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", []byte(geminiPatchRequest), nil, input, ¶m); len(chunks) != 0 { + t.Fatal("terminal reopened") + } + } + }) + } +} + +func TestGeminiApplyPatchWinningDeclarationAndNegativeCompatibility(t *testing.T) { + cases := []struct{ name, request, upstream, args, wantType, wantInput string }{ + {"function wins", `{"tools":[{"type":"function","name":"apply_patch"}],"input":[{"type":"additional_tools","tools":[{"type":"custom","name":"apply_patch"}]}]}`, "apply_patch", `{"n":1}`, "function_call", ""}, + {"direct function wins namespace", `{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch"}]},{"type":"function","name":"functions__apply_patch"}]}`, "functions__apply_patch", `{"n":1}`, "function_call", ""}, + {"other custom", `{"tools":[{"type":"custom","name":"exec"}]}`, "exec", `{"command":"ls"}`, "custom_tool_call", `{"command":"ls"}`}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + raw := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"` + test.upstream + `","args":` + test.args + `}}]},"finishReason":"STOP"}]}`) + out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini", []byte(test.request), nil, raw, nil) + item := gjson.GetBytes(out, "output.0") + if item.Get("type").String() != test.wantType || item.Get("input").String() != test.wantInput { + t.Fatalf("compatibility changed: %s", out) + } + var param any + for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", []byte(test.request), nil, raw, ¶m) { + kind, ev := parseSSEEvent(t, chunk) + if kind == "response.custom_tool_call_input.delta" || kind == "response.failed" { + t.Fatalf("patch behavior applied to non-patch: %s", chunk) + } + if kind == "response.completed" && ev.Get("response.output.0.type").String() != test.wantType { + t.Fatal(ev.Raw) + } + } + }) + } +} + +func TestGeminiApplyPatchConflictingNameAndPartIdentity(t *testing.T) { + for _, second := range []string{`{"partIndex":3,"functionCall":{"id":"c1","name":"functions__apply_patch","args":{"input":"a"}}}`, `{"partIndex":2,"functionCall":{"id":"c1","name":"other","args":{"input":"a"}}}`} { + var param any + first := []byte(`{"candidates":[{"content":{"parts":[{"partIndex":2,"functionCall":{"id":"c1","name":"functions__apply_patch","args":{"input":"a"}}}]}}]}`) + ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", []byte(geminiPatchRequest), nil, first, ¶m) + chunks := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", []byte(geminiPatchRequest), nil, []byte(`{"candidates":[{"content":{"parts":[`+second+`]}}]}`), ¶m) + if len(chunks) != 1 { + t.Fatalf("identity conflict ignored: %d events", len(chunks)) + } + kind, _ := parseSSEEvent(t, chunks[0]) + if kind != "response.failed" { + t.Fatal("missing identity failure") + } + } +} + +func TestGeminiApplyPatchNonStreamConflictingCompleteCalls(t *testing.T) { + var param any + raw := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"id":"c1","name":"functions__apply_patch","args":{"input":"a"}}},{"functionCall":{"id":"c1","name":"functions__apply_patch","args":{"input":"ab"}}}]}}]}`) + out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini", []byte(geminiPatchRequest), nil, raw, ¶m) + if len(out) != 0 { + t.Fatalf("conflicting source call IDs accepted: %s", out) + } + if state, ok := param.(interface{ ToolInputError() error }); !ok || state.ToolInputError() == nil { + t.Fatal("missing error state") + } +} + +func TestGeminiApplyPatchHistoryPreservesWhitespaceAndToolPair(t *testing.T) { + request := []byte(`{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch"}]}],"input":[{"type":"custom_tool_call","call_id":"c1","namespace":"functions","name":"apply_patch","input":" *** Begin Patch\n*** Add File: 中.txt\n+😀\n*** End Patch\n "},{"type":"custom_tool_call_output","call_id":"c1","output":"ok"},{"role":"user","type":"message","content":"continue"}]}`) + out := ConvertOpenAIResponsesRequestToGemini("gemini-3.1-pro-preview", request, false) + want := " *** Begin Patch\n*** Add File: 中.txt\n+😀\n*** End Patch\n " + var call, result gjson.Result + gjson.GetBytes(out, "contents").ForEach(func(_, content gjson.Result) bool { + content.Get("parts").ForEach(func(_, part gjson.Result) bool { + if part.Get("functionCall").Exists() { + call = part.Get("functionCall") + } + if part.Get("functionResponse").Exists() { + result = part.Get("functionResponse") + } + return true + }) + return true + }) + if call.Get("args.input").String() != want || call.Get("name").String() != "functions__apply_patch" || result.Get("name").String() != "functions__apply_patch" { + t.Fatalf("history changed: %s", out) + } +} + +func TestGeminiApplyPatchDoesNotRebindOrdinaryFunctionCalls(t *testing.T) { + var param any + request := []byte(`{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"first"},{"type":"function","name":"second"}]}`) + for _, name := range []string{"first", "second"} { + chunks := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", request, nil, []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"id":"reused","name":"`+name+`","args":{"n":1}}}]}}]}`), ¶m) + var item gjson.Result + for _, chunk := range chunks { + kind, ev := parseSSEEvent(t, chunk) + if kind == "response.output_item.done" { + item = ev.Get("item") + } + } + if item.Get("name").String() != name || item.Get("type").String() != "function_call" { + t.Fatalf("ordinary function changed: %s", item.Raw) + } + } +} + +func TestGeminiApplyPatchDistinctUnkeyedCompleteCalls(t *testing.T) { + var param any + raw := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"functions__apply_patch","args":{"input":"a"}}},{"functionCall":{"name":"functions__apply_patch","args":{"input":"b"}}}]},"finishReason":"STOP"}]}`) + inputs := map[string]string{} + var final gjson.Result + for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", []byte(geminiPatchRequest), nil, raw, ¶m) { + kind, ev := parseSSEEvent(t, chunk) + switch kind { + case "response.custom_tool_call_input.delta": + inputs[ev.Get("item_id").String()] += ev.Get("delta").String() + case "response.custom_tool_call_input.done": + if inputs[ev.Get("item_id").String()] != ev.Get("input").String() { + t.Fatal(ev.Raw) + } + case "response.completed": + final = ev.Get("response") + } + } + if final.Get("output.#").Int() != 2 || final.Get("output.0.input").String() != "a" || final.Get("output.1.input").String() != "b" || final.Get("output.0.id").String() == final.Get("output.1.id").String() { + t.Fatalf("distinct calls merged: %s", final.Raw) + } +} + +func TestGeminiApplyPatchUnresolvableCallIdentityFailsClosed(t *testing.T) { + for _, part := range []string{`{"functionCall":{"args":{"input":"secret"}}}`, `{"partIndex":2,"functionCall":{"id":"c1","args":{"input":"secret"}}}`} { + var param any + raw := []byte(`{"candidates":[{"content":{"parts":[` + part + `]},"finishReason":"STOP"}]}`) + failures := 0 + for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", []byte(geminiPatchRequest), nil, raw, ¶m) { + kind, _ := parseSSEEvent(t, chunk) + if kind == "response.failed" { + failures++ + } + if kind == "response.completed" || kind == "response.function_call_arguments.delta" { + t.Fatalf("unresolvable identity accepted: %s", chunk) + } + } + if failures != 1 { + t.Fatalf("failures=%d", failures) + } + var nonStream any + if out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini", []byte(geminiPatchRequest), nil, raw, &nonStream); len(out) != 0 { + t.Fatalf("unresolvable non-stream accepted: %s", out) + } + } +} diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go index 3f4425530..5ac45128e 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go @@ -33,7 +33,20 @@ type geminiCompletedReasoningItem struct { Text string } +type geminiFunctionCallEvidence struct { + PartIndex int + HasPartIndex bool + ApplyPatch bool + RawName string + UpstreamID string + Input string + HasInput bool + Err error + PatchCall *translatorcommon.ApplyPatchCallState +} + type geminiToResponsesState struct { + translatorcommon.ApplyPatchErrorState Seq int ResponseID string CreatedAt int64 @@ -76,6 +89,7 @@ type geminiToResponsesState struct { FuncDone map[int]bool SanitizedNameMap map[string]string ToolIdentityMap map[string]util.ResponsesToolIdentity + FunctionEvidence map[string]*geminiFunctionCallEvidence // web search aggregation WebSearchStreamMode bool @@ -1041,7 +1055,20 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, finalizeMessage() st.LastSemanticKind = geminiResponsesCarrierFunction + evidence := geminiRecordFunctionEvidence(st, fc, explicitPartIndex, gjson.ValidBytes(rawJSON)) + if evidence.ApplyPatch && evidence.Err != nil { + st.SetToolInputError(evidence.Err) + st.Completed = true + out = append(out, emitEvent("response.failed", translatorcommon.ApplyPatchFailure(st.ResponseID, nextSeq()))) + return false + } + if evidence.RawName == "" { + return true + } rawName := fc.Get("name").String() + if evidence.ApplyPatch { + rawName = evidence.RawName + } identity, hasIdentity := st.ToolIdentityMap[rawName] if !hasIdentity { restored := util.RestoreSanitizedToolName(st.SanitizedNameMap, rawName) @@ -1050,6 +1077,15 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, name := identity.Name namespace := identity.Namespace isCustom := identity.Custom + if evidence.ApplyPatch && evidence.PatchCall != nil { + if _, _, errFinishArguments := evidence.PatchCall.FinishArguments(fc.Get("args").Raw); errFinishArguments != nil { + st.SetToolInputError(errFinishArguments) + st.Completed = true + out = append(out, emitEvent("response.failed", translatorcommon.ApplyPatchFailure(st.ResponseID, nextSeq()))) + return false + } + return true + } idx := st.NextIndex st.NextIndex++ @@ -1057,6 +1093,9 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, if st.FuncArgsBuf[idx] == nil { st.FuncArgsBuf[idx] = &strings.Builder{} } + if identity.ApplyPatch { + st.FuncCallIDs[idx] = evidence.UpstreamID + } if st.FuncCallIDs[idx] == "" { st.FuncCallIDs[idx] = fmt.Sprintf("call_%d_%d", time.Now().UnixNano(), atomic.AddUint64(&funcCallIDCounter, 1)) } @@ -1074,6 +1113,22 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, if isCustom { inputStr := util.UnwrapResponsesCustomToolInput(argsJSON) + var patchCall *translatorcommon.ApplyPatchCallState + if identity.ApplyPatch { + patchCall = &translatorcommon.ApplyPatchCallState{ItemID: fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx]), CallID: st.FuncCallIDs[idx], Name: name, Namespace: namespace, OutputIndex: idx} + _, input, errFinishArguments := patchCall.FinishArguments(argsJSON) + if !gjson.ValidBytes(rawJSON) { + errFinishArguments = fmt.Errorf("invalid Gemini apply_patch response JSON") + } + if errFinishArguments != nil { + st.SetToolInputError(errFinishArguments) + st.Completed = true + out = append(out, emitEvent("response.failed", translatorcommon.ApplyPatchFailure(st.ResponseID, nextSeq()))) + return false + } + inputStr = input + evidence.PatchCall = patchCall + } st.FuncInputBuf[idx] = inputStr // Emit item.added for custom tool call @@ -1085,6 +1140,10 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, item = translatorcommon.SetResponsesToolCallIdentity(item, name, namespace, "item") out = append(out, emitEvent("response.output_item.added", item)) + // Gemini delivers complete arguments; this delta is not an early preview. + if patchCall != nil && inputStr != "" { + out = append(out, emitEvent("response.custom_tool_call_input.delta", translatorcommon.ApplyPatchInputDelta(patchCall, inputStr, nextSeq()))) + } // Emit custom tool call input.done if !st.FuncDone[idx] { inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`) @@ -1092,6 +1151,9 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx])) inputDone, _ = sjson.SetBytes(inputDone, "output_index", idx) inputDone, _ = sjson.SetBytes(inputDone, "input", inputStr) + if patchCall != nil { + inputDone = translatorcommon.ApplyPatchInputDone(patchCall, inputStr, st.Seq) + } out = append(out, emitEvent("response.custom_tool_call_input.done", inputDone)) itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`) @@ -1155,8 +1217,17 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, }) } + if st.Completed { + return out + } + // Finalization on finishReason if fr := root.Get("candidates.0.finishReason"); fr.Exists() && fr.String() != "" { + if errIdentity := geminiPendingIdentityError(st); errIdentity != nil { + st.SetToolInputError(errIdentity) + st.Completed = true + return append(out, emitEvent("response.failed", translatorcommon.ApplyPatchFailure(st.ResponseID, nextSeq()))) + } if st.PendingReasoningSignature != "" { emitTrailingDetachedReasoning(st.PendingReasoningSignature) st.PendingReasoningSignature = "" @@ -1393,7 +1464,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, } // ConvertGeminiResponseToOpenAIResponsesNonStream aggregates Gemini response JSON into a single OpenAI Responses JSON object. -func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { +func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte { root := gjson.ParseBytes(rawJSON) root = unwrapGeminiResponseRoot(root) reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON) @@ -1557,6 +1628,8 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string currentMsgRuneOffset = 0 } + var toolInputError error + evidenceState := &geminiToResponsesState{ToolIdentityMap: toolIdentityMap} var outputs [][]byte appendOutput := func(itemJSON []byte) { outputs = append(outputs, itemJSON) @@ -1648,7 +1721,24 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string flushMessageOutput() currentMsgRuneOffset = 0 + explicitIndex := -1 + if p.Get("partIndex").Exists() { + explicitIndex = int(p.Get("partIndex").Int()) + } else if p.Get("index").Exists() { + explicitIndex = int(p.Get("index").Int()) + } + evidence := geminiRecordFunctionEvidence(evidenceState, fc, explicitIndex, gjson.ValidBytes(rawJSON)) + if evidence.ApplyPatch && evidence.Err != nil { + toolInputError = evidence.Err + return false + } + if evidence.RawName == "" { + return true + } rawName := fc.Get("name").String() + if evidence.ApplyPatch { + rawName = evidence.RawName + } identity, hasIdentity := toolIdentityMap[rawName] if !hasIdentity { restored := util.RestoreSanitizedToolName(sanitizedNameMap, rawName) @@ -1657,6 +1747,14 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string name := identity.Name namespace := identity.Namespace isCustom := identity.Custom + if identity.ApplyPatch && evidence.PatchCall != nil { + _, _, errFinishArguments := evidence.PatchCall.FinishArguments(fc.Get("args").Raw) + if errFinishArguments != nil { + toolInputError = errFinishArguments + return false + } + return true + } args := fc.Get("args") argsStr := "" @@ -1664,9 +1762,25 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string argsStr = args.Raw } callID := fmt.Sprintf("call_%x_%d", time.Now().UnixNano(), atomic.AddUint64(&funcCallIDCounter, 1)) + if identity.ApplyPatch && evidence.UpstreamID != "" { + callID = evidence.UpstreamID + } var itemJSON []byte if isCustom { inputStr := util.UnwrapResponsesCustomToolInput(argsStr) + if identity.ApplyPatch { + var patchCall translatorcommon.ApplyPatchCallState + _, input, errFinishArguments := patchCall.FinishArguments(argsStr) + if !gjson.ValidBytes(rawJSON) { + errFinishArguments = fmt.Errorf("invalid Gemini apply_patch response JSON") + } + if errFinishArguments != nil { + toolInputError = errFinishArguments + return false + } + inputStr = input + evidence.PatchCall = &patchCall + } itemJSON = []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`) itemJSON, _ = sjson.SetBytes(itemJSON, "id", fmt.Sprintf("ctc_%s", callID)) itemJSON, _ = sjson.SetBytes(itemJSON, "call_id", callID) @@ -1721,6 +1835,17 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string }) } + if toolInputError == nil { + toolInputError = geminiPendingIdentityError(evidenceState) + } + if toolInputError != nil { + if param != nil { + state := &translatorcommon.ApplyPatchErrorState{} + state.SetToolInputError(toolInputError) + *param = state + } + return nil + } flushReasoningOutput() flushMessageOutput() @@ -1846,3 +1971,138 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string return resp } + +// Full snapshots are evidence, never source prefixes. Stable IDs and explicit +// part indexes identify repeated snapshots without merging distinct unkeyed calls. +func geminiRecordFunctionEvidence(st *geminiToResponsesState, fc gjson.Result, partIndex int, validJSON bool) *geminiFunctionCallEvidence { + if st.FunctionEvidence == nil { + st.FunctionEvidence = make(map[string]*geminiFunctionCallEvidence) + } + var keys []string + if partIndex >= 0 { + keys = append(keys, fmt.Sprintf("part:%d", partIndex)) + } + if id := fc.Get("id").String(); id != "" { + keys = append(keys, "id:"+id) + } + // Never guess which later named call owns an unkeyed nameless snapshot. + if len(keys) == 0 && fc.Get("name").String() == "" { + keys = append(keys, fmt.Sprintf("unknown:%d", len(st.FunctionEvidence))) + } + var evidence *geminiFunctionCallEvidence + conflict := false + patchRelated := st.ToolIdentityMap[fc.Get("name").String()].ApplyPatch + for _, key := range keys { + if prior := st.FunctionEvidence[key]; prior != nil { + patchRelated = patchRelated || prior.ApplyPatch + if evidence == nil { + evidence = prior + } else if evidence != prior { + conflict = true + } + } + } + if conflict { + errIdentity := fmt.Errorf("conflicting apply_patch call indexes") + if patchRelated { + // Reject before rebinding either established call's aliases or provenance. + return &geminiFunctionCallEvidence{ApplyPatch: true, Err: errIdentity} + } + // Ordinary-only cross-key reuse keeps the legacy first-match behavior. + evidence.Err = errIdentity + } + if evidence == nil { + evidence = &geminiFunctionCallEvidence{} + } + recordError := func(err error) { + if evidence.Err == nil { + evidence.Err = err + } + } + if partIndex >= 0 { + if evidence.HasPartIndex && evidence.PartIndex != partIndex { + recordError(fmt.Errorf("conflicting apply_patch part index")) + } else { + evidence.PartIndex = partIndex + evidence.HasPartIndex = true + } + } + if st.ToolIdentityMap[fc.Get("name").String()].ApplyPatch { + evidence.ApplyPatch = true + } + for _, key := range keys { + st.FunctionEvidence[key] = evidence + } + if name := fc.Get("name").String(); name != "" { + if evidence.RawName != "" && evidence.RawName != name { + recordError(fmt.Errorf("conflicting apply_patch call name")) + } else { + evidence.RawName = name + } + } + if id := fc.Get("id").String(); id != "" { + if evidence.UpstreamID != "" && evidence.UpstreamID != id { + recordError(fmt.Errorf("conflicting apply_patch call ID")) + } else { + evidence.UpstreamID = id + } + } + var snapshot translatorcommon.ApplyPatchCallState + _, input, errFinishArguments := snapshot.FinishArguments(fc.Get("args").Raw) + if !validJSON { + recordError(fmt.Errorf("invalid Gemini apply_patch response JSON")) + } + if errFinishArguments != nil { + recordError(errFinishArguments) + } else { + if evidence.HasInput && evidence.Input != input { + recordError(fmt.Errorf("conflicting apply_patch complete snapshots")) + } + evidence.HasInput = true + evidence.Input = input + } + return evidence +} + +func geminiPendingIdentityError(st *geminiToResponsesState) error { + patchEnabled := false + for _, identity := range st.ToolIdentityMap { + if identity.ApplyPatch { + patchEnabled = true + break + } + } + if !patchEnabled { + return nil + } + for _, evidence := range st.FunctionEvidence { + if evidence.RawName == "" { + if evidence.Err != nil { + return evidence.Err + } + return fmt.Errorf("unresolved Gemini apply_patch call identity") + } + } + return nil +} + +// FinalizeToolInput rejects a patch-enabled stream lacking its source terminator. +func (st *geminiToResponsesState) FinalizeToolInput() [][]byte { + if st.ToolInputError() != nil || st.Completed { + return nil + } + enabled := false + for _, identity := range st.ToolIdentityMap { + if identity.ApplyPatch { + enabled = true + break + } + } + if !enabled { + return nil + } + st.SetToolInputError(fmt.Errorf("upstream apply_patch stream ended before protocol completion")) + st.Completed = true + st.Seq++ + return [][]byte{emitEvent("response.failed", translatorcommon.ApplyPatchFailure(st.ResponseID, st.Seq))} +} diff --git a/internal/translator/openai/interactions/responses/apply_patch_identity_test.go b/internal/translator/openai/interactions/responses/apply_patch_identity_test.go new file mode 100644 index 000000000..b197f4b17 --- /dev/null +++ b/internal/translator/openai/interactions/responses/apply_patch_identity_test.go @@ -0,0 +1,235 @@ +package responses + +import ( + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +func TestInteractionsApplyPatchNamedLateIdentity(t *testing.T) { + for _, first := range []string{"item", "call", "neither", "neither-item-first", "neither-call-first"} { + for _, boundary := range []string{"stop", "terminal"} { + t.Run(first+"-"+boundary, func(t *testing.T) { + var param any + step := map[string]any{"type": "function_call", "name": "functions__apply_patch"} + if first == "item" { + step["id"] = "item_2" + } + if first == "call" { + step["call_id"] = "call_2" + } + if events := patchSend(¶m, patchStep("step.start", step)); len(events) != 0 { + t.Fatalf("published provisional IDs: %v", events) + } + args := patchArguments(patchText) + for _, fragment := range []string{args[:15], args[15:]} { + if events := patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": fragment}})); len(events) != 0 { + t.Fatalf("published unresolved delta: %v", events) + } + } + if strings.HasPrefix(first, "neither-") { + if first == "neither-item-first" { + step["id"] = "item_2" + } else { + step["call_id"] = "call_2" + } + if events := patchSend(¶m, patchStep("step.start", step)); len(events) != 0 { + t.Fatalf("one late ID is still unresolved: %v", events) + } + } + step["id"], step["call_id"] = "item_2", "call_2" + step["arguments"] = map[string]any{"input": patchText} + var events []gjson.Result + if boundary == "stop" { + events = append(events, patchSend(¶m, patchStep("step.stop", step))...) + } + step["index"] = 2 + events = append(events, patchSend(¶m, patchJSON(map[string]any{"event_type": "interaction.completed", "interaction": map[string]any{"steps": []any{step}}}))...) + assertPatchLifecycle(t, events, patchText) + var fragments []string + for _, event := range events { + if event.Get("type").String() == "response.custom_tool_call_input.delta" { + fragments = append(fragments, event.Get("delta").String()) + } + } + if len(fragments) != 2 || fragments[0] != patchText[:5] || fragments[1] != patchText[5:] { + t.Fatalf("not the real buffered fragments: %q", fragments) + } + }) + } + } +} + +// The first supplied call ID is not a change to an upstream-established ID. +func TestInteractionsApplyPatchFirstLateIDAccepted(t *testing.T) { + var param any + patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "name": "functions__apply_patch"})) + patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": `{"input":"p`}})) + events := patchSend(¶m, patchStep("step.stop", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "p"}})) + if state := param.(*interactionsToResponsesStreamState); state.ToolInputError() != nil { + t.Fatalf("first real ID rejected: %v events=%v", state.ToolInputError(), events) + } +} + +func TestInteractionsApplyPatchNamedLateIdentityEvidence(t *testing.T) { + for _, tc := range []struct{ name, start, after string }{ + {"item-change", `{"event_type":"step.start","index":2,"step":{"type":"function_call","id":"item_2","name":"functions__apply_patch"}}`, `{"event_type":"step.stop","index":2,"step":{"id":"changed","call_id":"call_2"}}`}, + {"call-change", `{"event_type":"step.start","index":2,"step":{"type":"function_call","call_id":"call_2","name":"functions__apply_patch"}}`, `{"event_type":"step.stop","index":2,"step":{"id":"item_2","call_id":"changed"}}`}, + {"repeated-start-type", `{"event_type":"step.start","index":2,"step":{"type":"function_call","id":"item_2","name":"functions__apply_patch"}}`, `{"event_type":"step.start","index":2,"step":{"type":"model_output","id":"item_2"}}`}, + {"terminal-type", `{"event_type":"step.start","index":2,"step":{"type":"function_call","id":"item_2","name":"functions__apply_patch"}}`, `{"event_type":"interaction.completed","steps":[{"index":2,"type":"model_output","id":"item_2"}]}`}, + {"partial-snapshot", `{"event_type":"step.start","index":2,"step":{"type":"function_call","id":"item_2","name":"functions__apply_patch"}}`, `{"event_type":"step.stop","index":2,"step":{"arguments":"{\"input\":\"p"}}`}, + {"invalid-snapshot", `{"event_type":"step.start","index":2,"step":{"type":"function_call","call_id":"call_2","name":"functions__apply_patch"}}`, `{"event_type":"step.stop","index":2,"step":{"arguments":{"input":"p","extra":1}}}`}, + } { + t.Run(tc.name, func(t *testing.T) { + var param any + if events := patchSend(¶m, []byte(tc.start)); len(events) != 0 { + t.Fatalf("early output: %v", events) + } + events := patchSend(¶m, []byte(tc.after)) + if len(events) != 1 || events[0].Get("type").String() != "response.failed" || param.(*interactionsToResponsesStreamState).ToolInputError() == nil { + t.Fatalf("lost evidence: %v", events) + } + if events = patchSend(¶m, []byte(`{"event_type":"interaction.completed"}`)); len(events) != 0 { + t.Fatalf("failure reopened: %v", events) + } + }) + } +} + +// Skipped/invalid snapshots must retain conflicting unmatched aliases as well as indexes. +func TestInteractionsApplyPatchUnresolvedAllKeys(t *testing.T) { + for _, partial := range []bool{false, true} { + for _, discover := range []string{"index", "step-index", "item", "call"} { + t.Run(discover+map[bool]string{false: "-snapshot", true: "-partial"}[partial], func(t *testing.T) { + var param any + patchSend(¶m, []byte(`{"event_type":"step.start","index":2,"step":{"type":"function_call","id":"a"}}`)) + raw := `{"event_type":"step.start","index":3,"step":{"index":4,"type":"model_output","id":"a","call_id":"late"}}` + if partial { + raw = raw[:len(raw)-1] + } + patchSend(¶m, []byte(raw)) + root := map[string]any{"event_type": "step.start", "step": map[string]any{"type": "function_call", "name": "functions__apply_patch"}} + step := root["step"].(map[string]any) + switch discover { + case "index": + root["index"] = 3 + case "step-index": + step["index"] = 4 + case "item": + step["id"] = "a" + case "call": + step["call_id"] = "late" + } + events := patchSend(¶m, patchJSON(root)) + if len(events) != 1 || events[0].Get("type").String() != "response.failed" { + t.Fatalf("alias erased evidence: %v", events) + } + }) + } + } +} + +func TestInteractionsApplyPatchTerminalIdentityFallback(t *testing.T) { + for _, first := range []string{"item", "call", "neither"} { + for _, terminal := range []string{"interaction.completed", "finish", "done"} { + t.Run(first+"-"+terminal, func(t *testing.T) { + var param any + step := map[string]any{"type": "function_call", "name": "functions__apply_patch"} + id := "item_2" + if first == "item" { + step["id"] = id + } + if first == "call" { + id = "call_2" + step["call_id"] = id + } + if events := patchSend(¶m, patchStep("step.start", step)); len(events) != 0 { + t.Fatalf("early fallback: %v", events) + } + if events := patchSend(¶m, []byte(`{"event_type":"step.delta","index":2,"delta":{"type":"arguments_delta","arguments":"{\"input\":\"pq\"}"}}`)); len(events) != 0 { + t.Fatalf("early delta: %v", events) + } + if events := patchSend(¶m, patchStep("step.stop", nil)); len(events) != 0 { + t.Fatalf("step stop is not a response terminal: %v", events) + } + events := patchSend(¶m, patchJSON(map[string]any{"event_type": terminal})) + counts := map[string]int{} + for _, event := range events { + kind := event.Get("type").String() + identity, key := event, "item_id" + switch kind { + case "response.output_item.added", "response.output_item.done": + identity, key = event.Get("item"), "id" + case "response.completed": + identity, key = event.Get("response.output.0"), "id" + } + counts[kind]++ + if identity.Get(key).String() != id || identity.Get("call_id").String() != id { + t.Fatalf("fallback identity changed: %s", event.Raw) + } + } + for _, kind := range []string{"response.output_item.added", "response.custom_tool_call_input.delta", "response.custom_tool_call_input.done", "response.output_item.done"} { + if counts[kind] != 1 { + t.Fatalf("fallback lifecycle: %v", counts) + } + } + if events = patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "changed", "call_id": "changed", "name": "functions__apply_patch"})); len(events) != 0 { + t.Fatalf("terminal identity reopened: %v", events) + } + }) + } + } +} + +func TestInteractionsApplyPatchNamedLateIdentityInterleaved(t *testing.T) { + var param any + for _, raw := range []string{ + `{"event_type":"step.start","index":2,"step":{"type":"function_call","id":"a2","name":"functions__apply_patch"}}`, + `{"event_type":"step.start","index":3,"step":{"type":"function_call","call_id":"c3","name":"functions__apply_patch"}}`, + `{"event_type":"step.delta","index":2,"delta":{"type":"arguments_delta","arguments":"{\"input\":\"two\"}"}}`, + `{"event_type":"step.delta","index":3,"delta":{"type":"arguments_delta","arguments":"{\"input\":\"three\"}"}}`, + `{"event_type":"step.stop","index":2}`, + `{"event_type":"step.stop","index":3}`, + } { + if events := patchSend(¶m, []byte(raw)); len(events) != 0 { + t.Fatalf("early interleaved event: %v", events) + } + } + events := patchSend(¶m, []byte(`{"event_type":"interaction.completed","steps":[{"index":3,"type":"function_call","id":"a3","call_id":"c3","name":"functions__apply_patch","arguments":{"input":"three"}},{"index":2,"type":"function_call","id":"a2","call_id":"c2","name":"functions__apply_patch","arguments":{"input":"two"}}]}`)) + counts := map[int]map[string]int{} + for _, event := range events { + kind := event.Get("type").String() + if kind == "response.completed" { + for position, item := range event.Get("response.output").Array() { + index := position + 2 + if item.Get("id").String() != map[int]string{2: "a2", 3: "a3"}[index] || item.Get("call_id").String() != map[int]string{2: "c2", 3: "c3"}[index] || item.Get("input").String() != map[int]string{2: "two", 3: "three"}[index] { + t.Fatalf("final crossed calls: %s", event.Raw) + } + } + continue + } + index := int(event.Get("output_index").Int()) + if counts[index] == nil { + counts[index] = map[string]int{} + } + counts[index][kind]++ + identity, key := event, "item_id" + if event.Get("item").Exists() { + identity, key = event.Get("item"), "id" + } + if identity.Get(key).String() != map[int]string{2: "a2", 3: "a3"}[index] || identity.Get("call_id").String() != map[int]string{2: "c2", 3: "c3"}[index] { + t.Fatalf("crossed identities: %s", event.Raw) + } + if kind == "response.custom_tool_call_input.delta" && event.Get("delta").String() != map[int]string{2: "two", 3: "three"}[index] { + t.Fatalf("crossed fragments: %s", event.Raw) + } + } + for _, index := range []int{2, 3} { + for _, kind := range []string{"response.output_item.added", "response.custom_tool_call_input.delta", "response.custom_tool_call_input.done", "response.output_item.done"} { + if counts[index][kind] != 1 { + t.Fatalf("interleaved lifecycle: %v", counts) + } + } + } +} diff --git a/internal/translator/openai/interactions/responses/apply_patch_rereview_test.go b/internal/translator/openai/interactions/responses/apply_patch_rereview_test.go new file mode 100644 index 000000000..c5fa7cc87 --- /dev/null +++ b/internal/translator/openai/interactions/responses/apply_patch_rereview_test.go @@ -0,0 +1,219 @@ +package responses + +import ( + "context" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +func patchTypeReviewIdentity(key string) (int, map[string]any) { + index := -1 + step := map[string]any{} + if key == "index only" || key == "index and IDs" { + index = 2 + } + if key == "IDs only" || key == "index and IDs" || key == "item only" { + step["id"] = "item_2" + } + if key == "IDs only" || key == "index and IDs" || key == "call only" { + step["call_id"] = "call_2" + } + return index, step +} + +func patchTypeReviewEntries(entry string) []string { + if entry == "step.start" || entry == "step.stop" { + return []string{"step.start", "step.stop", "interaction.completed", "finish"} + } + return []string{entry} +} + +func patchTypeReviewFinal(entry string, steps ...any) []byte { + return patchJSON(map[string]any{"event_type": entry, "steps": steps}) +} + +func TestInteractionsApplyPatchPreNameConsistentTypeConflict(t *testing.T) { + for _, entry := range []string{"step.start", "step.stop", "interaction.completed", "finish"} { + for _, itemType := range []string{"model_output", "thought", "custom_tool_call"} { + for _, evidenceKey := range []string{"index and IDs", "index only", "IDs only"} { + for _, resolvedKey := range []string{"index and IDs", "index only", "IDs only", "item only", "call only"} { + for _, resolvedEntry := range patchTypeReviewEntries(entry) { + t.Run(strings.Join([]string{entry, itemType, evidenceKey, resolvedEntry, resolvedKey}, "/"), func(t *testing.T) { + var param any + if events := patchSend(¶m, []byte(`{"event_type":"step.start","index":2,"step":{"type":"function_call","id":"item_2","call_id":"call_2","arguments":{"input":"p"}}}`)); len(events) != 0 { + t.Fatalf("unnamed call announced: %v", events) + } + index, conflict := patchTypeReviewIdentity(evidenceKey) + conflict["type"] = itemType + resolvedIndex, resolved := patchTypeReviewIdentity(resolvedKey) + resolved["type"], resolved["name"] = "function_call", "functions__apply_patch" + resolved["arguments"] = map[string]any{"input": "p"} + var events []gjson.Result + if entry == "step.start" || entry == "step.stop" { + for _, event := range patchSend(¶m, patchReviewSnapshot(entry, index, conflict)) { + if event.Get("item.type").String() == "custom_tool_call" || strings.HasPrefix(event.Get("type").String(), "response.custom_tool_call_input.") || event.Get("type").String() == "response.failed" { + t.Fatalf("unresolved type evidence prematurely treated as patch: %s", event.Raw) + } + } + events = patchSend(¶m, patchReviewSnapshot(resolvedEntry, resolvedIndex, resolved)) + } else { + if index >= 0 { + conflict["index"] = index + } + if resolvedIndex >= 0 { + resolved["index"] = resolvedIndex + } + // Both snapshots belong to the same call in this single terminal event. + events = patchSend(¶m, patchTypeReviewFinal(entry, conflict, resolved)) + } + assertInteractionsPatchReviewFailure(t, ¶m, events) + state := param.(*interactionsToResponsesStreamState) + call := state.FunctionCalls[2] + if len(state.FunctionCalls) != 1 || len(state.PendingIdentityErrors) != 0 || call.PendingError == nil || !strings.Contains(call.PendingError.Error(), "type") || call.PatchCall != nil || call.Added { + t.Fatalf("type conflict lost or hidden by identity failure: %+v", state) + } + }) + } + } + } + } + } +} + +func TestInteractionsApplyPatchPreNameOmittedType(t *testing.T) { + for _, entry := range []string{"step.start", "step.stop", "interaction.completed", "finish"} { + for _, resolvedKey := range []string{"index and IDs", "index only", "IDs only", "item only", "call only"} { + for _, resolvedEntry := range patchTypeReviewEntries(entry) { + t.Run(entry+"/"+resolvedEntry+"/"+resolvedKey, func(t *testing.T) { + var param any + var events []gjson.Result + events = append(events, patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "arguments": map[string]any{"input": "p"}}))...) + _, snapshot := patchTypeReviewIdentity("index and IDs") + index, resolved := patchTypeReviewIdentity(resolvedKey) + resolved["type"], resolved["name"] = "function_call", "functions__apply_patch" + resolved["arguments"] = map[string]any{"input": "p"} + if entry == "step.start" || entry == "step.stop" { + events = append(events, patchSend(¶m, patchReviewSnapshot(entry, 2, snapshot))...) + events = append(events, patchSend(¶m, patchReviewSnapshot(resolvedEntry, index, resolved))...) + if resolvedEntry == "step.start" || resolvedEntry == "step.stop" { + events = append(events, patchSend(¶m, []byte(`{"event_type":"interaction.completed"}`))...) + } + } else { + snapshot["index"] = 2 + if index >= 0 { + resolved["index"] = index + } + events = append(events, patchSend(¶m, patchTypeReviewFinal(entry, snapshot, resolved))...) + } + assertPatchLifecycle(t, events, "p") + if state := param.(*interactionsToResponsesStreamState); state.ToolInputError() != nil || state.FunctionCalls[2].PendingError != nil || len(state.FunctionCalls) != 1 { + t.Fatalf("omitted type introduced a conflict: %+v", state) + } + }) + } + } + } +} + +func TestInteractionsApplyPatchPreNameTypeOrdinaryCompatibility(t *testing.T) { + requests := []struct{ name, request, upstream string }{ + {"ordinary", `{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"lookup"}]}`, "lookup"}, + {"function winner", `{"tools":[{"type":"function","name":"apply_patch"},{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch"}]}],"input":[{"type":"additional_tools","tools":[{"type":"custom","name":"apply_patch"}]}]}`, "apply_patch"}, + } + for _, request := range requests { + for _, entry := range []string{"step.start", "step.stop", "interaction.completed", "finish"} { + for _, itemType := range []string{"model_output", "thought", "custom_tool_call", "omitted"} { + t.Run(request.name+"/"+entry+"/"+itemType, func(t *testing.T) { + var param any + send := func(raw []byte) []gjson.Result { + return patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", []byte(request.request), nil, raw, ¶m)) + } + send(patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "arguments": map[string]any{"x": 1}})) + conflict := map[string]any{"index": 2, "id": "item_2", "call_id": "call_2"} + if itemType != "omitted" { + conflict["type"] = itemType + } + resolved := map[string]any{"index": 2, "type": "function_call", "id": "item_2", "call_id": "call_2", "name": request.upstream, "arguments": map[string]any{"x": 1}} + var events []gjson.Result + if entry == "step.start" || entry == "step.stop" { + events = append(events, send(patchReviewSnapshot(entry, 2, conflict))...) + events = append(events, send(patchReviewSnapshot("step.start", 2, resolved))...) + events = append(events, send(patchStep("step.stop", nil))...) + events = append(events, send([]byte(`{"event_type":"interaction.completed"}`))...) + } else { + events = send(patchTypeReviewFinal(entry, conflict, resolved)) + } + for _, event := range events { + if event.Get("type").String() == "response.failed" || strings.HasPrefix(event.Get("type").String(), "response.custom_tool_call_input.") || event.Get("item.type").String() == "custom_tool_call" { + t.Fatalf("ordinary winner received patch behavior: %s", event.Raw) + } + } + final := events[len(events)-1] + if final.Get("type").String() != "response.completed" || final.Get("response.output.0.type").String() != "function_call" || final.Get("response.output.0.name").String() != request.upstream || final.Get("response.output.0.arguments").String() != `{"x":1}` || param.(interface{ ToolInputError() error }).ToolInputError() != nil { + t.Fatalf("ordinary late identity changed: %v", events) + } + }) + } + } + } +} + +func TestInteractionsApplyPatchPreNameUnrelatedModelOutput(t *testing.T) { + for _, entry := range []string{"interaction.completed", "finish"} { + t.Run(entry, func(t *testing.T) { + var param any + patchSend(¶m, patchReviewSnapshot("step.start", 0, map[string]any{"type": "model_output", "id": "message"})) + patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 0, "delta": map[string]any{"type": "text", "text": "hello"}})) + patchSend(¶m, patchReviewSnapshot("step.stop", 0, nil)) + patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "arguments": map[string]any{"input": "p"}})) + events := patchSend(¶m, patchTypeReviewFinal(entry, map[string]any{"type": "model_output", "id": "message", "content": "hello"}, map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "p"}})) + final := events[len(events)-1] + if final.Get("type").String() != "response.completed" || final.Get("response.output.#").Int() != 2 || final.Get("response.output.0.content.0.text").String() != "hello" || final.Get("response.output.1.input").String() != "p" || param.(*interactionsToResponsesStreamState).ToolInputError() != nil { + t.Fatalf("unrelated message became a type conflict: %v", events) + } + }) + } +} + +func TestInteractionsApplyPatchPreNameNoBridgeLegacy(t *testing.T) { + requests := []struct{ name, request, upstream string }{ + {"ordinary only", `{"tools":[{"type":"function","name":"lookup"}]}`, "lookup"}, + {"function winner without bridge", `{"tools":[{"type":"function","name":"apply_patch"}],"input":[{"type":"additional_tools","tools":[{"type":"custom","name":"apply_patch"}]}]}`, "apply_patch"}, + } + for _, request := range requests { + for _, entry := range []string{"step.start", "interaction.completed", "finish"} { + t.Run(request.name+"/"+entry, func(t *testing.T) { + var param any + send := func(raw []byte) []gjson.Result { + return patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", []byte(request.request), nil, raw, ¶m)) + } + send(patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "arguments": map[string]any{"x": 1}})) + message := map[string]any{"index": 2, "type": "model_output", "id": "item_2", "call_id": "call_2"} + resolved := map[string]any{"index": 2, "type": "function_call", "id": "item_2", "call_id": "call_2", "name": request.upstream, "arguments": map[string]any{"x": 1}} + if entry == "step.start" { + events := send(patchReviewSnapshot(entry, 2, message)) + if len(events) != 2 || events[0].Get("item.type").String() != "message" || events[1].Get("type").String() != "response.content_part.added" { + t.Fatalf("ordinary-only repeated start changed: %v", events) + } + send(patchReviewSnapshot("step.start", 2, resolved)) + send(patchStep("step.stop", nil)) + events = send([]byte(`{"event_type":"interaction.completed"}`)) + if len(events) != 1 || events[0].Get("response.output.0.name").String() != request.upstream || events[0].Get("response.output.0.arguments").String() != `{"x":1}` { + t.Fatalf("ordinary-only function changed: %v", events) + } + } else { + events := send(patchTypeReviewFinal(entry, message, resolved)) + state := param.(*interactionsToResponsesStreamState) + if len(events) != 1 || events[0].Get("type").String() != "response.completed" || state.FunctionCalls[2].RawName != "" || state.FunctionCalls[2].Added { + t.Fatalf("non-patch final filtering changed: %v", events) + } + } + if state := param.(*interactionsToResponsesStreamState); state.ToolInputError() != nil || interactionsHasPatchBridge(state) { + t.Fatalf("ordinary winner became a patch: %+v", state) + } + }) + } + } +} diff --git a/internal/translator/openai/interactions/responses/apply_patch_review_test.go b/internal/translator/openai/interactions/responses/apply_patch_review_test.go new file mode 100644 index 000000000..79958f304 --- /dev/null +++ b/internal/translator/openai/interactions/responses/apply_patch_review_test.go @@ -0,0 +1,603 @@ +package responses + +import ( + "context" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +func TestInteractionsApplyPatchOrdinaryFunctionStopSnapshotDeltas(t *testing.T) { + requests := []struct { + name, request, upstream string + }{ + {"ordinary function", `{"tools":[{"type":"function","name":"lookup"}]}`, "lookup"}, + {"ordinary function with patch bridge", `{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"lookup"}]}`, "lookup"}, + {"function winner named apply_patch", `{"tools":[{"type":"function","name":"apply_patch"}],"input":[{"type":"additional_tools","tools":[{"type":"custom","name":"apply_patch"}]}]}`, "apply_patch"}, + } + for _, request := range requests { + for _, identityAt := range []string{"start", "after delta", "stop", "delta before start"} { + t.Run(request.name+"/"+identityAt, func(t *testing.T) { + var param any + var events []gjson.Result + send := func(raw []byte) []gjson.Result { + more := patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", []byte(request.request), nil, raw, ¶m)) + events = append(events, more...) + return more + } + step := map[string]any{"type": "function_call", "arguments": map[string]any{}} + identity := func() { + step["id"], step["call_id"], step["name"] = "item_2", "call_2", request.upstream + } + if identityAt == "start" { + identity() + } + if identityAt != "delta before start" { + send(patchStep("step.start", step)) + } + args := `{"x":1}` + deltas := send(patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": args}})) + if identityAt != "start" && len(deltas) != 0 { + t.Fatalf("arguments emitted before announcement: %v", deltas) + } + identity() + if identityAt == "after delta" || identityAt == "delta before start" { + send(patchStep("step.start", step)) + } + step["arguments"] = map[string]any{"x": 1} + send(patchStep("step.stop", step)) + if more := send(patchStep("step.stop", step)); len(more) != 0 { + t.Fatalf("repeated stop replayed arguments: %v", more) + } + send([]byte(`{"event_type":"interaction.completed"}`)) + var delta strings.Builder + counts := map[string]int{} + for _, event := range events { + kind := event.Get("type").String() + counts[kind]++ + switch kind { + case "response.function_call_arguments.delta": + if counts["response.output_item.added"] != 1 || event.Get("item_id").String() != "item_2" { + t.Fatalf("delta precedes real announcement: %s", event.Raw) + } + delta.WriteString(event.Get("delta").String()) + case "response.function_call_arguments.done": + if event.Get("arguments").String() != args { + t.Fatalf("done arguments changed: %s", event.Raw) + } + case "response.output_item.done": + if event.Get("item.arguments").String() != args || event.Get("item.type").String() != "function_call" || event.Get("item.name").String() != request.upstream { + t.Fatalf("ordinary function changed: %s", event.Raw) + } + case "response.completed": + if event.Get("response.output.0.arguments").String() != args { + t.Fatalf("final arguments changed: %s", event.Raw) + } + } + } + if delta.String() != args || counts["response.function_call_arguments.delta"] != 1 || counts["response.function_call_arguments.done"] != 1 || counts["response.output_item.done"] != 1 || counts["response.completed"] != 1 || counts["response.failed"] != 0 { + t.Fatalf("arguments replayed or lost: delta=%q counts=%v", delta.String(), counts) + } + }) + } + } +} + +func assertInteractionsPatchReviewFailure(t *testing.T, param *any, events []gjson.Result) { + t.Helper() + if len(events) != 1 || events[0].Get("type").String() != "response.failed" || events[0].Get("response.error.code").String() != "invalid_tool_arguments" { + t.Fatalf("expected only sanitized failure: %v", events) + } + if strings.Contains(events[0].Raw, "secret") || strings.Contains(events[0].Raw, `\"input\"`) { + t.Fatalf("raw arguments leaked: %s", events[0].Raw) + } + if state, ok := (*param).(interface{ ToolInputError() error }); !ok || state.ToolInputError() == nil { + t.Fatal("missing tool input error") + } + for _, raw := range [][]byte{[]byte(`{"event_type":"interaction.completed"}`), []byte(`{"event_type":"interaction.failed"}`), []byte(`[DONE]`), patchStep("step.stop", nil)} { + if more := patchSend(param, raw); len(more) != 0 { + t.Fatalf("failed response reopened: %v", more) + } + } +} + +func TestInteractionsApplyPatchCompletedItemStopAndFinalTypeConflicts(t *testing.T) { + for _, entry := range []string{"step.start", "step.stop", "interaction.completed", "finish"} { + for _, key := range []string{"index", "id", "call_id"} { + for _, itemType := range []string{"model_output", "thought", "custom_tool_call"} { + t.Run(entry+"/"+key+"/"+itemType, func(t *testing.T) { + var param any + patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "p"}})) + completed := patchSend(¶m, patchStep("step.stop", nil)) + if len(completed) != 3 || completed[len(completed)-1].Get("type").String() != "response.output_item.done" { + t.Fatalf("fixture did not complete patch item: %v", completed) + } + step := map[string]any{"type": itemType} + event := map[string]any{"event_type": entry, "step": step} + if key == "index" { + step["index"] = 2 + event["index"] = 2 + } else if key == "id" { + step["id"] = "item_2" + } else { + step["call_id"] = "call_2" + } + if entry == "interaction.completed" { + event["interaction"] = map[string]any{"steps": []any{step}} + } else if entry == "finish" { + event["steps"] = []any{step} + } + assertInteractionsPatchReviewFailure(t, ¶m, patchSend(¶m, patchJSON(event))) + }) + } + } + } +} + +func TestInteractionsApplyPatchFinalOnlyIdentityAndEnvelope(t *testing.T) { + for _, entry := range []string{"interaction.completed", "finish"} { + for _, location := range []string{"interaction", "root"} { + for _, evidence := range []string{"unnamed", "malformed named", "valid named"} { + t.Run(entry+"/"+location+"/"+evidence, func(t *testing.T) { + step := map[string]any{"index": 2, "type": "function_call", "id": "item_2", "call_id": "call_2", "arguments": map[string]any{"input": "secret"}} + if evidence != "unnamed" { + step["name"] = "functions__apply_patch" + } + event := map[string]any{"event_type": entry} + path := "steps" + if location == "interaction" { + event["interaction"] = map[string]any{"id": "r", "steps": []any{step}} + path = "interaction.steps" + } else { + event["steps"] = []any{step} + } + raw := patchJSON(event) + if evidence == "malformed named" { + raw = raw[:len(raw)-1] + if gjson.ValidBytes(raw) || gjson.GetBytes(raw, path+".0.name").String() != "functions__apply_patch" { + t.Fatal("fixture must retain a recoverable patch step in incomplete outer JSON") + } + } + var param any + events := patchSend(¶m, raw) + if evidence == "valid named" { + assertPatchLifecycle(t, events, "secret") + } else { + assertInteractionsPatchReviewFailure(t, ¶m, events) + } + }) + } + } + } +} + +func TestInteractionsApplyPatchFinalOnlyOtherToolsKeepLegacyBehavior(t *testing.T) { + for _, name := range []string{"lookup", "exec"} { + for _, malformed := range []bool{false, true} { + t.Run(name+map[bool]string{false: "/valid", true: "/malformed"}[malformed], func(t *testing.T) { + request := []byte(`{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"lookup"},{"type":"custom","name":"exec"}]}`) + raw := patchJSON(map[string]any{"event_type": "interaction.completed", "steps": []any{map[string]any{"type": "function_call", "name": name, "arguments": map[string]any{"x": 1}}}}) + if malformed { + raw = raw[:len(raw)-1] + } + var param any + events := patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", request, nil, raw, ¶m)) + if len(events) != 1 || events[0].Get("type").String() != "response.completed" || len(events[0].Get("response.output").Array()) != 0 { + t.Fatalf("legacy final-only ordinary/custom behavior changed: %v", events) + } + if state := param.(interface{ ToolInputError() error }); state.ToolInputError() != nil { + t.Fatalf("ordinary/custom arguments received patch validation: %v", state.ToolInputError()) + } + }) + } + } +} + +func TestInteractionsApplyPatchFinalSnapshotsPreserveOtherOutputsAndCalls(t *testing.T) { + var param any + request := []byte(`{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch"}]},{"type":"function","name":"lookup"},{"type":"custom","name":"exec"}]}`) + send := func(index int, entry, key string, value any) []gjson.Result { + return patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", request, nil, patchJSON(map[string]any{"event_type": entry, "index": index, key: value}), ¶m)) + } + snapshots := []any{ + map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "p"}}, + map[string]any{"type": "function_call", "id": "ordinary", "name": "lookup", "arguments": map[string]any{"x": 1}}, + map[string]any{"type": "function_call", "id": "custom", "name": "exec", "arguments": map[string]any{"command": "ls"}}, + map[string]any{"type": "model_output", "id": "message", "content": "hello"}, + } + for index, step := range snapshots { + send(index, "step.start", "step", step) + if index == 3 { + send(index, "step.delta", "delta", map[string]any{"type": "text", "text": "hello"}) + } + send(index, "step.stop", "step", step) + } + // Array positions are not explicit call indexes when the supplied IDs identify other steps. + finalSteps := []any{snapshots[3], snapshots[2], snapshots[1], snapshots[0]} + raw := patchJSON(map[string]any{"event_type": "interaction.completed", "interaction": map[string]any{"steps": finalSteps}}) + events := patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", request, nil, raw, ¶m)) + if len(events) != 1 || events[0].Get("type").String() != "response.completed" { + t.Fatalf("legitimate reordered snapshots failed: %v", events) + } + output := events[0].Get("response.output") + if len(output.Array()) != 4 || output.Get("0.input").String() != "p" || output.Get("1.arguments").String() != `{"x":1}` || output.Get("2.input").String() != `{"command":"ls"}` || output.Get("3.content.0.text").String() != "hello" { + t.Fatalf("other output/call lost or rebound: %s", output.Raw) + } +} + +func TestInteractionsApplyPatchFinalOnlyCallDoesNotRebindUnrelatedItem(t *testing.T) { + var param any + request := []byte(`{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch"}]},{"type":"function","name":"lookup"}]}`) + send := func(raw []byte) []gjson.Result { + return patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", request, nil, raw, ¶m)) + } + send([]byte(`{"event_type":"step.start","index":0,"step":{"type":"function_call","id":"ordinary","name":"lookup","arguments":{"x":1}}}`)) + send([]byte(`{"event_type":"step.stop","index":0}`)) + events := send([]byte(`{"event_type":"interaction.completed","steps":[{"type":"function_call","id":"patch","call_id":"patch_call","name":"functions__apply_patch","arguments":{"input":"p"}}]}`)) + final := events[len(events)-1] + if final.Get("type").String() != "response.completed" || final.Get("response.output.0.id").String() != "ordinary" || final.Get("response.output.0.arguments").String() != `{"x":1}` || final.Get("response.output.1.id").String() != "patch" || final.Get("response.output.1.input").String() != "p" { + t.Fatalf("array position rebound unrelated call: %v", events) + } +} + +func TestInteractionsApplyPatchExistingCallAcceptsOmittedSnapshotType(t *testing.T) { + for _, entry := range []string{"step.start", "step.stop", "interaction.completed"} { + t.Run(entry, func(t *testing.T) { + var param any + patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "p"}})) + patchSend(¶m, patchStep("step.stop", nil)) + step := map[string]any{"id": "item_2", "arguments": map[string]any{"input": "p"}} + raw := patchStep(entry, step) + if entry == "interaction.completed" { + raw = patchJSON(map[string]any{"event_type": entry, "steps": []any{step}}) + } + events := patchSend(¶m, raw) + if entry != "interaction.completed" { + if len(events) != 0 { + t.Fatalf("equivalent snapshot replayed lifecycle: %v", events) + } + events = patchSend(¶m, []byte(`{"event_type":"interaction.completed"}`)) + } + if len(events) != 1 || events[0].Get("type").String() != "response.completed" || events[0].Get("response.output.0.input").String() != "p" { + t.Fatalf("omitted type erased existing patch identity: %v", events) + } + }) + } +} + +func TestInteractionsApplyPatchMalformedPreNameEnvelopeRetainsSuppliedID(t *testing.T) { + var param any + if events := patchSend(¶m, []byte(`{"event_type":"step.start","step":{"type":"function_call","id":"item_2","call_id":"call_2","arguments":{"input":"secret"}}`)); len(events) != 0 { + t.Fatalf("unnamed malformed evidence leaked: %v", events) + } + events := patchSend(¶m, []byte(`{"event_type":"interaction.completed","steps":[{"type":"function_call","id":"item_2","call_id":"call_2","name":"functions__apply_patch","arguments":{"input":"secret"}}]}`)) + assertInteractionsPatchReviewFailure(t, ¶m, events) +} + +func TestInteractionsApplyPatchFinalOnlyUnnamedUnkeyedFunctionFails(t *testing.T) { + var param any + events := patchSend(¶m, []byte(`{"event_type":"interaction.completed","steps":[{"type":"function_call","arguments":{"input":"secret"}}]}`)) + assertInteractionsPatchReviewFailure(t, ¶m, events) +} + +func TestInteractionsApplyPatchOrdinaryLateIdentityReplaysOnlyBufferedPrefix(t *testing.T) { + var param any + request := []byte(`{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"lookup"}]}`) + var events []gjson.Result + send := func(raw []byte) { + events = append(events, patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", request, nil, raw, ¶m))...) + } + send([]byte(`{"event_type":"step.start","index":2,"step":{"type":"function_call","arguments":{}}}`)) + send([]byte(`{"event_type":"step.delta","index":2,"delta":{"type":"arguments_delta","arguments":"{\"x\":"}}`)) + send([]byte(`{"event_type":"step.start","index":2,"step":{"type":"function_call","id":"item_2","name":"lookup"}}`)) + send([]byte(`{"event_type":"step.delta","index":2,"delta":{"type":"arguments_delta","arguments":"1}"}}`)) + send([]byte(`{"event_type":"step.stop","index":2,"step":{"type":"function_call","id":"item_2","name":"lookup","arguments":{"x":1}}}`)) + send([]byte(`{"event_type":"interaction.completed"}`)) + var delta strings.Builder + for _, event := range events { + if event.Get("type").String() == "response.function_call_arguments.delta" { + delta.WriteString(event.Get("delta").String()) + } + if event.Get("type").String() == "response.function_call_arguments.done" && event.Get("arguments").String() != `{"x":1}` { + t.Fatal(event.Raw) + } + } + if delta.String() != `{"x":1}` || events[len(events)-1].Get("response.output.0.arguments").String() != delta.String() { + t.Fatalf("late-identity prefix replayed or lost: delta=%q events=%v", delta.String(), events) + } +} + +func patchReviewSnapshot(entry string, index int, step map[string]any) []byte { + event := map[string]any{"event_type": entry} + if entry == "step.start" || entry == "step.stop" { + event["step"] = step + if index >= 0 { + event["index"] = index + } + } else { + if index >= 0 { + step["index"] = index + } + if entry == "finish" { + event["steps"] = []any{step} + } else { + event["interaction"] = map[string]any{"steps": []any{step}} + } + } + return patchJSON(event) +} + +func TestInteractionsApplyPatchConflictingIndexAndIDs(t *testing.T) { + identities := []struct { + name, itemID, callID string + index int + }{ + {"both IDs", "item_2", "call_2", 3}, + {"item ID only", "item_2", "", 3}, + {"call ID only", "", "call_2", 3}, + {"matching item wrong call", "item_2", "wrong_call", 3}, + {"wrong item matching call", "wrong_item", "call_2", 3}, + {"item and other patch call", "item_2", "call_4", 3}, + {"other patch item and call", "item_4", "call_2", 3}, + {"index of other patch", "item_2", "call_2", 4}, + {"split IDs without index", "item_2", "call_4", -1}, + {"split IDs at real index", "item_2", "call_4", 2}, + } + for _, entry := range []string{"step.start", "step.stop", "interaction.completed", "finish"} { + for _, phase := range []string{"active item", "completed item"} { + for _, identity := range identities { + for _, snapshot := range []string{"model_output", "thought", "custom_tool_call", "function same input", "function changed input", "omitted type"} { + t.Run(entry+"/"+phase+"/"+identity.name+"/"+snapshot, func(t *testing.T) { + var param any + patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch"})) + patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": patchArguments("p")}})) + patchSend(¶m, patchReviewSnapshot("step.start", 4, map[string]any{"type": "function_call", "id": "item_4", "call_id": "call_4", "name": "functions__apply_patch", "arguments": map[string]any{"input": "q"}})) + if phase == "completed item" { + completed := patchSend(¶m, patchStep("step.stop", nil)) + if len(completed) != 2 || completed[1].Get("type").String() != "response.output_item.done" { + t.Fatalf("fixture did not complete real source input: %v", completed) + } + } + step := map[string]any{} + if identity.itemID != "" { + step["id"] = identity.itemID + } + if identity.callID != "" { + step["call_id"] = identity.callID + } + switch snapshot { + case "function same input", "function changed input", "omitted type": + step["name"] = "functions__apply_patch" + input := "p" + if snapshot == "function changed input" { + input = "secret" + } + step["arguments"] = map[string]any{"input": input} + if snapshot != "omitted type" { + step["type"] = "function_call" + } + default: + step["type"] = snapshot + } + assertInteractionsPatchReviewFailure(t, ¶m, patchSend(¶m, patchReviewSnapshot(entry, identity.index, step))) + state := param.(*interactionsToResponsesStreamState) + if len(state.FunctionCalls) != 2 || state.FunctionCalls[2].PatchCall.OutputIndex != 2 { + t.Fatalf("conflicting identity created or rebound a patch call: %+v", state.FunctionCalls) + } + }) + } + } + } + } +} + +func TestInteractionsApplyPatchRootAndStepIndexesMustAgree(t *testing.T) { + for _, entry := range []string{"step.start", "step.stop"} { + for _, indexes := range [][2]int{{2, 3}, {3, 2}} { + t.Run(entry+"/"+string(patchJSON(indexes)), func(t *testing.T) { + var param any + patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "p"}})) + patchSend(¶m, patchStep("step.stop", nil)) + step := map[string]any{"index": indexes[1], "type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "p"}} + assertInteractionsPatchReviewFailure(t, ¶m, patchSend(¶m, patchReviewSnapshot(entry, indexes[0], step))) + }) + } + } +} + +func TestInteractionsApplyPatchPreNameIndexConflictRetained(t *testing.T) { + for _, entry := range []string{"step.start", "step.stop", "interaction.completed", "finish"} { + for _, key := range []string{"id", "call_id", "both"} { + for _, snapshotType := range []string{"model_output", "function_call", "omitted"} { + for _, resolvedAt := range []string{"original index", "changed index", "IDs only", "original index without IDs", "changed index without IDs"} { + t.Run(entry+"/"+key+"/"+snapshotType+"/"+resolvedAt, func(t *testing.T) { + var param any + patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "arguments": map[string]any{"input": "p"}})) + conflict := map[string]any{"index": 3} + if key != "call_id" { + conflict["id"] = "item_2" + } + if key != "id" { + conflict["call_id"] = "call_2" + } + if snapshotType != "omitted" { + conflict["type"] = snapshotType + } + resolved := map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "p"}} + index := -1 + if strings.HasPrefix(resolvedAt, "original index") { + index = 2 + } else if strings.HasPrefix(resolvedAt, "changed index") { + index = 3 + } + if strings.HasSuffix(resolvedAt, "without IDs") { + delete(resolved, "id") + delete(resolved, "call_id") + } + var events []gjson.Result + if entry == "step.start" || entry == "step.stop" { + // Unknown names must not make identity contradictions disappear later. + patchSend(¶m, patchReviewSnapshot(entry, 3, conflict)) + events = patchSend(¶m, patchReviewSnapshot("interaction.completed", index, resolved)) + } else { + if index >= 0 { + resolved["index"] = index + } + events = patchSend(¶m, patchJSON(map[string]any{"event_type": entry, "steps": []any{conflict, resolved}})) + } + assertInteractionsPatchReviewFailure(t, ¶m, events) + for _, call := range param.(*interactionsToResponsesStreamState).FunctionCalls { + if call.PatchCall != nil || call.Added { + t.Fatal("late identity announced a patch after contradictory evidence") + } + } + }) + } + } + } + } +} + +func TestInteractionsApplyPatchConsistentSnapshotIdentityMatrix(t *testing.T) { + for _, entry := range []string{"step.start", "step.stop", "interaction.completed", "finish"} { + for _, key := range []string{"index and IDs", "IDs only", "item only", "call only"} { + for _, snapshotType := range []string{"function_call", "omitted"} { + t.Run(entry+"/"+key+"/"+snapshotType, func(t *testing.T) { + var param any + var events []gjson.Result + events = append(events, patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "p"}}))...) + events = append(events, patchSend(¶m, patchStep("step.stop", nil))...) + step := map[string]any{"arguments": map[string]any{"input": "p"}} + index := -1 + if key == "index and IDs" { + index = 2 + } + if key != "call only" { + step["id"] = "item_2" + } + if key != "item only" { + step["call_id"] = "call_2" + } + if snapshotType != "omitted" { + step["type"] = snapshotType + } + events = append(events, patchSend(¶m, patchReviewSnapshot(entry, index, step))...) + if entry == "step.start" || entry == "step.stop" { + events = append(events, patchSend(¶m, []byte(`{"event_type":"interaction.completed"}`))...) + } + assertPatchLifecycle(t, events, "p") + if state := param.(*interactionsToResponsesStreamState); len(state.FunctionCalls) != 1 || state.ToolInputError() != nil { + t.Fatalf("consistent snapshot changed identity: %+v", state) + } + }) + } + } + } +} + +func TestInteractionsApplyPatchUnmatchedNewIDsRemainIndependent(t *testing.T) { + for _, entry := range []string{"step.start", "interaction.completed", "finish"} { + for _, index := range []int{-1, 3} { + t.Run(entry+"/"+string(patchJSON(index)), func(t *testing.T) { + var param any + patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "p"}})) + patchSend(¶m, patchStep("step.stop", nil)) + step := map[string]any{"type": "function_call", "id": "new_item", "call_id": "new_call", "name": "functions__apply_patch", "arguments": map[string]any{"input": "q"}} + events := patchSend(¶m, patchReviewSnapshot(entry, index, step)) + if entry == "step.start" { + events = append(events, patchSend(¶m, []byte(`{"event_type":"interaction.completed"}`))...) + } + final := events[len(events)-1] + if final.Get("type").String() != "response.completed" || len(final.Get("response.output").Array()) != 2 { + t.Fatalf("unmatched new IDs were rejected: %v", events) + } + inputs := map[string]string{} + for _, item := range final.Get("response.output").Array() { + inputs[item.Get("call_id").String()] = item.Get("input").String() + } + if inputs["call_2"] != "p" || inputs["new_call"] != "q" || param.(*interactionsToResponsesStreamState).ToolInputError() != nil { + t.Fatalf("new call rebound existing patch: %v", events) + } + }) + } + } +} + +func TestInteractionsApplyPatchOrdinaryExplicitIndexKeepsLegacyBehavior(t *testing.T) { + var param any + request := []byte(`{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"lookup"}]}`) + send := func(raw []byte) []gjson.Result { + return patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", request, nil, raw, ¶m)) + } + for _, index := range []int{2, 3} { + step := map[string]any{"type": "function_call", "id": "ordinary", "call_id": "ordinary_call", "name": "lookup", "arguments": map[string]any{"x": index}} + send(patchReviewSnapshot("step.start", index, step)) + send(patchReviewSnapshot("step.stop", index, step)) + } + events := send([]byte(`{"event_type":"interaction.completed"}`)) + if len(events) != 1 || events[0].Get("type").String() != "response.completed" || events[0].Get("response.output.0.arguments").String() != `{"x":2}` || events[0].Get("response.output.1.arguments").String() != `{"x":3}` || param.(*interactionsToResponsesStreamState).ToolInputError() != nil { + t.Fatalf("ordinary explicit-index behavior received patch validation: %v", events) + } +} + +func TestInteractionsApplyPatchThreeEventChangedIndexReproduction(t *testing.T) { + for _, snapshot := range []string{ + `{"index":3,"type":"model_output","id":"item_2","call_id":"call_2"}`, + `{"index":3,"type":"function_call","id":"item_2","call_id":"call_2","name":"functions__apply_patch","arguments":{"input":"secret"}}`, + } { + t.Run(snapshot, func(t *testing.T) { + var param any + patchSend(¶m, []byte(`{"event_type":"step.start","index":2,"step":{"type":"function_call","id":"item_2","call_id":"call_2","name":"functions__apply_patch","arguments":{"input":"p"}}}`)) + patchSend(¶m, []byte(`{"event_type":"step.stop","index":2}`)) + assertInteractionsPatchReviewFailure(t, ¶m, patchSend(¶m, []byte(`{"event_type":"interaction.completed","steps":[`+snapshot+`]}`))) + if len(param.(*interactionsToResponsesStreamState).FunctionCalls) != 1 { + t.Fatal("terminal snapshot created a second call with the same IDs") + } + }) + } +} + +func TestInteractionsApplyPatchIDsCannotSelectUnrelatedOrdinaryIndex(t *testing.T) { + for _, entry := range []string{"step.start", "step.stop", "interaction.completed", "finish"} { + for _, snapshotType := range []string{"model_output", "function_call"} { + t.Run(entry+"/"+snapshotType, func(t *testing.T) { + var param any + request := []byte(`{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch"}]},{"type":"function","name":"lookup"}]}`) + send := func(raw []byte) []gjson.Result { + return patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", request, nil, raw, ¶m)) + } + send(patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "p"}})) + send(patchStep("step.stop", nil)) + send(patchReviewSnapshot("step.start", 3, map[string]any{"type": "function_call", "id": "ordinary", "call_id": "ordinary_call", "name": "lookup", "arguments": map[string]any{"x": 1}})) + send(patchReviewSnapshot("step.stop", 3, nil)) + step := map[string]any{"type": snapshotType, "id": "item_2", "call_id": "call_2", "name": "lookup", "arguments": map[string]any{"x": 2}} + assertInteractionsPatchReviewFailure(t, ¶m, send(patchReviewSnapshot(entry, 3, step))) + if call := param.(*interactionsToResponsesStreamState).FunctionCalls[3]; call.ID != "ordinary" || call.Arguments.String() != `{"x":1}` || call.PatchCall != nil { + t.Fatalf("conflicting patch IDs mutated an unrelated ordinary call: %+v", call) + } + }) + } + } +} + +func TestInteractionsApplyPatchNestedChangedIndexAndCompletedSource(t *testing.T) { + for _, entry := range []string{"step.start", "step.stop", "interaction.completed", "finish"} { + for _, index := range []int{-1, 2, 3} { + t.Run(entry+"/"+string(patchJSON(index)), func(t *testing.T) { + var param any + patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch"})) + patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": patchArguments("p")}})) + patchSend(¶m, patchStep("step.stop", nil)) + step := map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "secret"}} + if index >= 0 { + step["index"] = index + } + assertInteractionsPatchReviewFailure(t, ¶m, patchSend(¶m, patchReviewSnapshot(entry, -1, step))) + if len(param.(*interactionsToResponsesStreamState).FunctionCalls) != 1 { + t.Fatal("function snapshot bypassed completed real source comparison") + } + }) + } + } +} diff --git a/internal/translator/openai/interactions/responses/apply_patch_source_stop_test.go b/internal/translator/openai/interactions/responses/apply_patch_source_stop_test.go new file mode 100644 index 000000000..dfa0db98a --- /dev/null +++ b/internal/translator/openai/interactions/responses/apply_patch_source_stop_test.go @@ -0,0 +1,221 @@ +package responses + +import ( + "fmt" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +// Source completion is a barrier even when the winning name or either ID is late. +func TestInteractionsApplyPatchSourceStopFragments(t *testing.T) { + for _, first := range []string{"item", "call", "neither", "ready"} { + for _, lateName := range []bool{false, true} { + for _, input := range []string{"partial", "complete"} { + for _, discovery := range []string{"terminal", "identity", "coincident-snapshot"} { + t.Run(fmt.Sprintf("%s/late-name=%v/%s/%s", first, lateName, input, discovery), func(t *testing.T) { + var param any + step := map[string]any{"type": "function_call"} + if first == "item" || first == "ready" { + step["id"] = "item_2" + } + if first == "call" || first == "ready" { + step["call_id"] = "call_2" + } + if !lateName { + step["name"] = "functions__apply_patch" + } + before, after, final := `{"input":"p`, `q"}`, "pq" + if input == "complete" { + before, after, final = `{"input":"p"}`, " \t\n", "p" + } + var events []gjson.Result + events = append(events, patchSend(¶m, patchStep("step.start", step))...) + events = append(events, patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": before}}))...) + events = append(events, patchSend(¶m, patchStep("step.stop", nil))...) + state := param.(*interactionsToResponsesStreamState) + if (first != "ready" || lateName) && len(events) != 0 { + t.Fatalf("unresolved call published: %v", events) + } + resolved := map[string]any{"index": 2, "type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": final}, "provider_secret": "RAW_SECRET"} + post := map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": after}} + if discovery == "coincident-snapshot" { + post["step"] = resolved + } + events = append(events, patchSend(¶m, patchJSON(post))...) + if !lateName || discovery == "coincident-snapshot" { + if state.ToolInputError() == nil { + t.Fatalf("post-stop fragment accepted before identity readiness: %v", events) + } + if first != "ready" || lateName { + if !strings.Contains(state.ToolInputError().Error(), "after source stop") { + t.Fatalf("snapshot/replay erased source ordering: %v", state.ToolInputError()) + } + } + } else { + call := state.FunctionCalls[2] + if state.ToolInputError() != nil || call.PendingError == nil || !strings.Contains(call.PendingError.Error(), "after source stop") { + t.Fatalf("unnamed call lost pending source violation: %+v", call) + } + if len(call.ArgumentFragments) != 1 || call.ArgumentFragments[0] != before { + t.Fatalf("buffered a fragment after source stop: %q", call.ArgumentFragments) + } + } + if discovery == "identity" { + events = append(events, patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "step": resolved, "delta": map[string]any{"type": "arguments_delta", "arguments": ""}}))...) + } + events = append(events, patchSend(¶m, patchJSON(map[string]any{"event_type": "interaction.completed", "steps": []any{resolved}}))...) + failures := 0 + for _, event := range events { + if (first != "ready" || lateName) && event.Get("type").String() != "response.failed" { + t.Fatalf("unresolved stopped call published success: %s", event.Raw) + } + if event.Get("type").String() == "response.failed" { + failures++ + if event.Get("response.error.code").String() != "invalid_tool_arguments" { + t.Fatalf("unsanitized failure: %s", event.Raw) + } + } + if event.Get("type").String() == "response.completed" || strings.Contains(event.Raw, "RAW_SECRET") { + t.Fatalf("source violation completed/leaked: %s", event.Raw) + } + } + if failures != 1 || state.ToolInputError() == nil { + t.Fatalf("failure count=%d events=%v", failures, events) + } + if more := patchSend(¶m, []byte(`[DONE]`)); len(more) != 0 { + t.Fatalf("failure reopened: %v", more) + } + }) + } + } + } + } +} + +func TestInteractionsApplyPatchSourceStopLateIdentityReplay(t *testing.T) { + for _, first := range []string{"item", "call", "neither"} { + for _, lateName := range []bool{false, true} { + for _, boundary := range []string{"before-stop", "identity-after-stop", "terminal-snapshot"} { + t.Run(fmt.Sprintf("%s/late-name=%v/%s", first, lateName, boundary), func(t *testing.T) { + var param any + step := map[string]any{"type": "function_call"} + if first == "item" { + step["id"] = "item_2" + } + if first == "call" { + step["call_id"] = "call_2" + } + if !lateName { + step["name"] = "functions__apply_patch" + } + if events := patchSend(¶m, patchStep("step.start", step)); len(events) != 0 { + t.Fatalf("provisional identity: %v", events) + } + args := patchArguments(patchText) + for _, fragment := range []string{args[:15], args[15:]} { + if events := patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": fragment}})); len(events) != 0 { + t.Fatalf("unresolved delta: %v", events) + } + } + if boundary != "before-stop" { + if events := patchSend(¶m, patchStep("step.stop", nil)); len(events) != 0 { + t.Fatalf("stop guessed an identity: %v", events) + } + } + resolved := map[string]any{"index": 2, "type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch"} + var events []gjson.Result + if boundary != "terminal-snapshot" { + events = patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "step": resolved, "delta": map[string]any{"type": "arguments_delta", "arguments": ""}})) + wantCount := 3 + if boundary == "identity-after-stop" { + wantCount = 5 + if !param.(*interactionsToResponsesStreamState).FunctionCalls[2].ItemDoneEmitted { + t.Fatal("identity-only update did not publish pending completion") + } + } + if len(events) != wantCount || param.(*interactionsToResponsesStreamState).Terminal { + t.Fatalf("replay waited for response terminal: %v", events) + } + if boundary == "before-stop" { + events = append(events, patchSend(¶m, patchStep("step.stop", nil))...) + } + } + resolved["arguments"] = map[string]any{"input": patchText} + events = append(events, patchSend(¶m, patchJSON(map[string]any{"event_type": "interaction.completed", "steps": []any{resolved}}))...) + assertPatchLifecycle(t, events, patchText) + var fragments []string + for _, event := range events { + if event.Get("type").String() == "response.custom_tool_call_input.delta" { + fragments = append(fragments, event.Get("delta").String()) + } + } + if len(fragments) != 2 || fragments[0] != patchText[:5] || fragments[1] != patchText[5:] { + t.Fatalf("snapshot replaced real source fragments: %q", fragments) + } + }) + } + } + } +} + +// An arguments delta can establish a candidate before its first named start. +func TestInteractionsApplyPatchSourceStopBeforeStart(t *testing.T) { + for _, before := range []string{`{"input":"p`, `{"input":"pq"}`} { + t.Run(before, func(t *testing.T) { + var param any + patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": before}})) + patchSend(¶m, patchStep("step.stop", nil)) + post := `q"}` + if strings.HasSuffix(before, "}") { + post = " " + } + patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": post}})) + if call := param.(*interactionsToResponsesStreamState).FunctionCalls[2]; call.PendingError == nil { + t.Fatal("stop before named start lost source evidence") + } + events := patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "pq"}})) + if len(events) != 1 || events[0].Get("type").String() != "response.failed" { + t.Fatalf("late start repaired a stopped call: %v", events) + } + }) + } +} + +func TestInteractionsApplyPatchSourceStopOrdinaryFunction(t *testing.T) { + var param any + for _, raw := range []string{ + `{"event_type":"step.start","index":2,"step":{"type":"function_call","id":"ordinary","call_id":"ordinary_call"}}`, + `{"event_type":"step.delta","index":2,"delta":{"type":"arguments_delta","arguments":"{\"path\":\"a"}}`, + `{"event_type":"step.stop","index":2}`, + `{"event_type":"step.delta","index":2,"delta":{"type":"arguments_delta","arguments":".txt\"}"}}`, + } { + if events := patchSend(¶m, []byte(raw)); len(events) != 0 { + t.Fatalf("unnamed ordinary call announced: %v", events) + } + } + events := patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "ordinary", "call_id": "ordinary_call", "name": "external_read_file"})) + events = append(events, patchSend(¶m, patchStep("step.stop", nil))...) + events = append(events, patchSend(¶m, []byte(`{"event_type":"interaction.completed"}`))...) + state := param.(*interactionsToResponsesStreamState) + if state.ToolInputError() != nil { + t.Fatalf("patch-only source violation broke ordinary function: %v", state.ToolInputError()) + } + counts := map[string]int{} + for _, event := range events { + kind := event.Get("type").String() + counts[kind]++ + if strings.Contains(kind, "custom_tool_call") || kind == "response.failed" { + t.Fatalf("ordinary function bridged: %s", event.Raw) + } + if kind == "response.function_call_arguments.delta" && event.Get("delta").String() != `{"path":"a.txt"}` { + t.Fatalf("ordinary buffered fragments changed: %s", event.Raw) + } + } + for _, kind := range []string{"response.output_item.added", "response.function_call_arguments.delta", "response.function_call_arguments.done", "response.output_item.done", "response.completed"} { + if counts[kind] != 1 { + t.Fatalf("ordinary lifecycle changed: %v", counts) + } + } +} diff --git a/internal/translator/openai/interactions/responses/apply_patch_test.go b/internal/translator/openai/interactions/responses/apply_patch_test.go new file mode 100644 index 000000000..36ac1c8b3 --- /dev/null +++ b/internal/translator/openai/interactions/responses/apply_patch_test.go @@ -0,0 +1,398 @@ +package responses + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +const patchRequest = `{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch","format":{"type":"grammar","definition":"start: patch"}}]}]}` +const patchText = " *** Begin Patch\n*** Add File: 中.txt\n+😀\n*** End Patch\n " + +func patchJSON(v any) []byte { b, _ := json.Marshal(v); return b } +func patchArguments(input string) string { return string(patchJSON(map[string]string{"input": input})) } +func patchStep(event string, step any) []byte { + return patchJSON(map[string]any{"event_type": event, "index": 2, "step": step}) +} +func patchEvents(chunks [][]byte) []gjson.Result { + var events []gjson.Result + for _, chunk := range chunks { + for _, line := range strings.Split(string(chunk), "\n") { + if strings.HasPrefix(line, "data: ") && gjson.Valid(strings.TrimPrefix(line, "data: ")) { + events = append(events, gjson.Parse(strings.TrimPrefix(line, "data: "))) + } + } + } + return events +} +func patchSend(param *any, raw []byte) []gjson.Result { + return patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", []byte(patchRequest), nil, raw, param)) +} + +func TestInteractionsApplyPatchDeclarationAndHistory(t *testing.T) { + request := gjson.Parse(patchRequest).Value().(map[string]any) + request["input"] = []any{map[string]any{"type": "custom_tool_call", "name": "apply_patch", "namespace": "functions", "call_id": "c1", "input": patchText}, map[string]any{"type": "custom_tool_call_output", "call_id": "c1", "output": "ok"}} + out := gjson.ParseBytes(ConvertOpenAIResponsesRequestToInteractions("devin/swe-2", patchJSON(request), false)) + if !strings.Contains(out.Get("tools.0.description").String(), "*** Begin Patch") || out.Get("tools.0.parameters.additionalProperties").Bool() || !out.Get("tools.0.parameters.additionalProperties").Exists() { + t.Fatalf("missing patch contract: %s", out.Raw) + } + if out.Get("input.0.arguments.input").String() != patchText || out.Get("input.1.name").String() != "functions__apply_patch" { + t.Fatalf("history changed: %s", out.Raw) + } +} + +func TestInteractionsApplyPatchEverySplitPreviewAndCompletion(t *testing.T) { + args := strings.NewReplacer("中", `\u4e2d`, "😀", `\ud83d\ude00`).Replace(patchArguments(patchText)) + for split := 0; split <= len(args); split++ { + var param any + var events []gjson.Result + events = append(events, patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{}}))...) + for _, fragment := range []string{args[:split], args[split:]} { + events = append(events, patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": fragment}}))...) + } + var preview strings.Builder + for _, ev := range events { + if ev.Get("type").String() == "response.custom_tool_call_input.delta" { + preview.WriteString(ev.Get("delta").String()) + } + } + if preview.String() != patchText { + t.Fatalf("split %d: no decoded preview before stop: %q", split, preview.String()) + } + events = append(events, patchSend(¶m, patchStep("step.stop", nil))...) + events = append(events, patchSend(¶m, []byte(`{"event_type":"interaction.completed","interaction":{"id":"r"}}`))...) + assertPatchLifecycle(t, events, patchText) + } +} + +func assertPatchLifecycle(t *testing.T, events []gjson.Result, want string) { + t.Helper() + var delta strings.Builder + counts := map[string]int{} + lastSeq := int64(0) + for _, ev := range events { + kind := ev.Get("type").String() + counts[kind]++ + if seq := ev.Get("sequence_number").Int(); seq <= lastSeq { + t.Fatalf("sequence: %s", ev.Raw) + } else { + lastSeq = seq + } + switch kind { + case "response.custom_tool_call_input.delta", "response.custom_tool_call_input.done": + if ev.Get("item_id").String() != "item_2" || ev.Get("call_id").String() != "call_2" || ev.Get("output_index").Int() != 2 { + t.Fatalf("event identity: %s", ev.Raw) + } + if kind == "response.custom_tool_call_input.delta" { + delta.WriteString(ev.Get("delta").String()) + } else if ev.Get("input").String() != want { + t.Fatalf("done input: %s", ev.Raw) + } + case "response.output_item.added", "response.output_item.done": + if ev.Get("item.id").String() != "item_2" || ev.Get("item.call_id").String() != "call_2" || ev.Get("item.namespace").String() != "functions" || ev.Get("item.name").String() != "apply_patch" { + t.Fatalf("item identity: %s", ev.Raw) + } + if kind == "response.output_item.done" && ev.Get("item.input").String() != want { + t.Fatalf("item input: %s", ev.Raw) + } + case "response.completed": + if ev.Get("response.output.0.input").String() != want || ev.Get("response.output.0.id").String() != "item_2" || ev.Get("response.output.0.call_id").String() != "call_2" { + t.Fatalf("final input: %s", ev.Raw) + } + } + } + if delta.String() != want || counts["response.output_item.added"] != 1 || counts["response.custom_tool_call_input.done"] != 1 || counts["response.output_item.done"] != 1 || counts["response.completed"] != 1 || counts["response.function_call_arguments.delta"] != 0 { + t.Fatalf("lifecycle counts=%v delta=%q", counts, delta.String()) + } +} + +func TestInteractionsApplyPatchInvalidNonStream(t *testing.T) { + for _, args := range []string{`{}`, `{"input":1}`, `{"input":"a","input":"b"}`, `{"input":"secret"`, `{"input":"x","extra":1}`} { + var param any + raw := patchJSON(map[string]any{"id": "r", "steps": []any{map[string]any{"type": "function_call", "name": "functions__apply_patch", "arguments": args}}}) + out := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "devin/swe-2", []byte(patchRequest), nil, raw, ¶m) + if len(out) != 0 { + t.Fatalf("invalid arguments returned output: %s", out) + } + if state, ok := param.(interface{ ToolInputError() error }); !ok || state.ToolInputError() == nil { + t.Fatal("missing error state") + } + } +} + +func TestInteractionsApplyPatchSnapshotMatrix(t *testing.T) { + start := func(name, id, args string) []byte { + step := map[string]any{"type": "function_call", "id": id, "call_id": "call_2"} + if name != "" { + step["name"] = name + } + if args != "" { + step["arguments"] = args + } + return patchStep("step.start", step) + } + delta := func(args string) []byte { + return patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": args}}) + } + stop := func(args string) []byte { + return patchStep("step.stop", map[string]any{"name": "functions__apply_patch", "arguments": args}) + } + final := func(args string) []byte { + return patchJSON(map[string]any{"event_type": "interaction.completed", "interaction": map[string]any{"id": "r", "steps": []any{map[string]any{"index": 2, "type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": args}}}}) + } + cases := []struct { + name string + inputs [][]byte + fail bool + want string + }{ + {"partial prefix completed by stop", [][]byte{start("functions__apply_patch", "item_2", ""), delta(`{"input":"a`), stop(`{"input":"ab"}`), final(`{"input":"ab"}`)}, false, "ab"}, + {"equivalent full snapshot", [][]byte{start("functions__apply_patch", "item_2", `{"input":"a"}`), stop(`{ "input" : "\u0061" }`), final(`{"input":"a"}`)}, false, "a"}, + {"full source cannot extend", [][]byte{start("functions__apply_patch", "item_2", ""), delta(`{"input":"a"}`), stop(`{"input":"ab"}`)}, true, ""}, + {"complete snapshot cannot extend", [][]byte{start("functions__apply_patch", "item_2", `{"input":"a"}`), stop(`{"input":"ab"}`)}, true, ""}, + {"invalid snapshot before name retained", [][]byte{start("", "item_2", `{"input":1}`), start("functions__apply_patch", "item_2", `{"input":"a"}`)}, true, ""}, + {"truncated snapshot before name retained", [][]byte{start("", "item_2", `{"input":"a`), start("functions__apply_patch", "item_2", `{"input":"a"}`)}, true, ""}, + {"conflicting ID before name retained", [][]byte{start("", "item_2", ""), start("", "wrong", ""), start("functions__apply_patch", "item_2", `{"input":"a"}`)}, true, ""}, + {"unknown-name fragments replayed", [][]byte{start("", "item_2", ""), delta(`{"input":"a`), start("functions__apply_patch", "item_2", ""), delta(`b"}`), stop(`{"input":"ab"}`), final(`{"input":"ab"}`)}, false, "ab"}, + {"completed item conflicts at final", [][]byte{start("functions__apply_patch", "item_2", `{"input":"a"}`), stop(`{"input":"a"}`), final(`{"input":"ab"}`)}, true, ""}, + {"completed item conflicts at repeated stop", [][]byte{start("functions__apply_patch", "item_2", `{"input":"a"}`), stop(`{"input":"a"}`), stop(`{"input":"ab"}`)}, true, ""}, + {"completed item rejects later delta", [][]byte{start("functions__apply_patch", "item_2", `{"input":"a"}`), stop(`{"input":"a"}`), delta(`{"input":"ab"}`)}, true, ""}, + {"truncated delta cannot complete without snapshot", [][]byte{start("functions__apply_patch", "item_2", ""), delta(`{"input":"a`), patchStep("step.stop", nil)}, true, ""}, + {"illegal delta remains failed", [][]byte{start("functions__apply_patch", "item_2", ""), delta(`{"input":1}`), stop(`{"input":"a"}`)}, true, ""}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + var param any + var events []gjson.Result + for _, input := range test.inputs { + events = append(events, patchSend(¶m, input)...) + } + failures := 0 + for _, ev := range events { + if ev.Get("type").String() == "response.failed" { + failures++ + if ev.Get("response.error.code").String() != "invalid_tool_arguments" { + t.Fatal(ev.Raw) + } + } + } + if test.fail { + if failures != 1 { + t.Fatalf("failures=%d events=%v", failures, events) + } + if state, ok := param.(interface{ ToolInputError() error }); !ok || state.ToolInputError() == nil { + t.Fatal("missing error") + } + } else { + if failures != 0 { + t.Fatalf("unexpected failure: %v", events) + } + assertPatchLifecycle(t, events, test.want) + } + for _, input := range [][]byte{[]byte(`{"event_type":"interaction.completed"}`), []byte(`{"event_type":"interaction.failed"}`), []byte(`[DONE]`), patchStep("step.start", map[string]any{"type": "model_output"}), delta("late")} { + if more := patchSend(¶m, input); len(more) != 0 { + t.Fatalf("terminal reopened: %v", more) + } + } + }) + } +} + +func TestInteractionsApplyPatchWinningDeclarationAndNegativeCompatibility(t *testing.T) { + cases := []struct { + name, request, upstream, args, wantType, wantInput string + patch bool + }{ + {"top function beats additional custom", `{"tools":[{"type":"function","name":"apply_patch","parameters":{"type":"object","properties":{"n":{"type":"number"}}}}],"input":[{"type":"additional_tools","tools":[{"type":"custom","name":"apply_patch"}]}]}`, "apply_patch", `{"n":1}`, "function_call", "", false}, + {"direct function beats namespace custom", `{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch"}]},{"type":"function","name":"functions__apply_patch"}]}`, "functions__apply_patch", `{"n":1}`, "function_call", "", false}, + {"other custom remains lenient", `{"tools":[{"type":"custom","name":"exec"}]}`, "exec", `{"command":"ls"}`, "custom_tool_call", `{"command":"ls"}`, false}, + {"sanitization collision keeps qualified Interactions name", `{"tools":[{"type":"namespace","name":"a.b","tools":[{"type":"custom","name":"apply_patch"}]},{"type":"function","name":"a_b__apply_patch"}]}`, "a.b__apply_patch", `{"input":"p"}`, "custom_tool_call", "p", true}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + request := []byte(test.request) + translated := gjson.ParseBytes(ConvertOpenAIResponsesRequestToInteractions("devin/swe-2", request, false)) + declaration := translated.Get("tools.0") + if strings.Contains(declaration.Get("description").String(), "*** Begin Patch") != test.patch { + t.Fatalf("wrong contract: %s", translated.Raw) + } + var param any + send := func(raw []byte) []gjson.Result { + return patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", request, nil, raw, ¶m)) + } + send(patchStep("step.start", map[string]any{"type": "function_call", "id": "c", "name": test.upstream})) + send(patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": test.args}})) + send(patchStep("step.stop", nil)) + events := send([]byte(`{"event_type":"interaction.completed"}`)) + item := events[len(events)-1].Get("response.output.0") + if item.Get("type").String() != test.wantType || item.Get("input").String() != test.wantInput { + t.Fatalf("compatibility changed: %s", item.Raw) + } + if test.wantType == "function_call" && item.Get("arguments").String() != test.args { + t.Fatalf("function arguments changed: %s", item.Raw) + } + }) + } +} + +func TestInteractionsApplyPatchMalformedEnvelopeAndTerminalFailure(t *testing.T) { + var param any + patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch"})) + events := patchSend(¶m, []byte(`{"event_type":"step.stop","index":2,"step":{"arguments":{"input":"secret"}}`)) + if len(events) != 1 || events[0].Get("type").String() != "response.failed" { + t.Fatalf("malformed complete snapshot accepted: %v", events) + } + if more := patchSend(¶m, []byte(`{"event_type":"interaction.completed"}`)); len(more) != 0 { + t.Fatal("failure reopened") + } + var nonStream any + out := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "devin", []byte(patchRequest), nil, []byte(`{"id":"r","steps":[{"type":"function_call","name":"functions__apply_patch","arguments":{"input":"secret"}}]`), &nonStream) + if len(out) != 0 { + t.Fatalf("malformed non-stream accepted: %s", out) + } +} + +func TestInteractionsApplyPatchDeltaBeforeStartDoesNotLeakArguments(t *testing.T) { + var param any + delta := patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": `{"input":"a`}}) + if events := patchSend(¶m, delta); len(events) != 0 { + t.Fatalf("raw arguments emitted before tool identity: %v", events) + } + var events []gjson.Result + events = append(events, patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch"}))...) + events = append(events, patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": `b"}`}}))...) + events = append(events, patchSend(¶m, patchStep("step.stop", nil))...) + events = append(events, patchSend(¶m, []byte(`{"event_type":"interaction.completed"}`))...) + assertPatchLifecycle(t, events, "ab") +} + +func TestInteractionsApplyPatchInitialSnapshotDoesNotFakePreview(t *testing.T) { + var param any + events := patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "p"}})) + for _, ev := range events { + if ev.Get("type").String() == "response.custom_tool_call_input.delta" { + t.Fatal("initial complete snapshot presented as early generation") + } + } +} + +func TestInteractionsApplyPatchEarlyPreviewBeforeJSONCompletion(t *testing.T) { + var param any + patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch"})) + events := patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": `{"input":"*** Begin Patch\n`}})) + if len(events) != 1 || events[0].Get("delta").String() != "*** Begin Patch\n" || events[0].Get("type").String() != "response.custom_tool_call_input.delta" { + t.Fatalf("no early preview: %v", events) + } +} + +func TestInteractionsApplyPatchLateIdentityAtStopAndCompletedItemTypeConflict(t *testing.T) { + var param any + patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "arguments": map[string]any{"input": "p"}})) + if ev := patchSend(¶m, patchStep("step.stop", nil)); len(ev) != 0 { + t.Fatalf("unknown tool completed with raw arguments: %v", ev) + } + events := patchSend(¶m, patchStep("step.stop", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "p"}})) + if len(events) != 4 { + t.Fatalf("late identity did not complete: %v", events) + } + events = patchSend(¶m, patchStep("step.start", map[string]any{"type": "model_output", "id": "item_2"})) + if len(events) != 1 || events[0].Get("type").String() != "response.failed" { + t.Fatalf("completed item type conflict ignored: %v", events) + } +} + +func TestInteractionsApplyPatchDoesNotChangeOtherFunctionNames(t *testing.T) { + var param any + request := []byte(`{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"read_file"}]}`) + chunks := ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", request, nil, patchStep("step.start", map[string]any{"type": "function_call", "id": "c", "name": "external_read_file"}), ¶m) + events := patchEvents(chunks) + if len(events) != 1 || events[0].Get("item.name").String() != "external_read_file" { + t.Fatalf("borrowed Antigravity name alias: %v", events) + } +} + +func TestInteractionsApplyPatchInterleavedCallsRemainIndependent(t *testing.T) { + var param any + var events []gjson.Result + send := func(index int, event string, value any) { + key := "step" + if event == "step.delta" { + key = "delta" + } + events = append(events, patchSend(¶m, patchJSON(map[string]any{"event_type": event, "index": index, key: value}))...) + } + for index, name := range []string{"c1", "c2"} { + send(index, "step.start", map[string]any{"type": "function_call", "id": name, "call_id": "call_" + name, "name": "functions__apply_patch"}) + } + send(0, "step.delta", map[string]any{"type": "arguments_delta", "arguments": `{"input":"a`}) + send(1, "step.delta", map[string]any{"type": "arguments_delta", "arguments": `{"input":"b`}) + send(0, "step.delta", map[string]any{"type": "arguments_delta", "arguments": `1"}`}) + send(1, "step.delta", map[string]any{"type": "arguments_delta", "arguments": `2"}`}) + send(1, "step.stop", nil) + send(0, "step.stop", nil) + events = append(events, patchSend(¶m, []byte(`{"event_type":"interaction.completed"}`))...) + inputs := map[string]string{} + for _, ev := range events { + if ev.Get("type").String() == "response.custom_tool_call_input.delta" { + id := ev.Get("item_id").String() + inputs[id] += ev.Get("delta").String() + if ev.Get("call_id").String() != "call_"+id { + t.Fatal(ev.Raw) + } + } + if ev.Get("type").String() == "response.custom_tool_call_input.done" && ev.Get("input").String() != inputs[ev.Get("item_id").String()] { + t.Fatal(ev.Raw) + } + } + final := events[len(events)-1] + if inputs["c1"] != "a1" || inputs["c2"] != "b2" || final.Get("response.output.0.input").String() != "a1" || final.Get("response.output.1.input").String() != "b2" { + t.Fatalf("cross-call contamination: inputs=%v final=%s", inputs, final.Raw) + } +} + +func TestInteractionsApplyPatchUpstreamFailureClosesEveryEvent(t *testing.T) { + var param any + patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch"})) + events := patchSend(¶m, []byte(`{"event_type":"interaction.failed","error":{"message":"upstream failed"}}`)) + if len(events) != 1 || events[0].Get("type").String() != "response.failed" { + t.Fatal(events) + } + for _, raw := range [][]byte{[]byte(`{"event_type":"interaction.completed"}`), []byte(`{"event_type":"interaction.failed"}`), []byte(`[DONE]`), patchStep("step.stop", nil)} { + if more := patchSend(¶m, raw); len(more) != 0 { + t.Fatalf("failed response reopened: %v", more) + } + } +} + +func TestInteractionsApplyPatchUnresolvedIdentityCannotComplete(t *testing.T) { + var param any + patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "c1", "arguments": map[string]any{"input": "secret"}})) + events := patchSend(¶m, []byte(`{"event_type":"interaction.completed"}`)) + if len(events) != 1 || events[0].Get("type").String() != "response.failed" { + t.Fatalf("unresolved identity accepted: %v", events) + } +} + +func TestInteractionsApplyPatchCompletedItemRejectsEmptySnapshot(t *testing.T) { + var param any + patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "a"}})) + patchSend(¶m, patchStep("step.stop", nil)) + events := patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{}})) + if len(events) != 1 || events[0].Get("type").String() != "response.failed" { + t.Fatalf("complete empty snapshot was treated as initial placeholder: %v", events) + } +} + +func TestInteractionsApplyPatchNonStreamRejectsUnresolvedIdentity(t *testing.T) { + var param any + raw := []byte(`{"steps":[{"type":"function_call","id":"c1","arguments":{"input":"secret"}}]}`) + if out := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "devin", []byte(patchRequest), nil, raw, ¶m); len(out) != 0 { + t.Fatalf("unresolved identity accepted: %s", out) + } + if state, ok := param.(interface{ ToolInputError() error }); !ok || state.ToolInputError() == nil { + t.Fatal("missing error state") + } +} diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_request.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_request.go index 8052c2dc7..2ab284e11 100644 --- a/internal/translator/openai/interactions/responses/interactions_openai_responses_request.go +++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_request.go @@ -3,6 +3,7 @@ package responses import ( "strings" + applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch" translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" "github.com/router-for-me/CLIProxyAPI/v8/internal/util" "github.com/tidwall/gjson" @@ -481,7 +482,11 @@ func appendResponsesToolsToInteractions(out []byte, root gjson.Result, forAntigr item := []byte(`{"type":"function","name":""}`) item, _ = sjson.SetBytes(item, "name", name) - if desc := util.ResponsesToolDescription(descriptor.Tool); desc != "" { + desc := util.ResponsesToolDescription(descriptor.Tool) + if applypatch.IsCustomTool(descriptor.Tool) { + desc = applypatch.Description(descriptor.Tool) + } + if desc != "" { if forDevin { desc = translatorcommon.SanitizeDevinToolDescription(descriptor.Name, desc) if descriptor.LocalName != "" && descriptor.LocalName != descriptor.Name { @@ -491,7 +496,9 @@ func appendResponsesToolsToInteractions(out []byte, root gjson.Result, forAntigr item, _ = sjson.SetBytes(item, "description", desc) } - if descriptor.ToolType == "custom" { + if applypatch.IsCustomTool(descriptor.Tool) { + item, _ = sjson.SetRawBytes(item, "parameters", applypatch.Parameters()) + } else if descriptor.ToolType == "custom" { item, _ = sjson.SetRawBytes(item, "parameters", []byte(`{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}`)) } else { params := util.ResponsesToolParameters(descriptor.Tool) diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go index 914dda014..ed9e35288 100644 --- a/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go +++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go @@ -4,9 +4,11 @@ import ( "bytes" "context" "fmt" + "sort" "strings" "time" + applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch" "github.com/router-for-me/CLIProxyAPI/v8/internal/signature" translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" "github.com/router-for-me/CLIProxyAPI/v8/internal/util" @@ -15,28 +17,50 @@ import ( ) type interactionsToResponsesStreamState struct { - ID string - EnvironmentID string - 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 - ToolIdentityMap map[string]util.ResponsesToolIdentity + translatorcommon.ApplyPatchErrorState + ID string + EnvironmentID string + 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 + Terminal bool + SourceFailed bool + ToolIdentityMap map[string]util.ResponsesToolIdentity + PendingEnvelopeError error + PendingIdentityErrors map[int]error + ItemIdentityIndexes map[string]int + CallIdentityIndexes map[string]int + ForAntigravity bool } type interactionsFunctionCallState struct { - ID string - Name string - Namespace string - IsCustom bool - Arguments strings.Builder - InitialArgumentsEmitted bool - ArgumentsDoneEmitted bool - ItemDoneEmitted bool + ID string + CallID string + ItemIDSeen bool + CallIDSeen bool + InitialArguments string + RawName string + Added bool + PatchCall *translatorcommon.ApplyPatchCallState + PendingError error + SnapshotArguments string + SnapshotInput string + HasSnapshot bool + Name string + Namespace string + IsCustom bool + Arguments strings.Builder + ArgumentFragments []string + SourceStopped bool + StopPending bool + IdentityFinalized bool + ArgumentsDoneEmitted bool + ItemDoneEmitted bool } type responsesToInteractionsStreamState struct { @@ -65,6 +89,7 @@ func ConvertInteractionsResponseToOpenAIResponses(ctx context.Context, modelName *param = &interactionsToResponsesStreamState{} } st := (*param).(*interactionsToResponsesStreamState) + st.ForAntigravity = isAntigravityModel(modelName) if st.FunctionCalls == nil { st.FunctionCalls = make(map[int]*interactionsFunctionCallState) } @@ -89,13 +114,13 @@ func ConvertInteractionsResponseToOpenAIResponses(ctx context.Context, modelName reqJSON = requestRawJSON } if len(reqJSON) > 0 { - st.ToolIdentityMap = util.ResponsesToolReverseIdentityMap(reqJSON) + st.ToolIdentityMap = interactionsToolIdentityMap(reqJSON, st.ForAntigravity) } } return convertInteractionsEventToResponses(modelName, originalRequestRawJSON, requestRawJSON, rawJSON, st) } -func ConvertInteractionsResponseToOpenAIResponsesNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { +func ConvertInteractionsResponseToOpenAIResponsesNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte { _ = ctx _ = originalRequestRawJSON _ = requestRawJSON @@ -114,15 +139,54 @@ func ConvertInteractionsResponseToOpenAIResponsesNonStream(ctx context.Context, } var toolIdentityMap map[string]util.ResponsesToolIdentity if len(reqJSON) > 0 { - toolIdentityMap = util.ResponsesToolReverseIdentityMap(reqJSON) + toolIdentityMap = interactionsToolIdentityMap(reqJSON, forAntigravity) + } + patchEnabled := false + for _, identity := range toolIdentityMap { + if identity.ApplyPatch { + patchEnabled = true + break + } + } + var toolInputError error + status := firstNonEmpty(root.Get("status").String(), root.Get("interaction.status").String()) + sourceError := firstExisting(root.Get("error"), root.Get("interaction.error")) + if patchEnabled && (status == "failed" || (sourceError.Exists() && sourceError.Type != gjson.Null)) { + toolInputError = fmt.Errorf("upstream apply_patch interaction failed") } var outputs [][]byte steps.ForEach(func(_, step gjson.Result) bool { + if toolInputError != nil { + return false + } + if patchEnabled && step.Get("type").String() == "function_call" && step.Get("name").String() == "" { + toolInputError = fmt.Errorf("unresolved Interactions apply_patch call identity") + return false + } + if step.Get("type").String() == "function_call" && toolIdentityMap[step.Get("name").String()].ApplyPatch { + if !gjson.ValidBytes(rawJSON) { + toolInputError = fmt.Errorf("invalid Interactions apply_patch response JSON") + return false + } + var patchCall translatorcommon.ApplyPatchCallState + if _, _, errFinishArguments := patchCall.FinishArguments(jsonStringValue(step.Get("arguments"), "{}")); errFinishArguments != nil { + toolInputError = errFinishArguments + return false + } + } if item, ok := interactionsStepToResponsesOutput(step, forAntigravity, toolIdentityMap); ok { outputs = append(outputs, item) } return true }) + if toolInputError != nil { + if param != nil { + state := &translatorcommon.ApplyPatchErrorState{} + state.SetToolInputError(toolInputError) + *param = state + } + return nil + } if len(outputs) > 0 { out, _ = sjson.SetRawBytes(out, "output", translatorcommon.JoinRawArray(outputs)) } @@ -143,21 +207,60 @@ func ConvertInteractionsResponseToOpenAIResponsesNonStream(ctx context.Context, } func convertInteractionsEventToResponses(modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, st *interactionsToResponsesStreamState) [][]byte { + if st.Done || st.ToolInputError() != nil || st.SourceFailed { + return nil + } payload := interactionsSSEPayload(rawJSON) if len(payload) == 0 { return nil } - if bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) { - if st.Done { - return nil + // A source sentinel remains legal after response completion, but only once. + isDone := bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) || (gjson.ValidBytes(payload) && gjson.GetBytes(payload, "event_type").String() == "done") + if isDone { + events := interactionsFinishPatchCalls(st) + if st.ToolInputError() != nil { + return events } st.Done = true - return [][]byte{[]byte("data: [DONE]")} + st.Terminal = true + return append(events, []byte("data: [DONE]")) + } + if st.Terminal { + return nil } root := gjson.ParseBytes(payload) if !root.Exists() { return nil } + if !gjson.ValidBytes(payload) { + errJSON := fmt.Errorf("invalid Interactions apply_patch event JSON") + index, errResolveStepIndex := interactionsResolveStepIndex(root.Get("index"), root.Get("step"), 0, st) + if errResolveStepIndex != nil { + return interactionsPatchFailure(st, errResolveStepIndex) + } + if strings.HasPrefix(root.Get("event_type").String(), "step.") { + call := st.FunctionCalls[index] + if call == nil { + call = &interactionsFunctionCallState{ID: root.Get("step.id").String(), CallID: root.Get("step.call_id").String(), ItemIDSeen: root.Get("step.id").String() != "", CallIDSeen: root.Get("step.call_id").String() != ""} + st.FunctionCalls[index] = call + } + if call.PendingError == nil { + call.PendingError = errJSON + } + if st.ToolIdentityMap[call.RawName].ApplyPatch || st.ToolIdentityMap[root.Get("step.name").String()].ApplyPatch { + return interactionsPatchFailure(st, errJSON) + } + } else { + if st.PendingEnvelopeError == nil { + st.PendingEnvelopeError = errJSON + } + for _, call := range st.FunctionCalls { + if call.PatchCall != nil { + return interactionsPatchFailure(st, st.PendingEnvelopeError) + } + } + } + } switch root.Get("event_type").String() { case "interaction.created": return [][]byte{responsesCreatedEvent(modelName, originalRequestRawJSON, requestRawJSON, root, st)} @@ -168,15 +271,46 @@ func convertInteractionsEventToResponses(modelName string, originalRequestRawJSO case "step.stop": return interactionsStepStopToResponses(root, st) case "interaction.completed", "finish": - return [][]byte{responsesCompletedEvent(modelName, root, st)} - case "response.failed", "interaction.failed": - return [][]byte{responsesFailedEvent(modelName, root, st)} - case "done": - if st.Done { - return nil + var events [][]byte + steps := firstExisting(root.Get("interaction.steps"), root.Get("steps")) + patchEnabled := interactionsHasPatchBridge(st) + steps.ForEach(func(key, step gjson.Result) bool { + index, errResolveStepIndex := interactionsResolveStepIndex(step.Get("index"), step, int(key.Int()), st) + if errResolveStepIndex != nil { + events = append(events, interactionsPatchFailure(st, errResolveStepIndex)...) + return false + } + call := st.FunctionCalls[index] + // Patch-enabled unnamed functions retain evidence before final snapshot filtering. + if call == nil || (call.PatchCall == nil && !st.ToolIdentityMap[call.RawName].ApplyPatch && (!patchEnabled || call.RawName != "")) { + if step.Get("type").String() != "function_call" { + return true + } + unresolved := patchEnabled && (step.Get("name").String() == "" || (call != nil && call.RawName == "")) + if !st.ToolIdentityMap[step.Get("name").String()].ApplyPatch && !unresolved { + return true + } + } + events = append(events, interactionsUpdateFunctionCall(index, step, st, false)...) + return !st.Terminal + }) + if st.Terminal { + return events } - st.Done = true - return [][]byte{[]byte("data: [DONE]")} + events = append(events, interactionsFinishPatchCalls(st)...) + if st.Terminal { + return events + } + st.Terminal = true + return append(events, responsesCompletedEvent(modelName, root, st)) + case "response.failed", "interaction.failed": + if interactionsHasPatchBridge(st) { + return interactionsPatchFailure(st, fmt.Errorf("upstream apply_patch interaction failed")) + } + st.SourceFailed = true + st.Terminal = true + return [][]byte{responsesFailedEvent(modelName, root, st)} + } return nil } @@ -256,10 +390,20 @@ func responsesCreatedEvent(modelName string, originalRequestRawJSON, requestRawJ } func interactionsStepStartToResponses(modelName string, root gjson.Result, st *interactionsToResponsesStreamState) [][]byte { - index := int(root.Get("index").Int()) step := root.Get("step") + index, errResolveStepIndex := interactionsResolveStepIndex(root.Get("index"), step, 0, st) + if errResolveStepIndex != nil { + return interactionsPatchFailure(st, errResolveStepIndex) + } stepType := step.Get("type").String() + // A repeated start must not overwrite a possible patch call's type evidence. + if call := st.FunctionCalls[index]; call != nil && (call.PatchCall != nil || st.ToolIdentityMap[call.RawName].ApplyPatch || (call.RawName == "" && interactionsHasPatchBridge(st))) { + return interactionsUpdateFunctionCall(index, step, st, true) + } itemID := firstNonEmpty(step.Get("id").String(), step.Get("call_id").String(), fmt.Sprintf("item_%d", index)) + if stepType == "function_call" { + return interactionsUpdateFunctionCall(index, step, st, true) + } st.ItemIDs[index] = itemID st.ItemTypes[index] = stepType switch stepType { @@ -282,65 +426,38 @@ func interactionsStepStartToResponses(modelName string, root gjson.Result, st *i added, _ = sjson.SetBytes(added, "item.encrypted_content", signature) } return [][]byte{emitResponsesEvent("response.output_item.added", added)} - case "function_call": - if st.FunctionCalls[index] != nil { - return nil - } - rawName := step.Get("name").String() - name := rawName - var namespace string - var isCustom bool - if isAntigravityModel(modelName) { - name = translatorcommon.AntigravityUpstreamToolNameToClient(name) - } - if st.ToolIdentityMap != nil { - if identity, ok := st.ToolIdentityMap[rawName]; ok { - name = identity.Name - namespace = identity.Namespace - isCustom = identity.Custom - } else if identity, ok := st.ToolIdentityMap[name]; ok { - name = identity.Name - namespace = identity.Namespace - isCustom = identity.Custom - } - } - call := &interactionsFunctionCallState{ - ID: itemID, - Name: name, - Namespace: namespace, - IsCustom: isCustom, - } - if args := step.Get("arguments"); args.Exists() && strings.TrimSpace(args.Raw) != "{}" { - call.Arguments.WriteString(jsonStringValue(args, "{}")) - } - st.FunctionCalls[index] = call - var added []byte - if call.IsCustom { - added = []byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"","type":"custom_tool_call","call_id":"","name":"","input":"","status":"in_progress"}}`) - } else { - added = []byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"","type":"function_call","call_id":"","name":"","arguments":"","status":"in_progress"}}`) - } - 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) - if call.Namespace != "" { - added, _ = sjson.SetBytes(added, "item.namespace", call.Namespace) - } - added, _ = sjson.SetBytes(added, "item.name", call.Name) - events := [][]byte{emitResponsesEvent("response.output_item.added", added)} - if !call.IsCustom && call.Arguments.Len() > 0 && !call.InitialArgumentsEmitted { - events = append(events, responsesFunctionCallArgumentsDeltaToResponses(index, itemID, call.Arguments.String(), st)) - call.InitialArgumentsEmitted = true - } - return events } return nil } func interactionsStepDeltaToResponses(root gjson.Result, st *interactionsToResponsesStreamState) [][]byte { - index := int(root.Get("index").Int()) + index, errResolveStepIndex := interactionsResolveStepIndex(root.Get("index"), root.Get("step"), 0, st) + if errResolveStepIndex != nil { + return interactionsPatchFailure(st, errResolveStepIndex) + } + // Check the source barrier before a same-event snapshot or identity update can + // replay fragments and publish completion. Unknown names retain the violation. + if call := st.FunctionCalls[index]; call != nil && call.SourceStopped && root.Get("delta.type").String() == "arguments_delta" && root.Get("delta.arguments").String() != "" { + errSourceStop := fmt.Errorf("apply_patch delta after source stop") + if call.PatchCall != nil || st.ToolIdentityMap[call.RawName].ApplyPatch || st.ToolIdentityMap[root.Get("step.name").String()].ApplyPatch { + return interactionsPatchFailure(st, errSourceStop) + } + if call.RawName == "" && interactionsHasPatchBridge(st) && call.PendingError == nil { + call.PendingError = errSourceStop + } + } + if root.Get("step").IsObject() { + updates := interactionsUpdateFunctionCall(index, root.Get("step"), st, false) + if st.Terminal { + return updates + } + // Process the same real delta after its late identity update, without + // replaying the update or turning its snapshot into parameter fragments. + raw, _ := sjson.DeleteBytes([]byte(root.Raw), "step") + raw, _ = sjson.SetBytes(raw, "index", index) + return append(updates, interactionsStepDeltaToResponses(gjson.ParseBytes(raw), st)...) + } delta := root.Get("delta") switch delta.Get("type").String() { case "thought_summary": @@ -358,11 +475,37 @@ func interactionsStepDeltaToResponses(root gjson.Result, st *interactionsToRespo return nil case "arguments_delta": arguments := delta.Get("arguments").String() + if st.FunctionCalls[index] == nil { + st.FunctionCalls[index] = &interactionsFunctionCallState{} + } if call := st.FunctionCalls[index]; call != nil { + if delta.Get("invalid_json_str").Exists() { + call.PendingError = fmt.Errorf("legacy freeform arguments are invalid for apply_patch") + if call.PatchCall != nil || st.ToolIdentityMap[call.RawName].ApplyPatch { + return interactionsPatchFailure(st, call.PendingError) + } + } + if call.PatchCall != nil { + if call.ItemDoneEmitted && arguments != "" { + return interactionsPatchFailure(st, fmt.Errorf("apply_patch delta after item completion")) + } + call.Arguments.WriteString(arguments) + delta, errPushArguments := call.PatchCall.PushArguments(arguments) + if errPushArguments != nil { + return interactionsPatchFailure(st, errPushArguments) + } + return interactionsPatchDelta(st, call, delta) + } if call.ItemDoneEmitted { return nil } call.Arguments.WriteString(arguments) + if !call.SourceStopped && (call.RawName == "" || st.ToolIdentityMap[call.RawName].ApplyPatch) { + call.ArgumentFragments = append(call.ArgumentFragments, arguments) + } + if call.RawName == "" { + return nil + } if call.IsCustom { return nil } @@ -408,7 +551,25 @@ func responsesCustomToolCallInputDoneToResponses(index int, itemID, input string } func interactionsStepStopToResponses(root gjson.Result, st *interactionsToResponsesStreamState) [][]byte { - index := int(root.Get("index").Int()) + index, errResolveStepIndex := interactionsResolveStepIndex(root.Get("index"), root.Get("step"), 0, st) + if errResolveStepIndex != nil { + return interactionsPatchFailure(st, errResolveStepIndex) + } + // Source completion is independent of downstream identity and publication, + // including candidates established by arguments before their first start. + if call := st.FunctionCalls[index]; call != nil { + call.SourceStopped = true + if call.RawName == "" { + call.StopPending = true + } + } + var updates [][]byte + if st.ItemTypes[index] == "function_call" && root.Get("step").IsObject() { + updates = interactionsUpdateFunctionCall(index, root.Get("step"), st, false) + if st.Terminal { + return updates + } + } itemID := st.ItemIDs[index] switch st.ItemTypes[index] { case "model_output": @@ -437,13 +598,51 @@ func interactionsStepStopToResponses(root gjson.Result, st *interactionsToRespon case "function_call": call := st.FunctionCalls[index] if call == nil { - call = &interactionsFunctionCallState{ID: itemID} + call = &interactionsFunctionCallState{ID: itemID, SourceStopped: true} st.FunctionCalls[index] = call } - if call.ItemDoneEmitted { - return nil + if call.RawName == "" { + call.StopPending = true + return updates + } + if st.ToolIdentityMap[call.RawName].ApplyPatch && call.PatchCall == nil { + call.StopPending = true + return updates + } + if call.ItemDoneEmitted { + return updates + } + events := updates + if call.PatchCall != nil { + args := call.SnapshotArguments + if !call.HasSnapshot { + args = call.Arguments.String() + } + if call.HasSnapshot && gjson.Valid(call.Arguments.String()) { + var source translatorcommon.ApplyPatchCallState + _, input, errFinishArguments := source.FinishArguments(call.Arguments.String()) + if errFinishArguments != nil { + return interactionsPatchFailure(st, errFinishArguments) + } + if input != call.SnapshotInput { + return interactionsPatchFailure(st, fmt.Errorf("apply_patch complete source conflicts with snapshot")) + } + } + tail, input, errFinishArguments := call.PatchCall.FinishArguments(args) + if errFinishArguments != nil { + return interactionsPatchFailure(st, errFinishArguments) + } + events = append(events, interactionsPatchDelta(st, call, tail)...) + events = append(events, emitResponsesEvent("response.custom_tool_call_input.done", translatorcommon.ApplyPatchInputDone(call.PatchCall, input, nextResponsesSeq(st)))) + item, _ := responsesCompletedOutputItem(index, "function_call", st) + done := []byte(`{"type":"response.output_item.done"}`) + done, _ = sjson.SetBytes(done, "sequence_number", nextResponsesSeq(st)) + done, _ = sjson.SetBytes(done, "output_index", index) + done, _ = sjson.SetRawBytes(done, "item", item) + call.ItemDoneEmitted = true + call.ArgumentsDoneEmitted = true + return append(events, emitResponsesEvent("response.output_item.done", done)) } - events := make([][]byte, 0, 2) if call.IsCustom { input := util.UnwrapResponsesCustomToolInput(call.Arguments.String()) if !call.ArgumentsDoneEmitted { @@ -454,7 +653,7 @@ func interactionsStepStopToResponses(root gjson.Result, st *interactionsToRespon 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) + done, _ = sjson.SetBytes(done, "item.call_id", call.CallID) if call.Namespace != "" { done, _ = sjson.SetBytes(done, "item.namespace", call.Namespace) } @@ -473,7 +672,7 @@ func interactionsStepStopToResponses(root gjson.Result, st *interactionsToRespon 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) + done, _ = sjson.SetBytes(done, "item.call_id", call.CallID) if call.Namespace != "" { done, _ = sjson.SetBytes(done, "item.namespace", call.Namespace) } @@ -671,12 +870,15 @@ func responsesCompletedOutputItem(index int, itemType string, st *interactionsTo item := []byte(`{"id":"","type":"custom_tool_call","call_id":"","name":"","input":"","status":"completed"}`) itemID := st.ItemIDs[index] item, _ = sjson.SetBytes(item, "id", itemID) - item, _ = sjson.SetBytes(item, "call_id", itemID) + item, _ = sjson.SetBytes(item, "call_id", call.CallID) if call.Namespace != "" { item, _ = sjson.SetBytes(item, "namespace", call.Namespace) } item, _ = sjson.SetBytes(item, "name", call.Name) input := util.UnwrapResponsesCustomToolInput(responsesFunctionCallArguments(call)) + if call.PatchCall != nil { + input = call.PatchCall.Decoder.Input() + } item, _ = sjson.SetBytes(item, "input", input) return item, true } @@ -685,6 +887,7 @@ func responsesCompletedOutputItem(index int, itemType string, st *interactionsTo item, _ = sjson.SetBytes(item, "id", itemID) item, _ = sjson.SetBytes(item, "call_id", itemID) if call := st.FunctionCalls[index]; call != nil { + item, _ = sjson.SetBytes(item, "call_id", call.CallID) if call.Namespace != "" { item, _ = sjson.SetBytes(item, "namespace", call.Namespace) } @@ -1300,3 +1503,372 @@ func (st *responsesToInteractionsStreamState) hasSentFunctionArgs(keys []string) } return false } + +// Interactions uses qualified names directly, rather than Gemini's sanitized names. +func interactionsToolIdentityMap(rawJSON []byte, forAntigravity bool) map[string]util.ResponsesToolIdentity { + root := gjson.ParseBytes(rawJSON) + if request := root.Get("request"); request.Exists() { + root = request + } + identities := make(map[string]util.ResponsesToolIdentity) + for name, descriptor := range util.CollectResponsesToolWinners(root) { + identity := util.ResponsesToolIdentity{Name: descriptor.LocalName, Namespace: descriptor.Namespace, Custom: descriptor.ToolType == "custom", ApplyPatch: applypatch.IsCustomTool(descriptor.Tool)} + identities[name] = identity + } + if forAntigravity { + for name, descriptor := range util.CollectResponsesToolWinners(root) { + upstreamName := translatorcommon.AntigravityToolNameToUpstream(name) + if _, exists := identities[upstreamName]; !exists { + identities[upstreamName] = util.ResponsesToolIdentity{Name: descriptor.LocalName, Namespace: descriptor.Namespace, Custom: descriptor.ToolType == "custom", ApplyPatch: applypatch.IsCustomTool(descriptor.Tool)} + } + } + } + return identities +} + +func interactionsPatchFailure(st *interactionsToResponsesStreamState, err error) [][]byte { + if st.Terminal { + return nil + } + st.SetToolInputError(err) + st.Terminal = true + return [][]byte{emitResponsesEvent("response.failed", translatorcommon.ApplyPatchFailure(st.ID, nextResponsesSeq(st)))} +} + +func interactionsPatchDelta(st *interactionsToResponsesStreamState, call *interactionsFunctionCallState, delta string) [][]byte { + if delta == "" { + return nil + } + return [][]byte{emitResponsesEvent("response.custom_tool_call_input.delta", translatorcommon.ApplyPatchInputDelta(call.PatchCall, delta, nextResponsesSeq(st)))} +} + +// Reconcile every supplied index and ID before snapshot types are inspected. +func interactionsResolveStepIndex(explicitIndex, step gjson.Result, fallback int, st *interactionsToResponsesStreamState) (int, error) { + stepIndex := step.Get("index") + index, indexed := fallback, explicitIndex.Exists() || stepIndex.Exists() + if explicitIndex.Exists() { + index = int(explicitIndex.Int()) + } else if stepIndex.Exists() { + index = int(stepIndex.Int()) + } + itemID, callID := step.Get("id").String(), step.Get("call_id").String() + matched := make(map[int]bool) + if itemID != "" { + if callIndex, exists := st.ItemIdentityIndexes[itemID]; exists { + matched[callIndex] = true + } + } + if callID != "" { + if callIndex, exists := st.CallIdentityIndexes[callID]; exists { + matched[callIndex] = true + } + } + if !indexed && len(matched) > 0 { + // The fallback array position is not evidence when a supplied alias matches. + first := true + for callIndex := range matched { + if first || callIndex < index { + index = callIndex + first = false + } + } + } + for callIndex, call := range st.FunctionCalls { + if (itemID != "" && (call.ItemIDSeen || call.Added) && itemID == call.ID) || (callID != "" && (call.CallIDSeen || call.Added) && callID == call.CallID) { + if !indexed && (len(matched) == 0 || callIndex < index) { + index = callIndex + } + matched[callIndex] = true + } + } + if !indexed && len(matched) == 0 { + found := false + for itemIndex, id := range st.ItemIDs { + if itemID != "" && itemID == id && (!found || itemIndex < index) { + index, found = itemIndex, true + } + } + // A final array position must not rebind an unrelated item with a different supplied ID. + if !found && (itemID != "" || callID != "") { + for st.FunctionCalls[index] != nil || st.ItemTypes[index] != "" { + index++ + } + } + } + related := map[int]bool{index: true} + if explicitIndex.Exists() { + related[int(explicitIndex.Int())] = true + } + if stepIndex.Exists() { + related[int(stepIndex.Int())] = true + } + conflict := len(matched) > 1 || (explicitIndex.Exists() && stepIndex.Exists() && explicitIndex.Int() != stepIndex.Int()) + for callIndex := range matched { + related[callIndex] = true + if (explicitIndex.Exists() && int(explicitIndex.Int()) != callIndex) || (stepIndex.Exists() && int(stepIndex.Int()) != callIndex) { + conflict = true + } + } + patchRelated := st.ToolIdentityMap[step.Get("name").String()].ApplyPatch + for callIndex := range related { + if call := st.FunctionCalls[callIndex]; call != nil { + patchRelated = patchRelated || call.PatchCall != nil || st.ToolIdentityMap[call.RawName].ApplyPatch + if (itemID != "" && call.ItemIDSeen && itemID != call.ID) || (callID != "" && call.CallIDSeen && callID != call.CallID) { + conflict = true + } + } + } + if conflict { + errIdentity := fmt.Errorf("conflicting Interactions apply_patch step identity") + if st.PendingIdentityErrors == nil { + st.PendingIdentityErrors = make(map[int]error) + } + // Retain both sides even when an unnamed non-function snapshot is skipped. + for callIndex := range related { + if st.PendingIdentityErrors[callIndex] == nil { + st.PendingIdentityErrors[callIndex] = errIdentity + } + if call := st.FunctionCalls[callIndex]; call != nil && call.PendingError == nil { + call.PendingError = errIdentity + } + } + } + // Keep unmatched aliases even on conflicts or partial invalid snapshots, so + // later provenance through any supplied key cannot erase the contradiction. + if st.ItemIdentityIndexes == nil { + st.ItemIdentityIndexes = make(map[string]int) + } + if st.CallIdentityIndexes == nil { + st.CallIdentityIndexes = make(map[string]int) + } + if itemID != "" { + if _, exists := st.ItemIdentityIndexes[itemID]; !exists { + st.ItemIdentityIndexes[itemID] = index + } + } + if callID != "" { + if _, exists := st.CallIdentityIndexes[callID]; !exists { + st.CallIdentityIndexes[callID] = index + } + } + if patchRelated { + for callIndex := range related { + if errIdentity := st.PendingIdentityErrors[callIndex]; errIdentity != nil { + return index, errIdentity + } + } + } + // Ordinary functions retain their explicit-index behavior; only patch identities fail closed. + return index, nil +} + +func interactionsHasPatchBridge(st *interactionsToResponsesStreamState) bool { + for _, identity := range st.ToolIdentityMap { + if identity.ApplyPatch { + return true + } + } + return false +} + +// Keep evidence even before the upstream name identifies the winning declaration. +func interactionsUpdateFunctionCall(index int, step gjson.Result, st *interactionsToResponsesStreamState, initial bool) [][]byte { + call := st.FunctionCalls[index] + if call == nil { + call = &interactionsFunctionCallState{} + st.FunctionCalls[index] = call + } + recordError := func(err error) { + if call.PendingError == nil { + call.PendingError = err + } + } + if step.Get("type").Exists() && step.Get("type").String() != "function_call" { + recordError(fmt.Errorf("conflicting apply_patch item type")) + } + mergeID := func(target *string, seen *bool, value string) { + if value == "" { + return + } + if (*seen || (call.Added && !st.ToolIdentityMap[call.RawName].ApplyPatch)) && *target != value { + recordError(fmt.Errorf("conflicting apply_patch call identity")) + return + } + *target = value + *seen = true + } + mergeID(&call.ID, &call.ItemIDSeen, step.Get("id").String()) + mergeID(&call.CallID, &call.CallIDSeen, step.Get("call_id").String()) + if name := step.Get("name").String(); name != "" { + if call.RawName != "" && call.RawName != name { + recordError(fmt.Errorf("conflicting apply_patch call name")) + } else { + call.RawName = name + } + } + if call.PendingError != nil && st.ToolIdentityMap[step.Get("name").String()].ApplyPatch { + return interactionsPatchFailure(st, call.PendingError) + } + if args := step.Get("arguments"); args.Exists() && !(initial && !call.HasSnapshot && call.Arguments.Len() == 0 && !call.ItemDoneEmitted && strings.TrimSpace(jsonStringValue(args, "")) == "{}") { + arguments := jsonStringValue(args, "{}") + // A later complete snapshot is not a new prefix for already buffered fragments. + if !call.Added && call.InitialArguments == "" && call.Arguments.Len() == 0 { + call.InitialArguments = arguments + } + var snapshot translatorcommon.ApplyPatchCallState + _, input, errFinishArguments := snapshot.FinishArguments(arguments) + if errFinishArguments != nil { + recordError(errFinishArguments) + } else { + if call.HasSnapshot && input != call.SnapshotInput { + recordError(fmt.Errorf("conflicting apply_patch full snapshots")) + } + if call.PatchCall != nil && call.ItemDoneEmitted && input != call.PatchCall.Decoder.Input() { + recordError(fmt.Errorf("apply_patch snapshot conflicts with completed input")) + } + call.HasSnapshot = true + call.SnapshotInput = input + call.SnapshotArguments = arguments + } + } + st.ItemIDs[index] = call.ID + st.ItemTypes[index] = "function_call" + if call.RawName == "" { + return nil + } + identity, known := st.ToolIdentityMap[call.RawName] + if known { + call.Name = identity.Name + call.Namespace = identity.Namespace + call.IsCustom = identity.Custom + } else { + call.Name = call.RawName + if st.ForAntigravity { + call.Name = translatorcommon.AntigravityUpstreamToolNameToClient(call.RawName) + } + } + if identity.ApplyPatch { + if st.PendingEnvelopeError != nil { + return interactionsPatchFailure(st, st.PendingEnvelopeError) + } + if call.PendingError != nil { + return interactionsPatchFailure(st, call.PendingError) + } + // Upstream evidence and downstream readiness are independent. A first late + // ID may still be adopted; no provisional patch identity has escaped. + if !(call.ItemIDSeen && call.CallIDSeen) && !call.IdentityFinalized { + return nil + } + } else { + call.ArgumentFragments = nil + if !call.ItemIDSeen && !call.Added { + call.ID = firstNonEmpty(call.CallID, fmt.Sprintf("item_%d", index)) + } + if !call.CallIDSeen && !call.Added { + call.CallID = call.ID + } + } + st.ItemIDs[index] = call.ID + var events [][]byte + // Replay buffered ordinary arguments only when the item is first announced. + announced := !call.Added + if announced { + if !identity.ApplyPatch && call.InitialArguments != "" { + fragments := call.Arguments.String() + call.Arguments.Reset() + call.Arguments.WriteString(call.InitialArguments) + call.Arguments.WriteString(fragments) + } + itemType, inputKey := "function_call", "arguments" + if call.IsCustom { + itemType, inputKey = "custom_tool_call", "input" + } + added := []byte(`{"type":"response.output_item.added","item":{"status":"in_progress"}}`) + added, _ = sjson.SetBytes(added, "sequence_number", nextResponsesSeq(st)) + added, _ = sjson.SetBytes(added, "output_index", index) + added, _ = sjson.SetBytes(added, "item.type", itemType) + added, _ = sjson.SetBytes(added, "item."+inputKey, "") + added, _ = sjson.SetBytes(added, "item.id", call.ID) + added, _ = sjson.SetBytes(added, "item.call_id", call.CallID) + added = translatorcommon.SetResponsesToolCallIdentity(added, call.Name, call.Namespace, "item") + events = append(events, emitResponsesEvent("response.output_item.added", added)) + call.Added = true + } + if identity.ApplyPatch && call.PatchCall == nil { + call.PatchCall = &translatorcommon.ApplyPatchCallState{ItemID: call.ID, CallID: call.CallID, Name: call.Name, Namespace: call.Namespace, OutputIndex: index} + for _, fragment := range call.ArgumentFragments { + delta, errPushArguments := call.PatchCall.PushArguments(fragment) + if errPushArguments != nil { + return append(events, interactionsPatchFailure(st, errPushArguments)...) + } + events = append(events, interactionsPatchDelta(st, call, delta)...) + } + call.ArgumentFragments = nil + } else if !call.IsCustom && announced && call.Arguments.Len() > 0 { + events = append(events, responsesFunctionCallArgumentsDeltaToResponses(index, call.ID, call.Arguments.String(), st)) + } + if call.StopPending && call.PatchCall != nil { + call.StopPending = false + events = append(events, interactionsStepStopToResponses(gjson.Parse(fmt.Sprintf(`{"index":%d}`, index)), st)...) + } + return events +} + +func interactionsFinishPatchCalls(st *interactionsToResponsesStreamState) [][]byte { + if interactionsHasPatchBridge(st) { + for _, call := range st.FunctionCalls { + if call.RawName == "" { + return interactionsPatchFailure(st, fmt.Errorf("unresolved Interactions apply_patch call identity")) + } + } + } + var indexes []int + for index, call := range st.FunctionCalls { + if st.ToolIdentityMap[call.RawName].ApplyPatch && !call.ItemDoneEmitted { + indexes = append(indexes, index) + } + } + sort.Ints(indexes) + var events [][]byte + for _, index := range indexes { + call := st.FunctionCalls[index] + if call.PatchCall == nil { + // Interactions already maps an absent call_id to id (and an absent id + // to call_id/item_). Freeze that compatibility mapping only at + // the response terminal, after all supplied snapshot IDs were reconciled. + if !call.ItemIDSeen { + call.ID = firstNonEmpty(call.CallID, fmt.Sprintf("item_%d", index)) + } + if !call.CallIDSeen { + call.CallID = call.ID + } + call.IdentityFinalized = true + events = append(events, interactionsUpdateFunctionCall(index, gjson.Result{}, st, false)...) + if st.Terminal { + break + } + } + root := gjson.Parse(fmt.Sprintf(`{"index":%d}`, index)) + events = append(events, interactionsStepStopToResponses(root, st)...) + if st.Terminal { + break + } + } + return events +} + +// FinalizeToolInput rejects a patch-enabled stream lacking its source terminator. +func (st *interactionsToResponsesStreamState) FinalizeToolInput() [][]byte { + if st.ToolInputError() != nil || st.Terminal { + return nil + } + enabled := false + enabled = interactionsHasPatchBridge(st) + if !enabled { + return nil + } + st.SetToolInputError(fmt.Errorf("upstream apply_patch stream ended before protocol completion")) + st.Terminal = true + st.Seq++ + return [][]byte{emitResponsesEvent("response.failed", translatorcommon.ApplyPatchFailure(st.ID, st.Seq))} +} diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go index 3b94999e3..1ca1ea56d 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go @@ -2618,3 +2618,26 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_NamespaceToolPrefi t.Fatalf("splitResponsesQualifiedFunctionCallFromRequest(raw, \"fs_read\") = (%q, %q), want (\"fs_read\", \"\")", name, namespace) } } + +func TestApplyPatchChatRequestContractAndHistory(t *testing.T) { + request := []byte(`{"tools":[{"type":"namespace","name":"editor","tools":[{"type":"custom","name":"apply_patch","description":"Edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.","format":{"type":"grammar","syntax":"lark","definition":"start: patch"},"cache_control":{"type":"ephemeral"}}]}],"input":[{"type":"custom_tool_call","namespace":"editor","name":"apply_patch","call_id":"c1","input":"*** Begin Patch\n*** Add File: a.txt\n+hello\n*** End Patch"},{"type":"custom_tool_call_output","call_id":"c1","output":"done"}]}`) + result := gjson.ParseBytes(ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", request, false)) + tool := result.Get("tools.0.function") + description := tool.Get("description").String() + schema := tool.Get("parameters") + for _, instruction := range []string{"*** Begin Patch", "*** End Patch", "*** Add File:", "*** Delete File:", "*** Update File:", "*** Move to:", "*** End of File", "@@", "start: patch", "JSON object"} { + if !strings.Contains(description, instruction) { + t.Errorf("missing instruction %q", instruction) + } + } + if strings.Contains(description, "do not wrap the patch in JSON") { + t.Fatal("contradictory freeform instructions") + } + if schema.Get("additionalProperties").Bool() || !schema.Get("additionalProperties").Exists() || schema.Get("required.0").String() != "input" { + t.Fatalf("not strict schema: %s", schema.Raw) + } + call := result.Get("messages.0.tool_calls.0") + if call.Get("function.name").String() != "editor__apply_patch" || gjson.Get(call.Get("function.arguments").String(), "input").String() != "*** Begin Patch\n*** Add File: a.txt\n+hello\n*** End Patch" || call.Get("id").String() != "c1" || result.Get("messages.1.tool_call_id").String() != "c1" || result.Get("messages.1.content").String() != "done" { + t.Fatalf("history mismatch: %s", result.Raw) + } +} diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response.go b/internal/translator/openai/openai/responses/openai_openai-responses_response.go index 758cbef4d..1a1ac352f 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go @@ -20,6 +20,8 @@ type oaiToResponsesStateReasoning struct { OutputIndex int } type oaiToResponsesState struct { + translatorcommon.ApplyPatchErrorState + ApplyPatchCalls map[string]*translatorcommon.ApplyPatchCallState RequestJSON []byte ToolIndex *responsesToolIndex RequestInitialized bool @@ -32,16 +34,17 @@ type oaiToResponsesState struct { ReasoningIndex int // aggregation buffers for response.output // Per-output message text buffers by index - MsgTextBuf map[int]*strings.Builder - ReasoningBuf strings.Builder - Reasonings []oaiToResponsesStateReasoning - FuncArgsBuf map[string]*strings.Builder - FuncNames map[string]string - FuncCallIDs map[string]string - FuncOutputIx map[string]int - FuncArgsSent map[string]int - MsgOutputIx map[int]int - NextOutputIx int + MsgTextBuf map[int]*strings.Builder + ReasoningBuf strings.Builder + Reasonings []oaiToResponsesStateReasoning + FuncArgsBuf map[string]*strings.Builder + FuncNames map[string]string + FuncCallIDs map[string]string + FuncIdentityConflicts map[string]bool + FuncOutputIx map[string]int + FuncArgsSent map[string]int + MsgOutputIx map[int]int + NextOutputIx int // message item state per output index MsgItemAdded map[int]bool // whether response.output_item.added emitted for message MsgContentAdded map[int]bool // whether response.content_part.added emitted for message @@ -214,7 +217,13 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, requestRawJSON []byte item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID)) item, _ = sjson.SetBytes(item, "status", toolStatus) - item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(args)) + input := "" + if patchCall := st.ApplyPatchCalls[key]; patchCall != nil { + input = patchCall.Decoder.Input() + } else { + input = unwrapCustomToolInput(args) + } + item, _ = sjson.SetBytes(item, "input", input) item, _ = sjson.SetBytes(item, "call_id", callID) item = st.ToolIndex.applyIdentity(item, name, "") outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: item}) @@ -276,20 +285,23 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, } } st := (*param).(*oaiToResponsesState) + if st.ToolInputError() != nil || st.CompletedEmitted { + return nil + } if bytes.HasPrefix(rawJSON, []byte("data:")) { rawJSON = bytes.TrimSpace(rawJSON[5:]) } rawJSON = bytes.TrimSpace(rawJSON) - if len(rawJSON) == 0 { - return [][]byte{} - } if !st.RequestInitialized { st.RequestJSON = pickRequestJSON(originalRequestRawJSON, requestRawJSON) st.ToolIndex = newResponsesToolIndex(gjson.ParseBytes(st.RequestJSON)) st.RequestInitialized = true } + if len(rawJSON) == 0 { + return nil + } requestForNamespace := st.RequestJSON isDone := bytes.Equal(rawJSON, []byte("[DONE]")) if isDone && (!st.Started || st.CompletedEmitted) { @@ -344,6 +356,12 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, } toolStateKey := func(outputIndex, toolIndex int) string { return fmt.Sprintf("%d:%d", outputIndex, toolIndex) } var out [][]byte + failToolInput := func(err error) { + if st.ToolInputError() == nil { + st.SetToolInputError(err) + out = append(out, emitRespEvent("response.failed", translatorcommon.ApplyPatchFailure(st.ResponseID, nextSeq()))) + } + } emitToolItem := func(key string, force bool) { if st.FuncItemAdded[key] { return @@ -360,6 +378,10 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.FuncNames[key] = customToolName } } + if st.ToolIndex.isApplyPatch(name) && st.FuncIdentityConflicts[key] { + failToolInput(fmt.Errorf("conflicting apply_patch call identity")) + return + } if callID == "" { callID = fmt.Sprintf("call_%s_%s", st.ResponseID, strings.ReplaceAll(key, ":", "_")) st.FuncCallIDs[key] = callID @@ -369,6 +391,13 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, _, isCustomTool := st.CustomToolNames[name] st.FuncItemCustom[key] = isCustomTool if isCustomTool { + if st.ToolIndex.isApplyPatch(name) { + d := st.ToolIndex.byChat[name] + st.ApplyPatchCalls[key] = &translatorcommon.ApplyPatchCallState{ + ItemID: fmt.Sprintf("ctc_%s", callID), CallID: callID, + Name: d.localName, Namespace: d.namespace, OutputIndex: outputIndex, + } + } o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`) o, _ = sjson.SetBytes(o, "sequence_number", nextSeq()) o, _ = sjson.SetBytes(o, "output_index", outputIndex) @@ -388,7 +417,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.FuncItemAdded[key] = true } emitPendingFunctionArgs := func(key string) { - if !st.FuncItemAdded[key] || st.FuncItemCustom[key] { + if !st.FuncItemAdded[key] || st.ToolInputError() != nil { return } argsBuf := st.FuncArgsBuf[key] @@ -397,6 +426,18 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, } args := argsBuf.String() delta := args[st.FuncArgsSent[key]:] + if st.FuncItemCustom[key] { + if patchCall := st.ApplyPatchCalls[key]; patchCall != nil { + patchDelta, errPushArguments := patchCall.PushArguments(delta) + if errPushArguments != nil { + failToolInput(errPushArguments) + } else if patchDelta != "" { + out = append(out, emitRespEvent("response.custom_tool_call_input.delta", translatorcommon.ApplyPatchInputDelta(patchCall, patchDelta, nextSeq()))) + } + st.FuncArgsSent[key] = len(args) + } + return + } callID := st.FuncCallIDs[key] ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`) ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq()) @@ -415,9 +456,11 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.ReasoningBuf.Reset() st.ReasoningID = "" st.ReasoningIndex = 0 + st.ApplyPatchCalls = make(map[string]*translatorcommon.ApplyPatchCallState) st.FuncArgsBuf = make(map[string]*strings.Builder) st.FuncNames = make(map[string]string) st.FuncCallIDs = make(map[string]string) + st.FuncIdentityConflicts = make(map[string]bool) st.FuncOutputIx = make(map[string]int) st.FuncArgsSent = make(map[string]int) st.MsgOutputIx = make(map[int]int) @@ -528,6 +571,9 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, } finalizeOpenItems := func() { + if st.ToolInputError() != nil { + return + } if len(st.MsgItemAdded) > 0 { idxs := make([]int, 0, len(st.MsgItemAdded)) for idx := range st.MsgItemAdded { @@ -568,12 +614,19 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, // If stream ended without finish_reason: // If no arguments or partial/invalid JSON arguments were received, do not synthesize empty arguments // or complete the in-flight tool call item as successfully completed. - if st.FinishReason == "" && (!hasArgs || !gjson.Valid(b.String())) { + name := st.ToolIndex.canonicalName(st.FuncNames[key]) + if name == "" { + name, _ = st.ToolIndex.singleCustomName() + } + if !st.ToolIndex.isApplyPatch(name) && st.FinishReason == "" && (!hasArgs || !gjson.Valid(b.String())) { continue } emitToolItem(key, true) emitPendingFunctionArgs(key) + if st.ToolInputError() != nil { + return + } callID := st.FuncCallIDs[key] if callID == "" || st.FuncItemDone[key] { continue @@ -592,13 +645,27 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, } if st.FuncItemCustom[key] { - input := unwrapCustomToolInput(args) - inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`) - inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq()) - inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", callID)) - inputDone, _ = sjson.SetBytes(inputDone, "output_index", outputIndex) - inputDone, _ = sjson.SetBytes(inputDone, "input", input) - out = append(out, emitRespEvent("response.custom_tool_call_input.done", inputDone)) + input := "" + if patchCall := st.ApplyPatchCalls[key]; patchCall != nil { + tail, fullInput, errFinishArguments := patchCall.FinishArguments(args) + if errFinishArguments != nil { + failToolInput(errFinishArguments) + return + } + input = fullInput + if tail != "" { + out = append(out, emitRespEvent("response.custom_tool_call_input.delta", translatorcommon.ApplyPatchInputDelta(patchCall, tail, nextSeq()))) + } + out = append(out, emitRespEvent("response.custom_tool_call_input.done", translatorcommon.ApplyPatchInputDone(patchCall, input, nextSeq()))) + } else { + input = unwrapCustomToolInput(args) + inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`) + inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq()) + inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", callID)) + inputDone, _ = sjson.SetBytes(inputDone, "output_index", outputIndex) + inputDone, _ = sjson.SetBytes(inputDone, "input", input) + out = append(out, emitRespEvent("response.custom_tool_call_input.done", inputDone)) + } itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`) itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) @@ -636,6 +703,9 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, if isDone { finalizeOpenItems() + if st.ToolInputError() != nil { + return out + } hasActiveUnfinishedTool := false for key := range st.FuncItemAdded { if !st.FuncItemDone[key] { @@ -750,6 +820,18 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, st.FuncArgsBuf[key] = &strings.Builder{} st.FuncOutputIx[key] = allocOutputIndex() } + newID, newName := tc.Get("id").String(), st.ToolIndex.canonicalName(tc.Get("function.name").String()) + oldID, oldName := st.FuncCallIDs[key], st.ToolIndex.canonicalName(st.FuncNames[key]) + // Retain conflicting nonempty IDs until the winning tool is known. + if newID != "" && oldID != "" && newID != oldID { + st.FuncIdentityConflicts[key] = true + } + if st.ToolIndex.isApplyPatch(oldName) || st.ToolIndex.isApplyPatch(newName) { + if st.FuncIdentityConflicts[key] || (newName != "" && oldName != "" && newName != oldName) { + failToolInput(fmt.Errorf("conflicting apply_patch call identity")) + return false + } + } if newCallID := tc.Get("id").String(); newCallID != "" && st.FuncCallIDs[key] == "" { st.FuncCallIDs[key] = newCallID } @@ -763,11 +845,15 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, } emitToolItem(key, false) emitPendingFunctionArgs(key) - return true + return st.ToolInputError() == nil }) } } + if st.ToolInputError() != nil { + return false + } + // finish_reason triggers item-level finalization. response.completed is // deferred until the terminal [DONE] marker so late usage-only chunks can // still populate response.usage. @@ -776,7 +862,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, finalizeOpenItems() } - return true + return st.ToolInputError() == nil }) } @@ -785,10 +871,14 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context, // ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream builds a single Responses JSON // from a non-streaming OpenAI Chat Completions response. -func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte { +func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte { root := gjson.ParseBytes(rawJSON) requestForNamespace := pickRequestJSON(originalRequestRawJSON, requestRawJSON) toolIndex := newResponsesToolIndex(gjson.ParseBytes(requestForNamespace)) + st := &oaiToResponsesState{} + if param != nil { + *param = st + } finishReason := root.Get("choices.0.finish_reason").String() incompleteDetails, isIncomplete := incompleteByFinishReason(finishReason) @@ -958,7 +1048,19 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`) item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID)) item, _ = sjson.SetBytes(item, "status", toolStatus) - item, _ = sjson.SetBytes(item, "input", unwrapCustomToolInput(args)) + input := "" + if toolIndex.isApplyPatch(name) { + patchCall := &translatorcommon.ApplyPatchCallState{} + _, fullInput, errFinishArguments := patchCall.FinishArguments(args) + if errFinishArguments != nil { + st.SetToolInputError(errFinishArguments) + return false + } + input = fullInput + } else { + input = unwrapCustomToolInput(args) + } + item, _ = sjson.SetBytes(item, "input", input) item, _ = sjson.SetBytes(item, "call_id", callID) item = toolIndex.applyIdentity(item, name, "") outputItems = append(outputItems, item) @@ -975,9 +1077,12 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co }) } } - return true + return st.ToolInputError() == nil }) } + if st.ToolInputError() != nil { + return []byte(gjson.GetBytes(translatorcommon.ApplyPatchFailure(id, 0), "response").Raw) + } if len(outputItems) > 0 { resp, _ = sjson.SetRawBytes(resp, "output", translatorcommon.JoinRawArray(outputItems)) } @@ -1004,3 +1109,25 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co return resp } + +// FinalizeToolInput rejects a patch-enabled stream lacking its source terminator. +func (st *oaiToResponsesState) FinalizeToolInput() [][]byte { + if st.ToolInputError() != nil || st.CompletedEmitted { + return nil + } + enabled := false + if st.ToolIndex != nil { + for name := range st.ToolIndex.byChat { + if st.ToolIndex.isApplyPatch(name) { + enabled = true + break + } + } + } + if !enabled { + return nil + } + st.SetToolInputError(fmt.Errorf("upstream apply_patch stream ended before protocol completion")) + st.Seq++ + return [][]byte{emitRespEvent("response.failed", translatorcommon.ApplyPatchFailure(st.ResponseID, st.Seq))} +} diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go index 7c2bd24e7..a05bae925 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go @@ -1669,3 +1669,451 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_SingleChunkWithBo t.Fatalf("unexpected completed message: got %q", got) } } + +func applyPatchChatStart(index int, id, name string) []byte { + return []byte(fmt.Sprintf(`data: {"id":"r1","choices":[{"index":0,"delta":{"tool_calls":[{"index":%d,"id":%q,"type":"function","function":{"name":%q,"arguments":""}}]}}]}`, index, id, name)) +} +func applyPatchChatFragment(index int, fragment string) []byte { + return []byte(fmt.Sprintf(`data: {"id":"r1","choices":[{"index":0,"delta":{"tool_calls":[{"index":%d,"function":{"arguments":%q}}]}}]}`, index, fragment)) +} +func applyPatchChatEnd() [][]byte { + return [][]byte{[]byte(`data: {"id":"r1","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`), []byte(`data: [DONE]`)} +} +func applyPatchChatEvents(t *testing.T, chunks [][]byte) []gjson.Result { + t.Helper() + var events []gjson.Result + for _, chunk := range chunks { + _, data := parseOpenAIResponsesSSEEvent(t, chunk) + events = append(events, data) + } + return events +} + +const applyPatchChatRequest = `{"tools":[{"type":"custom","name":"apply_patch","format":{"type":"grammar","syntax":"lark","definition":"start: patch"}}]}` + +func TestApplyPatchChatPreviewBeforeDone(t *testing.T) { + request := []byte(applyPatchChatRequest) + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, ¶m)) + } + feed(applyPatchChatStart(0, "c1", "apply_patch")) + preview := feed(applyPatchChatFragment(0, `{"input":"*** Begin Patch\n*** Add File: a.txt\n+hello\n`)) + if len(preview) != 1 || preview[0].Get("type").String() != "response.custom_tool_call_input.delta" || preview[0].Get("delta").String() != "*** Begin Patch\n*** Add File: a.txt\n+hello\n" { + t.Fatalf("missing real decoded preview: %v", preview) + } + if preview[0].Get("call_id").String() != "c1" || preview[0].Get("item_id").String() != "ctc_c1" { + t.Fatalf("preview identity: %s", preview[0].Raw) + } + var events []gjson.Result + events = append(events, preview...) + events = append(events, feed(applyPatchChatFragment(0, `*** End Patch"}`))...) + for _, chunk := range applyPatchChatEnd() { + events = append(events, feed(chunk)...) + } + want := "*** Begin Patch\n*** Add File: a.txt\n+hello\n*** End Patch" + var deltas strings.Builder + seenDone, seenItem, seenCompleted := false, false, false + lastSequence := 0 + for _, event := range events { + if sequence := int(event.Get("sequence_number").Int()); sequence <= lastSequence { + t.Fatalf("non-monotonic sequence: %s", event.Raw) + } else { + lastSequence = sequence + } + switch event.Get("type").String() { + case "response.custom_tool_call_input.delta": + deltas.WriteString(event.Get("delta").String()) + case "response.custom_tool_call_input.done": + seenDone = true + if deltas.String() != want || event.Get("input").String() != want || event.Get("call_id").String() != "c1" { + t.Fatalf("input.done mismatch: %s", event.Raw) + } + case "response.output_item.done": + seenItem = true + if !seenDone || event.Get("item.input").String() != want { + t.Fatalf("item.done mismatch: %s", event.Raw) + } + case "response.completed": + seenCompleted = true + if !seenItem || event.Get("response.output.0.input").String() != want { + t.Fatalf("completed mismatch: %s", event.Raw) + } + } + } + if !seenDone || !seenItem || !seenCompleted { + t.Fatalf("missing terminal events: %v", events) + } +} + +func TestApplyPatchChatLateIdentityAndInterleavedCalls(t *testing.T) { + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m)) + } + feed(applyPatchChatStart(0, "", "")) + for _, event := range feed(applyPatchChatFragment(0, `{"input":"first\n`)) { + if strings.Contains(event.Get("type").String(), "arguments.delta") || event.Get("type").String() == "response.custom_tool_call_input.delta" { + t.Fatalf("emitted before identity: %s", event.Raw) + } + } + var events []gjson.Result + events = append(events, feed(applyPatchChatStart(0, "c1", "apply_patch"))...) + events = append(events, feed(applyPatchChatStart(1, "c2", "apply_patch"))...) + events = append(events, feed(applyPatchChatFragment(1, `{"input":"second`))...) + events = append(events, feed(applyPatchChatFragment(0, `tail"}`))...) + events = append(events, feed(applyPatchChatFragment(1, ` tail"}`))...) + for _, chunk := range applyPatchChatEnd() { + events = append(events, feed(chunk)...) + } + inputs := map[string]string{} + indices := map[string]int64{} + done := map[string]string{} + for _, event := range events { + switch event.Get("type").String() { + case "response.custom_tool_call_input.delta": + id := event.Get("call_id").String() + inputs[id] += event.Get("delta").String() + indices[id] = event.Get("output_index").Int() + case "response.custom_tool_call_input.done": + done[event.Get("call_id").String()] = event.Get("input").String() + case "response.failed": + t.Fatalf("interleaved calls failed: %s", event.Raw) + } + } + if inputs["c1"] != "first\ntail" || inputs["c2"] != "second tail" || done["c1"] != inputs["c1"] || done["c2"] != inputs["c2"] || indices["c1"] == indices["c2"] { + t.Fatalf("mixed calls: inputs=%v done=%v indices=%v", inputs, done, indices) + } +} + +func TestApplyPatchChatInvalidArgumentsFailOnce(t *testing.T) { + for _, arguments := range []string{`plain patch`, `{}`, `{"input":42}`, `{"input":"x","extra":1}`, `{"input":"x","input":"y"}`, `{"input":"x"} {}`, `{"input":"unfinished`, `{"input":"bad\q"}`, `{"input":"\ud800"}`} { + t.Run(arguments, func(t *testing.T) { + var param any + var events []gjson.Result + feed := func(chunk []byte) { + events = append(events, applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m))...) + } + feed(applyPatchChatStart(0, "c1", "apply_patch")) + feed(applyPatchChatFragment(0, arguments)) + for _, chunk := range applyPatchChatEnd() { + feed(chunk) + } + for _, chunk := range applyPatchChatEnd() { + feed(chunk) + } + failureCount := 0 + for _, event := range events { + switch event.Get("type").String() { + case "response.failed": + failureCount++ + if event.Get("response.error.code").String() != "invalid_tool_arguments" { + t.Fatalf("wrong failure: %s", event.Raw) + } + case "response.completed", "response.incomplete", "response.custom_tool_call_input.done", "response.output_item.done": + t.Fatalf("invalid arguments succeeded: %s", event.Raw) + } + } + state, ok := param.(interface{ ToolInputError() error }) + if failureCount != 1 || !ok || state.ToolInputError() == nil { + t.Fatalf("missing retained failure: count=%d state=%T", failureCount, param) + } + }) + } +} + +func TestApplyPatchChatWinnerAndNamespace(t *testing.T) { + for _, tc := range []struct{ name, request, upstream, wantType, wantName, namespace string }{ + {"function", `{"tools":[{"type":"function","name":"apply_patch"}]}`, "apply_patch", "function_call", "apply_patch", ""}, + {"function wins", `{"tools":[{"type":"function","name":"apply_patch"}],"input":[{"type":"additional_tools","tools":[{"type":"custom","name":"apply_patch"}]}]}`, "apply_patch", "function_call", "apply_patch", ""}, + {"custom wins", `{"tools":[{"type":"custom","name":"apply_patch"}],"input":[{"type":"additional_tools","tools":[{"type":"function","name":"apply_patch"}]}]}`, "apply_patch", "custom_tool_call", "apply_patch", ""}, + {"namespace", `{"tools":[{"type":"namespace","name":"editor","tools":[{"type":"custom","name":"apply_patch"}]}]}`, "editor__apply_patch", "custom_tool_call", "apply_patch", "editor"}, + {"flat collision", `{"tools":[{"type":"function","name":"editor__apply_patch"},{"type":"namespace","name":"editor","tools":[{"type":"custom","name":"apply_patch"}]}]}`, "editor__apply_patch", "function_call", "editor__apply_patch", ""}, + {"same source custom first", `{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"apply_patch"}]}`, "apply_patch", "custom_tool_call", "apply_patch", ""}, + {"same source function first", `{"tools":[{"type":"function","name":"apply_patch"},{"type":"custom","name":"apply_patch"}]}`, "apply_patch", "function_call", "apply_patch", ""}, + {"namespace before flat", `{"tools":[{"type":"namespace","name":"editor","tools":[{"type":"custom","name":"apply_patch"}]},{"type":"function","name":"editor__apply_patch"}]}`, "editor__apply_patch", "custom_tool_call", "apply_patch", "editor"}, + {"other custom", `{"tools":[{"type":"custom","name":"edit"}]}`, "edit", "custom_tool_call", "edit", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + var param any + request := []byte(tc.request) + feed := func(chunk []byte) []gjson.Result { + return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, ¶m)) + } + feed(applyPatchChatStart(0, "c1", tc.upstream)) + args := `{"input":"x"}` + if tc.wantType == "function_call" || tc.name == "other custom" { + args = `{"not_input":42}` + } + events := feed(applyPatchChatFragment(0, args)) + for _, chunk := range applyPatchChatEnd() { + events = append(events, feed(chunk)...) + } + var item gjson.Result + for _, event := range events { + if event.Get("type").String() == "response.completed" { + item = event.Get("response.output.0") + } + } + if item.Get("type").String() != tc.wantType || item.Get("name").String() != tc.wantName || item.Get("namespace").String() != tc.namespace { + t.Fatalf("wrong winning identity: %s", item.Raw) + } + if tc.wantType == "function_call" && item.Get("arguments").String() != args { + t.Fatalf("ordinary function was unwrapped: %s", item.Raw) + } + if tc.name == "other custom" && item.Get("input").String() != args { + t.Fatalf("other custom changed: %s", item.Raw) + } + }) + } +} + +func TestApplyPatchChatNonStreamStrictInput(t *testing.T) { + for _, tc := range []struct { + arguments, want string + invalid bool + }{ + {`{"input":"*** Begin Patch\n*** End Patch"}`, "*** Begin Patch\n*** End Patch", false}, + {`{"input":12}`, "", true}, {`{"input":"truncated`, "", true}, {`{"input":"x","extra":true}`, "", true}, {`{"input":"\ud800"}`, "", true}, + } { + t.Run(tc.arguments, func(t *testing.T) { + var param any + raw := []byte(fmt.Sprintf(`{"id":"r1","choices":[{"index":0,"message":{"tool_calls":[{"id":"c1","function":{"name":"apply_patch","arguments":%q}}]},"finish_reason":"tool_calls"}]}`, tc.arguments)) + result := gjson.ParseBytes(ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(applyPatchChatRequest), nil, raw, ¶m)) + if tc.invalid { + state, ok := param.(interface{ ToolInputError() error }) + if result.Get("status").String() != "failed" || result.Get("error.code").String() != "invalid_tool_arguments" || !ok || state.ToolInputError() == nil { + t.Fatalf("invalid non-stream input succeeded: %s state=%T", result.Raw, param) + } + } else if result.Get("output.0.input").String() != tc.want { + t.Fatalf("non-stream input mismatch: %s", result.Raw) + } + }) + } +} + +func TestApplyPatchChatTerminalValidatesTruncatedCall(t *testing.T) { + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m)) + } + feed(applyPatchChatStart(0, "c1", "apply_patch")) + feed(applyPatchChatFragment(0, `{"input":"unfinished`)) + chunks := applyPatchChatEnd() + events := feed(chunks[len(chunks)-1]) + if len(events) != 1 || events[0].Get("type").String() != "response.failed" { + t.Fatalf("truncated terminal did not fail: %v", events) + } +} + +func TestApplyPatchChatUnicodeFragments(t *testing.T) { + arguments := `{"input":"line\n\u4f60\u597d \ud83d\ude00 \" \\ \u96ea"}` + for split := 1; split < len(arguments); split++ { + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m)) + } + feed(applyPatchChatStart(0, "c1", "apply_patch")) + events := feed(applyPatchChatFragment(0, arguments[:split])) + events = append(events, feed(applyPatchChatFragment(0, arguments[split:]))...) + for _, chunk := range applyPatchChatEnd() { + events = append(events, feed(chunk)...) + } + var input strings.Builder + for _, event := range events { + if event.Get("type").String() == "response.custom_tool_call_input.delta" { + input.WriteString(event.Get("delta").String()) + } + } + if input.String() != "line\n你好 😀 \" \\ 雪" { + t.Fatalf("split %d: input = %q", split, input.String()) + } + } +} + +func TestApplyPatchChatIdentityFieldsArriveSeparately(t *testing.T) { + for _, tc := range []struct{ id, name string }{{"c1", ""}, {"", "apply_patch"}} { + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m)) + } + feed(applyPatchChatStart(0, tc.id, tc.name)) + events := feed(applyPatchChatFragment(0, `{"input":"preview`)) + if len(events) != 0 { + t.Fatalf("preview without complete identity: %v", events) + } + events = feed(applyPatchChatStart(0, "c1", "apply_patch")) + found := false + for _, event := range events { + if event.Get("type").String() == "response.custom_tool_call_input.delta" { + found = event.Get("delta").String() == "preview" && event.Get("call_id").String() == "c1" + } + } + if !found { + t.Fatalf("buffer not released after identity: %v", events) + } + } +} + +func TestApplyPatchChatConflictingIdentityFails(t *testing.T) { + for _, tc := range []struct{ id, name string }{{"other", "apply_patch"}, {"c1", "other"}} { + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m)) + } + feed(applyPatchChatStart(0, "c1", "apply_patch")) + feed(applyPatchChatFragment(0, `{"input":"preview`)) + events := feed(applyPatchChatStart(0, tc.id, tc.name)) + for _, chunk := range applyPatchChatEnd() { + events = append(events, feed(chunk)...) + } + if len(events) != 1 || events[0].Get("type").String() != "response.failed" { + t.Fatalf("conflicting identity did not fail once: %v", events) + } + } +} + +func TestApplyPatchChatNonStreamOriginalDeclarationWins(t *testing.T) { + original := []byte(`{"tools":[{"type":"function","name":"apply_patch"}]}`) + var param any + raw := []byte(`{"id":"r1","choices":[{"index":0,"message":{"tool_calls":[{"id":"c1","function":{"name":"apply_patch","arguments":"{\"not_input\":42}"}}]},"finish_reason":"tool_calls"}]}`) + result := gjson.ParseBytes(ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "test", original, []byte(applyPatchChatRequest), raw, ¶m)) + if result.Get("output.0.type").String() != "function_call" || result.Get("output.0.arguments").String() != `{"not_input":42}` { + t.Fatalf("converted declaration overrode original: %s", result.Raw) + } +} + +func TestApplyPatchChatPendingIdentityConflictFails(t *testing.T) { + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m)) + } + feed(applyPatchChatStart(0, "c1", "")) + feed(applyPatchChatFragment(0, `{"input":"x"}`)) + events := feed(applyPatchChatStart(0, "c2", "apply_patch")) + if len(events) != 1 || events[0].Get("type").String() != "response.failed" { + t.Fatalf("pending ID silently replaced: %v", events) + } +} + +func TestApplyPatchChatMissingIDSynthesizedAtTerminal(t *testing.T) { + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m)) + } + feed(applyPatchChatStart(0, "", "apply_patch")) + feed(applyPatchChatFragment(0, `{"input":"x"}`)) + var events []gjson.Result + for _, chunk := range applyPatchChatEnd() { + events = append(events, feed(chunk)...) + } + var completed gjson.Result + for _, event := range events { + if event.Get("type").String() == "response.completed" { + completed = event + } + } + item := completed.Get("response.output.0") + if item.Get("type").String() != "custom_tool_call" || item.Get("input").String() != "x" || item.Get("call_id").String() == "" { + t.Fatalf("missing ID lost patch: %s", completed.Raw) + } +} + +func TestApplyPatchChatDeferredIdentityConflict(t *testing.T) { + for _, tc := range []struct { + name, request, resolvedID, resolvedName, wantType string + invalid bool + }{ + {"return to first ID", applyPatchChatRequest, "c1", "apply_patch", "", true}, + {"keep second ID", applyPatchChatRequest, "c2", "apply_patch", "", true}, + {"omit final ID", applyPatchChatRequest, "", "apply_patch", "", true}, + {"infer name at terminal", applyPatchChatRequest, "c1", "", "", true}, + {"ordinary function", `{"tools":[{"type":"function","name":"apply_patch"}]}`, "c1", "apply_patch", "function_call", false}, + {"function winner", `{"tools":[{"type":"function","name":"apply_patch"},{"type":"custom","name":"apply_patch"}]}`, "c1", "apply_patch", "function_call", false}, + {"other custom", `{"tools":[{"type":"custom","name":"edit"}]}`, "c1", "edit", "custom_tool_call", false}, + } { + t.Run(tc.name, func(t *testing.T) { + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(tc.request), nil, chunk, ¶m)) + } + feed(applyPatchChatStart(0, "c1", "")) + pending := feed([]byte(`data: {"id":"r1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c2","function":{"name":"","arguments":"{\"input\":\"x\"}"}}]}}]}`)) + if len(pending) != 0 { + t.Fatalf("unclassified call emitted events: %v", pending) + } + events := feed(applyPatchChatStart(0, tc.resolvedID, tc.resolvedName)) + for _, chunk := range applyPatchChatEnd() { + events = append(events, feed(chunk)...) + } + failures, completions := 0, 0 + var item gjson.Result + for _, event := range events { + switch event.Get("type").String() { + case "response.failed": + failures++ + if !tc.invalid || event.Get("response.error.code").String() != "invalid_tool_arguments" { + t.Fatalf("unexpected failure: %s", event.Raw) + } + case "response.completed": + completions++ + item = event.Get("response.output.0") + case "response.incomplete", "response.custom_tool_call_input.done", "response.output_item.done": + if tc.invalid { + t.Fatalf("conflicting pending identity succeeded: %s", event.Raw) + } + } + } + state := param.(interface{ ToolInputError() error }) + if tc.invalid { + if failures != 1 || completions != 0 || state.ToolInputError() == nil { + t.Fatalf("lost pending conflict: failures=%d completions=%d error=%v", failures, completions, state.ToolInputError()) + } + } else if completions != 1 || state.ToolInputError() != nil || item.Get("type").String() != tc.wantType || item.Get("call_id").String() != "c1" { + t.Fatalf("unrelated tool changed: %s error=%v", item.Raw, state.ToolInputError()) + } else if tc.wantType == "function_call" && item.Get("arguments").String() != `{"input":"x"}` { + t.Fatalf("ordinary arguments changed: %s", item.Raw) + } else if tc.wantType == "custom_tool_call" && item.Get("input").String() != "x" { + t.Fatalf("other custom input changed: %s", item.Raw) + } + }) + } +} + +func TestApplyPatchChatSuccessfulTerminalSealsState(t *testing.T) { + for _, tc := range []struct { + name string + late []byte + }{ + {"duplicate DONE", []byte(`data: [DONE]`)}, + {"post-terminal fragment", applyPatchChatFragment(0, `{"input":"late"}`)}, + } { + t.Run(tc.name, func(t *testing.T) { + var param any + feed := func(chunk []byte) []gjson.Result { + return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m)) + } + feed(applyPatchChatStart(0, "c1", "apply_patch")) + feed(applyPatchChatFragment(0, `{"input":"x"}`)) + end := applyPatchChatEnd() + for _, event := range feed(end[0]) { + if event.Get("type").String() == "response.completed" { + t.Fatalf("finish_reason prematurely sealed response: %s", event.Raw) + } + } + feed([]byte(`data: {"id":"r1","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15}}`)) + events := feed(end[1]) + if len(events) != 1 || events[0].Get("type").String() != "response.completed" || events[0].Get("response.output.0.input").String() != "x" || events[0].Get("response.usage.total_tokens").Int() != 15 { + t.Fatalf("missing successful completion or late usage: %v", events) + } + for _, chunk := range [][]byte{tc.late, end[1]} { + if late := feed(chunk); len(late) != 0 { + t.Fatalf("sealed response emitted more events: %v", late) + } + } + if errToolInputError := param.(interface{ ToolInputError() error }).ToolInputError(); errToolInputError != nil { + t.Fatalf("successful terminal became a failure: %v", errToolInputError) + } + }) + } +} diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go index ed4a645e3..6b6cb2902 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go @@ -4,6 +4,7 @@ import ( "strconv" "strings" + applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch" translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -218,9 +219,19 @@ func convertResponsesCustomToolToOpenAIChat(tool gjson.Result, overrideName stri if description := responsesToolDescription(tool); description != "" { chatTool, _ = sjson.SetBytes(chatTool, "function.description", description) } + if applypatch.IsCustomTool(tool) { + chatTool, _ = sjson.SetBytes(chatTool, "function.description", applypatch.Description(tool)) + chatTool, _ = sjson.SetRawBytes(chatTool, "function.parameters", applypatch.Parameters()) + } return chatTool, true } +// isApplyPatch resolves only the original winning custom declaration. +func (idx *responsesToolIndex) isApplyPatch(name string) bool { + d, ok := idx.byChat[name] + return ok && d.custom && applypatch.IsCustomTool(d.tool) +} + func convertResponsesFunctionToolToOpenAIChat(tool gjson.Result, overrideName string) ([]byte, bool) { name := strings.TrimSpace(overrideName) if name == "" { diff --git a/internal/util/nocopy_invariant_test.go b/internal/util/nocopy_invariant_test.go index bd25eab08..9dbfb5cee 100644 --- a/internal/util/nocopy_invariant_test.go +++ b/internal/util/nocopy_invariant_test.go @@ -104,6 +104,7 @@ type reviewedInPlaceByteWrite struct { } var reviewedInPlaceByteWrites = map[string]reviewedInPlaceByteWrite{ + "internal/translator/common/apply_patch_responses.go": {1, "shifts [][]byte item references in a private output slice; no byte of any JSON payload is rewritten"}, "internal/runtime/executor/claude_signing.go": {2, "writes CCH digits into bytes.Clone(body); the caller's body is never touched"}, "internal/runtime/executor/claude_executor_cloaking.go": {1, "shifts []string headers to prepend a block; no byte of any payload is rewritten"}, "internal/runtime/executor/claude_executor_request.go": {3, "shifts []string headers to insert a part; no byte of any payload is rewritten"}, diff --git a/internal/util/responses_tools.go b/internal/util/responses_tools.go index a031b2676..a8c95e88c 100644 --- a/internal/util/responses_tools.go +++ b/internal/util/responses_tools.go @@ -7,15 +7,17 @@ import ( "sort" "strings" + applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) // ResponsesToolIdentity represents the resolved identity of a tool in OpenAI Responses format. type ResponsesToolIdentity struct { - Name string - Namespace string - Custom bool + Name string + Namespace string + Custom bool + ApplyPatch bool // Resolved from the winning original declaration, never from the upstream name. } // ResponsesToolDescriptor is an internal representation of a tool declaration in a Responses request. @@ -298,9 +300,10 @@ func BuildGeminiFunctionDeclarations(root gjson.Result) ([][]byte, map[string]st } identity := ResponsesToolIdentity{ - Name: desc.LocalName, - Namespace: desc.Namespace, - Custom: desc.ToolType == "custom", + Name: desc.LocalName, + Namespace: desc.Namespace, + Custom: desc.ToolType == "custom", + ApplyPatch: applypatch.IsCustomTool(desc.Tool), } reverseMap[geminiName] = identity if desc.Name != geminiName { @@ -313,7 +316,10 @@ func BuildGeminiFunctionDeclarations(root gjson.Result) ([][]byte, map[string]st funcDecl, _ = sjson.SetBytes(funcDecl, "description", descStr) } - if desc.ToolType == "custom" { + if applypatch.IsCustomTool(desc.Tool) { + funcDecl, _ = sjson.SetBytes(funcDecl, "description", applypatch.Description(desc.Tool)) + funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", applypatch.Parameters()) + } else if desc.ToolType == "custom" { funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", []byte(`{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}`)) } else { params := responsesToolParameters(desc.Tool) diff --git a/internal/util/responses_tools_test.go b/internal/util/responses_tools_test.go index 39187baea..ba78fb4c4 100644 --- a/internal/util/responses_tools_test.go +++ b/internal/util/responses_tools_test.go @@ -1,6 +1,7 @@ package util import ( + "strings" "testing" "github.com/tidwall/gjson" @@ -226,3 +227,43 @@ func TestBuildGeminiFunctionDeclarations_DisambiguationAndLongNames(t *testing.T t.Fatalf("unexpected reverse identity for long name: %+v", identityLong) } } + +func TestBuildGeminiApplyPatchDeclaration(t *testing.T) { + root := gjson.Parse(`{"tools":[{"type":"custom","name":"apply_patch","format":{"type":"grammar","syntax":"lark","definition":"start: patch"}}]}`) + declarations, _, reverse := BuildGeminiFunctionDeclarations(root) + if len(declarations) != 1 { + t.Fatalf("declarations: %d", len(declarations)) + } + declaration := gjson.ParseBytes(declarations[0]) + if !strings.Contains(declaration.Get("description").String(), "*** Begin Patch") || !strings.Contains(declaration.Get("description").String(), "start: patch") { + t.Fatal("patch format is missing") + } + if declaration.Get("parametersJsonSchema.properties.input.type").String() != "string" || !declaration.Get("parametersJsonSchema.additionalProperties").Exists() || declaration.Get("parametersJsonSchema.additionalProperties").Bool() { + t.Fatal("input schema is missing") + } + if !reverse[declaration.Get("name").String()].Custom { + t.Fatal("custom client identity was lost") + } +} + +func TestBuildGeminiApplyPatchIdentityUsesWinningOriginalDeclaration(t *testing.T) { + root := gjson.Parse(`{"tools":[{"type":"namespace","name":"a.b","tools":[{"type":"custom","name":"apply_patch"}]},{"type":"function","name":"a_b__apply_patch","parameters":{"type":"object","properties":{"n":{"type":"number"}}}},{"type":"function","name":"apply_patch"}],"input":[{"type":"additional_tools","tools":[{"type":"custom","name":"apply_patch"}]}]}`) + declarations, forward, reverse := BuildGeminiFunctionDeclarations(root) + if len(declarations) != 3 { + t.Fatalf("declarations=%d", len(declarations)) + } + if forward["a.b__apply_patch"] == forward["a_b__apply_patch"] { + t.Fatal("sanitized collision was not separated") + } + for _, raw := range declarations { + decl := gjson.ParseBytes(raw) + identity := reverse[decl.Get("name").String()] + patch := identity.Custom && identity.Namespace == "a.b" + if identity.ApplyPatch != patch || strings.Contains(decl.Get("description").String(), "*** Begin Patch") != patch { + t.Fatalf("wrong winning identity=%+v declaration=%s", identity, raw) + } + } + if reverse[forward["apply_patch"]].Custom || reverse[forward["apply_patch"]].ApplyPatch { + t.Fatal("ordinary function became custom patch") + } +} diff --git a/sdk/api/handlers/apply_patch_capability.go b/sdk/api/handlers/apply_patch_capability.go new file mode 100644 index 000000000..e2d344e2f --- /dev/null +++ b/sdk/api/handlers/apply_patch_capability.go @@ -0,0 +1,13 @@ +package handlers + +// SupportsApplyPatchModel follows the same public, non-image routing as execution. +func (h *BaseAPIHandler) SupportsApplyPatchModel(model string) bool { + if h == nil || h.AuthManager == nil { + return false + } + providers, _, errGetRequestDetails := h.getRequestDetails(model) + if errGetRequestDetails != nil { + return false + } + return h.AuthManager.SupportsApplyPatchForProviders(providers) +} diff --git a/sdk/api/handlers/apply_patch_capability_test.go b/sdk/api/handlers/apply_patch_capability_test.go new file mode 100644 index 000000000..9525eaf16 --- /dev/null +++ b/sdk/api/handlers/apply_patch_capability_test.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" + coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" +) + +type patchRouteExecutor struct { + coreauth.ProviderExecutor + id string + supported bool +} + +func (e *patchRouteExecutor) Identifier() string { return e.id } +func (e *patchRouteExecutor) SupportsApplyPatch() bool { return e.supported } + +func TestApplyPatchModelExactPublicRoute(t *testing.T) { + m := coreauth.NewManager(nil, nil, nil) + m.RegisterExecutor(&patchRouteExecutor{id: "patch-supported", supported: true}) + m.RegisterExecutor(&patchRouteExecutor{id: "patch-unsupported"}) + r := registry.GetGlobalRegistry() + for _, tc := range []struct { + id, provider string + models []*registry.ModelInfo + }{ + {"patch-route-supported", "patch-supported", []*registry.ModelInfo{{ID: "public-patch-alias"}, {ID: "mixed-patch-alias"}, {ID: "gpt-image-2"}, {ID: "registered-patch(high)"}}}, + {"patch-route-unsupported", "patch-unsupported", []*registry.ModelInfo{{ID: "mixed-patch-alias"}, {ID: "unsupported-patch-alias", MetadataModelID: "public-patch-alias"}}}, + } { + r.RegisterClient(tc.id, tc.provider, tc.models) + t.Cleanup(func() { r.UnregisterClient(tc.id) }) + } + h := &BaseAPIHandler{AuthManager: m} + for _, tc := range []struct { + model string + want bool + }{ + {"public-patch-alias", true}, {"public-patch-alias(high)", true}, {"registered-patch(high)", true}, + {"mixed-patch-alias", false}, {"unsupported-patch-alias", false}, {"unknown-patch", false}, + {"gpt-image-2", false}, {"gpt-image-2(high)", false}, {"", false}, + } { + if got := h.SupportsApplyPatchModel(tc.model); got != tc.want { + t.Errorf("%q: got %v, want %v", tc.model, got, tc.want) + } + } + var absent *BaseAPIHandler + if absent.SupportsApplyPatchModel("public-patch-alias") || (&BaseAPIHandler{}).SupportsApplyPatchModel("public-patch-alias") { + t.Fatal("absent manager advertised support") + } +} diff --git a/sdk/api/handlers/openai/codex_client_models.go b/sdk/api/handlers/openai/codex_client_models.go index f2f2f09a7..c35b69be1 100644 --- a/sdk/api/handlers/openai/codex_client_models.go +++ b/sdk/api/handlers/openai/codex_client_models.go @@ -11,7 +11,8 @@ func (h *OpenAIAPIHandler) codexClientModelsResponse(clientVersion ...string) ma version = clientVersion[0] } optimizeMultiAgentV2 := h != nil && h.Cfg != nil && h.Cfg.CodexOptimizeMultiAgentV2 - return codexmodels.BuildResponseForClient(h.Models(), registry.GetGlobalRegistry().GetModelProviders, optimizeMultiAgentV2, version) + modelRegistry := registry.GetGlobalRegistry() + return codexmodels.BuildResponseForClientWithToolCapabilities(h.Models(), modelRegistry.GetModelProviders, modelRegistry.GetResponsesWebSearchCapability, h.SupportsApplyPatchModel, optimizeMultiAgentV2, version) } // CodexClientModelsResponse builds a Codex client model response. diff --git a/sdk/api/handlers/openai/codex_client_models_test.go b/sdk/api/handlers/openai/codex_client_models_test.go index e43c74b9b..98efc29ca 100644 --- a/sdk/api/handlers/openai/codex_client_models_test.go +++ b/sdk/api/handlers/openai/codex_client_models_test.go @@ -5,7 +5,9 @@ import ( "github.com/router-for-me/CLIProxyAPI/v8/internal/config" "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor" "github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" ) func TestCodexClientModelsResponseMultiAgentV2FollowsConfig(t *testing.T) { @@ -297,3 +299,65 @@ func TestCodexClientModelsResponse_DevinDisplayName(t *testing.T) { t.Errorf("standard-model display_name = %q, want Standard Model", got) } } + +// catalogUnknownExecutor preserves the required executor ABI without opting in. +type catalogUnknownExecutor struct{ auth.ProviderExecutor } + +type catalogUnsupportedExecutor struct{ auth.ProviderExecutor } + +func (catalogUnsupportedExecutor) SupportsApplyPatch() bool { return false } + +func TestCodexClientModelsApplyPatchRouting(t *testing.T) { + modelRegistry := registry.GetGlobalRegistry() + registrations := []struct { + provider string + models []*registry.ModelInfo + }{ + {"codex", []*registry.ModelInfo{{ID: "gpt-5.5"}, {ID: "gpt-reserve"}, {ID: "gpt-image-2"}, {ID: "catalog-patch-mixed"}, {ID: "catalog-patch-partial"}}}, + {"catalog-custom", []*registry.ModelInfo{{ID: "catalog-patch-synthetic"}, {ID: "catalog-patch-mixed"}, {ID: "catalog-patch-alias", MetadataModelID: "gpt-5.5", SupportedInputModalities: []string{"text"}}}}, + {"catalog-remote", []*registry.ModelInfo{{ID: "catalog-patch-partial"}, {ID: "catalog-patch-unknown", MetadataModelID: "gpt-5.5"}, {ID: "team/gpt-5.5"}}}, + {"catalog-disabled", []*registry.ModelInfo{{ID: "catalog-patch-disabled"}}}, + } + for _, registration := range registrations { + clientID := "sdk-patch-" + registration.provider + modelRegistry.RegisterClient(clientID, registration.provider, registration.models) + t.Cleanup(func() { modelRegistry.UnregisterClient(clientID) }) + } + manager := auth.NewManager(nil, nil, nil) + handler := NewOpenAIAPIHandler(handlers.NewBaseAPIHandlers(&config.SDKConfig{}, manager)) + assertPatch := func(t *testing.T, response map[string]any, id string, want any) { + t.Helper() + for _, entry := range response["models"].([]map[string]any) { + if entry["slug"] == id { + value, present := entry["apply_patch_tool_type"] + if !present || value != want { + t.Errorf("%s apply_patch_tool_type = %#v (present %v), want %#v", id, value, present, want) + } + return + } + } + t.Fatalf("missing model %q", id) + } + // A provider label alone is not an executor capability claim. + assertPatch(t, handler.codexClientModelsResponse("0.153.4"), "gpt-5.5", nil) + manager.RegisterExecutor(executor.NewCodexAutoExecutor(&config.Config{})) + manager.RegisterExecutor(executor.NewOpenAICompatExecutor("catalog-custom", &config.Config{})) + manager.RegisterExecutor(catalogUnknownExecutor{executor.NewOpenAICompatExecutor("catalog-remote", &config.Config{})}) + manager.RegisterExecutor(catalogUnsupportedExecutor{executor.NewOpenAICompatExecutor("catalog-disabled", &config.Config{})}) + for _, version := range []string{"", "0.137.0", "0.153.4", "cpa"} { + t.Run(version, func(t *testing.T) { + response := handler.codexClientModelsResponse(version) + for _, id := range []string{"gpt-5.5", "gpt-reserve", "catalog-patch-synthetic", "catalog-patch-alias", "catalog-patch-mixed"} { + assertPatch(t, response, id, "freeform") + } + for _, id := range []string{"gpt-image-2", "catalog-patch-partial", "catalog-patch-unknown", "team/gpt-5.5", "catalog-patch-disabled"} { + assertPatch(t, response, id, nil) + } + }) + } + manager.RegisterExecutor(catalogUnknownExecutor{executor.NewCodexAutoExecutor(&config.Config{})}) + assertPatch(t, handler.codexClientModelsResponse("0.153.4"), "gpt-5.5", nil) + withoutManager := NewOpenAIAPIHandler(handlers.NewBaseAPIHandlers(&config.SDKConfig{}, nil)) + assertPatch(t, withoutManager.codexClientModelsResponse("0.153.4"), "catalog-patch-synthetic", nil) + +} diff --git a/sdk/cliproxy/auth/apply_patch_capability.go b/sdk/cliproxy/auth/apply_patch_capability.go new file mode 100644 index 000000000..220980158 --- /dev/null +++ b/sdk/cliproxy/auth/apply_patch_capability.go @@ -0,0 +1,25 @@ +package auth + +// ApplyPatchSupport optionally advertises the actual executor's tool contract. +// It does not change ProviderExecutor or the plugin ABI. +type ApplyPatchSupport interface { + SupportsApplyPatch() bool +} + +// SupportsApplyPatchForProviders requires support from every routing candidate. +func (m *Manager) SupportsApplyPatchForProviders(providers []string) bool { + if m == nil || len(providers) == 0 { + return false + } + for _, provider := range providers { + executor, okExecutor := m.Executor(provider) + if !okExecutor { + return false + } + support, okSupport := executor.(ApplyPatchSupport) + if !okSupport || !support.SupportsApplyPatch() { + return false + } + } + return true +} diff --git a/sdk/cliproxy/auth/apply_patch_capability_test.go b/sdk/cliproxy/auth/apply_patch_capability_test.go new file mode 100644 index 000000000..e931a38e4 --- /dev/null +++ b/sdk/cliproxy/auth/apply_patch_capability_test.go @@ -0,0 +1,34 @@ +package auth + +import "testing" + +type patchSupportExecutor struct { + *replaceAwareExecutor + supported bool +} + +func (e *patchSupportExecutor) SupportsApplyPatch() bool { return e.supported } + +func TestApplyPatchManagerAllCandidates(t *testing.T) { + m := NewManager(nil, nil, nil) + m.RegisterExecutor(&patchSupportExecutor{&replaceAwareExecutor{id: "custom-name"}, true}) + m.RegisterExecutor(&patchSupportExecutor{&replaceAwareExecutor{id: "denied"}, false}) + m.RegisterExecutor(&replaceAwareExecutor{id: "codex"}) + for _, tc := range []struct { + providers []string + want bool + }{ + {nil, false}, {[]string{"unknown"}, false}, {[]string{"codex"}, false}, + {[]string{"custom-name"}, true}, {[]string{"custom-name", "custom-name"}, true}, + {[]string{"custom-name", "denied"}, false}, {[]string{"custom-name", "unknown"}, false}, + {[]string{"custom-name", "codex"}, false}, + } { + if got := m.SupportsApplyPatchForProviders(tc.providers); got != tc.want { + t.Errorf("providers %v: got %v, want %v", tc.providers, got, tc.want) + } + } + var absent *Manager + if absent.SupportsApplyPatchForProviders([]string{"custom-name"}) { + t.Fatal("nil manager has capability") + } +} diff --git a/sdk/translator/registry.go b/sdk/translator/registry.go index 82cb95c89..b04411614 100644 --- a/sdk/translator/registry.go +++ b/sdk/translator/registry.go @@ -272,6 +272,10 @@ func (r *Registry) TranslateStream(ctx context.Context, from, to Format, model s outputs = [][]byte{translated} } } + // Retained tool failures are never recovered by raw fallback or plugin normalization. + if translationToolInputFailed(param) { + return outputs + } if outputs == nil && !usedNativeTransform { outputs = [][]byte{body} } @@ -304,12 +308,24 @@ func (r *Registry) TranslateNonStream(ctx context.Context, from, to Format, mode body = translated } } + if translationToolInputFailed(param) || (fn.NonStream != nil && body == nil) { + return nil + } if hooks != nil { body = hooks.NormalizeResponseAfter(ctx, from, to, model, originalRequestRawJSON, requestRawJSON, body, false) } return body } +// translationToolInputFailed consumes only the optional, request-local error contract. +func translationToolInputFailed(param *any) bool { + if param == nil { + return false + } + state, okState := (*param).(interface{ ToolInputError() error }) + return okState && state.ToolInputError() != nil +} + // TranslateTokenCount applies the registered token count response translator. func (r *Registry) TranslateTokenCount(ctx context.Context, from, to Format, count int64, rawJSON []byte) []byte { r.mu.RLock() diff --git a/sdk/translator/registry_test.go b/sdk/translator/registry_test.go index 8ce655d51..eea44e905 100644 --- a/sdk/translator/registry_test.go +++ b/sdk/translator/registry_test.go @@ -2,6 +2,7 @@ package translator import ( "context" + "errors" "testing" "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" @@ -500,3 +501,52 @@ func TestUnregisterRestoresFormatPair(t *testing.T) { t.Fatal("unregister removed a different format pair") } } + +type patchRegistryError struct{ err error } + +func (s *patchRegistryError) ToolInputError() error { return s.err } + +func TestApplyPatchRegistryNilAndRetainedFailure(t *testing.T) { + for _, native := range []bool{false, true} { + r := NewRegistry() + r.SetPluginHooks(&fakePluginHooks{normalizeAfter: func([]byte) []byte { return []byte(`{"success":true}`) }}) + if native { + r.Register(FormatOpenAI, FormatOpenAIResponse, nil, ResponseTransform{ + Stream: func(_ context.Context, _ string, _, _, _ []byte, param *any) [][]byte { + *param = &patchRegistryError{errors.New("invalid")} + return nil + }, + NonStream: func(_ context.Context, _ string, _, _, _ []byte, param *any) []byte { + *param = &patchRegistryError{errors.New("invalid")} + return nil + }, + }) + } + var param any = &patchRegistryError{errors.New("invalid")} + if got := r.TranslateStream(context.Background(), FormatOpenAIResponse, FormatOpenAI, "m", nil, nil, []byte(`{"RAW_SECRET":true}`), ¶m); got != nil { + t.Errorf("native=%v: stream failure recovered as %s", native, got) + } + if got := r.TranslateNonStream(context.Background(), FormatOpenAIResponse, FormatOpenAI, "m", nil, nil, []byte(`{"RAW_SECRET":true}`), ¶m); got != nil { + t.Errorf("native=%v: nonstream failure recovered as %s", native, got) + } + } +} + +func TestApplyPatchRegistryNativeNilDoesNotInvokeRecovery(t *testing.T) { + r := NewRegistry() + r.SetPluginHooks(&fakePluginHooks{normalizeAfter: func([]byte) []byte { + t.Error("nil native output passed through plugin recovery") + return []byte(`{"success":true}`) + }}) + r.Register(FormatOpenAI, FormatOpenAIResponse, nil, ResponseTransform{ + Stream: func(context.Context, string, []byte, []byte, []byte, *any) [][]byte { return nil }, + NonStream: func(context.Context, string, []byte, []byte, []byte, *any) []byte { return nil }, + }) + var param any + if r.TranslateStream(context.Background(), FormatOpenAIResponse, FormatOpenAI, "m", nil, nil, []byte(`{"RAW_SECRET":true}`), ¶m) != nil { + t.Fatal("native stream nil became raw success") + } + if r.TranslateNonStream(context.Background(), FormatOpenAIResponse, FormatOpenAI, "m", nil, nil, []byte(`{"RAW_SECRET":true}`), ¶m) != nil { + t.Fatal("native nonstream nil became raw success") + } +}