返回 DeepSeek-Reasonix
provider.go
根目录 / internal / provider / provider.go
1 // Package provider defines the model-backend abstraction and a registry mapping
2 // a provider "kind" to a factory. Concrete implementations live in subpackages
3 // (e.g. provider/openai) and self-register via init(). The core resolves
4 // providers by kind from config and never hardcodes 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 "sort"
17 "strings"
18 "syscall"
19 "unicode"
20
21 "reasonix/internal/nilutil"
22 )
23
24 // Role is the role of a message.
25 type Role string
26
27 const (
28 RoleSystem Role = "system"
29 RoleUser Role = "user"
30 RoleAssistant Role = "assistant"
31 RoleTool Role = "tool"
32 )
33
34 // LocalOnlyToolName/ID make display-only records safe when a newer transcript
35 // is opened by an older Reasonix binary that does not know Message.LocalOnly.
36 // Old wire normalization treats this unmatched tool result as an orphan and
37 // drops it instead of replaying partial content to the model.
38 const (
39 LocalOnlyToolName = "__reasonix_local_only__"
40 LocalOnlyToolID = "__reasonix_local_only__"
41 )
42
43 // Message is a single conversation message.
44 type Message struct {
45 Role Role `json:"role"`
46 // Content is the provider-visible conversation content. Keeping this legacy
47 // field provider-visible preserves replay for older CLI/Desktop releases.
48 Content string `json:"content,omitempty"`
49 // RawContent is the user-authored form of a user turn, when it differs from
50 // Content because the host added transient context. Older releases ignore
51 // this field and still replay the provider-visible Content safely.
52 RawContent string `json:"raw_content,omitempty"`
53 // ProviderContent is a transitional field written by early Context Engine v2
54 // builds. Loaders migrate it into Content/RawContent before normal use.
55 ProviderContent string `json:"provider_content,omitempty"`
56 Images []string `json:"images,omitempty"` // data URLs (data:<mime>;base64,…) on user (attachments) and tool (MCP image results) messages; embedded only for vision-capable models
57 ReasoningContent string `json:"reasoning_content,omitempty"` // assistant: thinking-mode chain-of-thought, round-tripped on multi-turn
58 // ReasoningID is the provider-issued identifier of the reasoning item
59 // (OpenAI Responses schema: Reasoning.id is required on input items).
60 // Captured from the streamed output item and round-tripped back into
61 // the input on subsequent turns, matching the wire schema.
62 ReasoningID string `json:"reasoning_id,omitempty"`
63 // ReasoningStatus is the final status of the reasoning item
64 // ("in_progress" | "completed") as issued by the server's done event,
65 // round-tripped back into the input alongside ReasoningID.
66 ReasoningStatus string `json:"reasoning_status,omitempty"`
67 // ReasoningSignature is an opaque, provider-issued proof that ReasoningContent
68 // is genuine model output. Anthropic requires the signed thinking block be
69 // replayed on the next turn when a tool call followed thinking; providers
70 // without signed reasoning (e.g. the openai-compatible ones) leave it empty.
71 // Round-tripped alongside ReasoningContent.
72 ReasoningSignature string `json:"reasoning_signature,omitempty"`
73 ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set by assistant
74 // ResponsesItems preserves provider-issued Responses API output items that
75 // must be replayed on a stateless follow-up. Today only DeepSeek
76 // web_search_call items use this path; other providers ignore the field.
77 // Keeping the opaque JSON on the assistant turn makes resume/restart safe,
78 // while omitempty keeps old session files byte-compatible when unused.
79 ResponsesItems []json.RawMessage `json:"responses_items,omitempty"`
80 ToolCallID string `json:"tool_call_id,omitempty"` // links a tool result to its call
81 Name string `json:"name,omitempty"` // tool message: tool name
82 MemoryCitations []MemoryCitation `json:"memoryCitations,omitempty"` // local UI metadata; provider requests ignore it
83 WorkDurationMs int64 `json:"workDurationMs,omitempty"` // local UI metadata; provider requests ignore it
84 CreatedAt int64 `json:"createdAt,omitempty"` // local UI metadata; unix milliseconds; stripped before provider requests
85 Edited bool `json:"edited,omitempty"` // local UI metadata; provider requests ignore it
86 Original string `json:"original,omitempty"` // user prompt before inline edit
87 // LocalOnly marks durable transcript content that must never be sent to a
88 // model provider. Interrupted streaming output uses it so every frontend can
89 // replay what the user saw without feeding partial reasoning or tool-call
90 // arguments back into the next request.
91 LocalOnly bool `json:"local_only,omitempty"`
92 DecisionReceipt *DecisionReceipt `json:"decision_receipt,omitempty"`
93 // DecisionReceipts are local-only metadata attached to a provider-visible
94 // message. Keeping them on the existing assistant record preserves the
95 // assistant/tool-result adjacency required by current and older readers.
96 // ModelMessages strips the field before handing requests to providers.
97 DecisionReceipts []*DecisionReceipt `json:"decision_receipts,omitempty"`
98 InterruptedTurn *InterruptedTurnRecovery `json:"interrupted_turn,omitempty"`
99 // ToolExecution is local shell UI metadata on tool-result messages. It is
100 // persisted for Desktop/CLI/Serve cards and stripped by ModelMessages before
101 // any provider request so tool schemas and prompt-cache prefixes stay stable.
102 ToolExecution *ToolExecution `json:"tool_execution,omitempty"`
103 }
104
105 // ToolExecution is host-local shell metadata mirrored from tool.ShellExecution.
106 // Provider serializers must never emit this object on the wire.
107 type ToolExecution struct {
108 Kind string `json:"kind,omitempty"`
109 Shell string `json:"shell,omitempty"`
110 ShellVersion string `json:"shellVersion,omitempty"`
111 Platform string `json:"platform,omitempty"`
112 SupportsAndAnd bool `json:"supportsAndAnd"`
113 State string `json:"state,omitempty"`
114 FailurePhase string `json:"failurePhase,omitempty"`
115 ExitCode *int `json:"exitCode,omitempty"`
116 OutputTail string `json:"outputTail,omitempty"`
117 MutationRisk string `json:"mutationRisk,omitempty"`
118 Verification string `json:"verification,omitempty"`
119 DurationMs int64 `json:"durationMs,omitempty"`
120 }
121
122 // DecisionReceipt is durable, provider-excluded evidence of a user-owned
123 // approval decision. It intentionally contains only bounded labels and the
124 // outcome, never free-form guidance or provider-visible content.
125 type DecisionReceipt struct {
126 ID string `json:"id"`
127 Kind string `json:"kind"`
128 Tool string `json:"tool,omitempty"`
129 Subject string `json:"subject,omitempty"`
130 Outcome string `json:"outcome"`
131 }
132
133 // InterruptedTurnRecovery is the durable, provider-excluded handoff for a turn
134 // that stopped before producing a clean final answer. It contains only bounded
135 // structural facts; raw partial reasoning remains on the LocalOnly Message for
136 // display and is never copied into the recovery prompt.
137 type InterruptedTurnRecovery struct {
138 Pending bool `json:"pending,omitempty"`
139 CompletedTools []InterruptedToolSummary `json:"completed_tools,omitempty"`
140 InterruptedTools []string `json:"interrupted_tools,omitempty"`
141 DroppedPartialText bool `json:"dropped_partial_text,omitempty"`
142 DroppedPartialReasoning bool `json:"dropped_partial_reasoning,omitempty"`
143 }
144
145 // InterruptedToolSummary records a completed, fully paired tool call without
146 // duplicating its arguments or result. The canonical assistant/tool messages
147 // immediately before the recovery record remain the source of truth.
148 type InterruptedToolSummary struct {
149 ID string `json:"id,omitempty"`
150 Name string `json:"name"`
151 Files []string `json:"files,omitempty"`
152 Added int `json:"added,omitempty"`
153 Removed int `json:"removed,omitempty"`
154 }
155
156 // MemoryCitation is local display metadata for memories that influenced an
157 // assistant turn. Provider implementations must not forward it to model APIs.
158 type MemoryCitation struct {
159 ID string `json:"id,omitempty"`
160 Source string `json:"source"`
161 LineStart int `json:"lineStart,omitempty"`
162 LineEnd int `json:"lineEnd,omitempty"`
163 Note string `json:"note,omitempty"`
164 Kind string `json:"kind,omitempty"`
165 }
166
167 // ParseImageDataURL splits a `data:<media-type>;base64,<payload>` URL into its
168 // media type and base64 payload. ok is false for anything that isn't a base64
169 // data URL — providers that need the split (Anthropic) skip those silently.
170 func ParseImageDataURL(dataURL string) (mediaType, base64Data string, ok bool) {
171 rest, found := strings.CutPrefix(dataURL, "data:")
172 if !found {
173 return "", "", false
174 }
175 meta, payload, found := strings.Cut(rest, ",")
176 if !found {
177 return "", "", false
178 }
179 mt, found := strings.CutSuffix(meta, ";base64")
180 if !found || mt == "" {
181 return "", "", false
182 }
183 return mt, payload, true
184 }
185
186 // ToolCall is a tool invocation requested by the model. Arguments is raw JSON.
187 type ToolCall struct {
188 ID string `json:"id"`
189 Name string `json:"name"`
190 Arguments string `json:"arguments"`
191 // ThoughtSignature is an opaque Gemini-issued proof attached to a function
192 // call. OpenAI-compatible Gemini endpoints require it on message replay.
193 ThoughtSignature string `json:"thought_signature,omitempty"`
194 Diff string `json:"diff,omitempty"`
195 Added int `json:"added,omitempty"`
196 Removed int `json:"removed,omitempty"`
197 // Resolved* fields are Reasonix-local display metadata for stable proxy
198 // calls such as use_capability. Provider request builders deliberately
199 // serialize only provider-visible fields, so these values never alter the
200 // provider-visible conversation or prompt-cache prefix.
201 ResolvedName string `json:"resolved_name,omitempty"`
202 CapabilityID string `json:"capability_id,omitempty"`
203 ResolvedReadOnly *bool `json:"resolved_read_only,omitempty"`
204 }
205
206 // ToolSchema is a tool definition exposed to the model. Parameters is JSON Schema.
207 type ToolSchema struct {
208 Name string `json:"name"`
209 Description string `json:"description"`
210 Parameters json.RawMessage `json:"parameters"`
211 }
212
213 // Request is a single completion request.
214 type Request struct {
215 Messages []Message
216 Tools []ToolSchema
217 Temperature *float64 // nil = omit; non-nil = send the value, including 0
218 MaxTokens int
219 // ResponseFormat, when non-nil, asks the endpoint for structured JSON
220 // output (Responses: text.format.type=json_object). Nil omits the field
221 // entirely — the common path must stay byte-stable for prompt caching.
222 ResponseFormat *ResponseFormat `json:"ResponseFormat,omitempty"`
223 }
224
225 // ResponseFormat asks a provider to constrain its output shape.
226 type ResponseFormat struct {
227 // Type is the structured format: "json_object" is the only shape the
228 // Responses endpoints currently define (MiMo/DashScope/OpenAI).
229 Type string `json:"type"`
230 }
231
232 // DefaultReasoningOutputTokens is the conservative provider-side budget used
233 // for official reasoning APIs whose documented contract safely accepts 32K.
234 // Unknown compatible gateways must opt in through configuration instead of
235 // inheriting this value merely because they implement an OpenAI-shaped wire.
236 const DefaultReasoningOutputTokens = 32 * 1024
237
238 // TemperaturePtr wraps v in a pointer so callers that explicitly want a
239 // specific temperature, including 0 for deterministic output, can distinguish
240 // that intent from "not set, use the provider default".
241 func TemperaturePtr(v float64) *float64 { return &v }
242
243 // OptionalTemperature returns nil when v is zero, matching the historical
244 // config behavior where 0 meant "not configured", and a pointer otherwise.
245 func OptionalTemperature(v float64) *float64 {
246 if v == 0 {
247 return nil
248 }
249 return &v
250 }
251
252 // interruptedToolResult stands in for a tool result that never landed — an
253 // assistant tool_calls turn whose execution was cut short (interrupt, crash) and
254 // later resumed. Sending such a turn unanswered trips the OpenAI/DeepSeek 400
255 // "An assistant message with 'tool_calls' must be followed by tool messages
256 // responding to each 'tool_call_id'".
257 const interruptedToolResult = "[no result: the previous turn was interrupted before this tool call completed]"
258
259 // SanitizeToolPairing is the provider-side alias for NormalizeMessages. It repairs
260 // a history so it satisfies the tool-call contract the OpenAI-compatible and
261 // Anthropic APIs enforce (every assistant tool_calls answered, no orphan tool
262 // messages, truncated args closed) right before sending it to the wire — without
263 // touching the stored session. Kept as a distinct name so call sites read as
264 // "defensive wire prep" rather than "session mutation".
265 func SanitizeToolPairing(msgs []Message) []Message { return NormalizeMessages(msgs) }
266
267 // ModelMessages removes durable display-only records before a request is
268 // handed to any provider. Healthy sessions without such records keep their
269 // original backing slice, preserving the allocation and prompt-cache fast path.
270 func ModelMessages(msgs []Message) []Message {
271 needsCopy := false
272 for _, m := range msgs {
273 if m.LocalOnly || m.RawContent != "" || m.ProviderContent != "" || m.DecisionReceipt != nil || len(m.DecisionReceipts) > 0 || m.ToolExecution != nil {
274 needsCopy = true
275 break
276 }
277 }
278 if !needsCopy {
279 return msgs
280 }
281 out := make([]Message, 0, len(msgs))
282 for _, candidate := range msgs {
283 if candidate.LocalOnly {
284 continue
285 }
286 if candidate.ProviderContent != "" {
287 candidate.Content = candidate.ProviderContent
288 candidate.ProviderContent = ""
289 }
290 candidate.RawContent = ""
291 candidate.DecisionReceipt = nil
292 candidate.DecisionReceipts = nil
293 // Local shell metadata must never enter provider request bytes.
294 candidate.ToolExecution = nil
295 out = append(out, candidate)
296 }
297 return out
298 }
299
300 // NormalizeMessages repairs a conversation history so it satisfies the tool-call
301 // contract the OpenAI-compatible and Anthropic APIs enforce: every assistant
302 // tool_calls entry must be answered by a following tool message for its id, and a
303 // tool message must follow such a call. It backfills a placeholder result for any
304 // unanswered call (so the turn stays intact), drops orphan tool messages,
305 // backfills empty tool-call names from their results (#4727 — old sessions saved
306 // before adde2d3e can carry an empty name), 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 loads
310 // use NormalizeSessionMessages so they can share the assistant-turn repairs
311 // without deleting standalone tool messages that must round-trip through
312 // reasonix --resume.
313 //
314 // A well-formed history — no unanswered calls, no orphan results, no empty tool-
315 // call names, no truncated args — returns the input slice unchanged (same backing
316 // array, zero allocation). This keeps the prefix-cache key stable for healthy
317 // sessions and makes repeated normalization cheap.
318 func NormalizeMessages(msgs []Message) []Message {
319 return normalizeMessages(msgs, true)
320 }
321
322 // NormalizeSessionMessages applies only repairs that are safe to persist in a
323 // saved session. It shares assistant-turn repairs with NormalizeMessages, but
324 // preserves existing tool messages instead of dropping or reordering them so
325 // Save/LoadSession remains a byte-for-byte conversation round trip for histories
326 // that were already on disk.
327 func NormalizeSessionMessages(msgs []Message) []Message {
328 return normalizeMessages(attachStandaloneDecisionReceipts(msgs), false)
329 }
330
331 // attachStandaloneDecisionReceipts migrates the short-lived receipt encoding
332 // that stored a LocalOnly assistant message between an assistant tool call and
333 // its result. Folding that metadata into the latest assistant message repairs
334 // already-written sessions before tool-pair normalization can fabricate a
335 // placeholder. Healthy histories return the original slice unchanged.
336 func attachStandaloneDecisionReceipts(msgs []Message) []Message {
337 target := -1
338 needsMigration := false
339 for i, m := range msgs {
340 switch {
341 case m.Role == RoleUser && !m.LocalOnly:
342 target = -1
343 case m.Role == RoleAssistant && !m.LocalOnly:
344 target = i
345 case target >= 0 && m.LocalOnly && m.DecisionReceipt != nil:
346 needsMigration = true
347 }
348 if needsMigration {
349 break
350 }
351 }
352 if !needsMigration {
353 return msgs
354 }
355
356 out := make([]Message, 0, len(msgs))
357 target = -1
358 for _, m := range msgs {
359 switch {
360 case m.Role == RoleUser && !m.LocalOnly:
361 target = -1
362 case m.Role == RoleAssistant && !m.LocalOnly:
363 out = append(out, m)
364 target = len(out) - 1
365 continue
366 case target >= 0 && m.LocalOnly && m.DecisionReceipt != nil:
367 receipts := append([]*DecisionReceipt(nil), out[target].DecisionReceipts...)
368 out[target].DecisionReceipts = append(receipts, m.DecisionReceipt)
369 continue
370 }
371 out = append(out, m)
372 }
373 return out
374 }
375
376 func normalizeMessages(msgs []Message, dropOrphanTools bool) []Message {
377 if normalized, ok := tryNormalizeFastPath(msgs, dropOrphanTools); ok {
378 return normalized // well-formed: pass through without allocating
379 }
380 out := make([]Message, 0, len(msgs))
381 for i := 0; i < len(msgs); {
382 m := msgs[i]
383 if m.LocalOnly {
384 if !dropOrphanTools {
385 out = append(out, m)
386 }
387 i++
388 continue
389 }
390 if m.Role == RoleAssistant && len(m.ToolCalls) > 0 {
391 j := i + 1
392 for j < len(msgs) && msgs[j].Role == RoleTool && !msgs[j].LocalOnly {
393 j++
394 }
395 // Backfill empty tool-call names from the corresponding tool
396 // results so the model sees which tool was invoked (#4727).
397 // The wire-format fix (openai.go) ensures empty fields are
398 // never omitted, so this backfill is a UX improvement, not a
399 // correctness requirement.
400 calls := backfillToolCallNames(m.ToolCalls, msgs[i+1:j])
401 m.ToolCalls = calls
402 out = append(out, repairToolCallArgs(m))
403 if dropOrphanTools {
404 out = append(out, pairToolResults(calls, msgs[i+1:j])...)
405 } else {
406 out = append(out, sessionToolResults(calls, msgs[i+1:j])...)
407 }
408 i = j
409 continue
410 }
411 if m.Role == RoleTool {
412 if !dropOrphanTools {
413 out = append(out, m)
414 }
415 // Orphan tool message: provider sends drop it; session loads preserve it.
416 i++
417 continue
418 }
419 out = append(out, m)
420 i++
421 }
422 return out
423 }
424
425 // tryNormalizeFastPath reports whether msgs needs no repair and, if so, returns
426 // it as-is so the caller can skip allocating. Healthy tool-call/tool-result
427 // turns pass through unchanged; malformed turns take the slow path.
428 func tryNormalizeFastPath(msgs []Message, dropOrphanTools bool) ([]Message, bool) {
429 for i := 0; i < len(msgs); {
430 m := msgs[i]
431 if m.LocalOnly {
432 if dropOrphanTools {
433 return nil, false
434 }
435 i++
436 continue
437 }
438 if m.Role == RoleAssistant && len(m.ToolCalls) > 0 {
439 j := i + 1
440 for j < len(msgs) && msgs[j].Role == RoleTool && !msgs[j].LocalOnly {
441 j++
442 }
443 if !toolTurnWellFormed(m.ToolCalls, msgs[i+1:j]) || needsToolCallArgRepair(m.ToolCalls) {
444 return nil, false
445 }
446 i = j
447 continue
448 }
449 if m.Role == RoleTool && dropOrphanTools {
450 return nil, false
451 }
452 i++
453 }
454 return msgs, true
455 }
456
457 func toolTurnWellFormed(calls []ToolCall, results []Message) bool {
458 if len(calls) != len(results) {
459 return false
460 }
461 for _, tc := range calls {
462 if tc.Name == "" {
463 return false
464 }
465 }
466 for k, tc := range calls {
467 if results[k].ToolCallID != tc.ID {
468 return false
469 }
470 if results[k].Name != tc.Name {
471 return false
472 }
473 }
474 return true
475 }
476
477 func needsToolCallArgRepair(calls []ToolCall) bool {
478 for _, tc := range calls {
479 if tc.Arguments != "" && !json.Valid([]byte(tc.Arguments)) {
480 return true
481 }
482 }
483 return false
484 }
485
486 // repairToolCallArgs returns m with any undecodable tool-call Arguments closed
487 // into valid JSON (copy-on-write; the caller's history is never mutated). Empty
488 // arguments pass through — some gateways send "" for no-arg tools.
489 func repairToolCallArgs(m Message) Message {
490 broken := false
491 for _, tc := range m.ToolCalls {
492 if tc.Arguments != "" && !json.Valid([]byte(tc.Arguments)) {
493 broken = true
494 break
495 }
496 }
497 if !broken {
498 return m
499 }
500 calls := make([]ToolCall, len(m.ToolCalls))
501 copy(calls, m.ToolCalls)
502 for i := range calls {
503 if calls[i].Arguments == "" || json.Valid([]byte(calls[i].Arguments)) {
504 continue
505 }
506 calls[i].Arguments = closeTruncatedJSON(calls[i].Arguments)
507 }
508 m.ToolCalls = calls
509 return m
510 }
511
512 // closeTruncatedJSON best-effort completes a JSON document cut off mid-stream
513 // (unterminated string, open braces, dangling comma/colon); anything still
514 // invalid after closing degrades to "{}".
515 func closeTruncatedJSON(s string) string {
516 var stack []byte
517 inStr, esc := false, false
518 for i := 0; i < len(s); i++ {
519 c := s[i]
520 if inStr {
521 switch {
522 case esc:
523 esc = false
524 case c == '\\':
525 esc = true
526 case c == '"':
527 inStr = false
528 }
529 continue
530 }
531 switch c {
532 case '"':
533 inStr = true
534 case '{':
535 stack = append(stack, '}')
536 case '[':
537 stack = append(stack, ']')
538 case '}', ']':
539 if len(stack) > 0 {
540 stack = stack[:len(stack)-1]
541 }
542 }
543 }
544 out := s
545 if esc {
546 out = out[:len(out)-1]
547 }
548 if inStr {
549 out += `"`
550 }
551 trimmed := strings.TrimRight(out, " \t\r\n")
552 switch {
553 case strings.HasSuffix(trimmed, ","):
554 out = trimmed[:len(trimmed)-1]
555 case strings.HasSuffix(trimmed, ":"):
556 out = trimmed + "null"
557 }
558 for i := len(stack) - 1; i >= 0; i-- {
559 out += string(stack[i])
560 }
561 if !json.Valid([]byte(out)) {
562 return "{}"
563 }
564 return out
565 }
566
567 // pairToolResults answers each tool_call with its result, backfilling a
568 // placeholder for any unanswered one. Distinct non-empty ids pair by id (so
569 // reordered results re-sort to call order); empty or duplicate ids pair by
570 // position instead — some gateways stream tool calls by index with no id, and a
571 // map keyed on id would collapse those results into one (call order is preserved
572 // because the loop appends results in call order).
573 func pairToolResults(calls []ToolCall, avail []Message) []Message {
574 out := make([]Message, 0, len(calls))
575 if idDistinct(calls) {
576 byID := make(map[string]Message, len(avail))
577 for _, r := range avail {
578 byID[r.ToolCallID] = r
579 }
580 for _, tc := range calls {
581 if r, ok := byID[tc.ID]; ok {
582 r.Name = tc.Name
583 out = append(out, r)
584 } else {
585 out = append(out, Message{Role: RoleTool, ToolCallID: tc.ID, Name: tc.Name, Content: interruptedToolResult})
586 }
587 }
588 return out
589 }
590 for k, tc := range calls {
591 if k < len(avail) {
592 r := avail[k]
593 r.ToolCallID = tc.ID
594 r.Name = tc.Name
595 out = append(out, r)
596 } else {
597 out = append(out, Message{Role: RoleTool, ToolCallID: tc.ID, Name: tc.Name, Content: interruptedToolResult})
598 }
599 }
600 return out
601 }
602
603 // sessionToolResults preserves every stored tool result and appends placeholders
604 // only for calls that have no recorded answer. Load-time normalization must not
605 // drop or reorder user history; provider sends can still use pairToolResults for
606 // strict wire formatting.
607 func sessionToolResults(calls []ToolCall, avail []Message) []Message {
608 out := append([]Message(nil), avail...)
609 if idDistinct(calls) {
610 answered := make(map[string]struct{}, len(avail))
611 for _, r := range avail {
612 answered[r.ToolCallID] = struct{}{}
613 }
614 for _, tc := range calls {
615 if _, ok := answered[tc.ID]; !ok {
616 out = append(out, Message{Role: RoleTool, ToolCallID: tc.ID, Name: tc.Name, Content: interruptedToolResult})
617 }
618 }
619 return out
620 }
621 for k := len(avail); k < len(calls); k++ {
622 tc := calls[k]
623 out = append(out, Message{Role: RoleTool, ToolCallID: tc.ID, Name: tc.Name, Content: interruptedToolResult})
624 }
625 return out
626 }
627
628 // backfillToolCallNames returns calls with any empty Name filled in from the
629 // matching tool result (by id, then by position). Old sessions (#4727) may have
630 // saved assistant tool-calls with an empty name; backfilling gives the model
631 // useful context during replay. The common case (no empty names) returns the
632 // input unchanged without allocating. Unpaired calls keep their empty name,
633 // which the wire-format fix (openai.go) handles gracefully.
634 func backfillToolCallNames(calls []ToolCall, results []Message) []ToolCall {
635 missing := false
636 for _, c := range calls {
637 if c.Name == "" {
638 missing = true
639 break
640 }
641 }
642 if !missing {
643 return calls
644 }
645 out := make([]ToolCall, len(calls))
646 copy(out, calls)
647 if idDistinct(calls) {
648 byID := make(map[string]string, len(results))
649 for _, r := range results {
650 if r.Name != "" {
651 byID[r.ToolCallID] = r.Name
652 }
653 }
654 for k := range out {
655 if out[k].Name == "" {
656 if n, ok := byID[out[k].ID]; ok {
657 out[k].Name = n
658 }
659 }
660 }
661 return out
662 }
663 // Fallback: positional pairing (same order as pairToolResults).
664 for k := range out {
665 if out[k].Name == "" && k < len(results) {
666 out[k].Name = results[k].Name
667 }
668 }
669 return out
670 }
671
672 // idDistinct reports whether every call carries a non-empty id unique within the
673 // batch — the condition under which id-keyed pairing is safe.
674 func idDistinct(calls []ToolCall) bool {
675 seen := make(map[string]struct{}, len(calls))
676 for _, tc := range calls {
677 if tc.ID == "" {
678 return false
679 }
680 if _, dup := seen[tc.ID]; dup {
681 return false
682 }
683 seen[tc.ID] = struct{}{}
684 }
685 return true
686 }
687
688 // ChunkType identifies the kind of a streamed increment.
689 type ChunkType int
690
691 const (
692 ChunkText ChunkType = iota // text delta
693 ChunkReasoning // thinking-mode reasoning delta (before the visible answer)
694 ChunkToolCallStart // a tool call has begun (ToolCall: ID+Name; args still streaming)
695 ChunkToolCallArgsDelta // progress while a call's arguments stream (ToolCall: ID+Name; ArgChars: cumulative)
696 ChunkToolCall // one complete tool call
697 ChunkUsage // token usage for the completion
698 ChunkDone // completion finished normally
699 ChunkError // an error occurred
700 ChunkResponsesItem // a complete provider-issued Responses API output item for stateless replay
701 )
702
703 // Usage reports token accounting for a completion. Cache hit/miss come from
704 // either DeepSeek's top-level prompt_cache_{hit,miss}_tokens or the OpenAI/MiMo
705 // standard prompt_tokens_details.cached_tokens — the openai provider normalises
706 // both shapes into these fields. ReasoningTokens is the thinking-mode subset of
707 // CompletionTokens reported by thinking-capable models. FinishReason carries
708 // the model's last reported choices[0].finish_reason so the agent can surface
709 // abnormal terminations ("length", "content_filter", "repetition_truncation").
710 // Estimated marks counts reconstructed locally because the provider's terminal
711 // usage record did not arrive; exact provider usage leaves it false.
712 type Usage struct {
713 PromptTokens int
714 CompletionTokens int
715 TotalTokens int
716 CacheHitTokens int // prompt tokens served from cache
717 CacheMissTokens int // prompt tokens not cached, including CacheWriteTokens
718 CacheWriteTokens int // subset of CacheMissTokens used to create provider cache entries
719 CacheWriteBilledTokens float64 // cache-write charge expressed in ordinary input-token equivalents
720 ReasoningTokens int // subset of CompletionTokens spent on chain-of-thought
721 FinishReason string // "stop", "tool_calls", "length", "content_filter", "repetition_truncation", …
722 Estimated bool
723 // RequestCount is the number of provider requests represented by this
724 // aggregate. Zero means one request for backward compatibility. Recovery
725 // paths that merge multiple attempts set the exact count.
726 RequestCount int
727 // Context* fields describe the latest single-request shape for context
728 // gauges and rebind telemetry. When zero, consumers fall back to the
729 // billable Prompt/Completion/… fields. Multi-attempt sampling recovery
730 // sets PromptTokens (etc.) to the billable aggregate and fills Context*
731 // from the final attempt only.
732 ContextPromptTokens int
733 ContextCompletionTokens int
734 ContextReasoningTokens int
735 ContextCacheHitTokens int
736 ContextCacheMissTokens int
737 }
738
739 // ContextFillTokens returns the latest-attempt context fill (prompt+completion)
740 // used by status bars and context panels. Falls back to billable totals when
741 // no Context* fields were set (single-attempt / legacy usage events).
742 func (u *Usage) ContextFillTokens() int {
743 if u == nil {
744 return 0
745 }
746 if u.ContextPromptTokens > 0 || u.ContextCompletionTokens > 0 {
747 return u.ContextPromptTokens + u.ContextCompletionTokens
748 }
749 return u.PromptTokens + u.CompletionTokens
750 }
751
752 // ContextPromptForGauge returns the latest-attempt prompt size for context
753 // displays. Falls back to PromptTokens when ContextPromptTokens is unset.
754 func (u *Usage) ContextPromptForGauge() int {
755 if u == nil {
756 return 0
757 }
758 if u.ContextPromptTokens > 0 {
759 return u.ContextPromptTokens
760 }
761 return u.PromptTokens
762 }
763
764 // Pricing is a provider's per-1M-token rates, used to estimate spend. Currency
765 // is a display symbol or ISO-like code (default "¥"). toml tags let config decode it.
766 type Pricing struct {
767 CacheHit float64 `toml:"cache_hit"` // per 1M cached prompt tokens
768 Input float64 `toml:"input"` // per 1M uncached prompt tokens
769 Output float64 `toml:"output"` // per 1M completion tokens
770 Currency string `toml:"currency"`
771 }
772
773 // Cost estimates the spend for a usage record.
774 func (p *Pricing) Cost(u *Usage) float64 {
775 if p == nil || u == nil {
776 return 0
777 }
778 hit := u.CacheHitTokens
779 miss := u.CacheMissTokens
780 if hit+miss == 0 && u.PromptTokens > 0 {
781 miss = u.PromptTokens
782 } else if miss == 0 && hit > 0 && u.PromptTokens > hit {
783 miss = u.PromptTokens - hit
784 }
785 // CacheMissTokens intentionally remains the raw prompt-token denominator
786 // used by cache hit-rate displays, so cache writes are included there. For
787 // cost, split those writes back out and replace them with their provider-
788 // supplied input-token equivalent (for example Anthropic's 1.25x 5-minute
789 // writes or 2x 1-hour writes). Older providers leave both fields at zero and
790 // keep the legacy one-input-rate behavior. A write count without billed
791 // units also falls back to 1x for backward compatibility.
792 write := u.CacheWriteTokens
793 if write < 0 {
794 write = 0
795 }
796 if write > miss {
797 write = miss
798 }
799 billedWrite := 0.0
800 if write > 0 {
801 billedWrite = u.CacheWriteBilledTokens
802 if billedWrite <= 0 {
803 billedWrite = float64(write)
804 }
805 }
806 inputTokenUnits := float64(miss-write) + billedWrite
807 return (float64(hit)*p.CacheHit +
808 inputTokenUnits*p.Input +
809 float64(u.CompletionTokens)*p.Output) / 1e6
810 }
811
812 // Symbol returns the currency display symbol, defaulting to "¥".
813 func (p *Pricing) Symbol() string {
814 if p == nil || p.Currency == "" {
815 return "¥"
816 }
817 return currencySymbol(p.Currency)
818 }
819
820 func currencySymbol(currency string) string {
821 value := strings.TrimSpace(currency)
822 if value == "" {
823 return "¥"
824 }
825 switch strings.ToLower(value) {
826 case "cny", "rmb", "yuan", "renminbi", "cnh":
827 return "¥"
828 case "usd", "dollar", "dollars", "us dollar", "us dollars", "us$":
829 return "$"
830 case "eur", "euro", "euros":
831 return "€"
832 case "gbp", "pound", "pounds", "sterling":
833 return "£"
834 case "jpy", "yen":
835 return "¥"
836 }
837 switch value {
838 case "¥", "¥":
839 return "¥"
840 case "$", "€", "£":
841 return value
842 }
843 // any embedded currency sign → keep as-is (compact symbols like A$, HK$).
844 for _, r := range value {
845 if unicode.Is(unicode.Sc, r) {
846 return value
847 }
848 }
849 if isThreeLetterCurrencyCode(value) {
850 return strings.ToUpper(value) + " "
851 }
852 return "¥"
853 }
854
855 func isThreeLetterCurrencyCode(value string) bool {
856 if len(value) != 3 {
857 return false
858 }
859 for _, r := range value {
860 if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') {
861 return false
862 }
863 }
864 return true
865 }
866
867 // Chunk is a single streamed event. Read the field matching Type.
868 type Chunk struct {
869 Type ChunkType
870 Text string // ChunkText, ChunkReasoning
871 Signature string // ChunkReasoning: opaque proof for the reasoning (Anthropic thinking signature), when issued
872 // ReasoningID/ReasoningStatus ride the final ChunkReasoning of a turn
873 // (empty Text): the provider-issued reasoning item id/status captured
874 // from the SSE stream, so the Agent can persist them into the session
875 // and the next turn's input reasoning item round-trips them (review
876 // #7234 — OpenAI Responses schema marks Reasoning.id required).
877 ReasoningID string // ChunkReasoning: provider-issued reasoning item id
878 ReasoningStatus string // ChunkReasoning: final reasoning item status ("completed")
879 ToolCall *ToolCall // ChunkToolCallStart (ID+Name only), ChunkToolCallArgsDelta (ID+Name), ChunkToolCall (complete)
880 ArgChars int // ChunkToolCallArgsDelta: cumulative argument characters received for this call
881 ResponsesItem json.RawMessage // ChunkResponsesItem: opaque validated Responses API output item
882 Usage *Usage // ChunkUsage
883 Err error // ChunkError
884 }
885
886 // Fixed stream-interrupt reasons for observability. Values are a closed enum
887 // and must never carry URLs, tool arguments, file paths, or raw error text.
888 const (
889 StreamInterruptConnectionReset = "connection_reset"
890 StreamInterruptPrematureEOF = "premature_eof"
891 StreamInterruptIdleTimeout = "idle_timeout"
892 )
893
894 // StreamInterruptedError marks that the current sampling attempt never reached
895 // a clean provider terminal event and is therefore uncommitted. The Agent may
896 // replay the exact same provider request. Providers must not perform body-phase
897 // request replay themselves — that lives at the Agent layer so retry budgets,
898 // UI rollback, and tool execution stay single-owner. context.Canceled, auth,
899 // 4xx/schema errors, and unparseable complete protocol payloads must not use
900 // this type.
901 type StreamInterruptedError struct {
902 Err error
903 Reason string // one of the StreamInterrupt* constants; may be empty for older callers
904 }
905
906 func (e *StreamInterruptedError) Error() string {
907 if e == nil || e.Err == nil {
908 return "stream interrupted"
909 }
910 return e.Err.Error()
911 }
912
913 func (e *StreamInterruptedError) Unwrap() error {
914 if e == nil {
915 return nil
916 }
917 return e.Err
918 }
919
920 // StreamInterrupt wraps err as a StreamInterruptedError with a fixed reason.
921 func StreamInterrupt(err error, reason string) error {
922 if err == nil {
923 return nil
924 }
925 return &StreamInterruptedError{Err: err, Reason: reason}
926 }
927
928 // StreamInterruptReason returns the fixed reason when err is a stream
929 // interruption, or empty otherwise.
930 func StreamInterruptReason(err error) string {
931 var interrupted *StreamInterruptedError
932 if !errors.As(err, &interrupted) || interrupted == nil {
933 return ""
934 }
935 if interrupted.Reason != "" {
936 return interrupted.Reason
937 }
938 return ClassifyStreamInterrupt(interrupted.Err)
939 }
940
941 // ClassifyStreamInterrupt maps a transport error onto a fixed reason enum.
942 // Prefer attaching Reason at the emit site; this is a best-effort fallback.
943 func ClassifyStreamInterrupt(err error) string {
944 if err == nil {
945 return StreamInterruptPrematureEOF
946 }
947 msg := strings.ToLower(err.Error())
948 switch {
949 case strings.Contains(msg, "stalled") || strings.Contains(msg, "idle timeout") || strings.Contains(msg, "no data for"):
950 return StreamInterruptIdleTimeout
951 case errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) || strings.Contains(msg, "before completion") || strings.Contains(msg, "unexpected eof"):
952 return StreamInterruptPrematureEOF
953 case errors.Is(err, net.ErrClosed) || errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.ECONNABORTED) ||
954 strings.Contains(msg, "connection reset") || strings.Contains(msg, "forcibly closed") || strings.Contains(msg, "broken pipe"):
955 return StreamInterruptConnectionReset
956 default:
957 if IsConnReset(err) {
958 return StreamInterruptConnectionReset
959 }
960 return StreamInterruptPrematureEOF
961 }
962 }
963
964 func IsStreamInterrupted(err error) bool {
965 var interrupted *StreamInterruptedError
966 return errors.As(err, &interrupted)
967 }
968
969 // Provider is a chat-capable model backend.
970 type Provider interface {
971 // Name returns the provider instance name, e.g. "deepseek" / "mimo".
972 Name() string
973 // Stream starts a streaming completion, pushing increments on the channel.
974 // Cancelling ctx must abort the underlying request; a closed channel marks
975 // the end of the completion.
976 Stream(ctx context.Context, req Request) (<-chan Chunk, error)
977 }
978
979 // ToolCallReasoningPolicy is optionally implemented by providers whose protocol
980 // replays the provider-issued reasoning block on assistant tool_calls turns
981 // (DeepSeek thinking mode). The agent uses it to archive the original reasoning
982 // text on those turns (a display-translated copy must not round-trip to the
983 // API) and to warn when a turn arrives with none — the request still succeeds
984 // because the wire layer always emits the reasoning_content key for such turns,
985 // but the model loses its chain-of-thought context. Most providers leave this
986 // unset; callers must treat it as false.
987 type ToolCallReasoningPolicy interface {
988 RequiresToolCallReasoning() bool
989 }
990
991 // RequiresToolCallReasoning reports whether p replays reasoning_content on
992 // assistant tool_calls turns sent back in history.
993 func RequiresToolCallReasoning(p Provider) bool {
994 if nilutil.IsNil(p) {
995 return false
996 }
997 policy, ok := p.(ToolCallReasoningPolicy)
998 return ok && policy.RequiresToolCallReasoning()
999 }
1000
1001 // ReasoningRoundTripPolicy is optionally implemented by providers that require
1002 // every assistant message to preserve provider-issued reasoning in later
1003 // requests. This is broader than ToolCallReasoningPolicy, which covers only
1004 // assistant tool_calls turns.
1005 type ReasoningRoundTripPolicy interface {
1006 RequiresReasoningRoundTrip() bool
1007 }
1008
1009 // RequiresReasoningRoundTrip reports whether raw provider reasoning must be
1010 // retained and replayed on all assistant messages.
1011 func RequiresReasoningRoundTrip(p Provider) bool {
1012 if nilutil.IsNil(p) {
1013 return false
1014 }
1015 policy, ok := p.(ReasoningRoundTripPolicy)
1016 return ok && policy.RequiresReasoningRoundTrip()
1017 }
1018
1019 // MissingToolCallReasoningWarningPolicy is optionally implemented by providers
1020 // whose replay protocol requires reasoning_content, but whose active model may
1021 // not reliably emit it. The legacy Warning name is retained for source
1022 // compatibility; the agent now uses this policy for silent bounded recovery and
1023 // emits no user-visible protocol notice.
1024 type MissingToolCallReasoningWarningPolicy interface {
1025 WarnOnMissingToolCallReasoning() bool
1026 }
1027
1028 // MissingToolCallReasoningWarningIdentityPolicy optionally supplies the stable,
1029 // non-credential configuration identity used to rate-limit missing-reasoning
1030 // recovery attempts. The legacy name preserves adapters and persisted state.
1031 // Implementations may include adapter kind, endpoint, model, and thinking
1032 // controls; the raw identity never leaves memory and is hashed before
1033 // persistence.
1034 type MissingToolCallReasoningWarningIdentityPolicy interface {
1035 MissingToolCallReasoningWarningIdentity() string
1036 }
1037
1038 // WarnOnMissingToolCallReasoning reports whether a tool_calls turn with empty
1039 // reasoning_content should enter silent recovery. Its legacy name is preserved
1040 // for provider implementations compiled against the original diagnostic API.
1041 func WarnOnMissingToolCallReasoning(p Provider) bool {
1042 if nilutil.IsNil(p) {
1043 return false
1044 }
1045 policy, ok := p.(MissingToolCallReasoningWarningPolicy)
1046 if ok {
1047 return policy.WarnOnMissingToolCallReasoning()
1048 }
1049 return RequiresToolCallReasoning(p)
1050 }
1051
1052 // MissingToolCallReasoningWarningFingerprint returns an opaque stable key for
1053 // one provider configuration's recovery cooldown. Concrete adapters distinguish
1054 // endpoint/model/protocol changes; providers without the optional policy retain
1055 // a safe type-and-name fallback. The legacy name preserves the on-disk state
1056 // contract. The digest prevents local state from exposing raw endpoints or
1057 // model identifiers.
1058 func MissingToolCallReasoningWarningFingerprint(p Provider) string {
1059 if nilutil.IsNil(p) {
1060 return ""
1061 }
1062 identity := fmt.Sprintf("%T\x00%s", p, strings.TrimSpace(p.Name()))
1063 if policy, ok := p.(MissingToolCallReasoningWarningIdentityPolicy); ok {
1064 if configured := strings.TrimSpace(policy.MissingToolCallReasoningWarningIdentity()); configured != "" {
1065 identity = configured
1066 }
1067 }
1068 digest := sha256.Sum256([]byte(identity))
1069 return hex.EncodeToString(digest[:])
1070 }
1071
1072 // Config is a resolved provider instance configuration.
1073 type Config struct {
1074 Name string // instance name, e.g. "deepseek"
1075 BaseURL string // OpenAI-compatible endpoint
1076 Model string // model id
1077 APIKey string // resolved from api_key_env
1078 Extra map[string]any // kind-specific options
1079 }
1080
1081 // AuthError reports that a provider rejected the API key (HTTP 401/403). Its
1082 // message is already user-facing and actionable — it names the provider and,
1083 // when known, the environment variable the key comes from — and it carries the
1084 // server's own reason as Body, because relay gateways explain *why* the key was
1085 // rejected ("token expired", key not entitled to the model) in the response
1086 // body. Body is deliberately NOT part of Error(): servers echo masked key
1087 // fragments in auth bodies, and the ambient error string flows into logs,
1088 // status lines, and traces where key material must never propagate. Display
1089 // layers that want the reason read Body and extract it themselves. Providers
1090 // should return this (rather than a generic status error) for auth failures.
1091 type AuthError struct {
1092 Provider string // the provider instance name, e.g. "deepseek"
1093 KeyEnv string // the api_key_env the key is read from, when known
1094 KeySource string // human-readable source of KeyEnv, when known
1095 Status int // the HTTP status (401 or 403)
1096 HasKey bool // a non-empty key was sent — the server rejected it, vs. no key configured at all
1097 Body string // trimmed response-body snippet, the server's verbatim reason when it gave one
1098 }
1099
1100 func (e *AuthError) Error() string {
1101 key := "the API key"
1102 if e.KeyEnv != "" {
1103 key = e.KeyEnv
1104 }
1105 if e.KeySource != "" {
1106 key += " from " + e.KeySource
1107 }
1108 return fmt.Sprintf("authentication failed for provider %q (HTTP %d): %s is invalid or expired — update it (in .env or your environment) and retry, or run `reasonix setup`",
1109 e.Provider, e.Status, key)
1110 }
1111
1112 // Factory builds a Provider from a resolved Config.
1113 type Factory func(cfg Config) (Provider, error)
1114
1115 var registry = map[string]Factory{}
1116
1117 // Register adds a factory under a kind (e.g. "openai"). Intended for init().
1118 // It panics on a duplicate kind, since that is a compile-time wiring mistake.
1119 func Register(kind string, f Factory) {
1120 if _, dup := registry[kind]; dup {
1121 panic("provider: duplicate kind " + kind)
1122 }
1123 registry[kind] = f
1124 }
1125
1126 // New instantiates the provider of the given kind.
1127 func New(kind string, cfg Config) (Provider, error) {
1128 f, ok := registry[kind]
1129 if !ok {
1130 return nil, fmt.Errorf("provider: unknown kind %q (registered: %v)", kind, Kinds())
1131 }
1132 p, err := f(cfg)
1133 if err != nil {
1134 return nil, err
1135 }
1136 if nilutil.IsNil(p) {
1137 return nil, fmt.Errorf("provider: factory %q returned nil provider", kind)
1138 }
1139 return p, nil
1140 }
1141
1142 // Kinds returns the registered kinds, sorted.
1143 func Kinds() []string {
1144 out := make([]string, 0, len(registry))
1145 for k := range registry {
1146 out = append(out, k)
1147 }
1148 sort.Strings(out)
1149 return out
1150 }
1151
1151 lines GO