1package provider
2
3import (
4 "bytes"
5 "context"
6 "encoding/base64"
7 "encoding/json"
8 "fmt"
9 "io"
10 "net/http"
11 "strings"
12 "time"
13)
14
15const openaiDefaultBaseURL = "https://api.openai.com"
16
17type openaiClient struct {
18 apiKey string
19 baseURL string
20 oauth bool // when true, apiKey actually holds an OAuth access token
21 http *http.Client
22}
23
24// NewOpenAI creates an OpenAI client using an API key. baseURL may be empty.
25func NewOpenAI(apiKey, baseURL string) Client {
26 if baseURL == "" {
27 baseURL = openaiDefaultBaseURL
28 }
29 return &openaiClient{
30 apiKey: apiKey,
31 baseURL: strings.TrimRight(baseURL, "/"),
32 http: &http.Client{Timeout: 0},
33 }
34}
35
36// NewOpenAIOAuth creates an OpenAI client using a subscription OAuth access token.
37// The token is sent as an HTTP Bearer credential on the standard chat/completions endpoint.
38func NewOpenAIOAuth(accessToken, baseURL string) Client {
39 if baseURL == "" {
40 baseURL = openaiDefaultBaseURL
41 }
42 return &openaiClient{
43 apiKey: accessToken,
44 baseURL: strings.TrimRight(baseURL, "/"),
45 oauth: true,
46 http: &http.Client{Timeout: 0},
47 }
48}
49
50func (c *openaiClient) Name() string { return "openai" }
51
52// ---- wire types ----
53
54type oaiContentText struct {
55 Type string `json:"type"` // "text"
56 Text string `json:"text"`
57}
58
59type oaiContentImage struct {
60 Type string `json:"type"` // "image_url"
61 ImageURL struct {
62 URL string `json:"url"`
63 } `json:"image_url"`
64}
65
66type oaiToolCallFn struct {
67 Name string `json:"name"`
68 Arguments string `json:"arguments"` // JSON string
69}
70
71type oaiToolCall struct {
72 ID string `json:"id"`
73 Type string `json:"type"` // "function"
74 Function oaiToolCallFn `json:"function"`
75}
76
77type oaiMessage struct {
78 Role string `json:"role"`
79 Content interface{} `json:"content,omitempty"` // string or []block
80 Name string `json:"name,omitempty"`
81 ToolCalls []oaiToolCall `json:"tool_calls,omitempty"`
82 ToolCallID string `json:"tool_call_id,omitempty"`
83}
84
85type oaiTool struct {
86 Type string `json:"type"` // "function"
87 Function struct {
88 Name string `json:"name"`
89 Description string `json:"description,omitempty"`
90 Parameters json.RawMessage `json:"parameters"`
91 } `json:"function"`
92}
93
94type oaiStreamOptions struct {
95 IncludeUsage bool `json:"include_usage"`
96}
97
98type oaiRequest struct {
99 Model string `json:"model"`
100 Messages []oaiMessage `json:"messages"`
101 Tools []oaiTool `json:"tools,omitempty"`
102 ToolChoice string `json:"tool_choice,omitempty"`
103 Stream bool `json:"stream"`
104 StreamOptions *oaiStreamOptions `json:"stream_options,omitempty"`
105 Temperature *float32 `json:"temperature,omitempty"`
106 MaxTokens *int `json:"max_tokens,omitempty"`
107 MaxCompletionTok *int `json:"max_completion_tokens,omitempty"`
108 ReasoningEffort string `json:"reasoning_effort,omitempty"`
109}
110
111// ---- request building ----
112
113func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
114 m, err := FindModel("openai", req.Model)
115 if err != nil {
116 return nil, err
117 }
118 out := &oaiRequest{
119 Model: req.Model,
120 Stream: true,
121 StreamOptions: &oaiStreamOptions{IncludeUsage: true},
122 Temperature: req.Temperature,
123 }
124
125 maxTok := req.MaxTokens
126 if maxTok <= 0 {
127 maxTok = m.MaxOutput
128 }
129 if m.Reasoning {
130 out.MaxCompletionTok = &maxTok
131 if req.Reasoning != "" {
132 out.ReasoningEffort = strings.ToLower(req.Reasoning)
133 }
134 } else {
135 out.MaxTokens = &maxTok
136 }
137
138 if req.System != "" {
139 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})
140 }
141
142 for _, msg := range req.Messages {
143 switch msg.Role {
144 case RoleUser:
145 content := buildOAIUserContent(msg.Content)
146 out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content})
147 case RoleAssistant:
148 am := oaiMessage{Role: "assistant"}
149 var text strings.Builder
150 for _, b := range msg.Content {
151 switch v := b.(type) {
152 case TextBlock:
153 if text.Len() > 0 {
154 text.WriteString("\n")
155 }
156 text.WriteString(v.Text)
157 case ToolCallBlock:
158 args := v.Arguments
159 if len(args) == 0 {
160 args = json.RawMessage("{}")
161 }
162 am.ToolCalls = append(am.ToolCalls, oaiToolCall{
163 ID: v.ID,
164 Type: "function",
165 Function: oaiToolCallFn{
166 Name: v.Name,
167 Arguments: string(args),
168 },
169 })
170 }
171 }
172 if text.Len() > 0 {
173 am.Content = text.String()
174 }
175 out.Messages = append(out.Messages, am)
176 case RoleTool:
177 // Each ToolResultBlock becomes its own tool message.
178 for _, b := range msg.Content {
179 if tr, ok := b.(ToolResultBlock); ok {
180 var text strings.Builder
181 for _, inner := range tr.Content {
182 if tb, ok := inner.(TextBlock); ok {
183 if text.Len() > 0 {
184 text.WriteString("\n")
185 }
186 text.WriteString(tb.Text)
187 }
188 }
189 if tr.IsError && text.Len() > 0 {
190 text.WriteString(" [error]")
191 }
192 out.Messages = append(out.Messages, oaiMessage{
193 Role: "tool",
194 ToolCallID: tr.CallID,
195 Content: text.String(),
196 })
197 }
198 }
199 }
200 }
201
202 for _, t := range req.Tools {
203 var tool oaiTool
204 tool.Type = "function"
205 tool.Function.Name = t.Name
206 tool.Function.Description = t.Description
207 tool.Function.Parameters = t.Schema
208 out.Tools = append(out.Tools, tool)
209 }
210 if len(out.Tools) > 0 {
211 out.ToolChoice = "auto"
212 }
213
214 return out, nil
215}
216
217func buildOAIUserContent(blocks []Content) interface{} {
218 hasImage := false
219 for _, b := range blocks {
220 if _, ok := b.(ImageBlock); ok {
221 hasImage = true
222 break
223 }
224 }
225 if !hasImage {
226 var sb strings.Builder
227 for _, b := range blocks {
228 if tb, ok := b.(TextBlock); ok {
229 if sb.Len() > 0 {
230 sb.WriteString("\n")
231 }
232 sb.WriteString(tb.Text)
233 }
234 }
235 return sb.String()
236 }
237 var arr []interface{}
238 for _, b := range blocks {
239 switch v := b.(type) {
240 case TextBlock:
241 arr = append(arr, oaiContentText{Type: "text", Text: v.Text})
242 case ImageBlock:
243 var img oaiContentImage
244 img.Type = "image_url"
245 img.ImageURL.URL = "data:" + v.MimeType + ";base64," + base64.StdEncoding.EncodeToString(v.Data)
246 arr = append(arr, img)
247 }
248 }
249 return arr
250}
251
252// ---- streaming ----
253
254func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
255 wire, err := c.buildRequest(req)
256 if err != nil {
257 return nil, err
258 }
259 body, err := json.Marshal(wire)
260 if err != nil {
261 return nil, err
262 }
263 httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/chat/completions", bytes.NewReader(body))
264 if err != nil {
265 return nil, err
266 }
267 httpReq.Header.Set("content-type", "application/json")
268 httpReq.Header.Set("accept", "text/event-stream")
269 httpReq.Header.Set("authorization", "Bearer "+c.apiKey)
270
271 resp, err := c.http.Do(httpReq)
272 if err != nil {
273 return nil, fmt.Errorf("openai: %w", err)
274 }
275 if resp.StatusCode != http.StatusOK {
276 b, _ := io.ReadAll(resp.Body)
277 resp.Body.Close()
278 return nil, fmt.Errorf("openai: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
279 }
280
281 out := make(chan Event, 16)
282 go c.runStream(ctx, resp, req, out)
283 return out, nil
284}
285
286func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
287 defer close(out)
288 defer resp.Body.Close()
289
290 model, _ := FindModel("openai", req.Model)
291 out <- EventStart{Model: req.Model, Provider: "openai"}
292
293 raw := make(chan sseEvent, 16)
294 go readSSE(resp.Body, raw)
295
296 // Interleaved block tracking: text and tool_calls preserve their
297 // emission order so the assistant message renders in the same order
298 // the model produced it. The builder fires one kind of block at a
299 // time — incoming text deltas after a tool_call split into a fresh
300 // text block; subsequent tool_calls each get their own slot.
301 type blockEntry struct {
302 kind string // "text" | "tool_use"
303 textBuf strings.Builder
304 toolID string
305 toolName string
306 toolArgs strings.Builder
307 announced bool
308 }
309 var (
310 blocks []*blockEntry
311 currentText *blockEntry // most-recent text block, nil if none
312 toolByIdx = map[int]*blockEntry{} // openai tool_call index -> block
313 usage Usage
314 stop StopReason = StopEnd
315 finalErr error
316 )
317
318 appendText := func(delta string) {
319 if currentText == nil {
320 currentText = &blockEntry{kind: "text"}
321 blocks = append(blocks, currentText)
322 }
323 currentText.textBuf.WriteString(delta)
324 }
325
326 getOrCreateTool := func(idx int) *blockEntry {
327 if t, ok := toolByIdx[idx]; ok {
328 return t
329 }
330 t := &blockEntry{kind: "tool_use"}
331 toolByIdx[idx] = t
332 blocks = append(blocks, t)
333 // A new tool block breaks the current text block. Subsequent text
334 // deltas will start a fresh text block after this tool.
335 currentText = nil
336 return t
337 }
338
339 assembleMsg := func() Message {
340 content := []Content{}
341 for _, b := range blocks {
342 switch b.kind {
343 case "text":
344 if b.textBuf.Len() > 0 {
345 content = append(content, TextBlock{Text: b.textBuf.String()})
346 }
347 case "tool_use":
348 args := b.toolArgs.String()
349 if args == "" {
350 args = "{}"
351 }
352 content = append(content, ToolCallBlock{
353 ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args),
354 })
355 }
356 }
357 return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
358 }
359
360 sendDone := func() {
361 usage.CostUSD = ComputeCost(model, usage)
362 out <- EventUsage{Usage: usage}
363 out <- EventDone{Stop: stop, Err: finalErr, Message: assembleMsg()}
364 }
365
366 for {
367 select {
368 case <-ctx.Done():
369 stop = StopAborted
370 finalErr = ctx.Err()
371 sendDone()
372 return
373 case ev, ok := <-raw:
374 if !ok {
375 sendDone()
376 return
377 }
378 if ev.Data == "[DONE]" {
379 sendDone()
380 return
381 }
382 var chunk struct {
383 Choices []struct {
384 Index int `json:"index"`
385 Delta struct {
386 Content string `json:"content"`
387 ToolCalls []struct {
388 Index int `json:"index"`
389 ID string `json:"id"`
390 Type string `json:"type"`
391 Function struct {
392 Name string `json:"name"`
393 Arguments string `json:"arguments"`
394 } `json:"function"`
395 } `json:"tool_calls"`
396 } `json:"delta"`
397 FinishReason string `json:"finish_reason"`
398 } `json:"choices"`
399 Usage *struct {
400 PromptTokens int `json:"prompt_tokens"`
401 CompletionTokens int `json:"completion_tokens"`
402 PromptTokensDetails struct {
403 CachedTokens int `json:"cached_tokens"`
404 } `json:"prompt_tokens_details"`
405 } `json:"usage"`
406 Error *struct {
407 Message string `json:"message"`
408 Type string `json:"type"`
409 } `json:"error"`
410 }
411 if err := json.Unmarshal([]byte(ev.Data), &chunk); err != nil {
412 continue
413 }
414 if chunk.Error != nil {
415 stop = StopError
416 finalErr = fmt.Errorf("openai: %s", chunk.Error.Message)
417 sendDone()
418 return
419 }
420 if chunk.Usage != nil {
421 usage.InputTokens = chunk.Usage.PromptTokens - chunk.Usage.PromptTokensDetails.CachedTokens
422 if usage.InputTokens < 0 {
423 usage.InputTokens = chunk.Usage.PromptTokens
424 }
425 usage.OutputTokens = chunk.Usage.CompletionTokens
426 usage.CacheReadTokens = chunk.Usage.PromptTokensDetails.CachedTokens
427 }
428 for _, ch := range chunk.Choices {
429 if ch.Delta.Content != "" {
430 appendText(ch.Delta.Content)
431 out <- EventTextDelta{Delta: ch.Delta.Content}
432 }
433 for _, tc := range ch.Delta.ToolCalls {
434 t := getOrCreateTool(tc.Index)
435 if tc.ID != "" {
436 t.toolID = tc.ID
437 }
438 if tc.Function.Name != "" {
439 t.toolName = tc.Function.Name
440 }
441 if !t.announced && t.toolID != "" && t.toolName != "" {
442 t.announced = true
443 out <- EventToolStart{ID: t.toolID, Name: t.toolName}
444 }
445 if tc.Function.Arguments != "" {
446 t.toolArgs.WriteString(tc.Function.Arguments)
447 if t.announced {
448 out <- EventToolArgs{ID: t.toolID, Delta: tc.Function.Arguments}
449 }
450 }
451 }
452 switch ch.FinishReason {
453 case "stop":
454 stop = StopEnd
455 case "length":
456 stop = StopLength
457 case "tool_calls", "function_call":
458 stop = StopToolUse
459 for _, b := range blocks {
460 if b.kind == "tool_use" && b.announced {
461 out <- EventToolEnd{ID: b.toolID}
462 }
463 }
464 }
465 }
466 }
467 }
468}