1package provider
2
3import (
4 "bytes"
5 "context"
6 "encoding/base64"
7 "encoding/json"
8 "fmt"
9 "io"
10 "net/http"
11 "regexp"
12 "strings"
13 "sync"
14 "time"
15)
16
17const openaiDefaultBaseURL = "https://api.openai.com"
18
19// versionSegmentSuffix matches a trailing API version segment such as
20// "/v1" or Z.AI's "/v4".
21var versionSegmentSuffix = regexp.MustCompile(`/v\d+$`)
22
23// chatCompletionsURL builds the chat-completions endpoint for an
24// OpenAI-compatible base URL. A base that already carries an API
25// version segment gets "/chat/completions" appended directly; a bare
26// host (e.g. api.openai.com) gets the conventional "/v1/chat/completions".
27//
28// Matching any "/vN" segment (not just "/v1") keeps Z.AI's coding-plan
29// base, which ends in "/paas/v4", from getting a spurious "/v1" that
30// yields ".../paas/v4/v1/chat/completions" and a 404.
31func chatCompletionsURL(baseURL string) string {
32 if versionSegmentSuffix.MatchString(baseURL) {
33 return baseURL + "/chat/completions"
34 }
35 return baseURL + "/v1/chat/completions"
36}
37
38type openaiClient struct {
39 apiKey string
40 baseURL string
41 chatCompletionsPath string
42 name string
43 oauth bool // when true, apiKey actually holds an OAuth access token
44 headers map[string]string
45 http *http.Client
46 modelOverridesMu sync.RWMutex
47 modelOverrides map[string]Model
48}
49
50// NewOpenAI creates an OpenAI client using an API key. baseURL may be empty.
51func NewOpenAI(apiKey, baseURL string) Client {
52 if baseURL == "" {
53 baseURL = openaiDefaultBaseURL
54 }
55 return &openaiClient{
56 apiKey: apiKey,
57 baseURL: strings.TrimRight(baseURL, "/"),
58 name: "openai",
59 http: &http.Client{Timeout: 0},
60 }
61}
62
63// NewOpenAIOAuth creates an OpenAI client using a subscription OAuth access token.
64// The token is sent as an HTTP Bearer credential on the standard chat/completions endpoint.
65func NewOpenAIOAuth(accessToken, baseURL string) Client {
66 if baseURL == "" {
67 baseURL = openaiDefaultBaseURL
68 }
69 return &openaiClient{
70 apiKey: accessToken,
71 baseURL: strings.TrimRight(baseURL, "/"),
72 name: "openai",
73 oauth: true,
74 http: &http.Client{Timeout: 0},
75 }
76}
77
78// NewKimi creates a Kimi/Moonshot client. Kimi's chat API is OpenAI-compatible.
79func NewKimi(apiKey, baseURL string) Client {
80 return NewKimiWithHeaders(apiKey, baseURL, nil)
81}
82
83// NewDeepSeek creates a DeepSeek client. DeepSeek's chat API is
84// OpenAI-compatible at https://api.deepseek.com/v1.
85func NewDeepSeek(apiKey, baseURL string) Client {
86 if baseURL == "" {
87 baseURL = "https://api.deepseek.com/v1"
88 }
89 return &openaiClient{
90 apiKey: apiKey,
91 baseURL: strings.TrimRight(baseURL, "/"),
92 name: "deepseek",
93 http: &http.Client{Timeout: 0},
94 }
95}
96
97// NewKimiWithHeaders creates a Kimi/Moonshot client with extra headers.
98// Subscription tokens from Kimi Code need the official CLI's X-Msh-* headers.
99func NewKimiWithHeaders(apiKey, baseURL string, headers map[string]string) Client {
100 if baseURL == "" {
101 baseURL = "https://api.kimi.com/coding/v1"
102 }
103 return &openaiClient{
104 apiKey: apiKey,
105 baseURL: strings.TrimRight(baseURL, "/"),
106 name: "kimi",
107 headers: headers,
108 http: &http.Client{Timeout: 0},
109 }
110}
111
112func (c *openaiClient) Name() string {
113 if c.name != "" {
114 return c.name
115 }
116 return "openai"
117}
118
119func (c *openaiClient) SetModelMetadata(model Model) {
120 model = cloneModel(model)
121 c.modelOverridesMu.Lock()
122 if c.modelOverrides == nil {
123 c.modelOverrides = make(map[string]Model)
124 }
125 c.modelOverrides[model.ID] = model
126 c.modelOverridesMu.Unlock()
127}
128
129// ---- wire types ----
130
131type oaiContentText struct {
132 Type string `json:"type"` // "text"
133 Text string `json:"text"`
134}
135
136type oaiContentImage struct {
137 Type string `json:"type"` // "image_url"
138 ImageURL struct {
139 URL string `json:"url"`
140 } `json:"image_url"`
141}
142
143type oaiToolCallFn struct {
144 Name string `json:"name"`
145 Arguments string `json:"arguments"` // JSON string
146}
147
148type oaiToolCall struct {
149 ID string `json:"id"`
150 Type string `json:"type"` // "function"
151 Function oaiToolCallFn `json:"function"`
152}
153
154type oaiMessage struct {
155 Role string `json:"role"`
156 Content interface{} `json:"content,omitempty"` // string or []block
157 Name string `json:"name,omitempty"`
158 ToolCalls []oaiToolCall `json:"tool_calls,omitempty"`
159 ToolCallID string `json:"tool_call_id,omitempty"`
160 Tools []oaiTool `json:"tools,omitempty"`
161 // ReasoningContent carries the model's chain-of-thought summary
162 // alongside an assistant tool-call message. Required by Kimi's
163 // chat completions endpoint when thinking is enabled and the
164 // assistant message contains a tool call; OpenAI ignores it.
165 ReasoningContent string `json:"reasoning_content,omitempty"`
166}
167
168type oaiTool struct {
169 Type string `json:"type"` // "function"
170 Function struct {
171 Name string `json:"name"`
172 Description string `json:"description,omitempty"`
173 Parameters json.RawMessage `json:"parameters"`
174 } `json:"function"`
175}
176
177type oaiStreamOptions struct {
178 IncludeUsage bool `json:"include_usage"`
179}
180
181type oaiRequest struct {
182 Model string `json:"model"`
183 Messages []oaiMessage `json:"messages"`
184 Tools []oaiTool `json:"tools,omitempty"`
185 ToolChoice string `json:"tool_choice,omitempty"`
186 Stream bool `json:"stream"`
187 StreamOptions *oaiStreamOptions `json:"stream_options,omitempty"`
188 Temperature *float32 `json:"temperature,omitempty"`
189 MaxTokens *int `json:"max_tokens,omitempty"`
190 MaxCompletionTok *int `json:"max_completion_tokens,omitempty"`
191 ReasoningEffort string `json:"reasoning_effort,omitempty"`
192 ServiceTier string `json:"service_tier,omitempty"`
193}
194
195// ---- request building ----
196
197func (c *openaiClient) modelForRequest(id string) (Model, error) {
198 id = strings.TrimSpace(id)
199 c.modelOverridesMu.RLock()
200 model, ok := c.modelOverrides[id]
201 if ok {
202 model = cloneModel(model)
203 }
204 c.modelOverridesMu.RUnlock()
205 if ok {
206 return model, nil
207 }
208 model, err := FindModel(c.Name(), id)
209 if err != nil {
210 model, err = FindModel("", id)
211 }
212 return model, err
213}
214
215func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
216 req.Model = strings.TrimSpace(req.Model)
217 if err := ValidateFastMode(c.Name(), req.FastMode); err != nil {
218 return nil, err
219 }
220 // The OpenAI wire client is shared by many providers, so prefer its own
221 // provider namespace before falling back to a provider-agnostic lookup.
222 m, err := c.modelForRequest(req.Model)
223 if err != nil {
224 // Unknown model: use sensible defaults so local/custom
225 // models still work without a catalog entry.
226 m = Model{
227 ID: req.Model,
228 ContextWindow: 32768,
229 MaxOutput: 8192,
230 }
231 }
232 reasoning := ClampReasoningForModel(m, req.Reasoning)
233 out := &oaiRequest{
234 Model: req.Model,
235 Stream: true,
236 StreamOptions: &oaiStreamOptions{IncludeUsage: true},
237 Temperature: req.Temperature,
238 }
239 if req.FastMode {
240 out.ServiceTier = fastModeServiceTier
241 }
242
243 maxTok := req.MaxTokens
244 if maxTok <= 0 {
245 maxTok = m.MaxOutput
246 }
247 // Clamp max_tokens so output plus a minimum input reservation fits
248 // within the context window. Some providers (OpenRouter) enforce
249 // input + max_output <= context_length and reject requests where the
250 // total exceeds it. Reserving headroom guarantees the system prompt,
251 // first message, and tool definitions have room.
252 //
253 // The cap is derived from the context window, never from MaxOutput:
254 // MaxOutput is already the output ceiling, so subtracting from it
255 // would shrink every model's budget even when its output comfortably
256 // fits the window. We only lower maxTok when ContextWindow - reserve
257 // is actually tighter than the requested budget, which is exactly the
258 // pathological case (e.g. a model whose MaxOutput equals its window).
259 //
260 // The reserve is proportional (window/8, capped at 4096) rather than a
261 // flat 4096 so small-window models aren't over-penalized: a flat 4096
262 // would halve gpt-4's 8192 budget, while window/8 reserves a sensible
263 // 1024 there and still tops out at 4096 for large contexts.
264 //
265 // Some providers (OpenRouter) report inflated model-level context
266 // windows (e.g. 1000000) while the serving provider enforces a much
267 // tighter limit (e.g. 262144). Discovery already prefers the serving
268 // provider's smaller context_length, so m.ContextWindow is the real
269 // limit by the time we get here.
270 if m.ContextWindow > 0 {
271 reserve := m.ContextWindow / 8
272 const maxReserve = 4096
273 if reserve > maxReserve {
274 reserve = maxReserve
275 }
276 clamped := m.ContextWindow - reserve
277 if clamped < 1 {
278 clamped = 1
279 }
280 if maxTok > clamped {
281 maxTok = clamped
282 }
283 }
284 if m.Reasoning {
285 if maxTok > 0 {
286 out.MaxCompletionTok = &maxTok
287 }
288 effort := OpenAIReasoningEffort(reasoning)
289 if usesAdaptiveThinking(m) {
290 // Some gateways expose adaptive-thinking Anthropic models through
291 // the OpenAI-compatible chat-completions wire. They accept the
292 // same reasoning_effort knob, including the top "xhigh" tier;
293 // don't clamp zut's "maximum" to "high" for those models.
294 effort = OpenAICompatAnthropicEffort(reasoning)
295 }
296 // Explicit model mappings describe the endpoint's actual effort
297 // values and take precedence over conservative protocol defaults.
298 effort = reasoningEffortForModel(m, req.Reasoning, reasoning, effort)
299 if effort != "" {
300 out.ReasoningEffort = effort
301 }
302 } else if maxTok > 0 {
303 // Omit max_tokens when unknown (some discovered models don't
304 // advertise an output cap) so the server applies its own default
305 // instead of receiving an invalid max_tokens: 0.
306 out.MaxTokens = &maxTok
307 }
308
309 if system := req.SystemPrompt(); system != "" {
310 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: system})
311 }
312
313 deferredMode := isKimiDeferredModel(req.Model)
314 toolByName := make(map[string]Tool, len(req.Tools))
315 for _, t := range req.Tools {
316 toolByName[t.Name] = t
317 }
318 activatedTools := activatedToolNames(req.Messages)
319
320 // DeepSeek's chat-completions API rejects the multimodal content
321 // schema (parts arrays containing image_url). Force every user/tool
322 // message to a plain string and silently drop image blocks for
323 // this provider so historical sessions with screenshots still replay.
324 textOnly := c.name == "deepseek"
325
326 req.Messages = RepairOrphanedToolResults(req.Messages)
327 for _, msg := range req.Messages {
328 switch msg.Role {
329 case RoleDeveloper:
330 content := buildOAIUserContent(msg.Content, textOnly)
331 role := "developer"
332 if c.name == ProviderDeepSeek || (c.name == ProviderOpenCodeGo && isDeepSeekModel(req.Model)) {
333 role = "system"
334 }
335 out.Messages = append(out.Messages, oaiMessage{Role: role, Content: content})
336 case RoleUser:
337 content := buildOAIUserContent(msg.Content, textOnly)
338 out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content})
339 case RoleAssistant:
340 am := oaiMessage{Role: "assistant"}
341 var text strings.Builder
342 var reasoning strings.Builder
343 for _, b := range msg.Content {
344 switch v := b.(type) {
345 case TextBlock:
346 if strings.TrimSpace(v.Text) == "" {
347 continue
348 }
349 if text.Len() > 0 {
350 text.WriteString("\n")
351 }
352 text.WriteString(v.Text)
353 case ToolCallBlock:
354 args := v.Arguments
355 if len(args) == 0 || !json.Valid(args) {
356 args = json.RawMessage("{}")
357 }
358 am.ToolCalls = append(am.ToolCalls, oaiToolCall{
359 ID: v.ID,
360 Type: "function",
361 Function: oaiToolCallFn{
362 Name: v.Name,
363 Arguments: string(args),
364 },
365 })
366 case ReasoningBlock:
367 if v.Summary != "" {
368 if reasoning.Len() > 0 {
369 reasoning.WriteString("\n")
370 }
371 reasoning.WriteString(v.Summary)
372 }
373 }
374 }
375 if text.Len() > 0 {
376 am.Content = text.String()
377 }
378 if reasoning.Len() > 0 && len(am.ToolCalls) > 0 {
379 am.ReasoningContent = reasoning.String()
380 }
381 // Kimi rejects assistant messages with neither visible text nor
382 // tool calls ("assistant must not be empty"). This can happen when
383 // a previous stream produced only reasoning_content, which zut keeps
384 // internally for provider replay but cannot send back as standalone
385 // assistant content on OpenAI-compatible chat-completions APIs.
386 if am.Content == nil && len(am.ToolCalls) == 0 {
387 continue
388 }
389 out.Messages = append(out.Messages, am)
390 case RoleTool:
391 // Each ToolResultBlock becomes its own tool message. Preserve
392 // image blocks for vision-capable OpenAI models instead of
393 // flattening the tool output to plain text.
394 for _, b := range msg.Content {
395 if tr, ok := b.(ToolResultBlock); ok {
396 content := buildOAIToolContent(tr.Content, tr.IsError, textOnly)
397 out.Messages = append(out.Messages, oaiMessage{
398 Role: "tool",
399 ToolCallID: tr.CallID,
400 Content: content,
401 })
402 }
403 }
404 }
405 if deferredMode {
406 var loaded []oaiTool
407 for _, name := range msg.AddedToolNames {
408 if t, ok := toolByName[name]; ok && t.Deferred {
409 loaded = append(loaded, makeOAITool(t))
410 }
411 }
412 if len(loaded) > 0 {
413 out.Messages = append(out.Messages, oaiMessage{Role: "system", Tools: loaded})
414 }
415 }
416 }
417
418 for _, t := range req.Tools {
419 if t.Deferred && (deferredMode || !activatedTools[t.Name]) {
420 continue
421 }
422 out.Tools = append(out.Tools, makeOAITool(t))
423 }
424 if len(out.Tools) > 0 {
425 out.ToolChoice = "auto"
426 }
427
428 return out, nil
429}
430
431func isKimiDeferredModel(model string) bool {
432 model = strings.ToLower(model)
433 return model == "kimi-k3" || strings.HasSuffix(model, "/kimi-k3")
434}
435
436func isDeepSeekModel(model string) bool {
437 model = strings.ToLower(strings.TrimSpace(model))
438 return strings.HasPrefix(model, "deepseek-") || strings.Contains(model, "/deepseek-")
439}
440
441func makeOAITool(t Tool) oaiTool {
442 var tool oaiTool
443 tool.Type = "function"
444 tool.Function.Name = t.Name
445 tool.Function.Description = t.Description
446 tool.Function.Parameters = t.Schema
447 return tool
448}
449
450func buildOAIUserContent(blocks []Content, textOnly bool) interface{} {
451 hasImage := false
452 for _, b := range blocks {
453 if _, ok := b.(ImageBlock); ok {
454 hasImage = true
455 break
456 }
457 }
458 if textOnly || !hasImage {
459 // Keep multiple text blocks structured. The core provider projection
460 // adds message-time annotations as a separate block; collapsing that
461 // block with the user text changes the wire representation of the
462 // original prompt and can make consumers misclassify the request.
463 if !textOnly && len(blocks) > 1 {
464 return buildOAIContentBlocks(blocks, false)
465 }
466 var sb strings.Builder
467 for _, b := range blocks {
468 if tb, ok := b.(TextBlock); ok {
469 if sb.Len() > 0 {
470 sb.WriteString("\n")
471 }
472 sb.WriteString(tb.Text)
473 }
474 }
475 return sb.String()
476 }
477 return buildOAIContentBlocks(blocks, false)
478}
479
480func buildOAIToolContent(blocks []Content, isError, textOnly bool) interface{} {
481 hasImage := false
482 for _, b := range blocks {
483 if _, ok := b.(ImageBlock); ok {
484 hasImage = true
485 break
486 }
487 }
488 if textOnly || !hasImage {
489 var sb strings.Builder
490 for _, b := range blocks {
491 if tb, ok := b.(TextBlock); ok {
492 if sb.Len() > 0 {
493 sb.WriteString("\n")
494 }
495 sb.WriteString(tb.Text)
496 }
497 }
498 if isError && sb.Len() > 0 {
499 sb.WriteString(" [error]")
500 }
501 return sb.String()
502 }
503 return buildOAIContentBlocks(blocks, isError)
504}
505
506func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} {
507 var arr []interface{}
508 for _, b := range blocks {
509 switch v := b.(type) {
510 case TextBlock:
511 arr = append(arr, oaiContentText{Type: "text", Text: v.Text})
512 case ImageBlock:
513 var img oaiContentImage
514 img.Type = "image_url"
515 img.ImageURL.URL = "data:" + v.MimeType + ";base64," + base64.StdEncoding.EncodeToString(v.Data)
516 arr = append(arr, img)
517 }
518 }
519 if isError {
520 arr = append(arr, oaiContentText{Type: "text", Text: "[error]"})
521 }
522 return arr
523}
524
525// ---- streaming ----
526
527func (c *openaiClient) chatCompletionsURL() string {
528 if c.chatCompletionsPath != "" {
529 return strings.TrimRight(c.baseURL, "/") + "/" + strings.TrimLeft(c.chatCompletionsPath, "/")
530 }
531 return chatCompletionsURL(c.baseURL)
532}
533
534func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
535 req.Model = strings.TrimSpace(req.Model)
536 endpoint := c.chatCompletionsURL()
537 wire, err := c.buildRequest(req)
538 if err != nil {
539 return nil, err
540 }
541 body, err := json.Marshal(wire)
542 if err != nil {
543 return nil, err
544 }
545 newReq := func() (*http.Request, error) {
546 httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body))
547 if err != nil {
548 return nil, err
549 }
550 httpReq.Header.Set("content-type", "application/json")
551 httpReq.Header.Set("accept", "text/event-stream")
552 httpReq.Header.Set("authorization", "Bearer "+c.apiKey)
553 for k, v := range c.headers {
554 httpReq.Header.Set(k, v)
555 }
556 setOpenCodeGoHeaders(httpReq.Header, c.Name(), req.Context)
557 return httpReq, nil
558 }
559
560 resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
561 if err != nil {
562 return nil, fmt.Errorf("%s: %w", c.Name(), err)
563 }
564 if resp.StatusCode != http.StatusOK {
565 b, _ := io.ReadAll(resp.Body)
566 resp.Body.Close()
567 return nil, fmt.Errorf("%s: http %d: %s", c.Name(), resp.StatusCode, strings.TrimSpace(string(b)))
568 }
569
570 out := make(chan Event, 16)
571 go c.runStream(ctx, resp, req, out)
572 return out, nil
573}
574
575func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
576 defer close(out)
577 defer resp.Body.Close()
578
579 model, _ := c.modelForRequest(req.Model)
580 out <- EventStart{Model: req.Model, Provider: c.Name()}
581
582 raw := make(chan sseEvent, 16)
583 go readSSE(resp.Body, raw)
584
585 // Interleaved block tracking: text and tool_calls preserve their
586 // emission order so the assistant message renders in the same order
587 // the model produced it. The builder fires one kind of block at a
588 // time — incoming text deltas after a tool_call split into a fresh
589 // text block; subsequent tool_calls each get their own slot.
590 type blockEntry struct {
591 kind string // "text" | "tool_use"
592 textBuf strings.Builder
593 toolID string
594 toolName string
595 toolArgs strings.Builder
596 announced bool
597 }
598 var (
599 blocks []*blockEntry
600 currentText *blockEntry // most-recent text block, nil if none
601 toolByIdx = map[int]*blockEntry{} // openai tool_call index -> block
602 reasoningBuf strings.Builder
603 usage Usage
604 stop = StopEnd
605 finalErr error
606 )
607
608 appendText := func(delta string) {
609 if currentText == nil {
610 currentText = &blockEntry{kind: "text"}
611 blocks = append(blocks, currentText)
612 }
613 currentText.textBuf.WriteString(delta)
614 }
615
616 getOrCreateTool := func(idx int) *blockEntry {
617 if t, ok := toolByIdx[idx]; ok {
618 return t
619 }
620 t := &blockEntry{kind: "tool_use"}
621 toolByIdx[idx] = t
622 blocks = append(blocks, t)
623 // A new tool block breaks the current text block. Subsequent text
624 // deltas will start a fresh text block after this tool.
625 currentText = nil
626 return t
627 }
628
629 assembleMsg := func() Message {
630 content := []Content{}
631 for _, b := range blocks {
632 switch b.kind {
633 case "text":
634 if b.textBuf.Len() > 0 {
635 content = append(content, TextBlock{Text: b.textBuf.String()})
636 }
637 case "tool_use":
638 args := b.toolArgs.String()
639 if args == "" || !json.Valid([]byte(args)) {
640 args = "{}"
641 }
642 content = append(content, ToolCallBlock{
643 ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args),
644 })
645 }
646 }
647 if reasoningBuf.Len() > 0 {
648 content = append(content, ReasoningBlock{Summary: reasoningBuf.String()})
649 }
650 return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
651 }
652
653 sendDone := func() {
654 usage.CostUSD = ComputeCost(model, usage)
655 out <- EventUsage{Usage: usage}
656 out <- EventDone{Stop: stop, Err: finalErr, Message: assembleMsg()}
657 }
658
659 for {
660 select {
661 case <-ctx.Done():
662 stop = StopAborted
663 finalErr = ctx.Err()
664 sendDone()
665 return
666 case ev, ok := <-raw:
667 if !ok {
668 sendDone()
669 return
670 }
671 if ev.Err != nil {
672 stop = StopError
673 finalErr = fmt.Errorf("read SSE: %w", ev.Err)
674 sendDone()
675 return
676 }
677 if ev.Data == "[DONE]" {
678 sendDone()
679 return
680 }
681 var chunk struct {
682 Choices []struct {
683 Index int `json:"index"`
684 Delta struct {
685 Content string `json:"content"`
686 ReasoningContent string `json:"reasoning_content"`
687 ToolCalls []struct {
688 Index int `json:"index"`
689 ID string `json:"id"`
690 Type string `json:"type"`
691 Function struct {
692 Name string `json:"name"`
693 Arguments string `json:"arguments"`
694 } `json:"function"`
695 } `json:"tool_calls"`
696 } `json:"delta"`
697 FinishReason string `json:"finish_reason"`
698 } `json:"choices"`
699 Usage *struct {
700 PromptTokens int `json:"prompt_tokens"`
701 CompletionTokens int `json:"completion_tokens"`
702 PromptTokensDetails *openAIInputTokensDetails `json:"prompt_tokens_details"`
703 CompletionTokensDetails *struct {
704 ReasoningTokens int `json:"reasoning_tokens"`
705 } `json:"completion_tokens_details"`
706 } `json:"usage"`
707 Error *struct {
708 Message string `json:"message"`
709 Type string `json:"type"`
710 } `json:"error"`
711 }
712 if err := json.Unmarshal([]byte(ev.Data), &chunk); err != nil {
713 continue
714 }
715 if chunk.Error != nil {
716 stop = StopError
717 finalErr = fmt.Errorf("openai: %s", chunk.Error.Message)
718 sendDone()
719 return
720 }
721 if chunk.Usage != nil {
722 usage = normalizeOpenAIUsage(chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens, chunk.Usage.PromptTokensDetails)
723 if details := chunk.Usage.CompletionTokensDetails; details != nil {
724 usage.ReasoningTokens = details.ReasoningTokens
725 usage.ReasoningTokensKnown = true
726 }
727 }
728 for _, ch := range chunk.Choices {
729 if ch.Delta.ReasoningContent != "" {
730 reasoningBuf.WriteString(ch.Delta.ReasoningContent)
731 }
732 if ch.Delta.Content != "" {
733 appendText(ch.Delta.Content)
734 out <- EventTextDelta{Delta: ch.Delta.Content}
735 }
736 for _, tc := range ch.Delta.ToolCalls {
737 t := getOrCreateTool(tc.Index)
738 if tc.ID != "" {
739 t.toolID = tc.ID
740 }
741 if tc.Function.Name != "" {
742 t.toolName = tc.Function.Name
743 }
744 if !t.announced && t.toolID != "" && t.toolName != "" {
745 t.announced = true
746 out <- EventToolStart{ID: t.toolID, Name: t.toolName}
747 }
748 if tc.Function.Arguments != "" {
749 t.toolArgs.WriteString(tc.Function.Arguments)
750 if t.announced {
751 out <- EventToolArgs{ID: t.toolID, Delta: tc.Function.Arguments}
752 }
753 }
754 }
755 switch ch.FinishReason {
756 case "stop":
757 stop = StopEnd
758 case "length":
759 stop = StopLength
760 case "tool_calls", "function_call":
761 stop = StopToolUse
762 for _, b := range blocks {
763 if b.kind == "tool_use" && b.announced {
764 out <- EventToolEnd{ID: b.toolID}
765 }
766 }
767 }
768 }
769 }
770 }
771}