| 1 | package protocol |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "strings" |
| 7 | ) |
| 8 | |
| 9 | // Provider DTOs: the extension-hosted provider broker. The extension holds |
| 10 | // provider credentials and runs streams; the host only ever sees these |
| 11 | // credential-free public copies. Conversion to and from internal/provider |
| 12 | // types lives host-side in a later stage; these DTOs deliberately do not |
| 13 | // import internal/provider so the public wire schema stays self-contained. |
| 14 | |
| 15 | // ProviderDescriptor mirrors provider.Descriptor field-for-field as a public |
| 16 | // DTO. It never carries endpoints, credentials, headers, or env names. |
| 17 | type ProviderDescriptor struct { |
| 18 | Ref string `json:"ref" validate:"nonempty"` |
| 19 | DisplayName string `json:"displayName,omitempty"` |
| 20 | Model string `json:"model,omitempty"` |
| 21 | ContextWindow int `json:"contextWindow,omitempty" validate:"min=0"` |
| 22 | PricingCurrency string `json:"pricingCurrency,omitempty"` |
| 23 | CacheHitPerMillion float64 `json:"cacheHitPerMillion,omitempty" validate:"min=0"` |
| 24 | InputPerMillion float64 `json:"inputPerMillion,omitempty" validate:"min=0"` |
| 25 | OutputPerMillion float64 `json:"outputPerMillion,omitempty" validate:"min=0"` |
| 26 | Vision bool `json:"vision,omitempty"` |
| 27 | Tools bool `json:"tools,omitempty"` |
| 28 | Reasoning bool `json:"reasoning,omitempty"` |
| 29 | Efforts []string `json:"efforts,omitempty"` |
| 30 | DefaultEffort string `json:"defaultEffort,omitempty"` |
| 31 | ToolCallReasoning bool `json:"toolCallReasoning,omitempty"` |
| 32 | ReasoningRoundTrip bool `json:"reasoningRoundTrip,omitempty"` |
| 33 | WarnOnMissingToolCallReasoning bool `json:"warnOnMissingToolCallReasoning,omitempty"` |
| 34 | } |
| 35 | |
| 36 | // PluginRefOwner extracts the plugin ID from a plugin-namespaced provider ref |
| 37 | // (plugin/<pluginID>/<rest...>) — the namespace every extension-hosted |
| 38 | // provider ref carries. Anything else — including the two-segment |
| 39 | // "plugin/<model>" shape, which stays an ordinary host ref — returns "". |
| 40 | // Host layers (boot, config validation, frontends) use it to route plugin |
| 41 | // refs away from config-backed catalogs they can never appear in. |
| 42 | func PluginRefOwner(ref string) string { |
| 43 | rest, ok := strings.CutPrefix(ref, "plugin/") |
| 44 | if !ok { |
| 45 | return "" |
| 46 | } |
| 47 | pluginID, remainder, ok := strings.Cut(rest, "/") |
| 48 | if !ok || pluginID == "" || remainder == "" { |
| 49 | return "" |
| 50 | } |
| 51 | return pluginID |
| 52 | } |
| 53 | |
| 54 | // ProviderMessage is the public copy of provider.Message. It keeps the same |
| 55 | // JSON field names (snake_case) so transcripts read identically, and drops |
| 56 | // the local-only UI metadata fields that never belong on the wire. |
| 57 | type ProviderMessage struct { |
| 58 | Role ProviderRole `json:"role,omitempty"` |
| 59 | Content string `json:"content,omitempty" externalizable:"true"` |
| 60 | Images []string `json:"images,omitempty"` |
| 61 | ReasoningContent string `json:"reasoning_content,omitempty"` |
| 62 | ReasoningSignature string `json:"reasoning_signature,omitempty"` |
| 63 | ToolCalls []ProviderToolCall `json:"tool_calls,omitempty"` |
| 64 | ToolCallID string `json:"tool_call_id,omitempty"` |
| 65 | Name string `json:"name,omitempty"` |
| 66 | } |
| 67 | |
| 68 | // ProviderToolCall is the public copy of provider.ToolCall: provider-visible |
| 69 | // fields only, no Reasonix-local display metadata. |
| 70 | type ProviderToolCall struct { |
| 71 | ID string `json:"id" validate:"nonempty"` |
| 72 | Name string `json:"name" validate:"nonempty"` |
| 73 | Arguments string `json:"arguments"` |
| 74 | ThoughtSignature string `json:"thought_signature,omitempty"` |
| 75 | } |
| 76 | |
| 77 | // ProviderToolSchema is the public copy of provider.ToolSchema. Parameters is |
| 78 | // a JSON Schema object. |
| 79 | type ProviderToolSchema struct { |
| 80 | Name string `json:"name" validate:"nonempty"` |
| 81 | Description string `json:"description,omitempty"` |
| 82 | Parameters json.RawMessage `json:"parameters"` |
| 83 | } |
| 84 | |
| 85 | // ProviderResponseFormat asks an extension-hosted provider to constrain its |
| 86 | // output shape. It is optional so ordinary requests retain their existing, |
| 87 | // cache-stable wire representation. |
| 88 | type ProviderResponseFormat struct { |
| 89 | Type string `json:"type" validate:"nonempty"` |
| 90 | } |
| 91 | |
| 92 | // ProviderRequest is the credential-free completion request the host asks the |
| 93 | // extension to stream. Nil Messages/Tools arrays are invalid; empty arrays |
| 94 | // are the canonical form. |
| 95 | type ProviderRequest struct { |
| 96 | Messages []ProviderMessage `json:"messages"` |
| 97 | Tools []ProviderToolSchema `json:"tools"` |
| 98 | Temperature *float64 `json:"temperature,omitempty"` |
| 99 | MaxTokens int `json:"maxTokens" validate:"min=0"` |
| 100 | ResponseFormat *ProviderResponseFormat `json:"responseFormat,omitempty"` |
| 101 | } |
| 102 | |
| 103 | // Validate enforces the deterministic wire shape. |
| 104 | func (request ProviderRequest) Validate() error { |
| 105 | if request.Messages == nil || request.Tools == nil { |
| 106 | return validationError("messages and tools must be arrays") |
| 107 | } |
| 108 | if request.MaxTokens < 0 { |
| 109 | return validationError("maxTokens must be non-negative") |
| 110 | } |
| 111 | if request.ResponseFormat != nil && strings.TrimSpace(request.ResponseFormat.Type) == "" { |
| 112 | return validationError("responseFormat.type must be non-empty") |
| 113 | } |
| 114 | for _, tool := range request.Tools { |
| 115 | parameters := bytes.TrimSpace(tool.Parameters) |
| 116 | if len(parameters) == 0 || parameters[0] != '{' || !json.Valid(parameters) { |
| 117 | return validationError("tool parameters must be a JSON object") |
| 118 | } |
| 119 | } |
| 120 | return nil |
| 121 | } |
| 122 | |
| 123 | // ProviderUsage is the public copy of provider.Usage token accounting. |
| 124 | type ProviderUsage struct { |
| 125 | PromptTokens int `json:"promptTokens" validate:"min=0"` |
| 126 | CompletionTokens int `json:"completionTokens" validate:"min=0"` |
| 127 | TotalTokens int `json:"totalTokens" validate:"min=0"` |
| 128 | CacheHitTokens int `json:"cacheHitTokens" validate:"min=0"` |
| 129 | CacheMissTokens int `json:"cacheMissTokens" validate:"min=0"` |
| 130 | ReasoningTokens int `json:"reasoningTokens" validate:"min=0"` |
| 131 | FinishReason string `json:"finishReason,omitempty"` |
| 132 | } |
| 133 | |
| 134 | // ProviderError is deliberately generic, like the Remote broker's: raw |
| 135 | // provider errors can contain API keys, authorization headers, endpoints, or |
| 136 | // response bodies and must never cross the extension boundary. |
| 137 | type ProviderError struct { |
| 138 | Code ProviderErrorCode `json:"code"` |
| 139 | Message string `json:"message" validate:"nonempty"` |
| 140 | } |
| 141 | |
| 142 | // ProviderChunk is one chunk of an extension-hosted provider stream. |
| 143 | type ProviderChunk struct { |
| 144 | Type ProviderChunkType `json:"type"` |
| 145 | Text string `json:"text,omitempty"` |
| 146 | Signature string `json:"signature,omitempty"` |
| 147 | ToolCall *ProviderToolCall `json:"toolCall,omitempty"` |
| 148 | ArgChars int `json:"argChars,omitempty" validate:"min=0"` |
| 149 | Usage *ProviderUsage `json:"usage,omitempty"` |
| 150 | Error *ProviderError `json:"error,omitempty"` |
| 151 | } |
| 152 | |
| 153 | // Validate enforces chunk invariants the tags cannot express. |
| 154 | func (chunk ProviderChunk) Validate() error { |
| 155 | if chunk.ArgChars < 0 { |
| 156 | return validationError("argChars must be non-negative") |
| 157 | } |
| 158 | if chunk.Type == ChunkError && chunk.Error == nil { |
| 159 | return validationError("error chunks require error") |
| 160 | } |
| 161 | if chunk.Type != ChunkError && chunk.Error != nil { |
| 162 | return validationError("non-error chunks forbid error") |
| 163 | } |
| 164 | if chunk.Type == ChunkUsage && chunk.Usage == nil { |
| 165 | return validationError("usage chunks require usage") |
| 166 | } |
| 167 | return nil |
| 168 | } |
| 169 | |
| 170 | // ProviderCatalogParams asks for the extension's full provider catalog. |
| 171 | type ProviderCatalogParams struct{} |
| 172 | |
| 173 | // ProviderCatalogResult is the extension's non-secret provider catalog. |
| 174 | type ProviderCatalogResult struct { |
| 175 | Providers []ProviderDescriptor `json:"providers"` |
| 176 | } |
| 177 | |
| 178 | // StreamOpenParams opens one provider stream for a host turn. Chunks flow |
| 179 | // back as extension/provider/stream/chunk notifications numbered from |
| 180 | // SeqBase; the stream ends with exactly one stream/end notification. |
| 181 | type StreamOpenParams struct { |
| 182 | StreamID string `json:"streamId" validate:"nonempty"` |
| 183 | ProviderRef string `json:"providerRef" validate:"nonempty"` |
| 184 | Model string `json:"model,omitempty"` |
| 185 | Effort string `json:"effort,omitempty"` |
| 186 | Request ProviderRequest `json:"request"` |
| 187 | SeqBase int `json:"seqBase" validate:"min=0"` |
| 188 | } |
| 189 | |
| 190 | // Validate enforces required identifiers plus the request invariants. |
| 191 | func (p StreamOpenParams) Validate() error { |
| 192 | if strings.TrimSpace(p.StreamID) == "" || strings.TrimSpace(p.ProviderRef) == "" { |
| 193 | return validationError("streamId and providerRef are required") |
| 194 | } |
| 195 | return p.Request.Validate() |
| 196 | } |
| 197 | |
| 198 | // StreamOpenResult acknowledges the stream; chunks arrive as notifications. |
| 199 | type StreamOpenResult struct { |
| 200 | Accepted bool `json:"accepted"` |
| 201 | } |
| 202 | |
| 203 | // StreamCancelParams cancels one in-flight provider stream. |
| 204 | type StreamCancelParams struct { |
| 205 | StreamID string `json:"streamId" validate:"nonempty"` |
| 206 | } |
| 207 | |
| 208 | // StreamCancelResult acknowledges the cancel. |
| 209 | type StreamCancelResult struct { |
| 210 | Cancelled bool `json:"cancelled"` |
| 211 | } |
| 212 | |
| 213 | // StreamChunkParams is one provider chunk, Extension → Host. |
| 214 | type StreamChunkParams struct { |
| 215 | StreamID string `json:"streamId" validate:"nonempty"` |
| 216 | Seq int64 `json:"seq" validate:"min=1"` |
| 217 | Chunk ProviderChunk `json:"chunk"` |
| 218 | } |
| 219 | |
| 220 | // Validate enforces stream ordering preconditions and chunk invariants. |
| 221 | func (p StreamChunkParams) Validate() error { |
| 222 | if strings.TrimSpace(p.StreamID) == "" { |
| 223 | return validationError("streamId is required") |
| 224 | } |
| 225 | if p.Seq < 1 { |
| 226 | return validationError("seq must be >= 1") |
| 227 | } |
| 228 | return p.Chunk.Validate() |
| 229 | } |
| 230 | |
| 231 | // StreamEndParams ends a stream, success or failure. LastSeq freezes the |
| 232 | // terminal ordering boundary: the receiver must hold chunks 1..LastSeq before |
| 233 | // completing the stream, and a missing chunk is a stream_gap error. |
| 234 | type StreamEndParams struct { |
| 235 | StreamID string `json:"streamId" validate:"nonempty"` |
| 236 | LastSeq int64 `json:"lastSeq" validate:"min=0"` |
| 237 | // Error is a redacted, non-secret failure message when the stream failed. |
| 238 | Error string `json:"error,omitempty"` |
| 239 | // Interrupted is true when the stream was cut mid-flight (transport drop |
| 240 | // or cancel), not when it finished or failed cleanly. |
| 241 | Interrupted bool `json:"interrupted,omitempty"` |
| 242 | } |
| 243 |