| 1 | package providerconv |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | |
| 7 | "reasonix/internal/extension/protocol" |
| 8 | "reasonix/internal/provider" |
| 9 | ) |
| 10 | |
| 11 | // Round trips through the wire DTOs must preserve every provider-visible |
| 12 | // field and drop nothing the extension side needs. |
| 13 | func TestRequestRoundTripPreservesProviderVisibleFields(t *testing.T) { |
| 14 | temperature := 0.25 |
| 15 | req := provider.Request{ |
| 16 | Messages: []provider.Message{ |
| 17 | {Role: provider.RoleSystem, Content: "sys"}, |
| 18 | {Role: provider.RoleUser, Content: "hi", Images: []string{"data:image/png;base64,AA=="}}, |
| 19 | { |
| 20 | Role: provider.RoleAssistant, Content: "prev", |
| 21 | ReasoningContent: "because", ReasoningSignature: "sig", |
| 22 | ToolCalls: []provider.ToolCall{{ID: "c1", Name: "bash", Arguments: `{"cmd":"ls"}`, ThoughtSignature: "ts"}}, |
| 23 | }, |
| 24 | {Role: provider.RoleTool, ToolCallID: "c1", Name: "bash", Content: "ok"}, |
| 25 | }, |
| 26 | Tools: []provider.ToolSchema{{ |
| 27 | Name: "bash", Description: "run", Parameters: []byte(`{"type":"object"}`), |
| 28 | }}, |
| 29 | Temperature: &temperature, |
| 30 | MaxTokens: 64, |
| 31 | ResponseFormat: &provider.ResponseFormat{Type: "json_object"}, |
| 32 | } |
| 33 | |
| 34 | back := RequestFromProtocol(RequestToProtocol(req)) |
| 35 | if len(back.Messages) != len(req.Messages) || len(back.Tools) != 1 { |
| 36 | t.Fatalf("round trip = %+v", back) |
| 37 | } |
| 38 | assistant := back.Messages[2] |
| 39 | if assistant.ReasoningContent != "because" || assistant.ReasoningSignature != "sig" { |
| 40 | t.Fatalf("assistant reasoning = %+v", assistant) |
| 41 | } |
| 42 | if len(assistant.ToolCalls) != 1 || assistant.ToolCalls[0].ThoughtSignature != "ts" { |
| 43 | t.Fatalf("assistant tool calls = %+v", assistant.ToolCalls) |
| 44 | } |
| 45 | if back.Messages[1].Images[0] != "data:image/png;base64,AA==" { |
| 46 | t.Fatalf("images = %+v", back.Messages[1].Images) |
| 47 | } |
| 48 | if back.Tools[0].Name != "bash" || string(back.Tools[0].Parameters) != `{"type":"object"}` { |
| 49 | t.Fatalf("tools = %+v", back.Tools) |
| 50 | } |
| 51 | if back.Temperature == nil || *back.Temperature != temperature || back.MaxTokens != 64 { |
| 52 | t.Fatalf("scalars = %+v", back) |
| 53 | } |
| 54 | if back.ResponseFormat == nil || back.ResponseFormat.Type != "json_object" { |
| 55 | t.Fatalf("response format = %+v", back.ResponseFormat) |
| 56 | } |
| 57 | if RequestFromProtocol(RequestToProtocol(provider.Request{})).ResponseFormat != nil { |
| 58 | t.Fatal("nil response format must stay nil") |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | func TestUsageRoundTrip(t *testing.T) { |
| 63 | usage := &provider.Usage{ |
| 64 | PromptTokens: 1, CompletionTokens: 2, TotalTokens: 3, |
| 65 | CacheHitTokens: 4, CacheMissTokens: 5, ReasoningTokens: 6, FinishReason: "stop", |
| 66 | } |
| 67 | back := UsageFromProtocol(UsageToProtocol(usage)) |
| 68 | if *back != *usage { |
| 69 | t.Fatalf("usage round trip = %+v, want %+v", back, usage) |
| 70 | } |
| 71 | if UsageToProtocol(nil) != nil || UsageFromProtocol(nil) != nil { |
| 72 | t.Fatal("nil usage must stay nil") |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | func TestChunkFromProtocolMapsEveryType(t *testing.T) { |
| 77 | cases := []struct { |
| 78 | wire protocol.ProviderChunkType |
| 79 | want provider.ChunkType |
| 80 | }{ |
| 81 | {protocol.ChunkText, provider.ChunkText}, |
| 82 | {protocol.ChunkReasoning, provider.ChunkReasoning}, |
| 83 | {protocol.ChunkToolCallStart, provider.ChunkToolCallStart}, |
| 84 | {protocol.ChunkToolCallDelta, provider.ChunkToolCallArgsDelta}, |
| 85 | {protocol.ChunkToolCall, provider.ChunkToolCall}, |
| 86 | {protocol.ChunkUsage, provider.ChunkUsage}, |
| 87 | {protocol.ChunkDone, provider.ChunkDone}, |
| 88 | {protocol.ChunkError, provider.ChunkError}, |
| 89 | } |
| 90 | for _, tc := range cases { |
| 91 | got := ChunkFromProtocol(protocol.ProviderChunk{Type: tc.wire}).Type |
| 92 | if got != tc.want { |
| 93 | t.Fatalf("type %q mapped to %v, want %v", tc.wire, got, tc.want) |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestChunkFromProtocolErrorCodes(t *testing.T) { |
| 99 | const secret = "sk-abcdef1234567890SECRETKEY" |
| 100 | failed := ChunkFromProtocol(protocol.ProviderChunk{ |
| 101 | Type: protocol.ChunkError, |
| 102 | Error: &protocol.ProviderError{Code: protocol.ProviderFailed, Message: "provider rejected api_key=" + secret}, |
| 103 | }) |
| 104 | if failed.Err == nil || strings.Contains(failed.Err.Error(), secret) || provider.IsStreamInterrupted(failed.Err) { |
| 105 | t.Fatalf("failed chunk = %+v", failed) |
| 106 | } |
| 107 | if !strings.Contains(failed.Err.Error(), "provider rejected api_key=") { |
| 108 | t.Fatalf("failed error lost diagnostic context: %q", failed.Err) |
| 109 | } |
| 110 | interrupted := ChunkFromProtocol(protocol.ProviderChunk{ |
| 111 | Type: protocol.ChunkError, |
| 112 | Error: &protocol.ProviderError{Code: protocol.ProviderInterrupted, Message: "provider interrupted token=" + secret}, |
| 113 | }) |
| 114 | if !provider.IsStreamInterrupted(interrupted.Err) { |
| 115 | t.Fatalf("interrupted chunk = %+v", interrupted) |
| 116 | } |
| 117 | if strings.Contains(interrupted.Err.Error(), secret) { |
| 118 | t.Fatalf("interrupted error leaked credential: %q", interrupted.Err) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | func TestDescriptorFromProtocolCopiesFields(t *testing.T) { |
| 123 | wire := protocol.ProviderDescriptor{ |
| 124 | Ref: "plugin/demo/fake/x", DisplayName: "Demo", Model: "x", |
| 125 | ContextWindow: 128_000, PricingCurrency: "$", |
| 126 | CacheHitPerMillion: 0.1, InputPerMillion: 1.0, OutputPerMillion: 2.0, |
| 127 | Vision: true, Tools: true, Reasoning: true, |
| 128 | Efforts: []string{"low", "high"}, DefaultEffort: "low", |
| 129 | ToolCallReasoning: true, ReasoningRoundTrip: true, WarnOnMissingToolCallReasoning: true, |
| 130 | } |
| 131 | d := DescriptorFromProtocol(wire) |
| 132 | if d.Ref != wire.Ref || d.DisplayName != wire.DisplayName || d.Model != wire.Model || |
| 133 | d.ContextWindow != wire.ContextWindow || d.PricingCurrency != wire.PricingCurrency || |
| 134 | d.CacheHitPerMillion != wire.CacheHitPerMillion || d.InputPerMillion != wire.InputPerMillion || |
| 135 | d.OutputPerMillion != wire.OutputPerMillion || d.Vision != wire.Vision || d.Tools != wire.Tools || |
| 136 | d.Reasoning != wire.Reasoning || d.DefaultEffort != wire.DefaultEffort || |
| 137 | d.ToolCallReasoning != wire.ToolCallReasoning || d.ReasoningRoundTrip != wire.ReasoningRoundTrip || |
| 138 | d.WarnOnMissingToolCallReasoning != wire.WarnOnMissingToolCallReasoning || |
| 139 | len(d.Efforts) != 2 || d.Efforts[1] != "high" { |
| 140 | t.Fatalf("descriptor = %+v", d) |
| 141 | } |
| 142 | } |
| 143 |