Repository navigation
Expand file tree
/
Copy pathopenai.go
More file actions
647 lines (601 loc) · 22.4 KB
/
Copy pathopenai.go
File metadata and controls
647 lines (601 loc) · 22.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
package llm
import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
)
// OpenAI chat-completions format: the canonical passthrough. DeepSeek, Z.ai
// (GLM) and Kimi ride this format; quirk flags on ProviderConfig decide the
// thinking-field shape (reasoning_effort vs thinking object vs neither).
// ── request ──────────────────────────────────────────────────────────────
type oaThinking struct {
Type string `json:"type"` // "enabled" | "disabled"
}
type oaFunction struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type oaToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function oaFunction `json:"function"`
}
type oaMessage struct {
Role string `json:"role"`
// Content and ReasoningContent are pointers so the key can be present and
// empty: nil omits it, a pointer to "" keeps it on the wire.
Content *string `json:"content"` // nil keeps JSON null for tool calls
ContentParts []oaContentPart `json:"-"`
ReasoningContent *string `json:"reasoning_content,omitempty"`
ToolCalls []oaToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
func (m oaMessage) MarshalJSON() ([]byte, error) {
type alias oaMessage
if len(m.ContentParts) == 0 {
return json.Marshal(alias(m))
}
return json.Marshal(struct {
Role string `json:"role"`
Content []oaContentPart `json:"content"`
ReasoningContent *string `json:"reasoning_content,omitempty"`
ToolCalls []oaToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}{m.Role, m.ContentParts, m.ReasoningContent, m.ToolCalls, m.ToolCallID})
}
type oaImageURL struct {
URL string `json:"url"`
}
type oaContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *oaImageURL `json:"image_url,omitempty"`
File *oaFile `json:"file,omitempty"`
}
type oaFile struct {
Filename string `json:"filename"`
FileData string `json:"file_data"`
}
// openAIContent renders ordered content parts as chat-completions parts.
func openAIContent(m Message) []oaContentPart {
parts := make([]oaContentPart, 0, len(m.Parts))
for _, p := range m.Parts {
switch p.Type {
case ContentPartImage:
parts = append(parts, oaContentPart{Type: "image_url", ImageURL: &oaImageURL{URL: "data:" + wireMIME(p.MIMEType) + ";base64," + base64.StdEncoding.EncodeToString(p.Image)}})
case ContentPartImageURL:
parts = append(parts, oaContentPart{Type: "image_url", ImageURL: &oaImageURL{URL: p.URL}})
case ContentPartDocument:
parts = append(parts, oaContentPart{Type: "file", File: &oaFile{Filename: documentName(p), FileData: "data:application/pdf;base64," + base64.StdEncoding.EncodeToString(p.Data)}})
default:
parts = append(parts, oaContentPart{Type: "text", Text: p.Text})
}
}
return parts
}
func openAIText(m Message) *string { return &m.Content }
type oaToolDef struct {
Type string `json:"type"` // "function"
Function json.RawMessage `json:"function"`
}
type oaToolFn struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
}
type oaStreamOptions struct {
IncludeUsage bool `json:"include_usage"`
}
type oaRequest struct {
Model string `json:"model"`
Messages []oaMessage `json:"messages"`
Tools []oaToolDef `json:"tools,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Stop []string `json:"stop,omitempty"`
Stream bool `json:"stream,omitempty"`
StreamOptions *oaStreamOptions `json:"stream_options,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
IncludeReasoning *bool `json:"include_reasoning,omitempty"`
Thinking *oaThinking `json:"thinking,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
ResponseFormat *oaResponseFormat `json:"response_format,omitempty"`
Seed *int `json:"seed,omitempty"`
}
// buildOpenAIRequest renders the canonical request in OpenAI format.
func buildOpenAIRequest(cfg ProviderConfig, req *ChatRequest, model string, stream, includeStreamOptions bool) oaRequest {
q := cfg.Quirks
out := oaRequest{
Model: model,
Stop: req.Stop,
Stream: stream,
ToolChoice: openAIToolChoice(req.ToolChoice),
ParallelToolCalls: parallelToolCalls(req),
ResponseFormat: openAIResponseFormat(req.ResponseFormat),
Seed: req.Seed,
}
// OpenAI o-series/gpt-5 models reject max_tokens in favor of
// max_completion_tokens; everyone else keeps the classic parameter.
if modelUsesCompletionTokens(model) {
out.MaxCompletionTokens = req.MaxTokens
} else {
out.MaxTokens = req.MaxTokens
}
// One OpenAI system message per SystemBlock and per in-band system
// role. Concatenating would collapse prompt-tiering (stable base +
// volatile memory) and bust prefix cache on every memory refresh.
msgs := make([]oaMessage, 0, len(req.Messages)+len(req.System)+1)
appendSystem := func(text string) {
if t := strings.TrimRight(text, "\n"); t != "" {
s := t
msgs = append(msgs, oaMessage{Role: "system", Content: &s})
}
}
for _, b := range req.System {
appendSystem(b.Text)
}
for _, m := range req.Messages {
if m.Role == RoleSystem {
appendSystem(m.Content)
}
}
var toolImages []ContentPart // pending images from the current run of tool results
for i, m := range req.Messages {
switch m.Role {
case RoleSystem:
// already folded into the system message
continue
case RoleTool:
// Chat-completions tool messages carry text only: the text parts
// stay on the tool message and images ride a user message right
// after the run of tool results. IsError has no wire field here.
c := partsText(m)
if c == "" && len(m.Parts) > 0 {
c = "(image result attached below)" // some providers reject empty tool content
}
msgs = append(msgs, oaMessage{Role: "tool", Content: &c, ToolCallID: m.ToolCallID})
if imgs := imageLikeParts(m); len(imgs) > 0 {
toolImages = append(toolImages, TextPart("Images returned by tool call "+m.ToolCallID+":"))
toolImages = append(toolImages, imgs...)
}
if i+1 < len(req.Messages) && req.Messages[i+1].Role == RoleTool {
continue
}
if len(toolImages) > 0 {
msgs = append(msgs, oaMessage{Role: "user", ContentParts: openAIContent(Message{Parts: toolImages})})
toolImages = nil
}
case RoleAssistant:
om := oaMessage{Role: "assistant", Content: openAIText(m)} // Parts are user/tool only
// DeepSeek thinking mode requires the reasoning_content key on every
// replayed assistant turn once the request carries tools — including
// turns where the provider returned no reasoning of its own. Omitting
// the key there is a 400 that poisons every later request in the tool
// loop, so the empty echo is deliberate.
//
// Scope: this follows the quirk, so it applies to every assistant turn
// of a chat-completions request that carries tools on a flagged
// provider — including turns that made no tool call themselves, and
// not to zai/GLM, where no documentation confirms the same requirement.
// (A request diverted to /responses never reaches this builder: that
// format replays reasoning as an encrypted item instead.) Whether
// DeepSeek accepts a present-but-empty value is measured by
// TestE2EDeepSeekEmptyReasoningEcho, not assumed here.
if m.ReasoningContent != "" || (q.EchoReasoningWithTools && len(req.Tools) > 0) {
rc := m.ReasoningContent
om.ReasoningContent = &rc
}
for _, tc := range m.ToolCalls {
om.ToolCalls = append(om.ToolCalls, oaToolCall{
ID: tc.ID,
Type: "function",
Function: oaFunction{
Name: tc.Name,
Arguments: tc.Arguments,
},
})
}
msgs = append(msgs, om)
default: // user
om := oaMessage{Role: "user"}
if len(m.Parts) == 0 {
om.Content = openAIText(m)
} else {
om.ContentParts = openAIContent(m)
}
msgs = append(msgs, om)
}
}
out.Messages = msgs
for _, t := range req.Tools {
fn, _ := json.Marshal(oaToolFn{Name: t.Name, Description: t.Description, Parameters: t.Parameters})
out.Tools = append(out.Tools, oaToolDef{Type: "function", Function: fn})
}
// Temperature: 0 → omit (provider default); negative → explicit 0.
// Models that only accept the default temperature never get the field.
if req.Temperature != 0 && !modelForbidsTemperature(model) {
t := req.Temperature
if t < 0 {
t = 0
}
out.Temperature = &t
}
if req.TopP != 0 && !modelForbidsTemperature(model) {
p := req.TopP
if p < 0 {
p = 0
}
out.TopP = &p
}
if stream && includeStreamOptions {
out.StreamOptions = &oaStreamOptions{IncludeUsage: true}
}
switch {
case q.ThinkingObject:
// Anthropic-style thinking object (DeepSeek, GLM).
switch req.Thinking {
case "enabled":
out.Thinking = &oaThinking{Type: "enabled"}
case "disabled":
if q.forceThinkingModel(model) {
// GLM-5.3+ rejects disabled outright; the documented
// migration is enabled + reasoning_effort low.
out.Thinking = &oaThinking{Type: "enabled"}
if q.ReasoningEffort {
out.ReasoningEffort = "low"
}
} else {
out.Thinking = &oaThinking{Type: "disabled"}
}
case "low", "medium", "high", "max":
out.Thinking = &oaThinking{Type: "enabled"}
if q.ReasoningEffort {
// GLM has no "medium" effort level; odek's medium maps to high.
if req.Thinking == "medium" {
out.ReasoningEffort = "high"
} else {
out.ReasoningEffort = req.Thinking
}
}
}
case q.ReasoningEffort:
switch req.Thinking {
case "enabled":
out.ReasoningEffort = "medium"
case "low", "medium", "high":
out.ReasoningEffort = req.Thinking
case "max":
// "max" is the canonical highest level. OpenAI's portable
// reasoning_effort vocabulary tops out at "high".
out.ReasoningEffort = "high"
case "disabled":
// GPT-5.6 defaults to medium when the field is omitted, which
// 400s when function tools are present. Pin none so "disabled"
// is actually off on Chat Completions.
if chatCompletionsRejectsReasoningWithTools(model) {
out.ReasoningEffort = "none"
}
// "" → omit (provider default)
}
default:
// Provider accepts neither field (Kimi, plain gateways).
}
if q.IncludeReasoning {
// Gateway reasoning opt-in (OpenRouter legacy form of `reasoning: {}`).
// Pointer so the key is absent entirely without the quirk.
yes := true
out.IncludeReasoning = &yes
}
return out
}
// reasoningEffortNonePatched returns a copy of the request with
// reasoning_effort pinned to "none" (learn-once: the provider rejected
// reasoning_effort combined with function tools).
func reasoningEffortNonePatched(r oaRequest) oaRequest {
r.ReasoningEffort = "none"
if r.Thinking != nil {
r.Thinking = nil // effort and thinking object never combine here
}
return r
}
// ── response ─────────────────────────────────────────────────────────────
type oaRespToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
// oaFlexString decodes a JSON string tolerantly. Gateways in the wild send
// the reasoning fields as non-string JSON values (e.g. OpenRouter reasoning
// objects); a non-string value is skipped (decodes to "") instead of failing
// the whole response parse. Null decodes to "" as well.
type oaFlexString string
func (s *oaFlexString) UnmarshalJSON(b []byte) error {
if len(b) > 0 && b[0] == '"' {
var v string
if err := json.Unmarshal(b, &v); err != nil {
return err
}
*s = oaFlexString(v)
return nil
}
*s = "" // null, object, array, number, bool: skipped
return nil
}
type oaRespMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Refusal string `json:"refusal"`
ReasoningContent oaFlexString `json:"reasoning_content"`
Reasoning oaFlexString `json:"reasoning"`
ReasoningDetails []oaReasoningDetail `json:"reasoning_details"`
ToolCalls []oaRespToolCall `json:"tool_calls"`
}
// oaReasoningDetail is one entry of OpenRouter's reasoning_details array
// (docs: Reasoning Details API Shape). Only text and summary carry usable
// prose; encrypted entries are intentionally skipped.
type oaReasoningDetail struct {
Type string `json:"type"`
Text string `json:"text"`
Summary string `json:"summary"`
}
// foldReasoning reconciles the documented OpenAI-format reasoning response
// shapes into one string: reasoning_content (LiteLLM standardized) wins, then
// the reasoning string alias, then the reasoning_details fold in array order
// (reasoning.text → text, reasoning.summary → summary, encrypted skipped).
func foldReasoning(content, reasoning string, details []oaReasoningDetail) string {
if content != "" {
return content
}
if reasoning != "" {
return reasoning
}
var b strings.Builder
for _, d := range details {
part := d.Text
if part == "" {
part = d.Summary
}
if part == "" {
continue
}
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(part)
}
return b.String()
}
type oaRespChoice struct {
Message oaRespMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
type oaUsageDetails struct {
ReasoningTokens int `json:"reasoning_tokens"`
}
type oaPromptDetails struct {
CachedTokens int `json:"cached_tokens"`
}
type oaRespUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
CompletionTokensDetails oaUsageDetails `json:"completion_tokens_details"`
CacheCreationTokens int `json:"cache_creation_input_tokens"`
CacheReadTokens int `json:"cache_read_input_tokens"`
PromptTokensDetails *oaPromptDetails `json:"prompt_tokens_details"`
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens"`
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens"`
TopCachedTokens int `json:"cached_tokens"` // Moonshot/Kimi: top-level, subset of prompt_tokens
}
// usageFromOpenAI maps a chat-completions usage object onto canonical
// Usage with odek applyUsage exclusive normalization: OpenAI
// cached_tokens and DeepSeek hit/miss are subsets of prompt_tokens and
// are subtracted so PromptTokens is uncached-only; Anthropic-named
// cache fields are exclusive and are not subtracted. Guards keep
// hostile payloads from driving PromptTokens negative.
func usageFromOpenAI(u *oaRespUsage) Usage {
if u == nil {
return Usage{}
}
out := Usage{
PromptTokens: u.PromptTokens,
CompletionTokens: u.CompletionTokens,
ReasoningTokens: u.CompletionTokensDetails.ReasoningTokens,
CacheCreationTokens: u.CacheCreationTokens,
CacheReadTokens: u.CacheReadTokens,
}
if u.PromptTokensDetails != nil {
out.CachedTokens = u.PromptTokensDetails.CachedTokens
out.CacheReported = true
}
if u.CacheCreationTokens > 0 || u.CacheReadTokens > 0 {
out.CacheReported = true
}
switch {
case u.PromptCacheHitTokens > 0 || u.PromptCacheMissTokens > 0:
// DeepSeek native fields (authoritative when present; DeepSeek may
// echo the hit count as cached_tokens too). A hit is prompt content
// read from cache; a miss is ordinary uncached input and stays in
// PromptTokens. Guarded so hostile payloads never go negative.
out.CacheReported = true
if hit := u.PromptCacheHitTokens; hit <= out.PromptTokens {
out.PromptTokens -= hit
out.CacheReadTokens += hit
}
case u.CacheReadTokens > 0:
// Anthropic-named field from a gateway, already in CacheReadTokens.
// When the OpenAI-shaped cached_tokens rides along, prompt_tokens
// follows OpenAI (inclusive) semantics and both fields describe the
// same read: subtract it from the prompt once, never add it twice.
if d := u.PromptTokensDetails; d != nil && d.CachedTokens > 0 && d.CachedTokens <= out.PromptTokens {
out.PromptTokens -= d.CachedTokens
}
case u.PromptTokensDetails != nil && u.PromptTokensDetails.CachedTokens > 0 &&
u.PromptTokensDetails.CachedTokens <= out.PromptTokens:
out.PromptTokens -= u.PromptTokensDetails.CachedTokens
out.CacheReadTokens += u.PromptTokensDetails.CachedTokens
case u.TopCachedTokens > 0 && u.TopCachedTokens <= out.PromptTokens:
// Moonshot/Kimi report the cached subset top-level.
out.CacheReported = true
out.CachedTokens = u.TopCachedTokens
out.PromptTokens -= u.TopCachedTokens
out.CacheReadTokens += u.TopCachedTokens
}
return out
}
type oaResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []oaRespChoice `json:"choices"`
Usage *oaRespUsage `json:"usage"`
Error *oaErrorBody `json:"error"`
}
type oaErrorBody struct {
Message string `json:"message"`
Type string `json:"type"`
Code any `json:"code"`
}
// mapOpenAIFinishReason maps OpenAI finish_reason to canonical values.
func mapOpenAIFinishReason(s string) string {
switch s {
case "stop":
return FinishStop
case "length":
return FinishLength
case "tool_calls", "function_call":
return FinishToolCalls
case "content_filter":
return FinishContentFilter
case "":
return ""
default:
// Non-canonical reasons never leak into the public vocabulary.
return ""
}
}
// parseOpenAIResponse parses a buffered chat-completions response body.
func parseOpenAIResponse(body []byte) (*ChatResult, error) {
var r oaResponse
if err := json.Unmarshal(body, &r); err != nil {
return nil, fmt.Errorf("llm: parse response: %w", err)
}
if r.Error != nil && r.Error.Message != "" {
return nil, fmt.Errorf("llm: provider error: %s", r.Error.Message)
}
if len(r.Choices) == 0 {
return nil, fmt.Errorf("llm: response has no choices")
}
ch := r.Choices[0]
res := &ChatResult{
Content: ch.Message.Content,
ReasoningContent: foldReasoning(string(ch.Message.ReasoningContent), string(ch.Message.Reasoning), ch.Message.ReasoningDetails),
FinishReason: mapOpenAIFinishReason(ch.FinishReason),
Refusal: ch.Message.Refusal,
Meta: ResponseMeta{ID: r.ID, Model: r.Model},
}
for _, tc := range ch.Message.ToolCalls {
args := tc.Function.Arguments
if args == "" {
args = "{}"
}
res.ToolCalls = append(res.ToolCalls, ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: args,
})
}
// Named tool_choice (and many gateways) report "stop" for a tool turn.
if res.FinishReason == FinishStop && len(res.ToolCalls) > 0 {
res.FinishReason = FinishToolCalls
}
res.FinishReason = refusalFinish(res.FinishReason, res.Refusal)
if r.Usage != nil {
res.Usage = usageFromOpenAI(r.Usage)
}
return res, nil
}
// ── streaming ────────────────────────────────────────────────────────────
type oaStreamDelta struct {
Content string `json:"content"`
Refusal string `json:"refusal"`
ReasoningContent oaFlexString `json:"reasoning_content"`
Reasoning oaFlexString `json:"reasoning"`
ReasoningDetails []oaReasoningDetail `json:"reasoning_details"`
ToolCalls []oaStreamToolCall `json:"tool_calls"`
}
type oaStreamToolCall struct {
Index *int `json:"index"` // OpenAI sends it; default 0 when absent
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
type oaStreamChoice struct {
Delta oaStreamDelta `json:"delta"`
FinishReason string `json:"finish_reason"`
}
type oaStreamChunk struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []oaStreamChoice `json:"choices"`
Usage *oaRespUsage `json:"usage"`
Error json.RawMessage `json:"error"` // see streamErrorMessage
}
// mapOpenAIStreamEvent folds one SSE data payload into the accumulator and
// returns the canonical deltas to emit. done reports a terminal event.
func mapOpenAIStreamEvent(data []byte, acc *streamAccum) (deltas []Delta, done bool, err error) {
var c oaStreamChunk
if err := json.Unmarshal(data, &c); err != nil {
return nil, false, fmt.Errorf("llm: parse stream chunk: %w", err)
}
if msg, ok := streamErrorMessage(c.Error); ok {
return nil, false, fmt.Errorf("llm: provider error: %s", msg)
}
acc.noteMeta(c.ID, c.Model)
if c.Usage != nil {
acc.usage = usageFromOpenAI(c.Usage)
}
for _, ch := range c.Choices {
d := ch.Delta
if r := foldReasoning(string(d.ReasoningContent), string(d.Reasoning), d.ReasoningDetails); r != "" {
acc.reasoning.WriteString(r)
deltas = append(deltas, Delta{Kind: DeltaReasoning, Text: r})
}
if d.Content != "" {
acc.content.WriteString(d.Content)
deltas = append(deltas, Delta{Kind: DeltaContent, Text: d.Content})
}
if d.Refusal != "" {
acc.refusal.WriteString(d.Refusal)
deltas = append(deltas, Delta{Kind: DeltaRefusal, Text: d.Refusal})
}
for _, tc := range d.ToolCalls {
idx := acc.openAIToolKey(tc.Index, tc.ID)
call := acc.call(idx)
if tc.ID != "" {
call.id = tc.ID
}
if tc.Function.Name != "" {
call.name = tc.Function.Name
}
accDelta := Delta{Kind: DeltaToolArgs, Text: tc.Function.Arguments, ToolIndex: call.pos, ToolID: call.id, ToolName: call.name}
call.args.WriteString(tc.Function.Arguments)
deltas = append(deltas, accDelta)
}
if ch.FinishReason != "" {
acc.finishReason = mapOpenAIFinishReason(ch.FinishReason)
if acc.finishReason == FinishStop && len(acc.calls) > 0 {
acc.finishReason = FinishToolCalls
}
acc.sawFinish = true
}
}
return deltas, false, nil
}