diff --git a/internal/runtime/executor/helps/codex_multi_agent_v2.go b/internal/runtime/executor/helps/codex_multi_agent_v2.go index 4436ca102..981177ba8 100644 --- a/internal/runtime/executor/helps/codex_multi_agent_v2.go +++ b/internal/runtime/executor/helps/codex_multi_agent_v2.go @@ -38,13 +38,14 @@ func TranslateRequestWithCodexMultiAgentV2(ctx context.Context, headers http.Hea // TranslateRequestPairWithCodexMultiAgentV2 translates the untouched baseline // payload and the working payload that later stages mutate in place. Executors // normally assign the original payload to the request before translating, so both -// translations would rescan the same bytes and produce the same result. Request -// translation is deterministic and never aliases its input, so that case is -// translated once and duplicated, which removes a full extra pass over payloads -// that can reach tens of megabytes. +// translations would rescan the same bytes and produce the same result. Built-in +// request translation is deterministic, so that case is translated once and +// duplicated when no plugin hooks are installed. Hooks retain two invocations +// because they may have request-scoped output or side effects. This removes a +// full extra pass over payloads that can reach tens of megabytes. func TranslateRequestPairWithCodexMultiAgentV2(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, originalPayload, requestPayload []byte, stream bool) (original, working []byte) { original = TranslateRequestWithCodexMultiAgentV2(ctx, headers, cfg, from, to, model, originalPayload, stream) - if sameByteSlice(originalPayload, requestPayload) { + if sameByteSlice(originalPayload, requestPayload) && !sdktranslator.HasPluginHooks() { // The caller mutates the working copy, so it must not share the baseline array. return original, append([]byte(nil), original...) } diff --git a/internal/runtime/executor/helps/codex_multi_agent_v2_test.go b/internal/runtime/executor/helps/codex_multi_agent_v2_test.go index 321927337..0a1494825 100644 --- a/internal/runtime/executor/helps/codex_multi_agent_v2_test.go +++ b/internal/runtime/executor/helps/codex_multi_agent_v2_test.go @@ -11,8 +11,36 @@ import ( "github.com/router-for-me/CLIProxyAPI/v7/internal/config" _ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator" sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) +type pairRequestPluginHooks struct { + calls int64 +} + +func (h *pairRequestPluginHooks) NormalizeRequest(_ context.Context, _, _ sdktranslator.Format, _ string, body []byte, _ bool) []byte { + h.calls++ + updated, _ := sjson.SetBytes(body, "plugin_call", h.calls) + return updated +} + +func (*pairRequestPluginHooks) TranslateRequest(context.Context, sdktranslator.Format, sdktranslator.Format, string, []byte, bool) ([]byte, bool) { + return nil, false +} + +func (*pairRequestPluginHooks) NormalizeResponseBefore(context.Context, sdktranslator.Format, sdktranslator.Format, string, []byte, []byte, []byte, bool) []byte { + return nil +} + +func (*pairRequestPluginHooks) TranslateResponse(context.Context, sdktranslator.Format, sdktranslator.Format, string, []byte, []byte, []byte, bool) ([]byte, bool) { + return nil, false +} + +func (*pairRequestPluginHooks) NormalizeResponseAfter(context.Context, sdktranslator.Format, sdktranslator.Format, string, []byte, []byte, []byte, bool) []byte { + return nil +} + func geminiToolHistoryPayload(turns int) []byte { contents := []string{`{"role":"user","parts":[{"text":"start"}]}`} for i := 0; i < turns; i++ { @@ -99,6 +127,35 @@ func TestTranslateRequestPairTranslatesDistinctPayloads(t *testing.T) { } } +func TestTranslateRequestPairPreservesPluginHookInvocations(t *testing.T) { + hooks := &pairRequestPluginHooks{} + sdktranslator.SetPluginHooks(hooks) + t.Cleanup(func() { sdktranslator.SetPluginHooks(nil) }) + + payload := geminiToolHistoryPayload(1) + base, work := TranslateRequestPairWithCodexMultiAgentV2( + context.Background(), + http.Header{}, + &config.Config{}, + sdktranslator.FormatGemini, + sdktranslator.FromString("antigravity"), + "gemini-3.6-flash-high", + payload, + payload, + true, + ) + + if hooks.calls != 2 { + t.Fatalf("plugin hook calls = %d, want 2", hooks.calls) + } + if got := gjson.GetBytes(base, "plugin_call").Int(); got != 1 { + t.Fatalf("baseline plugin_call = %d, want 1", got) + } + if got := gjson.GetBytes(work, "plugin_call").Int(); got != 2 { + t.Fatalf("working plugin_call = %d, want 2", got) + } +} + func TestSameByteSlice(t *testing.T) { buf := []byte("payload") cases := []struct { diff --git a/sdk/translator/registry.go b/sdk/translator/registry.go index 6e9f0eed7..6fc819ddf 100644 --- a/sdk/translator/registry.go +++ b/sdk/translator/registry.go @@ -52,6 +52,13 @@ func (r *Registry) SetPluginHooks(hooks PluginHooks) { r.hooks = hooks } +// HasPluginHooks reports whether request or response translation hooks are installed. +func (r *Registry) HasPluginHooks() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.hooks != nil +} + // TranslateRequest converts a payload between schemas, returning the original payload // if no translator is registered. When falling back to the original payload, the // "model" field is still updated to match the resolved model name so that @@ -247,6 +254,11 @@ func SetPluginHooks(hooks PluginHooks) { defaultRegistry.SetPluginHooks(hooks) } +// HasPluginHooks reports whether hooks are installed on the default registry. +func HasPluginHooks() bool { + return defaultRegistry.HasPluginHooks() +} + // TranslateRequest is a helper on the default registry. func TranslateRequest(from, to Format, model string, rawJSON []byte, stream bool) []byte { return defaultRegistry.TranslateRequest(from, to, model, rawJSON, stream) diff --git a/sdk/translator/registry_test.go b/sdk/translator/registry_test.go index f154cb397..db7694429 100644 --- a/sdk/translator/registry_test.go +++ b/sdk/translator/registry_test.go @@ -61,6 +61,21 @@ func hasCall(calls []string, want string) bool { return false } +func TestHasPluginHooks(t *testing.T) { + registry := NewRegistry() + if registry.HasPluginHooks() { + t.Fatal("new registry unexpectedly reports plugin hooks") + } + registry.SetPluginHooks(&fakePluginHooks{}) + if !registry.HasPluginHooks() { + t.Fatal("registry did not report installed plugin hooks") + } + registry.SetPluginHooks(nil) + if registry.HasPluginHooks() { + t.Fatal("registry still reports cleared plugin hooks") + } +} + func TestTranslateRequest_FallbackNormalizesModel(t *testing.T) { r := NewRegistry()