返回 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 "maps"
17 "net/http"
18 "strings"
19 "sync"
20 "sync/atomic"
21 "time"
22
23 "reasonix/internal/netclient"
24 "reasonix/internal/provider"
25 "reasonix/internal/provider/openai"
26 )
27
28 const (
29 defaultStreamIdleTimeout = 300 * time.Second
30 maxReplayableSearchItemBytes = 512 * 1024
31 )
32
33 func init() {
34 provider.RegisterReasoning("responses", ReasoningForConfig)
35 provider.RegisterReasoning("dashscope-responses", ReasoningForConfig)
36 provider.Register("responses", newFromConfig)
37 provider.Register("dashscope-responses", newFromConfig)
38 }
39
40 // Config holds Responses API provider settings.
41 type Config struct {
42 HTTPClient *http.Client
43 Name string
44 DisplayName string
45 Protocol string
46 APIKey string
47 BaseURL string
48 Model string
49 ModelInfo *provider.ModelInfo
50 Effort string
51 Mode string // stateful | stateless; empty uses vendor detection.
52 Stateful *bool // legacy form of Mode; nil preserves vendor detection.
53 WebSearch bool // expose the provider-executed web_search tool.
54 Proxy netclient.ProxySpec
55 KeyEnv string
56 KeySource string
57 RequestURL string // optional exact Responses request URL; empty derives from BaseURL
58 // MaxOutputTokens is the total provider output budget. Zero omits the field
59 // on official DeepSeek (server 384K ceiling) and unknown endpoints; MiMo
60 // still applies its 16K/32K ladder. Negative values omit it.
61 MaxOutputTokens int
62 // SessionCache controls DashScope's opt-in header. The header is never sent
63 // to non-DashScope endpoints even when this value is true.
64 SessionCache *bool
65 // Extra carries kind-specific options; "vision" (bool) enables embedding
66 // attached Images as input_image parts on user turns.
67 Extra map[string]any
68 }
69
70 func (c Config) mode() string {
71 mode := strings.ToLower(strings.TrimSpace(c.Mode))
72 if mode == "stateful" || mode == "stateless" {
73 return mode
74 }
75 if c.Stateful != nil {
76 if *c.Stateful {
77 return "stateful"
78 }
79 return "stateless"
80 }
81 if capabilitiesFor(DetectVendor(c.BaseURL)).stateless {
82 return "stateless"
83 }
84 return "stateful"
85 }
86
87 // DetectVendor lives in vendor.go (capabilities table): it covers dashscope/
88 // deepseek (incl. eu.deepseek.com) / mimo via exact-host matching.
89
90 type client struct {
91 identityHeaders http.Header
92 reasoning provider.ReasoningCapability
93 name string
94 identity provider.RequestIdentity
95 apiKey, keyEnv, keySource string
96 baseURL, requestURL, model, effort string
97 vendor, mode string
98 caps vendorCapabilities
99 sessionCache bool
100 search provider.SearchPolicy
101 maxOutputTokens int
102 vision bool // model accepts image input; embed Images as input_image parts
103 modelInfo provider.ModelInfo
104 http *http.Client
105 idleTimeout time.Duration
106 authed atomic.Bool
107
108 mu sync.Mutex
109 lastResponseID string
110 expectedPrefixDigest string
111 }
112
113 // New creates a Responses API provider.
114 func New(cfg Config) provider.Provider {
115 cfg.Extra = maps.Clone(cfg.Extra)
116 if cfg.Extra == nil {
117 cfg.Extra = map[string]any{}
118 }
119 if cfg.RequestURL != "" {
120 cfg.Extra["request_url"] = cfg.RequestURL
121 }
122 resolved := provider.ApplyOpenCodeGoContract("responses", provider.Config{BaseURL: cfg.BaseURL, Model: cfg.Model, Extra: cfg.Extra})
123 cfg.Extra = resolved.Extra
124 vendor := DetectVendor(cfg.BaseURL)
125 cap := capabilitiesFor(vendor)
126 // Explicit replay contracts apply to compatible gateways as well as exact
127 // vendor hosts. Do not inherit endpoint defaults, headers, or output limits.
128 if protocol, _ := cfg.Extra["reasoning_protocol"].(string); strings.EqualFold(strings.TrimSpace(protocol), "deepseek") || strings.EqualFold(strings.TrimSpace(protocol), "mimo") {
129 cap.toolCallReasoning = true
130 }
131 maxOutputTokens := cfg.MaxOutputTokens
132 // Official DeepSeek omits max_output_tokens (server 384K). MiMo still uses
133 // the 16K/32K effort ladder. Compact_ratio is independent.
134 if maxOutputTokens == 0 && vendor == "mimo" {
135 maxOutputTokens = responsesAutoOutputBudget(vendor, cfg.Effort)
136 } else if maxOutputTokens == 0 && vendor != "deepseek" && cap.defaultMaxOutputTokens > 0 {
137 maxOutputTokens = cap.defaultMaxOutputTokens
138 }
139 sessionCache := cap.sessionCacheHeader
140 if cfg.SessionCache != nil {
141 sessionCache = *cfg.SessionCache
142 }
143 vision, _ := cfg.Extra["vision"].(bool)
144 if cfg.ModelInfo != nil {
145 vision = cfg.ModelInfo.SupportsInput(provider.ModalityImage)
146 }
147 // Official DeepSeek image input is pinned to one SKU. Ignore metadata or
148 // Extra["vision"] for Flash/Pro.
149 vision = openai.DeepSeekImageInputAllowed(vendor == "deepseek", cfg.RequestURL, cfg.Model, cfg.ModelInfo != nil, vision)
150 httpClient := &http.Client{}
151 if built, err := netclient.NewHTTPClient(cfg.Proxy, netclient.TransportOptions{
152 DialTimeout: 30 * time.Second, KeepAlive: 30 * time.Second,
153 TLSHandshakeTimeout: 15 * time.Second, ResponseHeaderTimeout: 300 * time.Second,
154 }); err == nil {
155 httpClient = built
156 }
157 if cfg.HTTPClient != nil {
158 httpClient = cfg.HTTPClient
159 }
160 baseURL := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
161 requestURL := strings.TrimSpace(cfg.RequestURL)
162 if requestURL == "" {
163 requestURL = baseURL + "/responses"
164 }
165 modelInfo := provider.ModelInfo{ID: cfg.Model, InputModalities: []provider.ModelModality{provider.ModalityText}}
166 if cfg.ModelInfo != nil {
167 modelInfo = *cfg.ModelInfo
168 modelInfo.ID = cfg.Model
169 }
170 clientWebSearch, _ := cfg.Extra["client_web_search"].(bool)
171 if reject, _ := cfg.Extra["reject_redirects"].(bool); reject {
172 httpClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
173 }
174 if vision {
175 modelInfo.InputModalities = []provider.ModelModality{provider.ModalityText, provider.ModalityImage}
176 } else if modelInfo.SupportsInput(provider.ModalityImage) {
177 modelInfo.InputModalities = []provider.ModelModality{provider.ModalityText}
178 }
179 return &client{
180 identityHeaders: provider.NewClientIdentityHeaders(),
181 name: cfg.Name,
182 identity: provider.RequestIdentity{Provider: cfg.Name, DisplayName: cfg.DisplayName, Protocol: cfg.Protocol},
183 apiKey: cfg.APIKey, keyEnv: cfg.KeyEnv, keySource: cfg.KeySource,
184 reasoning: ReasoningForConfig(provider.Config{BaseURL: cfg.BaseURL, Model: cfg.Model, Extra: cfg.Extra}),
185 baseURL: baseURL, requestURL: requestURL, model: cfg.Model, effort: cfg.Effort,
186 vendor: vendor, caps: cap, mode: cfg.mode(), sessionCache: sessionCache, search: provider.SearchPolicy{NativeEnabled: cfg.WebSearch, ClientEnabled: clientWebSearch}, maxOutputTokens: maxOutputTokens,
187 vision: vision,
188 modelInfo: modelInfo,
189 http: httpClient, idleTimeout: defaultStreamIdleTimeout,
190 }
191 }
192
193 func (c *client) ModelInfo() provider.ModelInfo {
194 if c == nil {
195 return provider.ModelInfo{}
196 }
197 info := c.modelInfo
198 info.InputModalities = append([]provider.ModelModality(nil), info.InputModalities...)
199 return info
200 }
201
202 func responsesReasoningDisabled(effort string) bool {
203 switch strings.ToLower(strings.TrimSpace(effort)) {
204 case "none", "disabled", "off":
205 return true
206 default:
207 return false
208 }
209 }
210
211 // responsesAutoOutputBudget is the MiMo (and similar) 16K/32K ladder.
212 // Official DeepSeek must not call this; it omits max_output_tokens instead.
213 func responsesAutoOutputBudget(vendor, effort string) int {
214 if responsesReasoningDisabled(effort) {
215 return provider.AutoOutputBudget(false, effort)
216 }
217 e := strings.ToLower(strings.TrimSpace(effort))
218 if vendor == "deepseek" && (e == "" || e == "auto") {
219 e = "high"
220 }
221 return provider.AutoOutputBudget(true, e)
222 }
223
224 func (c *client) Name() string { return c.name }
225
226 func (c *client) NativeToolSearchAvailable() bool {
227 return c != nil && provider.IsFirstPartyOpenAI(c.baseURL) && nativeToolSearchModel(c.model)
228 }
229
230 func nativeToolSearchModel(model string) bool {
231 model = strings.ToLower(strings.TrimSpace(model))
232 return strings.HasPrefix(model, "gpt-5.4") || strings.HasPrefix(model, "gpt-5.5") || strings.HasPrefix(model, "gpt-5.6")
233 }
234
235 func (c *client) sendOpts() provider.SendOptions {
236 return provider.SendOptions{Provider: c.name, ProviderDisplayName: c.identity.DisplayName, Protocol: c.identity.Protocol, KeyEnv: c.keyEnv, KeySource: c.keySource, KeyPresent: c.apiKey != "", RetryAuth: c.authed.Load()}
237 }
238
239 // ResetContext drops stateful continuation metadata. Full-input stateless mode
240 // is unaffected.
241 func (c *client) ResetContext() {
242 c.mu.Lock()
243 c.lastResponseID = ""
244 c.expectedPrefixDigest = ""
245 c.mu.Unlock()
246 }
247
248 func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
249 if c.effort != "auto" && c.effort != "off" {
250 if err := c.reasoning.Validate(c.model, c.effort); err != nil {
251 return nil, err
252 }
253 }
254 if err := c.reasoning.Validate(c.model, req.EffortOverride); err != nil {
255 return nil, err
256 }
257 requestCtx := provider.WithRequestAttemptCounter(ctx)
258 body, usedPrevious, wireMessages := c.buildRequestBody(req)
259 resp, err := c.send(requestCtx, body)
260 if err != nil && usedPrevious && isStalePreviousResponseError(err) {
261 // A stateful response ID may expire server-side. Retrying once with full
262 // history is safe because no response body has started streaming.
263 c.ResetContext()
264 body, _, wireMessages = c.buildRequestBody(req)
265 resp, err = c.send(requestCtx, body)
266 }
267 if err != nil {
268 return nil, err
269 }
270 c.authed.Store(true)
271 out := make(chan provider.Chunk, 64)
272 go c.readStream(requestCtx, resp, out, wireMessages)
273 return out, nil
274 }
275
276 func (c *client) send(ctx context.Context, body map[string]any) (*http.Response, error) {
277 payload, err := json.Marshal(body)
278 if err != nil {
279 return nil, fmt.Errorf("responses: marshal request: %w", err)
280 }
281 newRequest := func(ctx context.Context) (*http.Request, error) {
282 req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.requestURL, bytes.NewReader(payload))
283 if err != nil {
284 return nil, err
285 }
286 req.Header.Set("Content-Type", "application/json")
287 req.Header.Set("Authorization", "Bearer "+c.apiKey)
288 provider.ApplyOpenCodeGoHeaders(req, c.baseURL, c.identityHeaders)
289 if c.caps.sessionCacheHeader && c.sessionCache {
290 req.Header.Set("x-dashscope-session-cache", "enable")
291 }
292 return req, nil
293 }
294 return provider.SendWithRetry(ctx, c.http, c.sendOpts(), newRequest)
295 }
296
297 func isStalePreviousResponseError(err error) bool {
298 var apiErr *provider.APIError
299 if !errors.As(err, &apiErr) || apiErr.Status != http.StatusBadRequest {
300 return false
301 }
302 body := strings.ToLower(apiErr.Body)
303 mentionsID := strings.Contains(body, "previous_response_id") || strings.Contains(body, "previous response") || strings.Contains(body, "response id")
304 return mentionsID &&
305 (strings.Contains(body, "not found") || strings.Contains(body, "invalid") || strings.Contains(body, "expired"))
306 }
307
308 func (c *client) buildRequestBody(req provider.Request) (map[string]any, bool, []provider.Message) {
309 messages := provider.SanitizeToolPairing(provider.ModelMessages(req.Messages))
310 body := map[string]any{"model": c.model, "stream": true}
311
312 effort := c.effort
313 if req.EffortOverride != "" {
314 effort = req.EffortOverride
315 }
316
317 switch effort {
318 case "auto":
319 effort = ""
320 case "disabled", "off":
321 effort = "none"
322 }
323 if effort != "" {
324 body["reasoning"] = map[string]any{"effort": effort}
325 }
326 maxOutputTokens := req.MaxTokens
327 if maxOutputTokens == 0 {
328 maxOutputTokens = c.maxOutputTokens
329 }
330 if maxOutputTokens == 0 && c.vendor == "mimo" {
331 maxOutputTokens = responsesAutoOutputBudget(c.vendor, c.effort)
332 } else if maxOutputTokens == 0 && c.vendor != "deepseek" && c.caps.defaultMaxOutputTokens > 0 {
333 maxOutputTokens = c.caps.defaultMaxOutputTokens
334 }
335 if maxOutputTokens > 0 {
336 body["max_output_tokens"] = maxOutputTokens
337 }
338 if req.ResponseFormat != nil && req.ResponseFormat.Type != "" {
339 // Structured output: Responses text.format. MiMo/DashScope/OpenAI
340 // all accept {"text":{"format":{"type":"json_object"}}}. The model
341 // only emits JSON when the instructions also demand it.
342 body["text"] = map[string]any{
343 "format": map[string]any{"type": req.ResponseFormat.Type},
344 }
345 }
346 if req.Temperature != nil && !c.caps.ignoresTemperature {
347 body["temperature"] = *req.Temperature
348 }
349 if c.search.NativeEnabled || len(req.Tools) > 0 {
350 body["tools"] = encodeResponsesTools(c, req)
351 }
352 instructions, rest := splitInstructions(messages)
353 if instructions != "" {
354 body["instructions"] = instructions
355 }
356
357 c.mu.Lock()
358 previousID, expectedDigest := c.lastResponseID, c.expectedPrefixDigest
359 c.mu.Unlock()
360 if c.canUseStatefulContinuation(messages, previousID, expectedDigest) {
361 body["input"] = messages[len(messages)-1].Content
362 body["previous_response_id"] = previousID
363 return body, true, messages
364 }
365
366 body["input"] = messagesToInput(rest, c.vision, c.search.NativeEnabled, c.caps.summaryRequired)
367 return body, false, messages
368 }
369
370 func inputImagePart(ref string) map[string]string {
371 switch provider.ClassifyImage(ref) {
372 case provider.ImageFileID:
373 return map[string]string{"type": "input_image", "file_id": ref}
374 case provider.ImageDataURL, provider.ImageHTTPURL:
375 return map[string]string{"type": "input_image", "image_url": ref}
376 default:
377 return nil
378 }
379 }
380
381 func splitInstructions(messages []provider.Message) (string, []provider.Message) {
382 if len(messages) == 0 || messages[0].Role != provider.RoleSystem {
383 return "", messages
384 }
385 return messages[0].Content, messages[1:]
386 }
387
388 func decodeReplayableWebSearchItem(raw json.RawMessage) (map[string]any, bool) {
389 if len(raw) == 0 || len(raw) > maxReplayableSearchItemBytes || !json.Valid(raw) {
390 return nil, false
391 }
392 var item map[string]any
393 if err := json.Unmarshal(raw, &item); err != nil || item["type"] != "web_search_call" {
394 return nil, false
395 }
396 id, _ := item["id"].(string)
397 status, _ := item["status"].(string)
398 if strings.TrimSpace(id) == "" || status != "completed" {
399 return nil, false
400 }
401 return item, true
402 }
403
404 func (c *client) conversationDigest(messages []provider.Message) string {
405 instructions, rest := splitInstructions(messages)
406 // Digest must mirror the wire exactly: the stateful fast path compares
407 // this against the previous request's input, so a mismatch would skip
408 // previous_response_id and force a full replay (cache-hit loss). Use the
409 // same vision/summary knobs as buildRequestBody.
410 payload, _ := json.Marshal(struct {
411 Instructions string `json:"instructions,omitempty"`
412 Input []map[string]any `json:"input"`
413 }{Instructions: instructions, Input: messagesToInput(rest, c.vision, c.search.NativeEnabled, c.caps.summaryRequired)})
414 sum := sha256.Sum256(payload)
415 return hex.EncodeToString(sum[:])
416 }
417
418 type streamedCall struct {
419 id, name, arguments string
420 argChars int
421 completed bool
422 }
423
424 func (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk, requestMessages []provider.Message) {
425 defer resp.Body.Close()
426 defer close(out)
427
428 scanner := bufio.NewScanner(resp.Body)
429 scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
430 idle := c.idleTimeout
431 if idle <= 0 {
432 idle = defaultStreamIdleTimeout
433 }
434 watchDone := make(chan struct{})
435 activity := make(chan struct{}, 1)
436 var reasoningSnapshots responseReasoningSnapshots
437 var stalled atomic.Bool
438 go func() {
439 timer := time.NewTimer(idle)
440 defer timer.Stop()
441 for {
442 select {
443 case <-ctx.Done():
444 _ = resp.Body.Close()
445 return
446 case <-watchDone:
447 return
448 case <-activity:
449 if !timer.Stop() {
450 select {
451 case <-timer.C:
452 default:
453 }
454 }
455 timer.Reset(idle)
456 case <-timer.C:
457 stalled.Store(true)
458 _ = resp.Body.Close()
459 return
460 }
461 }
462 }()
463 defer close(watchDone)
464
465 calls := make(map[string]*streamedCall)
466 callOrder := make([]string, 0)
467 callForItem := func(itemID string) *streamedCall {
468 if call := calls[itemID]; call != nil {
469 return call
470 }
471 call := &streamedCall{id: itemID}
472 calls[itemID] = call
473 callOrder = append(callOrder, itemID)
474 return call
475 }
476 textDeltas := make(map[string]bool)
477 reasoningDeltas := make(map[string]bool)
478 seenSearchItems := make(map[string]struct{})
479 var responsesItems []json.RawMessage
480 var text, reasoning strings.Builder
481 reasoningID := ""
482 reasoningStatus := ""
483 terminal := false
484 failed := false
485 completedResponseID := ""
486
487 for scanner.Scan() {
488 select {
489 case activity <- struct{}{}:
490 default:
491 }
492 line := scanner.Text()
493 if !strings.HasPrefix(line, "data:") {
494 continue
495 }
496 data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
497 if data == "[DONE]" {
498 terminal = true
499 break
500 }
501 var event sseEvent
502 if json.Unmarshal([]byte(data), &event) != nil {
503 continue
504 }
505 key := fmt.Sprintf("%s:%d", event.ItemID, event.ContentIndex)
506 reasoningSnapshots.capture(event)
507 switch event.Type {
508 case "response.output_text.delta":
509 textDeltas[key] = true
510 text.WriteString(event.Delta)
511 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkText, Text: event.Delta}) {
512 return
513 }
514 case "response.output_text.done":
515 if event.Text != "" && !textDeltas[key] {
516 text.WriteString(event.Text)
517 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkText, Text: event.Text}) {
518 return
519 }
520 }
521 case "response.reasoning_text.delta", "response.reasoning_summary_text.delta":
522 reasoningDeltas[key] = true
523 reasoning.WriteString(event.Delta)
524 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkReasoning, Text: event.Delta}) {
525 return
526 }
527 case "response.reasoning_text.done", "response.reasoning_summary_text.done":
528 if event.Text != "" && !reasoningDeltas[key] {
529 reasoning.WriteString(event.Text)
530 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkReasoning, Text: event.Text}) {
531 return
532 }
533 }
534 case "response.output_item.added":
535 if event.Item != nil {
536 switch event.Item.Type {
537 case "function_call":
538 call := callForItem(event.Item.ID)
539 call.id = event.Item.CallID
540 call.name = event.Item.Name
541 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: call.id, Name: call.name}}) {
542 return
543 }
544 case "reasoning":
545 // Capture the provider-issued reasoning item id so the
546 // next turn's input reasoning item can carry it (the
547 // OpenAI Responses schema marks Reasoning.id required).
548 if event.Item.ID != "" {
549 // 多段推理(DeepSeek 长思考分多段)时末段 id 覆盖:round-trip
550 // 合并为一个 reasoning item 只带末段 id(服务端接受)。
551 reasoningID = event.Item.ID
552 }
553 }
554 }
555 case "response.function_call_arguments.delta":
556 call := callForItem(event.ItemID)
557 call.arguments += event.Delta
558 call.argChars += len(event.Delta)
559 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkToolCallArgsDelta, ToolCall: &provider.ToolCall{ID: call.id, Name: call.name}, ArgChars: call.argChars}) {
560 return
561 }
562 case "response.function_call_arguments.done":
563 call := callForItem(event.ItemID)
564 if event.Arguments != "" {
565 call.arguments = event.Arguments
566 }
567 if !call.completed {
568 call.completed = true
569 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: call.id, Name: call.name, Arguments: call.arguments}}) {
570 return
571 }
572 }
573 case "response.output_item.done":
574 if event.Item != nil && event.Item.Type == "web_search_call" && c.search.NativeEnabled {
575 if _, ok := decodeReplayableWebSearchItem(event.Item.Raw); ok {
576 key := event.Item.ID
577 if key == "" {
578 key = string(event.Item.Raw)
579 }
580 if _, seen := seenSearchItems[key]; !seen {
581 seenSearchItems[key] = struct{}{}
582 raw := append(json.RawMessage(nil), event.Item.Raw...)
583 responsesItems = append(responsesItems, raw)
584 if !emitSearchReplay(ctx, out, raw) {
585 return
586 }
587 }
588 }
589 }
590 if event.Item != nil {
591 switch event.Item.Type {
592 case "function_call":
593 call := callForItem(event.Item.ID)
594 if event.Item.CallID != "" {
595 call.id = event.Item.CallID
596 }
597 if event.Item.Name != "" {
598 call.name = event.Item.Name
599 }
600 if event.Item.Arguments != "" {
601 call.arguments = event.Item.Arguments
602 }
603 if !call.completed {
604 call.completed = true
605 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: call.id, Name: call.name, Arguments: call.arguments}}) {
606 return
607 }
608 }
609 case "reasoning":
610 // The done event carries the final item status
611 // ("completed" after the thinking stream finishes);
612 // round-trip it with the reasoning item so the input
613 // matches the wire schema.
614 if event.Item.Status != "" {
615 reasoningStatus = event.Item.Status
616 }
617 }
618 }
619 case "response.completed", "response.incomplete", "response.failed":
620 terminal = true
621 if event.Type == "response.incomplete" {
622 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkReasoning, ReasoningState: provider.ReasoningIncomplete}) {
623 return
624 }
625 }
626 completedResponseID = terminalResponseID(event)
627 if !emitTerminalResponseUsage(ctx, out, event) {
628 return
629 }
630 if event.Type == "response.failed" {
631 failed = true
632 err := fmt.Errorf("responses: response failed")
633 if event.Response != nil && event.Response.Error != nil {
634 if authErr := authErrorFromResponse(c, event.Response.Error); authErr != nil {
635 err = authErr
636 } else {
637 err = fmt.Errorf("responses: %s", event.Response.Error.Message)
638 }
639 }
640 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkError, Err: err}) {
641 return
642 }
643 }
644 }
645 if terminal {
646 break
647 }
648 }
649
650 if ctx.Err() != nil {
651 return
652 }
653 if err := scanner.Err(); err != nil {
654 var reason string
655 if stalled.Load() {
656 err = fmt.Errorf("responses: stream idle timeout after %s", idle)
657 reason = provider.StreamInterruptIdleTimeout
658 } else {
659 reason = provider.ClassifyStreamInterrupt(err)
660 }
661 _ = sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkError, Err: provider.StreamInterrupt(err, reason)})
662 return
663 }
664 // Protocol-defined terminal response events are required. Connection close
665 // before a terminal event leaves the attempt uncommitted — including any
666 // complete tool calls already forwarded as speculative output.
667 if !terminal {
668 _ = sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkError, Err: provider.StreamInterrupt(io.ErrUnexpectedEOF, provider.StreamInterruptPrematureEOF)})
669 return
670 }
671 if !reasoningSnapshots.emit(ctx, out) {
672 return
673 }
674 responsesItems = append(responsesItems, reasoningSnapshots.items...)
675 if len(reasoningSnapshots.items) > 0 {
676 reasoningID, reasoningStatus = reasoningSnapshots.metadata()
677 }
678 if completedResponseID != "" {
679 assistant := provider.Message{Role: provider.RoleAssistant, Content: text.String(), ReasoningContent: reasoning.String(), ReasoningID: reasoningID, ReasoningStatus: reasoningStatus, ResponsesItems: responsesItems}
680 for _, itemID := range callOrder {
681 call := calls[itemID]
682 if call.completed {
683 assistant.ToolCalls = append(assistant.ToolCalls, provider.ToolCall{ID: call.id, Name: call.name, Arguments: call.arguments})
684 }
685 }
686 expected := append(append([]provider.Message(nil), requestMessages...), assistant)
687 c.mu.Lock()
688 c.lastResponseID = completedResponseID
689 c.expectedPrefixDigest = c.conversationDigest(expected)
690 c.mu.Unlock()
691 } else {
692 c.ResetContext()
693 }
694 if !failed {
695 // 把 reasoning item 的 id/status 作为元数据 chunk 流给 Agent
696 // (空 Text,随 ChunkReasoning 语义)——Agent 持久化进 session,
697 // 下一轮 input reasoning item 回传 id/status(评审 #7234 第 1 点)。
698 if reasoningID != "" || reasoningStatus != "" {
699 if !sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkReasoning, ReasoningID: reasoningID, ReasoningStatus: reasoningStatus}) {
700 return
701 }
702 }
703 _ = sendChunk(ctx, out, provider.Chunk{Type: provider.ChunkDone})
704 }
705 }
706
707 func sendChunk(ctx context.Context, out chan<- provider.Chunk, chunk provider.Chunk) bool {
708 select {
709 case out <- chunk:
710 return true
711 default:
712 }
713 notifySendChunkEnterBlocking()
714 select {
715 case out <- chunk:
716 return true
717 case <-ctx.Done():
718 return false
719 }
720 }
721
722 func usageFromResponse(response *sseResponse) *provider.Usage {
723 usage := &provider.Usage{}
724 if response == nil || response.Usage == nil {
725 return usage
726 }
727 u := response.Usage
728 cached, reasoning := 0, 0
729 if u.InputTokensDetails != nil {
730 cached = u.InputTokensDetails.CachedTokens
731 }
732 if u.OutputTokensDetails != nil {
733 reasoning = u.OutputTokensDetails.ReasoningTokens
734 }
735 miss := max(u.InputTokens-cached, 0)
736 total := u.TotalTokens
737 if total == 0 {
738 total = u.InputTokens + u.OutputTokens
739 }
740 return &provider.Usage{PromptTokens: u.InputTokens, CompletionTokens: u.OutputTokens, TotalTokens: total, CacheHitTokens: cached, CacheMissTokens: miss, ReasoningTokens: reasoning}
741 }
742
743 func authErrorFromResponse(c *client, responseError *sseError) error {
744 if responseError == nil {
745 return nil
746 }
747 value := strings.ToLower(responseError.Code + " " + responseError.Message)
748 if !strings.Contains(value, "auth") && !strings.Contains(value, "api key") && !strings.Contains(value, "unauthorized") && !strings.Contains(value, "forbidden") && !strings.Contains(value, "permission") {
749 return nil
750 }
751 status := http.StatusUnauthorized
752 if strings.Contains(value, "forbidden") || strings.Contains(value, "permission") {
753 status = http.StatusForbidden
754 }
755 return &provider.AuthError{Provider: c.name, ProviderDisplayName: c.identity.DisplayName, Protocol: c.identity.Protocol, KeyEnv: c.keyEnv, KeySource: c.keySource, Status: status, HasKey: c.apiKey != "", Body: responseError.Message}
756 }
757
758 type sseEvent struct {
759 Type string `json:"type"`
760 Delta string `json:"delta"`
761 Text string `json:"text"`
762 Arguments string `json:"arguments"`
763 ItemID string `json:"item_id"`
764 ContentIndex int `json:"content_index"`
765 Item *sseItem `json:"item"`
766 Response *sseResponse `json:"response"`
767 }
768
769 type sseItem struct {
770 ID, Type, CallID, Name, Arguments, Status string
771 Raw json.RawMessage
772 }
773
774 func (i *sseItem) UnmarshalJSON(data []byte) error {
775 var wire struct {
776 ID string `json:"id"`
777 Type string `json:"type"`
778 CallID string `json:"call_id"`
779 Name string `json:"name"`
780 Arguments string `json:"arguments"`
781 Status string `json:"status"`
782 }
783 if err := json.Unmarshal(data, &wire); err != nil {
784 return err
785 }
786 *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...)}
787 return nil
788 }
789
790 type sseResponse struct {
791 Output []sseItem `json:"output"`
792 ID string `json:"id"`
793 Usage *sseUsage `json:"usage"`
794 Error *sseError `json:"error"`
795 IncompleteDetails incompleteDetails `json:"incomplete_details"`
796 }
797
798 type incompleteDetails struct {
799 Reason string `json:"reason"`
800 }
801 type sseError struct {
802 Message string `json:"message"`
803 Code string `json:"code"`
804 }
805 type sseUsage struct {
806 InputTokens int `json:"input_tokens"`
807 OutputTokens int `json:"output_tokens"`
808 TotalTokens int `json:"total_tokens"`
809 InputTokensDetails *struct {
810 CachedTokens int `json:"cached_tokens"`
811 } `json:"input_tokens_details"`
812 OutputTokensDetails *struct {
813 ReasoningTokens int `json:"reasoning_tokens"`
814 } `json:"output_tokens_details"`
815 }
816
816 lines GO