| 1 | // Package providerconv holds the host-side conversions between |
| 2 | // internal/provider values and the public Extension Protocol v1 wire DTOs. |
| 3 | // The protocol package deliberately does not import internal/provider, so |
| 4 | // every host surface that moves provider data across the extension boundary — |
| 5 | // the agent's intercept wiring (stage 6b2) and the sidecar provider adapter |
| 6 | // (stage 7) — shares these helpers. Wire DTOs never carry credentials: |
| 7 | // ProviderError messages must be redacted by the producer before they cross, |
| 8 | // and the host defensively redacts them again at the trust boundary. |
| 9 | package providerconv |
| 10 | |
| 11 | import ( |
| 12 | "errors" |
| 13 | |
| 14 | "reasonix/internal/extension/protocol" |
| 15 | "reasonix/internal/provider" |
| 16 | "reasonix/internal/secrets" |
| 17 | ) |
| 18 | |
| 19 | // MessagesToProtocol copies provider messages into their public wire form, |
| 20 | // dropping the local-only UI metadata fields that never belong on the wire. |
| 21 | func MessagesToProtocol(msgs []provider.Message) []protocol.ProviderMessage { |
| 22 | out := make([]protocol.ProviderMessage, 0, len(msgs)) |
| 23 | for _, m := range msgs { |
| 24 | out = append(out, protocol.ProviderMessage{ |
| 25 | Role: protocol.ProviderRole(m.Role), |
| 26 | Content: m.Content, |
| 27 | Images: m.Images, |
| 28 | ReasoningContent: m.ReasoningContent, |
| 29 | ReasoningSignature: m.ReasoningSignature, |
| 30 | ToolCalls: ToolCallsToProtocol(m.ToolCalls), |
| 31 | ToolCallID: m.ToolCallID, |
| 32 | Name: m.Name, |
| 33 | }) |
| 34 | } |
| 35 | return out |
| 36 | } |
| 37 | |
| 38 | // MessagesFromProtocol copies wire messages back into provider messages. |
| 39 | func MessagesFromProtocol(msgs []protocol.ProviderMessage) []provider.Message { |
| 40 | out := make([]provider.Message, 0, len(msgs)) |
| 41 | for _, m := range msgs { |
| 42 | out = append(out, provider.Message{ |
| 43 | Role: provider.Role(m.Role), |
| 44 | Content: m.Content, |
| 45 | Images: m.Images, |
| 46 | ReasoningContent: m.ReasoningContent, |
| 47 | ReasoningSignature: m.ReasoningSignature, |
| 48 | ToolCalls: ToolCallsFromProtocol(m.ToolCalls), |
| 49 | ToolCallID: m.ToolCallID, |
| 50 | Name: m.Name, |
| 51 | }) |
| 52 | } |
| 53 | return out |
| 54 | } |
| 55 | |
| 56 | // ToolCallsToProtocol copies tool calls into their provider-visible wire form. |
| 57 | func ToolCallsToProtocol(calls []provider.ToolCall) []protocol.ProviderToolCall { |
| 58 | if len(calls) == 0 { |
| 59 | return nil |
| 60 | } |
| 61 | out := make([]protocol.ProviderToolCall, 0, len(calls)) |
| 62 | for _, c := range calls { |
| 63 | out = append(out, protocol.ProviderToolCall{ |
| 64 | ID: c.ID, |
| 65 | Name: c.Name, |
| 66 | Arguments: c.Arguments, |
| 67 | ThoughtSignature: c.ThoughtSignature, |
| 68 | }) |
| 69 | } |
| 70 | return out |
| 71 | } |
| 72 | |
| 73 | // ToolCallsFromProtocol copies wire tool calls back into provider tool calls. |
| 74 | func ToolCallsFromProtocol(calls []protocol.ProviderToolCall) []provider.ToolCall { |
| 75 | if len(calls) == 0 { |
| 76 | return nil |
| 77 | } |
| 78 | out := make([]provider.ToolCall, 0, len(calls)) |
| 79 | for _, c := range calls { |
| 80 | out = append(out, provider.ToolCall{ |
| 81 | ID: c.ID, |
| 82 | Name: c.Name, |
| 83 | Arguments: c.Arguments, |
| 84 | ThoughtSignature: c.ThoughtSignature, |
| 85 | }) |
| 86 | } |
| 87 | return out |
| 88 | } |
| 89 | |
| 90 | // RequestToProtocol copies a completion request into the credential-free wire |
| 91 | // form the host sends to an extension-hosted provider. |
| 92 | func RequestToProtocol(req provider.Request) protocol.ProviderRequest { |
| 93 | tools := make([]protocol.ProviderToolSchema, 0, len(req.Tools)) |
| 94 | for _, s := range req.Tools { |
| 95 | tools = append(tools, protocol.ProviderToolSchema{ |
| 96 | Name: s.Name, |
| 97 | Description: s.Description, |
| 98 | Parameters: s.Parameters, |
| 99 | }) |
| 100 | } |
| 101 | var responseFormat *protocol.ProviderResponseFormat |
| 102 | if req.ResponseFormat != nil { |
| 103 | responseFormat = &protocol.ProviderResponseFormat{Type: req.ResponseFormat.Type} |
| 104 | } |
| 105 | return protocol.ProviderRequest{ |
| 106 | Messages: MessagesToProtocol(req.Messages), |
| 107 | Tools: tools, |
| 108 | Temperature: req.Temperature, |
| 109 | MaxTokens: req.MaxTokens, |
| 110 | ResponseFormat: responseFormat, |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | // RequestFromProtocol copies a wire request back into a provider request. |
| 115 | func RequestFromProtocol(req protocol.ProviderRequest) provider.Request { |
| 116 | tools := make([]provider.ToolSchema, 0, len(req.Tools)) |
| 117 | for _, s := range req.Tools { |
| 118 | tools = append(tools, provider.ToolSchema{ |
| 119 | Name: s.Name, |
| 120 | Description: s.Description, |
| 121 | Parameters: s.Parameters, |
| 122 | }) |
| 123 | } |
| 124 | var responseFormat *provider.ResponseFormat |
| 125 | if req.ResponseFormat != nil { |
| 126 | responseFormat = &provider.ResponseFormat{Type: req.ResponseFormat.Type} |
| 127 | } |
| 128 | return provider.Request{ |
| 129 | Messages: MessagesFromProtocol(req.Messages), |
| 130 | Tools: tools, |
| 131 | Temperature: req.Temperature, |
| 132 | MaxTokens: req.MaxTokens, |
| 133 | ResponseFormat: responseFormat, |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // UsageToProtocol copies token accounting into its wire form. |
| 138 | func UsageToProtocol(u *provider.Usage) *protocol.ProviderUsage { |
| 139 | if u == nil { |
| 140 | return nil |
| 141 | } |
| 142 | return &protocol.ProviderUsage{ |
| 143 | PromptTokens: u.PromptTokens, |
| 144 | CompletionTokens: u.CompletionTokens, |
| 145 | TotalTokens: u.TotalTokens, |
| 146 | CacheHitTokens: u.CacheHitTokens, |
| 147 | CacheMissTokens: u.CacheMissTokens, |
| 148 | ReasoningTokens: u.ReasoningTokens, |
| 149 | FinishReason: u.FinishReason, |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // UsageFromProtocol copies wire token accounting back into a provider Usage. |
| 154 | func UsageFromProtocol(u *protocol.ProviderUsage) *provider.Usage { |
| 155 | if u == nil { |
| 156 | return nil |
| 157 | } |
| 158 | return &provider.Usage{ |
| 159 | PromptTokens: u.PromptTokens, |
| 160 | CompletionTokens: u.CompletionTokens, |
| 161 | TotalTokens: u.TotalTokens, |
| 162 | CacheHitTokens: u.CacheHitTokens, |
| 163 | CacheMissTokens: u.CacheMissTokens, |
| 164 | ReasoningTokens: u.ReasoningTokens, |
| 165 | FinishReason: u.FinishReason, |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | // DescriptorFromProtocol copies an extension's provider catalog entry into the |
| 170 | // host's descriptor form. The DTO mirrors provider.Descriptor field-for-field. |
| 171 | func DescriptorFromProtocol(d protocol.ProviderDescriptor) provider.Descriptor { |
| 172 | return provider.Descriptor{ |
| 173 | Ref: d.Ref, |
| 174 | DisplayName: d.DisplayName, |
| 175 | Model: d.Model, |
| 176 | ContextWindow: d.ContextWindow, |
| 177 | PricingCurrency: d.PricingCurrency, |
| 178 | CacheHitPerMillion: d.CacheHitPerMillion, |
| 179 | InputPerMillion: d.InputPerMillion, |
| 180 | OutputPerMillion: d.OutputPerMillion, |
| 181 | Vision: d.Vision, |
| 182 | Tools: d.Tools, |
| 183 | Reasoning: d.Reasoning, |
| 184 | Efforts: append([]string(nil), d.Efforts...), |
| 185 | DefaultEffort: d.DefaultEffort, |
| 186 | ToolCallReasoning: d.ToolCallReasoning, |
| 187 | ReasoningRoundTrip: d.ReasoningRoundTrip, |
| 188 | WarnOnMissingToolCallReasoning: d.WarnOnMissingToolCallReasoning, |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | // ChunkFromProtocol converts one inbound extension stream chunk. The protocol |
| 193 | // requires extensions to redact provider errors before sending them, but the |
| 194 | // host treats the sidecar as untrusted and defensively redacts again. A |
| 195 | // provider_interrupted code maps to StreamInterruptedError so the agent's |
| 196 | // interruption recovery applies. |
| 197 | func ChunkFromProtocol(chunk protocol.ProviderChunk) provider.Chunk { |
| 198 | converted := provider.Chunk{ |
| 199 | Type: chunkTypeFromProtocol(chunk.Type), |
| 200 | Text: chunk.Text, |
| 201 | Signature: chunk.Signature, |
| 202 | ArgChars: chunk.ArgChars, |
| 203 | } |
| 204 | if chunk.ToolCall != nil { |
| 205 | converted.ToolCall = &provider.ToolCall{ |
| 206 | ID: chunk.ToolCall.ID, |
| 207 | Name: chunk.ToolCall.Name, |
| 208 | Arguments: chunk.ToolCall.Arguments, |
| 209 | ThoughtSignature: chunk.ToolCall.ThoughtSignature, |
| 210 | } |
| 211 | } |
| 212 | if chunk.Usage != nil { |
| 213 | converted.Usage = UsageFromProtocol(chunk.Usage) |
| 214 | } |
| 215 | if chunk.Error != nil { |
| 216 | err := errors.New(secrets.RedactCredentials(chunk.Error.Message)) |
| 217 | if chunk.Error.Code == protocol.ProviderInterrupted { |
| 218 | err = &provider.StreamInterruptedError{Err: err} |
| 219 | } |
| 220 | converted.Err = err |
| 221 | } |
| 222 | return converted |
| 223 | } |
| 224 | |
| 225 | func chunkTypeFromProtocol(kind protocol.ProviderChunkType) provider.ChunkType { |
| 226 | switch kind { |
| 227 | case protocol.ChunkText: |
| 228 | return provider.ChunkText |
| 229 | case protocol.ChunkReasoning: |
| 230 | return provider.ChunkReasoning |
| 231 | case protocol.ChunkToolCallStart: |
| 232 | return provider.ChunkToolCallStart |
| 233 | case protocol.ChunkToolCallDelta: |
| 234 | return provider.ChunkToolCallArgsDelta |
| 235 | case protocol.ChunkToolCall: |
| 236 | return provider.ChunkToolCall |
| 237 | case protocol.ChunkUsage: |
| 238 | return provider.ChunkUsage |
| 239 | case protocol.ChunkDone: |
| 240 | return provider.ChunkDone |
| 241 | case protocol.ChunkError: |
| 242 | return provider.ChunkError |
| 243 | default: |
| 244 | return provider.ChunkError |
| 245 | } |
| 246 | } |
| 247 |