返回 DeepSeek-Reasonix
responses.go
根目录 / internal / provider / responses / responses.go
1 // Package responses implements the OpenAI Responses API wire protocol.
2 // DeepSeek uses it statelessly and requires the complete input history on every
3 // request; compatible stateful endpoints may opt into previous_response_id.
4 package responses
5
6 import (
7 "bufio"
8 "bytes"
9 "context"
10 "crypto/sha256"
11 "encoding/hex"
12 "encoding/json"
13 "errors"
14 "fmt"
15 "io"
16 "net/http"
17 "strings"
18 "sync"
19 "sync/atomic"
20 "time"
21
22 "reasonix/internal/netclient"
23 "reasonix/internal/provider"
24 )
25
26 const (
27 defaultStreamIdleTimeout = 120 * time.Second
28 maxReplayableSearchItemBytes = 512 * 1024
29 )
30
31 func init() {
32 provider.Register("responses", newFromConfig)
33 provider.Register("dashscope-responses", newFromConfig)
34 }
35
36 func newFromConfig(cfg provider.Config) (provider.Provider, error) {
37 effort, _ := cfg.Extra["effort"].(string)
38 mode, _ := cfg.Extra["mode"].(string)
39 webSearch, _ := cfg.Extra["web_search"].(bool)
40 var stateful *bool
41 switch value := cfg.Extra["stateful"].(type) {
42 case bool:
43 stateful = &value
44 case *bool:
45 stateful = value
46 }
47 proxy, _ := cfg.Extra["proxy_spec"].(netclient.ProxySpec)
48 keyEnv, _ := cfg.Extra["api_key_env"].(string)
49 keySource, _ := cfg.Extra["api_key_source"].(string)
50 maxOutputTokens, _ := cfg.Extra["max_output_tokens"].(int)
51 return New(Config{
52 Name: cfg.Name, APIKey: cfg.APIKey, BaseURL: cfg.BaseURL, Model: cfg.Model,
53 Effort: effort, Mode: mode, Stateful: stateful, WebSearch: webSearch, Proxy: proxy,
54 KeyEnv: keyEnv, KeySource: keySource, MaxOutputTokens: maxOutputTokens,
55 // Extra 原样透传:vision 等能力开关由调用方(boot/CLI)写入
56 // cfg.Extra,factory 若丢弃则 New() 读不到(评审 #7234 第 3 点)。
57 Extra: cfg.Extra,
58 }), nil
59 }
60
61 // Config holds Responses API provider settings.
62 type Config struct {
63 Name string
64 APIKey string
65 BaseURL string
66 Model string
67 Effort string
68 Mode string // stateful | stateless; empty uses vendor detection.
69 Stateful *bool // legacy form of Mode; nil preserves vendor detection.
70 WebSearch bool // expose the provider-executed web_search tool.
71 Proxy netclient.ProxySpec
72 KeyEnv string
73 KeySource string
74 // MaxOutputTokens is the total provider output budget. Zero enables Reasonix's
75 // 32K reasoning safety default on official DeepSeek and otherwise omits the
76 // field; thinking-disabled DeepSeek requests and negative values omit it.
77 MaxOutputTokens int
78 // SessionCache controls DashScope's opt-in header. The header is never sent
79 // to non-DashScope endpoints even when this value is true.
80 SessionCache *bool
81 // Extra carries kind-specific options; "vision" (bool) enables embedding
82 // attached Images as input_image parts on user turns.
83 Extra map[string]any
84 }
85
86 func (c Config) mode() string {
87 mode := strings.ToLower(strings.TrimSpace(c.Mode))
88 if mode == "stateful" || mode == "stateless" {
89 return mode
90 }
91 if c.Stateful != nil {
92 if *c.Stateful {
93 return "stateful"
94 }
95 return "stateless"
96 }
97 if capabilitiesFor(DetectVendor(c.BaseURL)).stateless {
98 return "stateless"
99 }
100 return "stateful"
101 }
102
103 // DetectVendor lives in vendor.go (capabilities table): it covers dashscope/
104 // deepseek (incl. eu.deepseek.com) / mimo via exact-host matching.
105
106 type client struct {
107 name, apiKey, keyEnv, keySource string
108 baseURL, model, effort string
109 vendor, mode string
110 caps vendorCapabilities
111 sessionCache bool
112 webSearch bool
113 maxOutputTokens int
114 vision bool // model accepts image input; embed Images as input_image parts
115 http *http.Client
116 idleTimeout time.Duration
117 authed atomic.Bool
118
119 mu sync.Mutex
120 lastResponseID string
121 expectedPrefixDigest string
122 }
123
124 // New creates a Responses API provider.
125 func New(cfg Config) provider.Provider {
126 vendor := DetectVendor(cfg.BaseURL)
127 cap := capabilitiesFor(vendor)
128 maxOutputTokens := cfg.MaxOutputTokens
129 // 默认输出预算从 vendor 表取(deepseek 32K / mimo 64K)——消除硬编码
130 // 常量分叉(review:responses.go 硬编码与 caps.defaultMaxOutputTokens
131 // 职责重叠)。条件保留:thinking-disabled 的 deepseek 请求不设自动
132 // 预算(与 openai.go 一致——服务端默认即可;测试断言该行为)。
133 if maxOutputTokens == 0 && cap.defaultMaxOutputTokens > 0 &&
134 !(vendor == "deepseek" && responsesReasoningDisabled(cfg.Effort)) {
135 maxOutputTokens = cap.defaultMaxOutputTokens
136 }
137 sessionCache := cap.sessionCacheHeader
138 if cfg.SessionCache != nil {
139 sessionCache = *cfg.SessionCache
140 }
141 vision, _ := cfg.Extra["vision"].(bool)
142 httpClient := &http.Client{Timeout: 300 * time.Second}
143 if built, err := netclient.NewHTTPClient(cfg.Proxy, netclient.TransportOptions{
144 DialTimeout: 30 * time.Second, KeepAlive: 30 * time.Second,
145 TLSHandshakeTimeout: 15 * time.Second, ResponseHeaderTimeout: 120 * time.Second,
146 }); err == nil {
147 httpClient = built
148 }
149 return &client{
150 name: cfg.Name, apiKey: cfg.APIKey, keyEnv: cfg.KeyEnv, keySource: cfg.KeySource,
151 baseURL: strings.TrimRight(cfg.BaseURL, "/"), model: cfg.Model, effort: cfg.Effort,
152 vendor: vendor, caps: cap, mode: cfg.mode(), sessionCache: sessionCache, webSearch: cfg.WebSearch, maxOutputTokens: maxOutputTokens,
153 vision: vision,
154 http: httpClient, idleTimeout: defaultStreamIdleTimeout,
155 }
156 }
157
158 func responsesReasoningDisabled(effort string) bool {
159 switch strings.ToLower(strings.TrimSpace(effort)) {
160 case "none", "disabled", "off":
161 return true
162 default:
163 return false
164 }
165 }
166
167 func (c *client) Name() string { return c.name }
168
169 // RequiresToolCallReasoning tells the agent to preserve stateless vendors'
170 // reasoning on assistant tool-call turns so the follow-up can replay it.
171 // DeepSeek and MiMo document this requirement for multi-turn tool calls.
172 func (c *client) RequiresToolCallReasoning() bool {
173 return c.caps.toolCallReasoning
174 }
175
176 func (c *client) MissingToolCallReasoningWarningIdentity() string {
177 if c == nil {
178 return ""
179 }
180 return strings.Join([]string{
181 "responses", strings.TrimSpace(c.name), strings.TrimSpace(c.baseURL),
182 strings.TrimSpace(c.model), strings.TrimSpace(c.vendor), strings.TrimSpace(c.mode), strings.TrimSpace(c.effort),
183 }, "\x00")
184 }
185
186 // WarnOnMissingToolCallReasoning reports a tool_calls turn that arrived
187 // without reasoning only for vendors whose endpoint reliably emits it.
188 // DeepSeek's official API emits tool-call reasoning for its pro-tier models,
189 // so a missing chain-of-thought there is a real degradation worth one warning.
190 // MiMo documents reasoning alongside tool calls but does not guarantee it on
191 // every round (observed: mimo-v2.5-pro tool-call turn with empty reasoning),
192 // so a missing chain-of-thought is endpoint-conditional, not a degradation
193 // signal — silence the warning. Capability-driven (review #7234):
194 // toolCallReasoning=false vendors (DashScope) never warn — no round-trip
195 // contract; singleSegmentReasoning=true vendors (MiMo) never warn — their
196 // tool-call thinking is a single optional segment. Only multi-segment
197 // thinking vendors that require replay (DeepSeek) warn, scoped to non-flash.
198 func (c *client) WarnOnMissingToolCallReasoning() bool {
199 if !c.caps.toolCallReasoning || c.caps.singleSegmentReasoning {
200 return false
201 }
202 model := strings.ToLower(strings.TrimSpace(c.model))
203 // Flash-tier DeepSeek models do not emit tool-call reasoning (same carve
204 // as openai.go expectsDeepSeekToolCallReasoning).
205 return !strings.Contains(model, "flash")
206 }
207
208 func (c *client) sendOpts() provider.SendOptions {
209 return provider.SendOptions{Provider: c.name, KeyEnv: c.keyEnv, KeySource: c.keySource, KeyPresent: c.apiKey != "", RetryAuth: c.authed.Load()}
210 }
211
212 // ResetContext drops stateful continuation metadata. Full-input stateless mode
213 // is unaffected.
214 func (c *client) ResetContext() {
215 c.mu.Lock()
216 c.lastResponseID = ""
217 c.expectedPrefixDigest = ""
218 c.mu.Unlock()
219 }
220
221 func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
222 requestCtx := provider.WithRequestAttemptCounter(ctx)
223 body, usedPrevious, wireMessages := c.buildRequestBody(req)
224 resp, err := c.send(requestCtx, body)
225 if err != nil && usedPrevious && isStalePreviousResponseError(err) {
226 // A stateful response ID may expire server-side. Retrying once with full
227 // history is safe because no response body has started streaming.
228 c.ResetContext()
229 body, _, wireMessages = c.buildRequestBody(req)
230 resp, err = c.send(requestCtx, body)
231 }
232 if err != nil {
233 return nil, err
234 }
235 c.authed.Store(true)
236 out := make(chan provider.Chunk, 64)
237 go c.readStream(requestCtx, resp, out, wireMessages)
238 return out, nil
239 }
240
241 func (c *client) send(ctx context.Context, body map[string]any) (*http.Response, error) {
242 payload, err := json.Marshal(body)
243 if err != nil {
244 return nil, fmt.Errorf("responses: marshal request: %w", err)
245 }
246 newRequest := func(ctx context.Context) (*http.Request, error) {
247 req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/responses", bytes.NewReader(payload))
248 if err != nil {
249 return nil, err
250 }
251 req.Header.Set("Content-Type", "application/json")
252 req.Header.Set("Authorization", "Bearer "+c.apiKey)
253 if c.caps.sessionCacheHeader && c.sessionCache {
254 req.Header.Set("x-dashscope-session-cache", "enable")
255 }
256 return req, nil
257 }
258 return provider.SendWithRetry(ctx, c.http, c.sendOpts(), newRequest)
259 }
260
261 func isStalePreviousResponseError(err error) bool {
262 var apiErr *provider.APIError
263 if !errors.As(err, &apiErr) || apiErr.Status != http.StatusBadRequest {
264 return false
265 }
266 body := strings.ToLower(apiErr.Body)
267 mentionsID := strings.Contains(body, "previous_response_id") || strings.Contains(body, "previous response") || strings.Contains(body, "response id")
268 return mentionsID &&
269 (strings.Contains(body, "not found") || strings.Contains(body, "invalid") || strings.Contains(body, "expired"))
270 }
271
272 func (c *client) buildRequestBody(req provider.Request) (map[string]any, bool, []provider.Message) {
273 messages := provider.SanitizeToolPairing(provider.ModelMessages(req.Messages))
274 body := map[string]any{"model": c.model, "stream": true}
275
276 effort := strings.ToLower(strings.TrimSpace(c.effort))
277 switch effort {
278 case "auto":
279 effort = ""
280 case "disabled", "off":
281 effort = "none"
282 }
283 if effort != "" {
284 body["reasoning"] = map[string]any{"effort": effort}
285 }
286 maxOutputTokens := req.MaxTokens
287 if maxOutputTokens == 0 {
288 maxOutputTokens = c.maxOutputTokens
289 }
290 if maxOutputTokens == 0 && c.caps.defaultMaxOutputTokens > 0 {
291 // 与 New() 构造期默认同条件:thinking-disabled 的 deepseek 请求
292 // 不设自动预算(服务端默认即可——测试断言该行为)。
293 if !(c.vendor == "deepseek" && responsesReasoningDisabled(c.effort)) {
294 maxOutputTokens = c.caps.defaultMaxOutputTokens
295 }
296 }
297 if maxOutputTokens > 0 {
298 body["max_output_tokens"] = maxOutputTokens
299 }
300 if req.ResponseFormat != nil && req.ResponseFormat.Type != "" {
301 // Structured output: Responses text.format. MiMo/DashScope/OpenAI
302 // all accept {"text":{"format":{"type":"json_object"}}}. The model
303 // only emits JSON when the instructions also demand it.
304 body["text"] = map[string]any{
305 "format": map[string]any{"type": req.ResponseFormat.Type},
306 }
307 }
308 if req.Temperature != nil && !c.caps.ignoresTemperature {
309 body["temperature"] = *req.Temperature
310 }
311 if c.webSearch || len(req.Tools) > 0 {
312 tools := make([]map[string]any, 0, len(req.Tools)+1)
313 // Keep the server tool first and stable across turns. DeepSeek executes
314 // this tool itself; ordinary Reasonix tools remain function entries.
315 if c.webSearch {
316 tools = append(tools, map[string]any{"type": "web_search"})
317 }
318 for _, tool := range req.Tools {
319 parameters := tool.Parameters
320 if len(parameters) == 0 {
321 parameters = provider.CanonicalizeSchema(nil)
322 }
323 tools = append(tools, map[string]any{
324 "type": "function", "name": tool.Name, "description": tool.Description,
325 "parameters": json.RawMessage(parameters),
326 })
327 }
328 body["tools"] = tools
329 }
330 instructions, rest := splitInstructions(messages)
331 if instructions != "" {
332 body["instructions"] = instructions
333 }
334
335 c.mu.Lock()
336 previousID, expectedDigest := c.lastResponseID, c.expectedPrefixDigest
337 c.mu.Unlock()
338 if c.mode == "stateful" && previousID != "" && len(messages) > 0 &&
339 messages[len(messages)-1].Role == provider.RoleUser &&
340 c.conversationDigest(messages[:len(messages)-1]) == expectedDigest {
341 body["input"] = messages[len(messages)-1].Content
342 body["previous_response_id"] = previousID
343 return body, true, messages
344 }
345
346 body["input"] = messagesToInput(rest, c.vision, c.vendor == "deepseek", c.caps.summaryRequired)
347 return body, false, messages
348 }
349
350 func splitInstructions(messages []provider.Message) (string, []provider.Message) {
351 if len(messages) == 0 || messages[0].Role != provider.RoleSystem {
352 return "", messages
353 }
354 return messages[0].Content, messages[1:]
355 }
356
357 func messagesToInput(messages []provider.Message, vision, replayDeepSeekItems, summary bool) []map[string]any {
358 input := make([]map[string]any, 0, len(messages)*2)
359 for _, message := range messages {
360 switch message.Role {
361 case provider.RoleSystem, provider.RoleUser:
362 // Text-only turns keep the documented TextInput string shape.
363 // Vision-capable user turns with attached images switch to the
364 // InputItemList array form ({type:input_text} + {type:input_image})
365 // so the text and every image ride the same message, matching the
366 // MiMo/DashScope multimodal example. The system message is always
367 // plain text: images only attach to user turns.
368 if vision && message.Role == provider.RoleUser && len(message.Images) > 0 {
369 parts := make([]map[string]string, 0, len(message.Images)+1)
370 if message.Content != "" {
371 parts = append(parts, map[string]string{"type": "input_text", "text": message.Content})
372 }
373 for _, url := range message.Images {
374 parts = append(parts, map[string]string{"type": "input_image", "image_url": url})
375 }
376 input = append(input, map[string]any{"role": "user", "content": parts})
377 } else {
378 input = append(input, map[string]any{"role": string(message.Role), "content": message.Content})
379 }
380 case provider.RoleAssistant:
381 if message.ReasoningContent != "" {
382 // Reasoning items: the OpenAI base format only needs
383 // `content`. DashScope additionally requires a `summary`
384 // list ("Invalid 'summary': summary is required and must be
385 // a list for reasoning."). Other vendors (MiMo) do not
386 // define summary in their schema; sending it leaks the
387 // reasoning text into an extra field the server may echo
388 // back into the model context, doubling chain-of-thought
389 // each turn — so only send it where the wire demands it.
390 item := map[string]any{
391 "type": "reasoning",
392 "content": []map[string]string{{"type": "reasoning_text", "text": message.ReasoningContent}},
393 }
394 if message.ReasoningID != "" {
395 // OpenAI Responses schema marks Reasoning.id required;
396 // round-trip the provider-issued id when we captured one.
397 item["id"] = message.ReasoningID
398 }
399 if message.ReasoningStatus != "" {
400 item["status"] = message.ReasoningStatus
401 }
402 if summary {
403 item["summary"] = []map[string]string{{"type": "summary_text", "text": message.ReasoningContent}}
404 }
405 input = append(input, item)
406 }
407 if replayDeepSeekItems {
408 for _, raw := range message.ResponsesItems {
409 if item, ok := decodeReplayableWebSearchItem(raw); ok {
410 input = append(input, item)
411 }
412 }
413 }
414 if message.Content != "" || len(message.ToolCalls) == 0 {
415 input = append(input, map[string]any{"role": "assistant", "content": message.Content})
416 }
417 for _, call := range message.ToolCalls {
418 input = append(input, map[string]any{
419 "type": "function_call", "call_id": call.ID,
420 "name": call.Name, "arguments": call.Arguments,
421 })
422 }
423 case provider.RoleTool:
424 input = append(input, map[string]any{
425 "type": "function_call_output", "call_id": message.ToolCallID, "output": message.Content,
426 })
427 }
428 }
429 return input
430 }
431
432 func decodeReplayableWebSearchItem(raw json.RawMessage) (map[string]any, bool) {
433 if len(raw) == 0 || len(raw) > maxReplayableSearchItemBytes || !json.Valid(raw) {
434 return nil, false
435 }
436 var item map[string]any
437 if err := json.Unmarshal(raw, &item); err != nil || item["type"] != "web_search_call" {
438 return nil, false
439 }
440 id, _ := item["id"].(string)
441 status, _ := item["status"].(string)
442 if strings.TrimSpace(id) == "" || status != "completed" {
443 return nil, false
444 }
445 return item, true
446 }
447
448 func (c *client) conversationDigest(messages []provider.Message) string {
449 instructions, rest := splitInstructions(messages)
450 // Digest must mirror the wire exactly: the stateful fast path compares
451 // this against the previous request's input, so a mismatch would skip
452 // previous_response_id and force a full replay (cache-hit loss). Use the
453 // same vision/summary knobs as buildRequestBody.
454 payload, _ := json.Marshal(struct {
455 Instructions string `json:"instructions,omitempty"`
456 Input []map[string]any `json:"input"`
457 }{Instructions: instructions, Input: messagesToInput(rest, c.vision, c.vendor == "deepseek", c.caps.summaryRequired)})
458 sum := sha256.Sum256(payload)
459 return hex.EncodeToString(sum[:])
460 }
461
462 type streamedCall struct {
463 id, name, arguments string
464 argChars int
465 completed bool
466 }
467
468 func (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk, requestMessages []provider.Message) {
469 defer resp.Body.Close()
470 defer close(out)
471
472 scanner := bufio.NewScanner(resp.Body)
473 scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
474 idle := c.idleTimeout
475 if idle <= 0 {
476 idle = defaultStreamIdleTimeout
477 }
478 watchDone := make(chan struct{})
479 activity := make(chan struct{}, 1)
480 var stalled atomic.Bool
481 go func() {
482 timer := time.NewTimer(idle)
483 defer timer.Stop()
484 for {
485 select {
486 case <-ctx.Done():
487 _ = resp.Body.Close()
488 return
489 case <-watchDone:
490 return
491 case <-activity:
492 if !timer.Stop() {
493 select {
494 case <-timer.C:
495 default:
496 }
497 }
498 timer.Reset(idle)
499 case <-timer.C:
500 stalled.Store(true)
501 _ = resp.Body.Close()
502 return
503 }
504 }
505 }()
506 defer close(watchDone)
507
508 calls := make(map[string]*streamedCall)
509 callOrder := make([]string, 0)
510 callForItem := func(itemID string) *streamedCall {
511 if call := calls[itemID]; call != nil {
512 return call
513 }
514 call := &streamedCall{id: itemID}
515 calls[itemID] = call
516 callOrder = append(callOrder, itemID)
517 return call
518 }
519 textDeltas := make(map[string]bool)
520 reasoningDeltas := make(map[string]bool)
521 seenSearchItems := make(map[string]struct{})
522 var responsesItems []json.RawMessage
523 var text, reasoning strings.Builder
524 reasoningID := ""
525 reasoningStatus := ""
526 terminal := false
527 failed := false
528 completedResponseID := ""
529
530 for scanner.Scan() {
531 select {
532 case activity <- struct{}{}:
533 default:
534 }
535 line := scanner.Text()
536 if !strings.HasPrefix(line, "data:") {
537 continue
538 }
539 data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
540 if data == "[DONE]" {
541 terminal = true
542 break
543 }
544 var event sseEvent
545 if json.Unmarshal([]byte(data), &event) != nil {
546 continue
547 }
548 key := fmt.Sprintf("%s:%d", event.ItemID, event.ContentIndex)
549 switch event.Type {
550 case "response.output_text.delta":
551 textDeltas[key] = true
552 text.WriteString(event.Delta)
553 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkText, Text: event.Delta}) {
554 return
555 }
556 case "response.output_text.done":
557 if event.Text != "" && !textDeltas[key] {
558 text.WriteString(event.Text)
559 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkText, Text: event.Text}) {
560 return
561 }
562 }
563 case "response.reasoning_text.delta", "response.reasoning_summary_text.delta":
564 reasoningDeltas[key] = true
565 reasoning.WriteString(event.Delta)
566 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkReasoning, Text: event.Delta}) {
567 return
568 }
569 case "response.reasoning_text.done", "response.reasoning_summary_text.done":
570 if event.Text != "" && !reasoningDeltas[key] {
571 reasoning.WriteString(event.Text)
572 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkReasoning, Text: event.Text}) {
573 return
574 }
575 }
576 case "response.output_item.added":
577 if event.Item != nil {
578 switch event.Item.Type {
579 case "function_call":
580 call := callForItem(event.Item.ID)
581 call.id = event.Item.CallID
582 call.name = event.Item.Name
583 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: call.id, Name: call.name}}) {
584 return
585 }
586 case "reasoning":
587 // Capture the provider-issued reasoning item id so the
588 // next turn's input reasoning item can carry it (the
589 // OpenAI Responses schema marks Reasoning.id required).
590 if event.Item.ID != "" {
591 // 多段推理(DeepSeek 长思考分多段)时末段 id 覆盖:round-trip
592 // 合并为一个 reasoning item 只带末段 id(服务端接受)。
593 reasoningID = event.Item.ID
594 }
595 }
596 }
597 case "response.function_call_arguments.delta":
598 call := callForItem(event.ItemID)
599 call.arguments += event.Delta
600 call.argChars += len(event.Delta)
601 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkToolCallArgsDelta, ToolCall: &provider.ToolCall{ID: call.id, Name: call.name}, ArgChars: call.argChars}) {
602 return
603 }
604 case "response.function_call_arguments.done":
605 call := callForItem(event.ItemID)
606 if event.Arguments != "" {
607 call.arguments = event.Arguments
608 }
609 if !call.completed {
610 call.completed = true
611 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: call.id, Name: call.name, Arguments: call.arguments}}) {
612 return
613 }
614 }
615 case "response.output_item.done":
616 if event.Item != nil && event.Item.Type == "web_search_call" && c.vendor == "deepseek" {
617 if _, ok := decodeReplayableWebSearchItem(event.Item.Raw); ok {
618 key := event.Item.ID
619 if key == "" {
620 key = string(event.Item.Raw)
621 }
622 if _, seen := seenSearchItems[key]; !seen {
623 seenSearchItems[key] = struct{}{}
624 raw := append(json.RawMessage(nil), event.Item.Raw...)
625 responsesItems = append(responsesItems, raw)
626 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkResponsesItem, ResponsesItem: raw}) {
627 return
628 }
629 }
630 }
631 }
632 if event.Item != nil {
633 switch event.Item.Type {
634 case "function_call":
635 call := callForItem(event.Item.ID)
636 if event.Item.CallID != "" {
637 call.id = event.Item.CallID
638 }
639 if event.Item.Name != "" {
640 call.name = event.Item.Name
641 }
642 if event.Item.Arguments != "" {
643 call.arguments = event.Item.Arguments
644 }
645 if !call.completed {
646 call.completed = true
647 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: call.id, Name: call.name, Arguments: call.arguments}}) {
648 return
649 }
650 }
651 case "reasoning":
652 // The done event carries the final item status
653 // ("completed" after the thinking stream finishes);
654 // round-trip it with the reasoning item so the input
655 // matches the wire schema.
656 if event.Item.Status != "" {
657 reasoningStatus = event.Item.Status
658 }
659 }
660 }
661 case "response.completed", "response.incomplete", "response.failed":
662 terminal = true
663 if event.Response != nil {
664 if event.Type == "response.completed" {
665 completedResponseID = event.Response.ID
666 }
667 usage := usageFromResponse(event.Response)
668 provider.ApplyRequestAttemptCount(ctx, usage)
669 if event.Type == "response.incomplete" {
670 switch event.Response.IncompleteDetails.Reason {
671 case "max_output_tokens":
672 usage.FinishReason = "length"
673 case "content_filter":
674 usage.FinishReason = "content_filter"
675 default:
676 usage.FinishReason = "incomplete"
677 }
678 } else if event.Type == "response.completed" && usage.FinishReason == "" {
679 // A completed response finished normally (stop). Preserve any
680 // vendor-specific reason already set by usageFromResponse.
681 usage.FinishReason = "stop"
682 }
683 // DashScope occasionally reports a completed event whose usage
684 // object exists but is all zeros (server-side reporting gap; the
685 // tokens were actually billed). Emitting that as ChunkUsage
686 // would corrupt cache-ratio and cost accounting with a spurious
687 // zero record. 但完成语义必须保留:全零+stop 也发送——计费层
688 // (Pricing.Cost)对全零记录天然返回 0 成本,不污染统计;而
689 // agent 侧 reasoningOnlyFinishHonoured 依赖收到 usage 对象
690 // (FinishReason=stop)才能确认 reasoning-only 完成(#7168
691 // 评审"完成语义保留"的完整实现——此前 stop 被抑制时该语义
692 // 失效,空回复被误判触发重试)。异常终止 reason
693 // (length/content_filter/...)始终上报。
694 if usage.TotalTokens > 0 || usage.FinishReason != "" {
695
696 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkUsage, Usage: usage}) {
697 return
698 }
699 }
700 }
701 if event.Type == "response.failed" {
702 failed = true
703 err := fmt.Errorf("responses: response failed")
704 if event.Response != nil && event.Response.Error != nil {
705 if authErr := authErrorFromResponse(c, event.Response.Error); authErr != nil {
706 err = authErr
707 } else {
708 err = fmt.Errorf("responses: %s", event.Response.Error.Message)
709 }
710 }
711 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkError, Err: err}) {
712 return
713 }
714 }
715 }
716 if terminal {
717 break
718 }
719 }
720
721 if ctx.Err() != nil {
722 return
723 }
724 if err := scanner.Err(); err != nil {
725 var reason string
726 if stalled.Load() {
727 err = fmt.Errorf("responses: stream idle timeout after %s", idle)
728 reason = provider.StreamInterruptIdleTimeout
729 } else {
730 reason = provider.ClassifyStreamInterrupt(err)
731 }
732 _ = sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkError, Err: provider.StreamInterrupt(err, reason)})
733 return
734 }
735 // Protocol-defined terminal response events are required. Connection close
736 // before a terminal event leaves the attempt uncommitted — including any
737 // complete tool calls already forwarded as speculative output.
738 if !terminal {
739 _ = sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkError, Err: provider.StreamInterrupt(io.ErrUnexpectedEOF, provider.StreamInterruptPrematureEOF)})
740 return
741 }
742 if completedResponseID != "" {
743 assistant := provider.Message{Role: provider.RoleAssistant, Content: text.String(), ReasoningContent: reasoning.String(), ReasoningID: reasoningID, ReasoningStatus: reasoningStatus, ResponsesItems: responsesItems}
744 for _, itemID := range callOrder {
745 call := calls[itemID]
746 if call.completed {
747 assistant.ToolCalls = append(assistant.ToolCalls, provider.ToolCall{ID: call.id, Name: call.name, Arguments: call.arguments})
748 }
749 }
750 expected := append(append([]provider.Message(nil), requestMessages...), assistant)
751 c.mu.Lock()
752 c.lastResponseID = completedResponseID
753 c.expectedPrefixDigest = c.conversationDigest(expected)
754 c.mu.Unlock()
755 } else {
756 c.ResetContext()
757 }
758 if !failed {
759 // 把 reasoning item 的 id/status 作为元数据 chunk 流给 Agent
760 // (空 Text,随 ChunkReasoning 语义)——Agent 持久化进 session,
761 // 下一轮 input reasoning item 回传 id/status(评审 #7234 第 1 点)。
762 if reasoningID != "" || reasoningStatus != "" {
763 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkReasoning, ReasoningID: reasoningID, ReasoningStatus: reasoningStatus}) {
764 return
765 }
766 }
767 _ = sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkDone})
768 }
769 }
770
771 func sendChunk(ctx context.Context, out chan<- provider.Chunk, chunk provider.Chunk) bool {
772 select {
773 case out <- chunk:
774 return true
775 default:
776 }
777 select {
778 case out <- chunk:
779 return true
780 case <-ctx.Done():
781 return false
782 }
783 }
784
785 func usageFromResponse(response *sseResponse) *provider.Usage {
786 usage := &provider.Usage{}
787 if response == nil || response.Usage == nil {
788 return usage
789 }
790 u := response.Usage
791 cached, reasoning := 0, 0
792 if u.InputTokensDetails != nil {
793 cached = u.InputTokensDetails.CachedTokens
794 }
795 if u.OutputTokensDetails != nil {
796 reasoning = u.OutputTokensDetails.ReasoningTokens
797 }
798 miss := u.InputTokens - cached
799 if miss < 0 {
800 miss = 0
801 }
802 total := u.TotalTokens
803 if total == 0 {
804 total = u.InputTokens + u.OutputTokens
805 }
806 return &provider.Usage{PromptTokens: u.InputTokens, CompletionTokens: u.OutputTokens, TotalTokens: total, CacheHitTokens: cached, CacheMissTokens: miss, ReasoningTokens: reasoning}
807 }
808
809 func authErrorFromResponse(c *client, responseError *sseError) error {
810 if responseError == nil {
811 return nil
812 }
813 value := strings.ToLower(responseError.Code + " " + responseError.Message)
814 if !strings.Contains(value, "auth") && !strings.Contains(value, "api key") && !strings.Contains(value, "unauthorized") && !strings.Contains(value, "forbidden") && !strings.Contains(value, "permission") {
815 return nil
816 }
817 status := http.StatusUnauthorized
818 if strings.Contains(value, "forbidden") || strings.Contains(value, "permission") {
819 status = http.StatusForbidden
820 }
821 return &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, KeySource: c.keySource, Status: status, HasKey: c.apiKey != "", Body: responseError.Message}
822 }
823
824 type sseEvent struct {
825 Type string `json:"type"`
826 Delta string `json:"delta"`
827 Text string `json:"text"`
828 Arguments string `json:"arguments"`
829 ItemID string `json:"item_id"`
830 ContentIndex int `json:"content_index"`
831 Item *sseItem `json:"item"`
832 Response *sseResponse `json:"response"`
833 }
834
835 type sseItem struct {
836 ID, Type, CallID, Name, Arguments, Status string
837 Raw json.RawMessage
838 }
839
840 func (i *sseItem) UnmarshalJSON(data []byte) error {
841 var wire struct {
842 ID string `json:"id"`
843 Type string `json:"type"`
844 CallID string `json:"call_id"`
845 Name string `json:"name"`
846 Arguments string `json:"arguments"`
847 Status string `json:"status"`
848 }
849 if err := json.Unmarshal(data, &wire); err != nil {
850 return err
851 }
852 *i = sseItem{ID: wire.ID, Type: wire.Type, CallID: wire.CallID, Name: wire.Name, Arguments: wire.Arguments, Status: wire.Status, Raw: append(json.RawMessage(nil), data...)}
853 return nil
854 }
855
856 type sseResponse struct {
857 ID string `json:"id"`
858 Usage *sseUsage `json:"usage"`
859 Error *sseError `json:"error"`
860 IncompleteDetails incompleteDetails `json:"incomplete_details"`
861 }
862
863 type incompleteDetails struct {
864 Reason string `json:"reason"`
865 }
866 type sseError struct {
867 Message string `json:"message"`
868 Code string `json:"code"`
869 }
870 type sseUsage struct {
871 InputTokens int `json:"input_tokens"`
872 OutputTokens int `json:"output_tokens"`
873 TotalTokens int `json:"total_tokens"`
874 InputTokensDetails *struct {
875 CachedTokens int `json:"cached_tokens"`
876 } `json:"input_tokens_details"`
877 OutputTokensDetails *struct {
878 ReasoningTokens int `json:"reasoning_tokens"`
879 } `json:"output_tokens_details"`
880 }
881
881 lines GO