返回 DeepSeek-Reasonix
provider.go
根目录 / internal / provider / provider.go
1 // Package provider defines the model-backend abstraction and a registry mappinga provider "kind" toafactory.
2 // Concrete implementations live in subpackages
3 // (e.g. provider/openai) and self-register via init().
4 // Thecoreresolvesprovidersbykindfromconfigandneverhardcodes a specific model.
5 package provider
6
7 import (
8 "context"
9 "crypto/sha256"
10 "encoding/hex"
11 "encoding/json"
12 "errors"
13 "fmt"
14 "io"
15 "net"
16 "net/http"
17 "slices"
18 "sort"
19 "strings"
20 "syscall"
21 "unicode"
22
23 "reasonix/internal/attachment"
24 "reasonix/internal/nilutil"
25 )
26
27 // Role is the role of a message.
28 type Role string
29
30 const (
31 RoleSystem Role = "system"
32 RoleUser Role = "user"
33 RoleAssistant Role = "assistant"
34 RoleTool Role = "tool"
35 )
36
37 // LocalOnlyToolName/ID make display-only records safe when a newer transcriptis opened by an older
38 // Reasonixbinary that does not know Message.LocalOnly.
39 // Oldwirenormalizationtreatsthisunmatchedtoolresultasanorphananddropsitinsteadofreplayingpartialcontenttothemodel.
40 const (
41 LocalOnlyToolName = "__reasonix_local_only__"
42 LocalOnlyToolID = "__reasonix_local_only__"
43 )
44
45 // Message is a single conversation message.
46 type Message struct {
47 Role Role `json:"role"`
48 // ID is local transcript identity (stable across saves, reloads, and log
49 // branches). Adapters never copy it to the wire; older readers ignore it.
50 ID string `json:"id,omitempty"`
51 // Origin distinguishes real user input from host-generated user-role protocol
52 // messages. omitempty keeps legacy sessions readable by previous releases.
53 Origin MessageOrigin `json:"origin,omitempty"`
54 // Content is the provider-visible conversation content.
55 // Keepingthislegacyfieldprovider-visiblepreservesreplay for older CLI/Desktop releases.
56 Content string `json:"content,omitempty"`
57 // RawContent holds the full local original when it differs from Content.
58 // Provider projections always strip it; bounded
59 // Contentisthestablewirerepresentationandkeepssessionfilessafefor older readers.
60 RawContent string `json:"raw_content,omitempty"`
61 // ProviderContent is a transitional field written by early Context Engine v2
62 // builds. Loaders migrate it into Content/RawContent before normal use.
63 ProviderContent string `json:"provider_content,omitempty"`
64 Images []string `json:"images,omitempty"` // vision refs: data URLs, http(s) image URLs, or Files API file-api- ids; embedded only for vision-capable models
65 // ImageInputs is the durable ordered image payload for new messages.
66 // Images remains the old-data read path. A message must not carry both.
67 ImageInputs []attachment.ImageInput `json:"image_inputs,omitempty"`
68 ReasoningContent string `json:"reasoning_content,omitempty"` // assistant: thinking-mode chain-of-thought, round-tripped on multi-turn
69 // ReasoningID is the provider-issued reasoning-item id (OpenAI Responses:
70 // Reasoning.id is required on input items),
71 // capturedfromthestreamedoutputitemandround-trippedbackintolaterinputs.
72 ReasoningID string `json:"reasoning_id,omitempty"`
73 // ReasoningStatus is the final status of the reasoning item
74 // ("in_progress" | "completed") as issued by the server's done event,
75 // round-tripped back into the input alongside ReasoningID.
76 ReasoningStatus string `json:"reasoning_status,omitempty"`
77 // ReasoningSignature is an opaque, provider-issued proof that ReasoningContentis genuine model output.
78 // Anthropic requires the signed thinking block be replayed on the next turn when a toolcallfollowedthinking;
79 // providers without signed reasoning (e.g. the openai-compatible ones) leave it empty.
80 ReasoningSignature string `json:"reasoning_signature,omitempty"`
81 ReasoningState ReasoningState `json:"reasoning_state,omitempty"`
82 ThinkingBlocks []ThinkingBlock `json:"thinking_blocks,omitempty"`
83 ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set by assistant
84 // ResponsesItems preserves provider-issued Responses API output items forstateless replay.
85 // omitemptykeepsoldsession files byte-compatible.
86 ResponsesItems []json.RawMessage `json:"responses_items,omitempty"`
87 ServerSearch []ServerSearchCall `json:"server_search,omitempty"` // cards + Anthropic replay; omitempty
88 ToolCallID string `json:"tool_call_id,omitempty"` // links a tool result to its call
89 Name string `json:"name,omitempty"` // tool message: tool name
90 MemoryCitations []MemoryCitation `json:"memoryCitations,omitempty"` // local UI metadata; provider requests ignore it
91 WorkDurationMs int64 `json:"workDurationMs,omitempty"` // local UI metadata; provider requests ignore it
92 CreatedAt int64 `json:"createdAt,omitempty"` // local UI metadata; unix milliseconds; stripped before provider requests
93 Edited bool `json:"edited,omitempty"` // local UI metadata; provider requests ignore it
94 Original string `json:"original,omitempty"` // user prompt before inline edit
95 // LocalOnly marks durable transcript content that must never be sent to amodel provider.
96 // Interruptedstreamingoutputusesitsoeveryfrontendcanreplaywhattheusersawwithoutfeedingpartialreasoningortool-callargumentsbackintothenextrequest.
97 LocalOnly bool `json:"local_only,omitempty"`
98 DecisionReceipt *DecisionReceipt `json:"decision_receipt,omitempty"`
99 // DecisionReceipts are local-only metadata attached to a provider-visiblemessage.
100 // Keepingthemontheexistingassistantrecordpreservestheassistant/tool-resultadjacencyrequiredbycurrentandolderreaders.
101 // ModelMessages strips the field before handing requests to providers.
102 DecisionReceipts []*DecisionReceipt `json:"decision_receipts,omitempty"`
103 InterruptedTurn *InterruptedTurnRecovery `json:"interrupted_turn,omitempty"`
104 // FinalReadinessRecovery is durable host state on a LocalOnly sentinel.
105 // ModelMessages removes it before provider serialization.
106 FinalReadinessRecovery *FinalReadinessRecovery `json:"final_readiness_recovery,omitempty"`
107 ProtocolRecovery json.RawMessage `json:"protocol_recovery,omitempty"`
108 ReadPause *ReadPause `json:"read_pause,omitempty"`
109 // ToolExecution is local shell UI metadata on tool-result messages. It ispersisted for
110 // Desktop/CLI/Servecards and stripped by
111 // ModelMessagesbeforeanyproviderrequestsotoolschemasandprompt-cacheprefixes stay stable.
112 ToolExecution *ToolExecution `json:"tool_execution,omitempty"`
113 ToolRunState ToolRunState `json:"tool_run_state,omitempty"`
114 // PresentedFiles is trusted, versioned host metadata produced by the
115 // built-in present tool. It is persisted for replay and stripped from
116 // provider requests. Older binaries ignore this optional object.
117 PresentedFiles *PresentedFilesMetadata `json:"presented_files,omitempty"`
118 // ReadResult is a persisted, host-only reader delivery envelope for diagnostics.
119 // ModelMessages strips it; provider serializers must never emit it on the wire.
120 ReadResult json.RawMessage `json:"read_result,omitempty"`
121 // ToolDiagnostic is persisted host recovery data, stripped by ModelMessages.
122 ToolDiagnostic json.RawMessage `json:"tool_diagnostic,omitempty"`
123 // ReadCompletion is a display-only terminal coverage receipt. It authorizes
124 // neither historical writes nor continuation in another run.
125 ReadCompletion *ReadCompletion `json:"read_completion,omitempty"`
126 // MCPApp is the local MCP Apps presentation for results from App-capableservers. Persisted for
127 // Desktopcardsand stripped by ModelMessages;
128 // provider serializers must never emit it on the wire.
129 MCPApp *MCPAppPresentation `json:"mcp_app,omitempty"`
130 // VisionSummary is durable local metadata generated by an optional imageunderstanding prepass.
131 // Itiscopiedinto provider-visible Content by theturn owner and stripped at the provider boundary.
132 VisionSummary *VisionSummary `json:"vision_summary,omitempty"`
133 }
134
135 // VisionSummary is a bounded, provider-independent description of one userturn's image attachments.
136 // Itcontains no image bytes, paths, or reasoning.
137 type VisionSummary struct {
138 Version int `json:"version"`
139 PromptVersion string `json:"prompt_version"`
140 ModelRef string `json:"model_ref"`
141 ImageDigests []string `json:"image_digests"`
142 Summary string `json:"summary"`
143 CreatedAt int64 `json:"created_at"`
144 }
145
146 // ToolExecution is host-local shell metadata mirrored from tool.ShellExecution.
147 // Provider serializers must never emit this object on the wire.
148 type ToolExecution struct {
149 Kind string `json:"kind,omitempty"`
150 Shell string `json:"shell,omitempty"`
151 ShellVersion string `json:"shellVersion,omitempty"`
152 Platform string `json:"platform,omitempty"`
153 SupportsAndAnd bool `json:"supportsAndAnd"`
154 State string `json:"state,omitempty"`
155 FailurePhase string `json:"failurePhase,omitempty"`
156 ExitCode *int `json:"exitCode,omitempty"`
157 OutputTail string `json:"outputTail,omitempty"`
158 MutationRisk string `json:"mutationRisk,omitempty"`
159 Verification string `json:"verification,omitempty"`
160 DurationMs int64 `json:"durationMs,omitempty"`
161 }
162
163 // DecisionReceipt is durable, provider-excluded evidence of a user-ownedapproval decision.
164 // Itintentionallycontains only bounded labels and theoutcome,
165 // neverfree-formguidanceorprovider-visiblecontent.
166 type DecisionReceipt struct {
167 ID string `json:"id"`
168 Kind string `json:"kind"`
169 Tool string `json:"tool,omitempty"`
170 Subject string `json:"subject,omitempty"`
171 Outcome string `json:"outcome"`
172 }
173
174 // MemoryCitation is local display metadata for memories that influenced anassistant turn.
175 // Providerimplementations must not forward it to model APIs.
176 type MemoryCitation struct {
177 ID string `json:"id,omitempty"`
178 Source string `json:"source"`
179 LineStart int `json:"lineStart,omitempty"`
180 LineEnd int `json:"lineEnd,omitempty"`
181 Note string `json:"note,omitempty"`
182 Kind string `json:"kind,omitempty"`
183 }
184
185 // ParseImageDataURL splits a `data:<media-type>;base64,<payload>` URL into itsmedia type and base64 payload.
186 // ok is false for anything that isn't a base64
187 // data URL — providers that need the split (Anthropic) skip those silently.
188 func ParseImageDataURL(dataURL string) (mediaType, base64Data string, ok bool) {
189 rest, found := strings.CutPrefix(dataURL, "data:")
190 if !found {
191 return "", "", false
192 }
193 meta, payload, found := strings.Cut(rest, ",")
194 if !found {
195 return "", "", false
196 }
197 mt, found := strings.CutSuffix(meta, ";base64")
198 if !found || mt == "" {
199 return "", "", false
200 }
201 return mt, payload, true
202 }
203
204 type ToolCall struct {
205 Recovery *ToolCallRecord `json:"tool_recovery,omitempty"` // local execution evidence; stripped from model input
206 WriteIntents []json.RawMessage `json:"write_intents,omitempty"` // local versioned evidence, stripped from model input
207 ID string `json:"id"`
208 Name string `json:"name"`
209 Arguments string `json:"arguments"`
210 // ThoughtSignature is an opaque Gemini-issued proof attached to a functioncall. OpenAI-compatible
211 // Geminiendpoints require it on message replay.
212 ThoughtSignature string `json:"thought_signature,omitempty"`
213 Diff string `json:"diff,omitempty"`
214 Added int `json:"added,omitempty"`
215 Removed int `json:"removed,omitempty"`
216 // Resolved* fields are Reasonix-local display metadata for stable proxycalls such as use_capability.
217 // Provider request builders deliberatelyserialize only provider-visible fields,
218 // sothesevaluesneveraltertheprovider-visible conversation orprompt-cache prefix.
219 ResolvedName string `json:"resolved_name,omitempty"`
220 CapabilityID string `json:"capability_id,omitempty"`
221 ResolvedReadOnly *bool `json:"resolved_read_only,omitempty"`
222 }
223
224 // ToolSchema is a tool definition exposed to the model. Parameters is JSON Schema.
225 type ToolSchema struct {
226 Name string `json:"name"`
227 Description string `json:"description"`
228 Parameters json.RawMessage `json:"parameters"`
229 Deferred bool `json:"deferred,omitempty"`
230 Strict bool `json:"strict,omitempty"`
231 Namespace string `json:"namespace,omitempty"`
232 }
233
234 // Request is a single completion request.
235 type Request struct {
236 Messages []Message
237 Tools []ToolSchema
238 Temperature *float64 // nil = omit; non-nil = send the value, including 0
239 MaxTokens int
240 // ResponseFormat, when non-nil, asks the endpoint for structured JSONoutput (Responses:
241 // text.format.type=json_object). Nil omits the fieldentirely —
242 // thecommonpathmuststaybyte-stableforpromptcaching.
243 ResponseFormat *ResponseFormat `json:"ResponseFormat,omitempty"`
244 EffortOverride string `json:"EffortOverride,omitempty"` // per-call reasoning-depth override; adapters apply it only when the endpoint's effort vocabulary accepts it
245 ToolSearch *ToolSearch `json:"-"`
246 }
247
248 // ResponseFormat asks a provider to constrain its output shape.
249 type ResponseFormat struct {
250 // Type is the structured format: "json_object" is the only shape the
251 // Responses endpoints currently define (MiMo/DashScope/OpenAI).
252 Type string `json:"type"`
253 }
254
255 // Auto ladder for max_output_tokens=0. Bounds completion only; never compact_ratio.
256 // Official DeepSeek does not use this ladder: Chat/Responses omit the field
257 // (server 384K ceiling) and Anthropic sends DeepSeekMaxOutputTokens.
258 const (
259 DefaultOrdinaryOutputTokens = 16 * 1024 // non-reasoning / non-DeepSeek
260 DefaultReasoningOutputTokens = 32 * 1024 // ordinary reasoning / MiMo
261 DefaultHighReasoningOutputTokens = 64 * 1024 // high/max effort on non-DeepSeek
262 DefaultHighOutputTokens = 128 * 1024 // explicit only; never auto
263 // DeepSeekMaxOutputTokens is the official V4 Flash/Pro completion ceiling.
264 // Pricing page: 输出长度最大 384K. K is decimal thousands, matching thedocumented 1M context = 1,000,000 tokens.
265 // Anthropic requires max_tokens.
266 DeepSeekMaxOutputTokens = 384_000
267 )
268
269 // AutoOutputBudget maps max_output_tokens=0 to 16K/32K/64K for non-DeepSeekvendors. Official
270 // DeepSeekomitsthe field (Chat/Responses) or sends
271 // DeepSeekMaxOutputTokens (Anthropic).
272 func AutoOutputBudget(reasoningEnabled bool, effort string) int {
273 if !reasoningEnabled {
274 return DefaultOrdinaryOutputTokens
275 }
276 switch strings.ToLower(strings.TrimSpace(effort)) {
277 case "high", "max":
278 return DefaultHighReasoningOutputTokens
279 default:
280 return DefaultReasoningOutputTokens
281 }
282 }
283
284 // interruptedToolResult stands in for a tool result that never landed — an
285 // assistant tool_calls turn whose execution was cut short (interrupt, crash) and
286 // later resumed. Sending such a turn unanswered trips the OpenAI/DeepSeek 400
287 // "An assistant message with 'tool_calls' must be followed by tool messages
288 // responding to each 'tool_call_id'".
289 const interruptedToolResult = "[no result: the previous turn was interrupted before this tool call completed]"
290
291 // SanitizeToolPairing is the provider-side alias for NormalizeMessages.
292 // Itrepairsahistorysoitsatisfiesthetool-call contract the OpenAI-compatible and
293 // Anthropic APIs enforce (every assistant tool_calls answered, no orphan toolmessages, truncated argsclosed)
294 // right before sending it to the wire — withouttouching the stored session.
295 // Keptasadistinctnamesocallsitesread as
296 // "defensive wire prep" rather than "session mutation".
297 func SanitizeToolPairing(msgs []Message) []Message { return NormalizeMessages(msgs) }
298
299 // NormalizeMessages repairs a conversation history so it satisfies the tool-callcontract the
300 // OpenAI-compatible and Anthropic APIs enforce:
301 // everyassistanttool_callsentrymustbeansweredbyafollowingtoolmessage for its id,
302 // andatoolmessagemustfollowsuch a call. It backfills a placeholder result for anyunanswered call
303 // (sotheturnstaysintact),
304 // dropsorphan tool messages,
305 // backfills empty tool-call names from their results (#4727 —
306 // oldsessionssavedbeforeadde2d3ecancarryanemptyname), and closes truncated call-argument
307 // JSON (DeepSeek 400s on replayed half-streamed args, #3953).
308 //
309 // This is the wire-safe entry point for provider requests. Stored session loadsuse
310 // NormalizeSessionMessagessotheycansharetheassistant-turnrepairswithoutdeletingstandalonetoolmessagesthatmustround-tripthroughreasonix
311 // --resume.
312 //
313 // A well-formed history — no unanswered calls, no orphan results, no empty tool-
314 // call names, no truncated args — returns the input slice unchanged (same backingarray, zero allocation).
315 // This keeps the prefix-cache key stable for healthysessions and makes repeated normalization cheap.
316 func NormalizeMessages(msgs []Message) []Message {
317 return normalizeMessages(msgs, true)
318 }
319
320 // NormalizeSessionMessages applies only repairs that are safe to persist in asaved session.
321 // Itsharesassistant-turn repairs with NormalizeMessages,
322 // butpreservesexistingtoolmessagesinsteadofdroppingorreordering them so
323 // Save/LoadSession remains a byte-for-byte conversation round trip for historiesthat were already on disk.
324 func NormalizeSessionMessages(msgs []Message) []Message {
325 return normalizeMessages(attachStandaloneDecisionReceipts(msgs), false)
326 }
327
328 // attachStandaloneDecisionReceipts migrates the short-lived receipt encodingthat stored a
329 // LocalOnlyassistantmessage between an assistant tool call andits result.
330 // Foldingthatmetadataintothelatestassistantmessagerepairsalready-writtensessionsbeforetool-pairnormalizationcanfabricateaplaceholder.
331 // Healthyhistoriesreturntheoriginalsliceunchanged.
332 func attachStandaloneDecisionReceipts(msgs []Message) []Message {
333 target := -1
334 needsMigration := false
335 for i, m := range msgs {
336 switch {
337 case m.Role == RoleUser && !m.LocalOnly:
338 target = -1
339 case m.Role == RoleAssistant && !m.LocalOnly:
340 target = i
341 case target >= 0 && m.LocalOnly && m.DecisionReceipt != nil:
342 needsMigration = true
343 }
344 if needsMigration {
345 break
346 }
347 }
348 if !needsMigration {
349 return msgs
350 }
351
352 out := make([]Message, 0, len(msgs))
353 target = -1
354 for _, m := range msgs {
355 switch {
356 case m.Role == RoleUser && !m.LocalOnly:
357 target = -1
358 case m.Role == RoleAssistant && !m.LocalOnly:
359 out = append(out, m)
360 target = len(out) - 1
361 continue
362 case target >= 0 && m.LocalOnly && m.DecisionReceipt != nil:
363 receipts := append([]*DecisionReceipt(nil), out[target].DecisionReceipts...)
364 out[target].DecisionReceipts = append(receipts, m.DecisionReceipt)
365 continue
366 }
367 out = append(out, m)
368 }
369 return out
370 }
371
372 func normalizeMessages(msgs []Message, dropOrphanTools bool) []Message {
373 if normalized, ok := tryNormalizeFastPath(msgs, dropOrphanTools); ok {
374 return normalized // well-formed: pass through without allocating
375 }
376 out := make([]Message, 0, len(msgs))
377 for i := 0; i < len(msgs); {
378 m := msgs[i]
379 if m.LocalOnly {
380 if !dropOrphanTools {
381 out = append(out, m)
382 }
383 i++
384 continue
385 }
386 if m.Role == RoleAssistant && len(m.ToolCalls) > 0 {
387 j := i + 1
388 for j < len(msgs) && msgs[j].Role == RoleTool && !msgs[j].LocalOnly {
389 j++
390 }
391 // Backfill empty tool-call names from the corresponding toolresults so the model sees which tool was invoked
392 // (#4727).
393 // The wire-format fix (openai.go) ensures empty fields arenever omitted, so this backfill is a
394 // UXimprovement, not acorrectness requirement.
395 calls := backfillToolCallNames(m.ToolCalls, msgs[i+1:j])
396 m.ToolCalls = calls
397 out = append(out, repairToolCallArgs(m))
398 if dropOrphanTools {
399 out = append(out, pairToolResults(calls, msgs[i+1:j])...)
400 } else {
401 out = append(out, sessionToolResults(calls, msgs[i+1:j])...)
402 }
403 i = j
404 continue
405 }
406 if m.Role == RoleTool {
407 if !dropOrphanTools {
408 out = append(out, m)
409 }
410 // Orphan tool message: provider sends drop it; session loads preserve it.
411 i++
412 continue
413 }
414 out = append(out, m)
415 i++
416 }
417 return out
418 }
419
420 // tryNormalizeFastPath reports whether msgs needs no repair and, if so,
421 // returnsitas-issothecallercanskipallocating. Healthy tool-call/tool-resultturns pass through unchanged;
422 // malformed turns take the slow path.
423 func tryNormalizeFastPath(msgs []Message, dropOrphanTools bool) ([]Message, bool) {
424 for i := 0; i < len(msgs); {
425 m := msgs[i]
426 if m.LocalOnly {
427 if dropOrphanTools {
428 return nil, false
429 }
430 i++
431 continue
432 }
433 if m.Role == RoleAssistant && len(m.ToolCalls) > 0 {
434 j := i + 1
435 for j < len(msgs) && msgs[j].Role == RoleTool && !msgs[j].LocalOnly {
436 j++
437 }
438 if !toolTurnWellFormed(m.ToolCalls, msgs[i+1:j]) || needsToolCallArgRepair(m.ToolCalls) {
439 return nil, false
440 }
441 i = j
442 continue
443 }
444 if m.Role == RoleTool && dropOrphanTools {
445 return nil, false
446 }
447 i++
448 }
449 return msgs, true
450 }
451
452 func toolTurnWellFormed(calls []ToolCall, results []Message) bool {
453 if len(calls) != len(results) {
454 return false
455 }
456 for _, tc := range calls {
457 if tc.Name == "" {
458 return false
459 }
460 }
461 for k, tc := range calls {
462 if results[k].ToolCallID != tc.ID {
463 return false
464 }
465 if results[k].Name != tc.Name {
466 return false
467 }
468 }
469 return true
470 }
471
472 func needsToolCallArgRepair(calls []ToolCall) bool {
473 for _, tc := range calls {
474 if tc.Arguments != "" && !json.Valid([]byte(tc.Arguments)) {
475 return true
476 }
477 }
478 return false
479 }
480
481 // repairToolCallArgs returns m with any undecodable tool-call Arguments closedinto valid JSON
482 // (copy-on-write; the caller's history is never mutated). Emptyarguments pass through — some gateways send
483 // "" for no-arg tools.
484 func repairToolCallArgs(m Message) Message {
485 broken := false
486 for _, tc := range m.ToolCalls {
487 if tc.Arguments != "" && !json.Valid([]byte(tc.Arguments)) {
488 broken = true
489 break
490 }
491 }
492 if !broken {
493 return m
494 }
495 calls := make([]ToolCall, len(m.ToolCalls))
496 copy(calls, m.ToolCalls)
497 for i := range calls {
498 if calls[i].Arguments == "" || json.Valid([]byte(calls[i].Arguments)) {
499 continue
500 }
501 calls[i].Arguments = closeTruncatedJSON(calls[i].Arguments)
502 }
503 m.ToolCalls = calls
504 return m
505 }
506
507 // closeTruncatedJSON best-effort completes a JSON document cut off mid-stream
508 // (unterminated string, open braces, dangling comma/colon); anything stillinvalid after closing degrades to
509 // "{}".
510 func closeTruncatedJSON(s string) string {
511 var stack []byte
512 inStr, esc := false, false
513 for i := range len(s) {
514 c := s[i]
515 if inStr {
516 switch {
517 case esc:
518 esc = false
519 case c == '\\':
520 esc = true
521 case c == '"':
522 inStr = false
523 }
524 continue
525 }
526 switch c {
527 case '"':
528 inStr = true
529 case '{':
530 stack = append(stack, '}')
531 case '[':
532 stack = append(stack, ']')
533 case '}', ']':
534 if len(stack) > 0 {
535 stack = stack[:len(stack)-1]
536 }
537 }
538 }
539 out := s
540 if esc {
541 out = out[:len(out)-1]
542 }
543 if inStr {
544 out += `"`
545 }
546 trimmed := strings.TrimRight(out, " \t\r\n")
547 switch {
548 case strings.HasSuffix(trimmed, ","):
549 out = trimmed[:len(trimmed)-1]
550 case strings.HasSuffix(trimmed, ":"):
551 out = trimmed + "null"
552 }
553 for _, v := range slices.Backward(stack) {
554 out += string(v)
555 }
556 if !json.Valid([]byte(out)) {
557 return "{}"
558 }
559 return out
560 }
561
562 // pairToolResults answers each tool_call with its result, backfilling aplaceholder for any unanswered one.
563 // Distinct non-empty ids pair by id (soreordered results re-sort to call order);
564 // emptyorduplicateidspairbyposition instead — some gatewaysstream tool calls by index with no id,
565 // andamapkeyed on id would collapse those results into one
566 // (callorder is preservedbecause the loop appendsresults in call order).
567 func pairToolResults(calls []ToolCall, avail []Message) []Message {
568 out := make([]Message, 0, len(calls))
569 if idDistinct(calls) {
570 byID := make(map[string]Message, len(avail))
571 for _, r := range avail {
572 byID[r.ToolCallID] = r
573 }
574 for _, tc := range calls {
575 if r, ok := byID[tc.ID]; ok {
576 r.Name = tc.Name
577 out = append(out, r)
578 } else {
579 out = append(out, Message{Role: RoleTool, ToolCallID: tc.ID, Name: tc.Name, Content: interruptedToolResult})
580 }
581 }
582 return out
583 }
584 for k, tc := range calls {
585 if k < len(avail) {
586 r := avail[k]
587 r.ToolCallID = tc.ID
588 r.Name = tc.Name
589 out = append(out, r)
590 } else {
591 out = append(out, Message{Role: RoleTool, ToolCallID: tc.ID, Name: tc.Name, Content: interruptedToolResult})
592 }
593 }
594 return out
595 }
596
597 // sessionToolResultspreserveseverystoredtoolresultandappendsplaceholdersonlyforcallsthathavenorecordedanswer.
598 // Load-timenormalizationmustnotdrop or reorder user history;
599 // providersendscanstillusepairToolResultsforstrictwireformatting.
600 func sessionToolResults(calls []ToolCall, avail []Message) []Message {
601 out := append([]Message(nil), avail...)
602 if idDistinct(calls) {
603 answered := make(map[string]struct{}, len(avail))
604 for _, r := range avail {
605 answered[r.ToolCallID] = struct{}{}
606 }
607 for _, tc := range calls {
608 if _, ok := answered[tc.ID]; !ok {
609 out = append(out, Message{Role: RoleTool, ToolCallID: tc.ID, Name: tc.Name, Content: interruptedToolResult})
610 }
611 }
612 return out
613 }
614 for k := len(avail); k < len(calls); k++ {
615 tc := calls[k]
616 out = append(out, Message{Role: RoleTool, ToolCallID: tc.ID, Name: tc.Name, Content: interruptedToolResult})
617 }
618 return out
619 }
620
621 // backfillToolCallNames returns calls with any empty Name filled in from thematching tool result (by id,
622 // then by position). Old sessions (#4727) may havesaved assistant tool-calls with an empty name;
623 // backfillinggives the modeluseful context during replay.
624 // The common case (no empty names) returns theinput unchanged without allocating.
625 // Unpairedcallskeeptheirempty name,
626 // which the wire-format fix (openai.go) handles gracefully.
627 func backfillToolCallNames(calls []ToolCall, results []Message) []ToolCall {
628 missing := false
629 for _, c := range calls {
630 if c.Name == "" {
631 missing = true
632 break
633 }
634 }
635 if !missing {
636 return calls
637 }
638 out := make([]ToolCall, len(calls))
639 copy(out, calls)
640 if idDistinct(calls) {
641 byID := make(map[string]string, len(results))
642 for _, r := range results {
643 if r.Name != "" {
644 byID[r.ToolCallID] = r.Name
645 }
646 }
647 for k := range out {
648 if out[k].Name == "" {
649 if n, ok := byID[out[k].ID]; ok {
650 out[k].Name = n
651 }
652 }
653 }
654 return out
655 }
656 // Fallback: positional pairing (same order as pairToolResults).
657 for k := range out {
658 if out[k].Name == "" && k < len(results) {
659 out[k].Name = results[k].Name
660 }
661 }
662 return out
663 }
664
665 // idDistinct reports whether every call carries a non-empty id unique within thebatch —
666 // theconditionunderwhich id-keyed pairing is safe.
667 func idDistinct(calls []ToolCall) bool {
668 seen := make(map[string]struct{}, len(calls))
669 for _, tc := range calls {
670 if tc.ID == "" {
671 return false
672 }
673 if _, dup := seen[tc.ID]; dup {
674 return false
675 }
676 seen[tc.ID] = struct{}{}
677 }
678 return true
679 }
680
681 // ChunkType identifies the kind of a streamed increment.
682 type ChunkType int
683
684 const (
685 ChunkText ChunkType = iota // text delta
686 ChunkReasoning // thinking-mode reasoning delta (before the visible answer)
687 ChunkToolCallStart // a tool call has begun (ToolCall: ID+Name; args still streaming)
688 ChunkToolCallArgsDelta // progress while a call's arguments stream (ToolCall: ID+Name; ArgChars: cumulative)
689 ChunkToolCall // one complete tool call
690 ChunkUsage // token usage for the completion
691 ChunkDone // completion finished normally
692 ChunkError // an error occurred
693 ChunkResponsesItem // a complete provider-issued Responses API output item for stateless replay
694 ChunkServerSearch // provider-executed web_search; not a client tool call
695 )
696
697 // Usage reports token accounting for a completion. Cache hit/miss come fromeither
698 // DeepSeek'stop-levelprompt_cache_{hit,miss}_tokens or the
699 // OpenAI/MiMostandardprompt_tokens_details.cached_tokens —
700 // theopenaiprovidernormalisesbothshapesintothesefields.
701 // ReasoningTokens is the thinking-mode subset of
702 // CompletionTokens reported by thinking-capable models.
703 // FinishReasoncarriesthemodel'slastreportedchoices[0].finish_reason sotheagentcansurfaceabnormalterminations
704 // ("length", "content_filter", "repetition_truncation").
705 // Estimated marks counts reconstructed locally because the provider's terminalusage record did not arrive;
706 // exact provider usage leaves it false.
707 type Usage struct {
708 Unknown bool `json:"unknown,omitempty"` // at least one request had no provider usage
709 PromptTokens int
710 CompletionTokens int
711 TotalTokens int
712 CacheHitTokens int // prompt tokens served from cache
713 CacheMissTokens int // prompt tokens not cached, including CacheWriteTokens
714 CacheWriteTokens int // subset of CacheMissTokens used to create provider cache entries
715 CacheWriteBilledTokens float64 // cache-write charge expressed in ordinary input-token equivalents
716 ReasoningTokens int // subset of CompletionTokens spent on chain-of-thought
717 FinishReason string // "stop", "tool_calls", "length", "content_filter", "repetition_truncation", …
718 Estimated bool
719 // RequestCount is the number of provider requests represented by thisaggregate.
720 // Zeromeansonerequestforbackward compatibility. Recoverypaths that merge multiple attempts settheexactcount.
721 RequestCount int
722 // Context* fields describe the latest single-request shape for contextgauges and rebind telemetry. Whenzero,
723 // consumers fall back to thebillable Prompt/Completion/… fields. Multi-attempt sampling recoverysets
724 // PromptTokens (etc.) tothebillable aggregate and fills Context*
725 // from the final attempt only.
726 ContextPromptTokens int
727 ContextCompletionTokens int
728 ContextReasoningTokens int
729 ContextCacheHitTokens int
730 ContextCacheMissTokens int
731 }
732
733 // ContextFillTokens returns the latest prompt occupancy used by context gauges.
734 func (u *Usage) ContextFillTokens() int {
735 return u.LatestPromptTokens()
736 }
737
738 // LatestPromptTokens returns the latest-attempt prompt size for context-awareruntime decisions. Falls backto
739 // PromptTokens for single-attempt legacy usage.
740 func (u *Usage) LatestPromptTokens() int {
741 if u == nil {
742 return 0
743 }
744 if u.ContextPromptTokens > 0 {
745 return u.ContextPromptTokens
746 }
747 return u.PromptTokens
748 }
749
750 // Pricing is a provider's per-1M-token rates, used to estimate spend. Currencyis a display symbol or
751 // ISO-like code (default "¥"). toml tags let config decode it.
752 type Pricing struct {
753 CacheHit float64 `toml:"cache_hit"` // per 1M cached prompt tokens
754 Input float64 `toml:"input"` // per 1M uncached prompt tokens
755 Output float64 `toml:"output"` // per 1M completion tokens
756 Currency string `toml:"currency"`
757 }
758
759 // Cost estimates the spend for a usage record. Compatibility adapter only —
760 // new host code must consume billing.CostQuote instead of aggregating floats.
761 func (p *Pricing) Cost(u *Usage) float64 {
762 if p == nil || u == nil {
763 return 0
764 }
765 // Keepthehistoricalfloatpathbyte-stableforteststhatassertexactfloatresultswithoutgoingthroughthefixed-pointquotelayer.
766 hit := u.CacheHitTokens
767 miss := u.CacheMissTokens
768 if hit+miss == 0 && u.PromptTokens > 0 {
769 miss = u.PromptTokens
770 } else if miss == 0 && hit > 0 && u.PromptTokens > hit {
771 miss = u.PromptTokens - hit
772 }
773 // CacheMissTokens intentionally remains the raw prompt-token denominatorused by cache hit-rate displays,
774 // socache writes are included there. Forcost, split those writes back out and replace themwiththeirprovider-
775 // supplied input-token equivalent (for example Anthropic's 1.25x 5-minutewrites or 2x 1-hour writes).
776 // Olderproviders leave both fields at zero andkeep the legacy one-input-rate behavior.
777 // Awritecountwithoutbilledunits also falls back to 1xforbackward compatibility.
778 write := min(max(u.CacheWriteTokens, 0), miss)
779 billedWrite := 0.0
780 if write > 0 {
781 billedWrite = u.CacheWriteBilledTokens
782 if billedWrite <= 0 {
783 billedWrite = float64(write)
784 }
785 }
786 inputTokenUnits := float64(miss-write) + billedWrite
787 return (float64(hit)*p.CacheHit +
788 inputTokenUnits*p.Input +
789 float64(u.CompletionTokens)*p.Output) / 1e6
790 }
791
792 // Symbol returns the currency display symbol, defaulting to "¥".
793 func (p *Pricing) Symbol() string {
794 if p == nil || p.Currency == "" {
795 return "¥"
796 }
797 return currencySymbol(p.Currency)
798 }
799
800 func currencySymbol(currency string) string {
801 value := strings.TrimSpace(currency)
802 if value == "" {
803 return "¥"
804 }
805 switch strings.ToLower(value) {
806 case "cny", "rmb", "yuan", "renminbi", "cnh":
807 return "¥"
808 case "usd", "dollar", "dollars", "us dollar", "us dollars", "us$":
809 return "$"
810 case "eur", "euro", "euros":
811 return "€"
812 case "gbp", "pound", "pounds", "sterling":
813 return "£"
814 case "jpy", "yen":
815 return "¥"
816 }
817 switch value {
818 case "¥", "¥":
819 return "¥"
820 case "$", "€", "£":
821 return value
822 }
823 // any embedded currency sign → keep as-is (compact symbols like A$, HK$).
824 for _, r := range value {
825 if unicode.Is(unicode.Sc, r) {
826 return value
827 }
828 }
829 if isThreeLetterCurrencyCode(value) {
830 return strings.ToUpper(value) + " "
831 }
832 return "¥"
833 }
834
835 func isThreeLetterCurrencyCode(value string) bool {
836 if len(value) != 3 {
837 return false
838 }
839 for _, r := range value {
840 if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') {
841 return false
842 }
843 }
844 return true
845 }
846
847 // Chunk is a single streamed event. Read the field matching Type.
848 type Chunk struct {
849 ThinkingBlock *ThinkingBlock
850 ReasoningState ReasoningState
851 Type ChunkType
852 Text string // ChunkText, ChunkReasoning
853 Signature string // ChunkReasoning: opaque proof for the reasoning (Anthropic thinking signature), when issued
854 // ReasoningID/ReasoningStatus ride the final ChunkReasoning of a turn
855 // (empty Text): the provider-issued reasoning item id/status capturedfrom the SSE stream, so the
856 // Agentcanpersist them into the sessionand the next turn's input reasoning item round-trips them (review
857 // #7234 — OpenAI Responses schema marks Reasoning.id required).
858 ReasoningID string // ChunkReasoning: provider-issued reasoning item id
859 ReasoningStatus string // ChunkReasoning: final reasoning item status ("completed")
860 ToolCall *ToolCall // ChunkToolCallStart (ID+Name only), ChunkToolCallArgsDelta (ID+Name), ChunkToolCall (complete)
861 ArgChars int // ChunkToolCallArgsDelta: cumulative argument characters received for this call
862 ResponsesItem json.RawMessage // ChunkResponsesItem: opaque validated Responses API output item
863 ServerSearch *ServerSearchCall // ChunkServerSearch: display card + replay payload
864 Usage *Usage // ChunkUsage
865 Err error // ChunkError
866 }
867
868 // Fixed stream-interrupt reasons for observability. Values are a closed enumand must never carry URLs,
869 // toolarguments, file paths, or raw error text.
870 const (
871 StreamInterruptConnectionReset = "connection_reset"
872 StreamInterruptPrematureEOF = "premature_eof"
873 StreamInterruptIdleTimeout = "idle_timeout"
874 )
875
876 // StreamInterruptedErrormarksthatthecurrentsamplingattemptneverreachedacleanproviderterminaleventandisthereforeuncommitted.
877 // The
878 // Agentmayreplay the exact same provider request. Providers must notperformbody-phaserequestreplaythemselves
879 // —
880 // that lives at the Agent layer so retry budgets,
881 // UI rollback, and tool execution stay single-owner. context.Canceled, auth,
882 // 4xx/schema errors, and unparseable complete protocol payloads must not usethis type.
883 type StreamInterruptedError struct {
884 Err error
885 Reason string // one of the StreamInterrupt* constants; may be empty for older callers
886 }
887
888 func (e *StreamInterruptedError) Error() string {
889 if e == nil || e.Err == nil {
890 return "stream interrupted"
891 }
892 return e.Err.Error()
893 }
894
895 func (e *StreamInterruptedError) Unwrap() error {
896 if e == nil {
897 return nil
898 }
899 return e.Err
900 }
901
902 // StreamInterrupt wraps err as a StreamInterruptedError with a fixed reason.
903 func StreamInterrupt(err error, reason string) error {
904 if err == nil {
905 return nil
906 }
907 return &StreamInterruptedError{Err: err, Reason: reason}
908 }
909
910 // StreamInterruptReason returns the fixed reason when err is a streaminterruption, or empty otherwise.
911 func StreamInterruptReason(err error) string {
912 var interrupted *StreamInterruptedError
913 if !errors.As(err, &interrupted) || interrupted == nil {
914 return ""
915 }
916 if interrupted.Reason != "" {
917 return interrupted.Reason
918 }
919 return ClassifyStreamInterrupt(interrupted.Err)
920 }
921
922 // ClassifyStreamInterrupt maps a transport error onto a fixed reason enum.
923 // Prefer attaching Reason at the emit site; this is a best-effort fallback.
924 func ClassifyStreamInterrupt(err error) string {
925 if err == nil {
926 return StreamInterruptPrematureEOF
927 }
928 msg := strings.ToLower(err.Error())
929 switch {
930 case strings.Contains(msg, "stalled") || strings.Contains(msg, "idle timeout") || strings.Contains(msg, "no data for"):
931 return StreamInterruptIdleTimeout
932 case errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) || strings.Contains(msg, "before completion") || strings.Contains(msg, "unexpected eof"):
933 return StreamInterruptPrematureEOF
934 case errors.Is(err, net.ErrClosed) || errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.ECONNABORTED) ||
935 strings.Contains(msg, "connection reset") || strings.Contains(msg, "forcibly closed") || strings.Contains(msg, "broken pipe"):
936 return StreamInterruptConnectionReset
937 default:
938 if IsConnReset(err) {
939 return StreamInterruptConnectionReset
940 }
941 return StreamInterruptPrematureEOF
942 }
943 }
944
945 func IsStreamInterrupted(err error) bool {
946 var interrupted *StreamInterruptedError
947 return errors.As(err, &interrupted)
948 }
949
950 // Provider is a chat-capable model backend.
951 type Provider interface {
952 // Name returns the provider instance name, e.g. "deepseek" / "mimo".
953 Name() string
954 // Stream starts a streaming completion, pushing increments on the channel.
955 // Cancelling ctx must abort the underlying request; a closed channel marksthe end of the completion.
956 Stream(ctx context.Context, req Request) (<-chan Chunk, error)
957 }
958
959 // ToolCallReasoningPolicyisoptionallyimplementedbyproviderswhoseprotocolreplaystheprovider-issuedreasoningblockonassistanttool_callsturns
960 // (DeepSeek thinking mode). The agent uses it to archive the original reasoningtext on those turns
961 // (adisplay-translated copy must not round-trip to the
962 // API) and to detect turns that arrive with none.
963 // Whetheranexplicitemptyvalueisavalidfinalfallbackisaseparate protocol capability.
964 // Mostprovidersleavethisunset; callers must treat it as false.
965 type ToolCallReasoningPolicy interface {
966 RequiresToolCallReasoning() bool
967 }
968
969 // RequiresToolCallReasoningreportswhetherpreplaysreasoning_contentonassistanttool_callsturnssentbackinhistory.
970 func RequiresToolCallReasoning(p Provider) bool {
971 if nilutil.IsNil(p) {
972 return false
973 }
974 policy, ok := p.(ToolCallReasoningPolicy)
975 return ok && policy.RequiresToolCallReasoning()
976 }
977
978 // ReasoningRoundTripPolicyisoptionallyimplementedbyprovidersthatrequireeveryassistantmessagetopreserveprovider-issuedreasoninginlaterrequests.
979 // This is broader than ToolCallReasoningPolicy, which covers onlyassistant tool_calls turns.
980 type ReasoningRoundTripPolicy interface {
981 RequiresReasoningRoundTrip() bool
982 }
983
984 // RequiresReasoningRoundTripreportswhetherrawproviderreasoningmustberetainedandreplayedonallassistantmessages.
985 func RequiresReasoningRoundTrip(p Provider) bool {
986 if nilutil.IsNil(p) {
987 return false
988 }
989 policy, ok := p.(ReasoningRoundTripPolicy)
990 return ok && policy.RequiresReasoningRoundTrip()
991 }
992
993 // MissingToolCallReasoningWarningPolicyisoptionallyimplementedbyproviderswhosereplayprotocolrequiresreasoning_content,
994 // but whose active model maynot reliably emit it. The legacy Warning name is retainedforsourcecompatibility;
995 // theagentnowusesthispolicy for silent bounded recovery andemits no user-visible protocol notice.
996 type MissingToolCallReasoningWarningPolicy interface {
997 WarnOnMissingToolCallReasoning() bool
998 }
999
1000 // MissingToolCallReasoningWarningIdentityPolicy optionally supplies the stable,
1001 // non-credential configuration identity used to rate-limit missing-reasoningrecovery attempts.
1002 // Thelegacynamepreserves adapters and persisted state.
1003 // Implementations may include adapter kind, endpoint, model, and thinkingcontrols;
1004 // therawidentityneverleavesmemory and is hashed beforepersistence.
1005 type MissingToolCallReasoningWarningIdentityPolicy interface {
1006 MissingToolCallReasoningWarningIdentity() string
1007 }
1008
1009 // WarnOnMissingToolCallReasoningreportswhetheratool_callsturnwithemptyreasoning_contentshouldentersilentrecovery.
1010 // Its legacy name ispreservedfor provider implementations compiled against the original diagnostic API.
1011 func WarnOnMissingToolCallReasoning(p Provider) bool {
1012 if nilutil.IsNil(p) {
1013 return false
1014 }
1015 policy, ok := p.(MissingToolCallReasoningWarningPolicy)
1016 if ok {
1017 return policy.WarnOnMissingToolCallReasoning()
1018 }
1019 return RequiresToolCallReasoning(p)
1020 }
1021
1022 // MissingToolCallReasoningWarningFingerprintreturnsanopaquestablekeyforoneproviderconfiguration'srecoverycooldown.
1023 // Concrete adapters distinguishendpoint/model/protocol changes;
1024 // providerswithouttheoptionalpolicyretainasafetype-and-namefallback.
1025 // The legacy name preserves the on-disk statecontract.
1026 // Thedigestpreventslocalstatefromexposingrawendpointsormodel identifiers.
1027 func MissingToolCallReasoningWarningFingerprint(p Provider) string {
1028 if nilutil.IsNil(p) {
1029 return ""
1030 }
1031 identity := fmt.Sprintf("%T\x00%s", p, strings.TrimSpace(p.Name()))
1032 if policy, ok := p.(MissingToolCallReasoningWarningIdentityPolicy); ok {
1033 if configured := strings.TrimSpace(policy.MissingToolCallReasoningWarningIdentity()); configured != "" {
1034 identity = configured
1035 }
1036 }
1037 digest := sha256.Sum256([]byte(identity))
1038 return hex.EncodeToString(digest[:])
1039 }
1040
1041 // Config is a resolved provider instance configuration.
1042 type Config struct {
1043 // HTTPClient supplies immutable credential-proxy transport without changing serialization or vendor identity.
1044 HTTPClient *http.Client
1045 Name string // stable instance id, e.g. "deepseek-anthropic"
1046 DisplayName string // user-editable label; empty falls back to Name
1047 Protocol string // configured wire adapter id
1048 BaseURL string // OpenAI-compatible endpoint
1049 Model string // model id
1050 APIKey string // resolved from api_key_env
1051 Extra map[string]any // kind-specific options
1052 // ModelInfo is adapter-owned metadata for the exact model instance. It is
1053 // optional so existing third-party factories remain source-compatible.
1054 ModelInfo *ModelInfo
1055 }
1056
1057 // Factory builds a Provider from a resolved Config.
1058 type Factory func(cfg Config) (Provider, error)
1059
1060 var registry = map[string]Factory{}
1061
1062 // Register adds a factory under a kind (e.g. "openai"). Intended for init().
1063 // It panics on a duplicate kind, since that is a compile-time wiring mistake.
1064 func Register(kind string, f Factory) {
1065 if _, dup := registry[kind]; dup {
1066 panic("provider: duplicate kind " + kind)
1067 }
1068 registry[kind] = f
1069 }
1070
1071 // New instantiates the provider of the given kind.
1072 func New(kind string, cfg Config) (Provider, error) {
1073 f, ok := registry[kind]
1074 if !ok {
1075 return nil, fmt.Errorf("provider: unknown kind %q (registered: %v)", kind, Kinds())
1076 }
1077 p, err := f(cfg)
1078 if err != nil {
1079 return nil, err
1080 }
1081 if nilutil.IsNil(p) {
1082 return nil, fmt.Errorf("provider: factory %q returned nil provider", kind)
1083 }
1084 return p, nil
1085 }
1086
1087 // Kinds returns the registered kinds, sorted.
1088 func Kinds() []string {
1089 out := make([]string, 0, len(registry))
1090 for k := range registry {
1091 out = append(out, k)
1092 }
1093 sort.Strings(out)
1094 return out
1095 }
1096
1096 lines GO