mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-11 14:47:58 +08:00
- Add `Stream` field to usage records, context helpers, and Redis queue payloads. - Propagate streaming mode across execution handlers, conductors, and usage reporters. Closes: #5361
725 lines
29 KiB
Go
725 lines
29 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
|
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
coreexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/usage"
|
|
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginapi"
|
|
)
|
|
|
|
type noopUsagePlugin struct{}
|
|
|
|
func (noopUsagePlugin) HandleUsage(context.Context, usage.Record) {}
|
|
|
|
type capturePluginExecutorUsagePlugin struct {
|
|
targetProvider string
|
|
records chan usage.Record
|
|
}
|
|
|
|
func newCapturePluginExecutorUsagePlugin(targetProvider string) *capturePluginExecutorUsagePlugin {
|
|
return &capturePluginExecutorUsagePlugin{
|
|
targetProvider: targetProvider,
|
|
records: make(chan usage.Record, 50),
|
|
}
|
|
}
|
|
|
|
func (p *capturePluginExecutorUsagePlugin) HandleUsage(_ context.Context, record usage.Record) {
|
|
if p.targetProvider != "" && record.Provider != p.targetProvider {
|
|
return
|
|
}
|
|
select {
|
|
case p.records <- record:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (p *capturePluginExecutorUsagePlugin) waitRecord(t *testing.T) usage.Record {
|
|
t.Helper()
|
|
select {
|
|
case rec := <-p.records:
|
|
return rec
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for usage record")
|
|
return usage.Record{}
|
|
}
|
|
}
|
|
|
|
func (p *capturePluginExecutorUsagePlugin) assertNoRecord(t *testing.T) {
|
|
t.Helper()
|
|
select {
|
|
case rec := <-p.records:
|
|
t.Fatalf("expected no usage record for %q, got %+v", p.targetProvider, rec)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
func registerUsagePluginForTest(t *testing.T, name string, plugin usage.Plugin) {
|
|
t.Helper()
|
|
usage.RegisterNamedPlugin(name, plugin)
|
|
t.Cleanup(func() {
|
|
usage.RegisterNamedPlugin(name, noopUsagePlugin{})
|
|
})
|
|
}
|
|
|
|
func TestHandlerPluginExecutorPublishesUsageNonStreamOpenAI(t *testing.T) {
|
|
targetPluginID := "custom-openai-plugin"
|
|
plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
|
|
registerUsagePluginForTest(t, "test-plugin-executor-usage-nonstream-openai", plugin)
|
|
|
|
originalModel := "gpt-4o"
|
|
|
|
openAIResponseBody := []byte(`{"id":"chatcmpl-1","choices":[{"message":{"role":"assistant","content":"hello"}}],"usage":{"prompt_tokens":12,"completion_tokens":34,"total_tokens":46}}`)
|
|
|
|
mockHost := &mockPluginUsageHost{
|
|
execResp: coreexecutor.Response{
|
|
Payload: openAIResponseBody,
|
|
},
|
|
}
|
|
mockHost.hasRouters = true
|
|
mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
|
|
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
|
|
}
|
|
|
|
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
|
|
handler.SetModelRouterHost(mockHost)
|
|
|
|
body, _, errMsg := handler.ExecuteWithAuthManager(context.Background(), "openai", originalModel, []byte(fmt.Sprintf(`{"model":%q}`, originalModel)), "")
|
|
if errMsg != nil {
|
|
t.Fatalf("ExecuteWithAuthManager() error = %+v", errMsg)
|
|
}
|
|
if len(body) == 0 {
|
|
t.Fatal("empty response body")
|
|
}
|
|
|
|
record := plugin.waitRecord(t)
|
|
if record.Provider != targetPluginID {
|
|
t.Errorf("record.Provider = %q, want %q", record.Provider, targetPluginID)
|
|
}
|
|
if record.Stream {
|
|
t.Errorf("record.Stream = true, want false")
|
|
}
|
|
if record.Detail.InputTokens != 12 || record.Detail.OutputTokens != 34 || record.Detail.TotalTokens != 46 {
|
|
t.Errorf("record.Detail = %+v, want prompt=12 completion=34 total=46", record.Detail)
|
|
}
|
|
}
|
|
|
|
func TestHandlerPluginExecutorPublishesUsageStreamOpenAI(t *testing.T) {
|
|
targetPluginID := "custom-stream-plugin"
|
|
plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
|
|
registerUsagePluginForTest(t, "test-plugin-executor-usage-stream-openai", plugin)
|
|
|
|
originalModel := "gpt-4o"
|
|
|
|
chunks := make(chan coreexecutor.StreamChunk, 3)
|
|
chunks <- coreexecutor.StreamChunk{Payload: []byte("data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n")}
|
|
chunks <- coreexecutor.StreamChunk{Payload: []byte("data: {\"choices\":[],\"usage\":{\"prompt_tokens\":15,\"completion_tokens\":25,\"total_tokens\":40}}\n\n")}
|
|
chunks <- coreexecutor.StreamChunk{Payload: []byte("data: [DONE]\n\n")}
|
|
close(chunks)
|
|
|
|
mockHost := &mockPluginUsageHost{
|
|
streamResult: &coreexecutor.StreamResult{Chunks: chunks},
|
|
}
|
|
mockHost.hasRouters = true
|
|
mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
|
|
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
|
|
}
|
|
|
|
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
|
|
handler.SetModelRouterHost(mockHost)
|
|
|
|
dataChan, _, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), "openai", originalModel, []byte(fmt.Sprintf(`{"model":%q,"stream":true}`, originalModel)), "")
|
|
for range dataChan {
|
|
}
|
|
for err := range errChan {
|
|
if err != nil {
|
|
t.Fatalf("stream error = %+v", err)
|
|
}
|
|
}
|
|
|
|
record := plugin.waitRecord(t)
|
|
if record.Provider != targetPluginID {
|
|
t.Errorf("record.Provider = %q, want %q", record.Provider, targetPluginID)
|
|
}
|
|
if !record.Stream {
|
|
t.Errorf("record.Stream = false, want true")
|
|
}
|
|
if record.Detail.InputTokens != 15 || record.Detail.OutputTokens != 25 || record.Detail.TotalTokens != 40 {
|
|
t.Errorf("record.Detail = %+v, want prompt=15 completion=25 total=40", record.Detail)
|
|
}
|
|
}
|
|
|
|
func TestHandlerPluginExecutorPublishesUsageStreamCodex(t *testing.T) {
|
|
targetPluginID := "custom-codex-stream-plugin"
|
|
plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
|
|
registerUsagePluginForTest(t, "test-plugin-executor-usage-stream-codex", plugin)
|
|
|
|
originalModel := "codex-5.2"
|
|
|
|
chunks := make(chan coreexecutor.StreamChunk, 2)
|
|
chunks <- coreexecutor.StreamChunk{Payload: []byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":18,\"output_tokens\":22,\"total_tokens\":40}}}\n\n")}
|
|
chunks <- coreexecutor.StreamChunk{Payload: []byte("data: [DONE]\n\n")}
|
|
close(chunks)
|
|
|
|
mockHost := &mockPluginUsageHost{
|
|
streamResult: &coreexecutor.StreamResult{Chunks: chunks},
|
|
}
|
|
mockHost.hasRouters = true
|
|
mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
|
|
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
|
|
}
|
|
|
|
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
|
|
handler.SetModelRouterHost(mockHost)
|
|
|
|
dataChan, _, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), "openai-response", originalModel, []byte(fmt.Sprintf(`{"model":%q,"stream":true}`, originalModel)), "")
|
|
for range dataChan {
|
|
}
|
|
for err := range errChan {
|
|
if err != nil {
|
|
t.Fatalf("stream error = %+v", err)
|
|
}
|
|
}
|
|
|
|
record := plugin.waitRecord(t)
|
|
if record.Provider != targetPluginID {
|
|
t.Errorf("record.Provider = %q, want %q", record.Provider, targetPluginID)
|
|
}
|
|
if record.Detail.InputTokens != 18 || record.Detail.OutputTokens != 22 || record.Detail.TotalTokens != 40 {
|
|
t.Errorf("record.Detail = %+v, want input=18 output=22 total=40", record.Detail)
|
|
}
|
|
}
|
|
|
|
func TestHandlerPluginExecutorPublishesUsageNonStreamClaude(t *testing.T) {
|
|
targetPluginID := "custom-claude-plugin"
|
|
plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
|
|
registerUsagePluginForTest(t, "test-plugin-executor-usage-nonstream-claude", plugin)
|
|
|
|
originalModel := "claude-3-5-sonnet"
|
|
|
|
claudeResponseBody := []byte(`{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"hello"}],"usage":{"input_tokens":50,"output_tokens":30,"output_tokens_details":{"thinking_tokens":10}}}`)
|
|
|
|
mockHost := &mockPluginUsageHost{
|
|
execResp: coreexecutor.Response{
|
|
Payload: claudeResponseBody,
|
|
},
|
|
}
|
|
mockHost.hasRouters = true
|
|
mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
|
|
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
|
|
}
|
|
|
|
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
|
|
handler.SetModelRouterHost(mockHost)
|
|
|
|
body, _, errMsg := handler.ExecuteWithAuthManager(context.Background(), "claude", originalModel, []byte(fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hi"}]}`, originalModel)), "")
|
|
if errMsg != nil {
|
|
t.Fatalf("ExecuteWithAuthManager() error = %+v", errMsg)
|
|
}
|
|
if len(body) == 0 {
|
|
t.Fatal("empty response body")
|
|
}
|
|
|
|
record := plugin.waitRecord(t)
|
|
if record.Provider != targetPluginID {
|
|
t.Errorf("record.Provider = %q, want %q", record.Provider, targetPluginID)
|
|
}
|
|
if record.Detail.InputTokens != 50 || record.Detail.OutputTokens != 30 || record.Detail.ReasoningTokens != 10 || record.Detail.TotalTokens != 80 {
|
|
t.Errorf("record.Detail = %+v, want input=50 output=30 reasoning=10 total=80", record.Detail)
|
|
}
|
|
}
|
|
|
|
func TestHandlerPluginExecutorPublishesUsageStreamClaude(t *testing.T) {
|
|
targetPluginID := "custom-claude-stream-plugin"
|
|
plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
|
|
registerUsagePluginForTest(t, "test-plugin-executor-usage-stream-claude", plugin)
|
|
|
|
originalModel := "claude-3-5-sonnet"
|
|
|
|
// Claude streams split usage between message_start (input, cache) and message_delta (output, thinking)
|
|
chunks := make(chan coreexecutor.StreamChunk, 4)
|
|
chunks <- coreexecutor.StreamChunk{Payload: []byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"usage\":{\"input_tokens\":100,\"cache_read_input_tokens\":50,\"cache_creation_input_tokens\":20,\"output_tokens\":1}}}\n\n")}
|
|
chunks <- coreexecutor.StreamChunk{Payload: []byte("event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n")}
|
|
chunks <- coreexecutor.StreamChunk{Payload: []byte("event: message_delta\ndata: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":25,\"output_tokens_details\":{\"thinking_tokens\":5}}}\n\n")}
|
|
chunks <- coreexecutor.StreamChunk{Payload: []byte("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")}
|
|
close(chunks)
|
|
|
|
mockHost := &mockPluginUsageHost{
|
|
streamResult: &coreexecutor.StreamResult{Chunks: chunks},
|
|
}
|
|
mockHost.hasRouters = true
|
|
mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
|
|
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
|
|
}
|
|
|
|
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
|
|
handler.SetModelRouterHost(mockHost)
|
|
|
|
dataChan, _, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), "claude", originalModel, []byte(fmt.Sprintf(`{"model":%q,"stream":true}`, originalModel)), "")
|
|
for range dataChan {
|
|
}
|
|
for err := range errChan {
|
|
if err != nil {
|
|
t.Fatalf("stream error = %+v", err)
|
|
}
|
|
}
|
|
|
|
record := plugin.waitRecord(t)
|
|
if record.Provider != targetPluginID {
|
|
t.Errorf("record.Provider = %q, want %q", record.Provider, targetPluginID)
|
|
}
|
|
if record.Detail.InputTokens != 100 || record.Detail.CacheReadTokens != 50 || record.Detail.CacheCreationTokens != 20 || record.Detail.OutputTokens != 25 || record.Detail.ReasoningTokens != 5 || record.Detail.TotalTokens != 195 {
|
|
t.Errorf("record.Detail = %+v, want input=100 cache_read=50 cache_creation=20 output=25 reasoning=5 total=195", record.Detail)
|
|
}
|
|
tb := record.Detail.TokenBreakdown
|
|
if !tb.Valid() || tb.TotalTokens != 195 || tb.Input.TotalTokens != 170 || tb.Input.UncachedTokens != 100 || tb.Input.CacheReadTokens != 50 || tb.Input.CacheWriteTokens != 20 || tb.Output.TotalTokens != 25 || tb.Output.NonReasoningTokens != 20 || tb.Output.ReasoningTokens != 5 {
|
|
t.Errorf("record.Detail.TokenBreakdown = %+v, want valid independent breakdown with total=195 input=170 output=25", tb)
|
|
}
|
|
}
|
|
|
|
func TestHandlerPluginExecutorPublishesUsageGemini(t *testing.T) {
|
|
targetPluginID := "custom-gemini-plugin"
|
|
plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
|
|
registerUsagePluginForTest(t, "test-plugin-executor-usage-gemini", plugin)
|
|
|
|
originalModel := "gemini-2.5-flash"
|
|
|
|
geminiResponseBody := []byte(`{"candidates":[{"content":{"parts":[{"text":"hello"}]}}],"usageMetadata":{"promptTokenCount":40,"candidatesTokenCount":60,"totalTokenCount":100}}`)
|
|
|
|
mockHost := &mockPluginUsageHost{
|
|
execResp: coreexecutor.Response{
|
|
Payload: geminiResponseBody,
|
|
},
|
|
}
|
|
mockHost.hasRouters = true
|
|
mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
|
|
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
|
|
}
|
|
|
|
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
|
|
handler.SetModelRouterHost(mockHost)
|
|
|
|
body, _, errMsg := handler.ExecuteWithAuthManager(context.Background(), "gemini", originalModel, []byte(fmt.Sprintf(`{"contents":[{"parts":[{"text":"hi"}]}]}`)), "")
|
|
if errMsg != nil {
|
|
t.Fatalf("ExecuteWithAuthManager() error = %+v", errMsg)
|
|
}
|
|
if len(body) == 0 {
|
|
t.Fatal("empty response body")
|
|
}
|
|
|
|
record := plugin.waitRecord(t)
|
|
if record.Provider != targetPluginID {
|
|
t.Errorf("record.Provider = %q, want %q", record.Provider, targetPluginID)
|
|
}
|
|
if record.Detail.InputTokens != 40 || record.Detail.OutputTokens != 60 || record.Detail.TotalTokens != 100 {
|
|
t.Errorf("record.Detail = %+v, want prompt=40 candidates=60 total=100", record.Detail)
|
|
}
|
|
}
|
|
|
|
func TestHandlerPluginExecutorPublishesUsageStreamInteractions(t *testing.T) {
|
|
targetPluginID := "custom-interactions-stream-plugin"
|
|
plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
|
|
registerUsagePluginForTest(t, "test-plugin-executor-usage-stream-interactions", plugin)
|
|
|
|
originalModel := "gemini-2.5-flash"
|
|
|
|
chunks := make(chan coreexecutor.StreamChunk, 2)
|
|
chunks <- coreexecutor.StreamChunk{Payload: []byte("data: {\"event_type\":\"finish\",\"metadata\":{\"total_usage\":{\"total_input_tokens\":30,\"total_output_tokens\":70,\"total_tokens\":100}}}\n\n")}
|
|
chunks <- coreexecutor.StreamChunk{Payload: []byte("data: [DONE]\n\n")}
|
|
close(chunks)
|
|
|
|
mockHost := &mockPluginUsageHost{
|
|
streamResult: &coreexecutor.StreamResult{Chunks: chunks},
|
|
}
|
|
mockHost.hasRouters = true
|
|
mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
|
|
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
|
|
}
|
|
|
|
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
|
|
handler.SetModelRouterHost(mockHost)
|
|
|
|
dataChan, _, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), "interactions", originalModel, []byte(fmt.Sprintf(`{"model":%q,"stream":true}`, originalModel)), "")
|
|
for range dataChan {
|
|
}
|
|
for err := range errChan {
|
|
if err != nil {
|
|
t.Fatalf("stream error = %+v", err)
|
|
}
|
|
}
|
|
|
|
record := plugin.waitRecord(t)
|
|
if record.Provider != targetPluginID {
|
|
t.Errorf("record.Provider = %q, want %q", record.Provider, targetPluginID)
|
|
}
|
|
if record.Detail.InputTokens != 30 || record.Detail.OutputTokens != 70 || record.Detail.TotalTokens != 100 {
|
|
t.Errorf("record.Detail = %+v, want input=30 output=70 total=100", record.Detail)
|
|
}
|
|
}
|
|
|
|
func TestHandlerPluginExecutorPublishesUsageStreamAntigravity(t *testing.T) {
|
|
targetPluginID := "custom-antigravity-stream-plugin"
|
|
plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
|
|
registerUsagePluginForTest(t, "test-plugin-executor-usage-stream-antigravity", plugin)
|
|
|
|
originalModel := "claude-3-5-sonnet"
|
|
|
|
chunks := make(chan coreexecutor.StreamChunk, 2)
|
|
chunks <- coreexecutor.StreamChunk{Payload: []byte("data: {\"response\":{\"usageMetadata\":{\"promptTokenCount\":33,\"candidatesTokenCount\":67,\"totalTokenCount\":100}}}\n\n")}
|
|
chunks <- coreexecutor.StreamChunk{Payload: []byte("data: [DONE]\n\n")}
|
|
close(chunks)
|
|
|
|
mockHost := &mockPluginUsageHost{
|
|
streamResult: &coreexecutor.StreamResult{Chunks: chunks},
|
|
}
|
|
mockHost.hasRouters = true
|
|
mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
|
|
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
|
|
}
|
|
|
|
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
|
|
handler.SetModelRouterHost(mockHost)
|
|
|
|
dataChan, _, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), "antigravity", originalModel, []byte(fmt.Sprintf(`{"model":%q,"stream":true}`, originalModel)), "")
|
|
for range dataChan {
|
|
}
|
|
for err := range errChan {
|
|
if err != nil {
|
|
t.Fatalf("stream error = %+v", err)
|
|
}
|
|
}
|
|
|
|
record := plugin.waitRecord(t)
|
|
if record.Provider != targetPluginID {
|
|
t.Errorf("record.Provider = %q, want %q", record.Provider, targetPluginID)
|
|
}
|
|
if record.Detail.InputTokens != 33 || record.Detail.OutputTokens != 67 || record.Detail.TotalTokens != 100 {
|
|
t.Errorf("record.Detail = %+v, want prompt=33 candidates=67 total=100", record.Detail)
|
|
}
|
|
}
|
|
|
|
func TestHandlerPluginExecutorPublishesFailure(t *testing.T) {
|
|
targetPluginID := "failing-plugin"
|
|
plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
|
|
registerUsagePluginForTest(t, "test-plugin-executor-usage-failure", plugin)
|
|
|
|
originalModel := "gpt-4o"
|
|
|
|
mockHost := &mockPluginUsageHost{
|
|
execErr: errors.New("upstream plugin failure"),
|
|
}
|
|
mockHost.hasRouters = true
|
|
mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
|
|
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
|
|
}
|
|
|
|
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
|
|
handler.SetModelRouterHost(mockHost)
|
|
|
|
_, _, errMsg := handler.ExecuteWithAuthManager(context.Background(), "openai", originalModel, []byte(fmt.Sprintf(`{"model":%q}`, originalModel)), "")
|
|
if errMsg == nil {
|
|
t.Fatal("expected ExecuteWithAuthManager() to fail")
|
|
}
|
|
|
|
record := plugin.waitRecord(t)
|
|
if record.Provider != targetPluginID {
|
|
t.Errorf("record.Provider = %q, want %q", record.Provider, targetPluginID)
|
|
}
|
|
if !record.Failed {
|
|
t.Error("record.Failed = false, want true")
|
|
}
|
|
if record.Fail.Body != "upstream plugin failure" {
|
|
t.Errorf("record.Fail.Body = %q, want %q", record.Fail.Body, "upstream plugin failure")
|
|
}
|
|
}
|
|
|
|
func TestHandlerPluginExecutorPublishesStreamFailure(t *testing.T) {
|
|
targetPluginID := "failing-stream-plugin"
|
|
plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
|
|
registerUsagePluginForTest(t, "test-plugin-executor-usage-stream-failure", plugin)
|
|
|
|
originalModel := "gpt-4o"
|
|
|
|
chunks := make(chan coreexecutor.StreamChunk, 2)
|
|
chunks <- coreexecutor.StreamChunk{Payload: []byte("data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n")}
|
|
chunks <- coreexecutor.StreamChunk{Err: errors.New("upstream stream broke")}
|
|
close(chunks)
|
|
|
|
mockHost := &mockPluginUsageHost{
|
|
streamResult: &coreexecutor.StreamResult{Chunks: chunks},
|
|
}
|
|
mockHost.hasRouters = true
|
|
mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
|
|
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
|
|
}
|
|
|
|
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
|
|
handler.SetModelRouterHost(mockHost)
|
|
|
|
dataChan, _, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), "openai", originalModel, []byte(fmt.Sprintf(`{"model":%q,"stream":true}`, originalModel)), "")
|
|
for range dataChan {
|
|
}
|
|
seenErr := false
|
|
for err := range errChan {
|
|
if err != nil {
|
|
seenErr = true
|
|
}
|
|
}
|
|
if !seenErr {
|
|
t.Fatal("expected stream error")
|
|
}
|
|
|
|
record := plugin.waitRecord(t)
|
|
if record.Provider != targetPluginID {
|
|
t.Errorf("record.Provider = %q, want %q", record.Provider, targetPluginID)
|
|
}
|
|
if !record.Failed {
|
|
t.Error("record.Failed = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestHandlerPluginExecutorPublishesStreamCancellation(t *testing.T) {
|
|
targetPluginID := "canceling-stream-plugin"
|
|
plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
|
|
registerUsagePluginForTest(t, "test-plugin-executor-usage-stream-cancel", plugin)
|
|
|
|
originalModel := "gpt-4o"
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
chunks := make(chan coreexecutor.StreamChunk, 5)
|
|
chunks <- coreexecutor.StreamChunk{Payload: []byte("data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n")}
|
|
|
|
mockHost := &mockPluginUsageHost{
|
|
streamResult: &coreexecutor.StreamResult{Chunks: chunks},
|
|
}
|
|
mockHost.hasRouters = true
|
|
mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
|
|
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
|
|
}
|
|
|
|
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
|
|
handler.SetModelRouterHost(mockHost)
|
|
|
|
dataChan, _, _ := handler.ExecuteStreamWithAuthManager(ctx, "openai", originalModel, []byte(fmt.Sprintf(`{"model":%q,"stream":true}`, originalModel)), "")
|
|
// Receive first chunk then cancel context
|
|
<-dataChan
|
|
cancel()
|
|
close(chunks)
|
|
|
|
record := plugin.waitRecord(t)
|
|
if record.Provider != targetPluginID {
|
|
t.Errorf("record.Provider = %q, want %q", record.Provider, targetPluginID)
|
|
}
|
|
if !record.Failed {
|
|
t.Error("record.Failed = false, want true for canceled stream")
|
|
}
|
|
}
|
|
|
|
func TestHandlerPluginExecutorSkipsUsageForNestedExecution(t *testing.T) {
|
|
targetPluginID := "nested-plugin"
|
|
plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
|
|
registerUsagePluginForTest(t, "test-plugin-executor-usage-nested", plugin)
|
|
|
|
originalModel := "gpt-4o"
|
|
|
|
openAIResponseBody := []byte(`{"id":"chatcmpl-1","choices":[{"message":{"role":"assistant","content":"hello"}}],"usage":{"prompt_tokens":12,"completion_tokens":34,"total_tokens":46}}`)
|
|
|
|
mockHost := &mockPluginUsageHost{
|
|
execResp: coreexecutor.Response{
|
|
Payload: openAIResponseBody,
|
|
},
|
|
}
|
|
mockHost.hasRouters = true
|
|
mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
|
|
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
|
|
}
|
|
|
|
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
|
|
handler.SetModelRouterHost(mockHost)
|
|
|
|
// ExecuteModel triggers execution with InternalSource = true (host.model.execute callback)
|
|
resp, errMsg := handler.ExecuteModel(context.Background(), ModelExecutionRequest{
|
|
EntryProtocol: "openai",
|
|
ExitProtocol: "openai",
|
|
Model: originalModel,
|
|
Body: []byte(fmt.Sprintf(`{"model":%q}`, originalModel)),
|
|
})
|
|
if errMsg != nil {
|
|
t.Fatalf("ExecuteModel() error = %+v", errMsg)
|
|
}
|
|
if len(resp.Body) == 0 {
|
|
t.Fatal("empty response body")
|
|
}
|
|
|
|
plugin.assertNoRecord(t)
|
|
}
|
|
|
|
func TestHandlerPluginExecutorSkipsOuterUsageWhenPluginCallsHostModelExecute(t *testing.T) {
|
|
targetPluginID := "agent-wrapper-plugin"
|
|
plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
|
|
registerUsagePluginForTest(t, "test-plugin-executor-nested-callback", plugin)
|
|
|
|
outerModel := "agent-wrapper-model"
|
|
innerModel := "inner-model"
|
|
|
|
manager := coreauth.NewManager(nil, nil, nil)
|
|
innerExecutor := &modelExecutionCaptureExecutor{
|
|
provider: "openai",
|
|
execute: func(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) {
|
|
return coreexecutor.Response{
|
|
Payload: []byte(`{"id":"chatcmpl-inner","choices":[{"message":{"role":"assistant","content":"inner"}}],"usage":{"prompt_tokens":5,"completion_tokens":5,"total_tokens":10}}`),
|
|
}, nil
|
|
},
|
|
}
|
|
manager.RegisterExecutor(innerExecutor)
|
|
auth := &coreauth.Auth{
|
|
ID: "auth-" + innerModel,
|
|
Provider: innerExecutor.Identifier(),
|
|
Status: coreauth.StatusActive,
|
|
}
|
|
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
|
|
t.Fatalf("manager.Register(): %v", errRegister)
|
|
}
|
|
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: innerModel}})
|
|
t.Cleanup(func() {
|
|
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
|
|
})
|
|
|
|
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
|
|
|
|
mockHost := &mockPluginUsageHost{}
|
|
mockHost.hasRouters = true
|
|
mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
|
|
if req.RequestedModel == outerModel {
|
|
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
|
|
}
|
|
return pluginapi.ModelRouteResponse{}, false
|
|
}
|
|
// When the plugin executor executes, it simulates calling back into the host via ExecuteModel
|
|
mockHost.execFunc = func(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) {
|
|
// Plugin calls back into host.model.execute using the provided ctx
|
|
innerResp, errInner := handler.ExecuteModel(ctx, ModelExecutionRequest{
|
|
EntryProtocol: "openai",
|
|
ExitProtocol: "openai",
|
|
Model: innerModel,
|
|
Body: []byte(fmt.Sprintf(`{"model":%q}`, innerModel)),
|
|
})
|
|
if errInner != nil {
|
|
return coreexecutor.Response{}, errInner.Error
|
|
}
|
|
// Return response payload back to outer caller
|
|
return coreexecutor.Response{Payload: innerResp.Body}, nil
|
|
}
|
|
|
|
handler.SetModelRouterHost(mockHost)
|
|
|
|
body, _, errMsg := handler.ExecuteWithAuthManager(context.Background(), "openai", outerModel, []byte(fmt.Sprintf(`{"model":%q}`, outerModel)), "")
|
|
if errMsg != nil {
|
|
t.Fatalf("ExecuteWithAuthManager() error = %+v", errMsg)
|
|
}
|
|
if len(body) == 0 {
|
|
t.Fatal("empty response body")
|
|
}
|
|
|
|
// Since inner ExecuteModel was executed, the outer plugin executor must NOT publish a duplicate record for targetPluginID
|
|
plugin.assertNoRecord(t)
|
|
}
|
|
|
|
func TestHandlerPluginExecutorSkipsOuterFailureWhenPluginCallsHostModelExecute(t *testing.T) {
|
|
targetPluginID := "agent-wrapper-plugin-failure"
|
|
plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
|
|
registerUsagePluginForTest(t, "test-plugin-executor-nested-callback-failure", plugin)
|
|
|
|
outerModel := "agent-wrapper-model-fail"
|
|
innerModel := "inner-model-fail"
|
|
|
|
manager := coreauth.NewManager(nil, nil, nil)
|
|
innerExecutor := &modelExecutionCaptureExecutor{
|
|
provider: "openai",
|
|
execute: func(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) {
|
|
return coreexecutor.Response{}, errors.New("inner failure")
|
|
},
|
|
}
|
|
manager.RegisterExecutor(innerExecutor)
|
|
auth := &coreauth.Auth{
|
|
ID: "auth-" + innerModel,
|
|
Provider: innerExecutor.Identifier(),
|
|
Status: coreauth.StatusActive,
|
|
}
|
|
if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
|
|
t.Fatalf("manager.Register(): %v", errRegister)
|
|
}
|
|
registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: innerModel}})
|
|
t.Cleanup(func() {
|
|
registry.GetGlobalRegistry().UnregisterClient(auth.ID)
|
|
})
|
|
|
|
handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
|
|
|
|
mockHost := &mockPluginUsageHost{}
|
|
mockHost.hasRouters = true
|
|
mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
|
|
if req.RequestedModel == outerModel {
|
|
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
|
|
}
|
|
return pluginapi.ModelRouteResponse{}, false
|
|
}
|
|
mockHost.execFunc = func(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) {
|
|
_, errInner := handler.ExecuteModel(ctx, ModelExecutionRequest{
|
|
EntryProtocol: "openai",
|
|
ExitProtocol: "openai",
|
|
Model: innerModel,
|
|
Body: []byte(fmt.Sprintf(`{"model":%q}`, innerModel)),
|
|
})
|
|
if errInner != nil {
|
|
return coreexecutor.Response{}, errInner.Error
|
|
}
|
|
return coreexecutor.Response{Payload: []byte("ok")}, nil
|
|
}
|
|
|
|
handler.SetModelRouterHost(mockHost)
|
|
|
|
_, _, errMsg := handler.ExecuteWithAuthManager(context.Background(), "openai", outerModel, []byte(fmt.Sprintf(`{"model":%q}`, outerModel)), "")
|
|
if errMsg == nil {
|
|
t.Fatal("expected failure")
|
|
}
|
|
|
|
// Since inner ExecuteModel was executed, the outer plugin executor must NOT publish a duplicate failure record
|
|
plugin.assertNoRecord(t)
|
|
}
|
|
|
|
type mockPluginUsageHost struct {
|
|
handlerDirectExecutorRouteHost
|
|
execResp coreexecutor.Response
|
|
execErr error
|
|
execFunc func(context.Context, string, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error)
|
|
streamResult *coreexecutor.StreamResult
|
|
}
|
|
|
|
func (h *mockPluginUsageHost) ExecutePluginExecutor(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) {
|
|
h.lastPluginID = pluginID
|
|
h.lastRequest = req
|
|
h.lastOptions = opts
|
|
if h.execFunc != nil {
|
|
return h.execFunc(ctx, pluginID, req, opts)
|
|
}
|
|
if h.execErr != nil {
|
|
return coreexecutor.Response{}, h.execErr
|
|
}
|
|
return h.execResp, nil
|
|
}
|
|
|
|
func (h *mockPluginUsageHost) ExecutePluginExecutorStream(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) {
|
|
h.lastPluginID = pluginID
|
|
h.lastRequest = req
|
|
h.lastOptions = opts
|
|
return h.streamResult, nil
|
|
}
|