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 // Look up the model by id across all providers (not just openai)
115 // because the OpenAI client is also used for ollama and other
116 // OpenAI-compatible backends.
117 m, err := FindModel("", req.Model)
118 if err != nil {
119 // Unknown model: use sensible defaults so local/custom
120 // models still work without a catalog entry.
121 m = Model{
122 ID: req.Model,
123 ContextWindow: 32768,
124 MaxOutput: 8192,
125 }
126 }
127 out := &oaiRequest{
128 Model: req.Model,
129 Stream: true,
130 StreamOptions: &oaiStreamOptions{IncludeUsage: true},
131 Temperature: req.Temperature,
132 }
133
134 maxTok := req.MaxTokens
135 if maxTok <= 0 {
136 maxTok = m.MaxOutput
137 }
138 if m.Reasoning {
139 out.MaxCompletionTok = &maxTok
140 if req.Reasoning != "" {
141 out.ReasoningEffort = strings.ToLower(req.Reasoning)
142 }
143 } else {
144 out.MaxTokens = &maxTok
145 }
146
147 if req.System != "" {
148 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})
149 }
150
151 for _, msg := range req.Messages {
152 switch msg.Role {
153 case RoleUser:
154 content := buildOAIUserContent(msg.Content)
155 out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content})
156 case RoleAssistant:
157 am := oaiMessage{Role: "assistant"}
158 var text strings.Builder
159 for _, b := range msg.Content {
160 switch v := b.(type) {
161 case TextBlock:
162 if text.Len() > 0 {
163 text.WriteString("\n")
164 }
165 text.WriteString(v.Text)
166 case ToolCallBlock:
167 args := v.Arguments
168 if len(args) == 0 {
169 args = json.RawMessage("{}")
170 }
171 am.ToolCalls = append(am.ToolCalls, oaiToolCall{
172 ID: v.ID,
173 Type: "function",
174 Function: oaiToolCallFn{
175 Name: v.Name,
176 Arguments: string(args),
177 },
178 })
179 }
180 }
181 if text.Len() > 0 {
182 am.Content = text.String()
183 }
184 out.Messages = append(out.Messages, am)
185 case RoleTool:
186 // Each ToolResultBlock becomes its own tool message. Preserve
187 // image blocks for vision-capable OpenAI models instead of
188 // flattening the tool output to plain text.
189 for _, b := range msg.Content {
190 if tr, ok := b.(ToolResultBlock); ok {
191 content := buildOAIToolContent(tr.Content, tr.IsError)
192 out.Messages = append(out.Messages, oaiMessage{
193 Role: "tool",
194 ToolCallID: tr.CallID,
195 Content: content,
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 return buildOAIContentBlocks(blocks, false)
238}
239
240func buildOAIToolContent(blocks []Content, isError bool) interface{} {
241 hasImage := false
242 for _, b := range blocks {
243 if _, ok := b.(ImageBlock); ok {
244 hasImage = true
245 break
246 }
247 }
248 if !hasImage {
249 var sb strings.Builder
250 for _, b := range blocks {
251 if tb, ok := b.(TextBlock); ok {
252 if sb.Len() > 0 {
253 sb.WriteString("\n")
254 }
255 sb.WriteString(tb.Text)
256 }
257 }
258 if isError && sb.Len() > 0 {
259 sb.WriteString(" [error]")
260 }
261 return sb.String()
262 }
263 return buildOAIContentBlocks(blocks, isError)
264}
265
266func buildOAIContentBlocks(blocks []Content, isError bool) []interface{} {
267 var arr []interface{}
268 for _, b := range blocks {
269 switch v := b.(type) {
270 case TextBlock:
271 arr = append(arr, oaiContentText{Type: "text", Text: v.Text})
272 case ImageBlock:
273 var img oaiContentImage
274 img.Type = "image_url"
275 img.ImageURL.URL = "data:" + v.MimeType + ";base64," + base64.StdEncoding.EncodeToString(v.Data)
276 arr = append(arr, img)
277 }
278 }
279 if isError {
280 arr = append(arr, oaiContentText{Type: "text", Text: "[error]"})
281 }
282 return arr
283}
284
285// ---- streaming ----
286
287func (c *openaiClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
288 wire, err := c.buildRequest(req)
289 if err != nil {
290 return nil, err
291 }
292 body, err := json.Marshal(wire)
293 if err != nil {
294 return nil, err
295 }
296 httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/chat/completions", bytes.NewReader(body))
297 if err != nil {
298 return nil, err
299 }
300 httpReq.Header.Set("content-type", "application/json")
301 httpReq.Header.Set("accept", "text/event-stream")
302 httpReq.Header.Set("authorization", "Bearer "+c.apiKey)
303
304 resp, err := c.http.Do(httpReq)
305 if err != nil {
306 return nil, fmt.Errorf("openai: %w", err)
307 }
308 if resp.StatusCode != http.StatusOK {
309 b, _ := io.ReadAll(resp.Body)
310 resp.Body.Close()
311 return nil, fmt.Errorf("openai: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
312 }
313
314 out := make(chan Event, 16)
315 go c.runStream(ctx, resp, req, out)
316 return out, nil
317}
318
319func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
320 defer close(out)
321 defer resp.Body.Close()
322
323 model, _ := FindModel("", req.Model)
324 out <- EventStart{Model: req.Model, Provider: "openai"}
325
326 raw := make(chan sseEvent, 16)
327 go readSSE(resp.Body, raw)
328
329 // Interleaved block tracking: text and tool_calls preserve their
330 // emission order so the assistant message renders in the same order
331 // the model produced it. The builder fires one kind of block at a
332 // time — incoming text deltas after a tool_call split into a fresh
333 // text block; subsequent tool_calls each get their own slot.
334 type blockEntry struct {
335 kind string // "text" | "tool_use"
336 textBuf strings.Builder
337 toolID string
338 toolName string
339 toolArgs strings.Builder
340 announced bool
341 }
342 var (
343 blocks []*blockEntry
344 currentText *blockEntry // most-recent text block, nil if none
345 toolByIdx = map[int]*blockEntry{} // openai tool_call index -> block
346 usage Usage
347 stop StopReason = StopEnd
348 finalErr error
349 )
350
351 appendText := func(delta string) {
352 if currentText == nil {
353 currentText = &blockEntry{kind: "text"}
354 blocks = append(blocks, currentText)
355 }
356 currentText.textBuf.WriteString(delta)
357 }
358
359 getOrCreateTool := func(idx int) *blockEntry {
360 if t, ok := toolByIdx[idx]; ok {
361 return t
362 }
363 t := &blockEntry{kind: "tool_use"}
364 toolByIdx[idx] = t
365 blocks = append(blocks, t)
366 // A new tool block breaks the current text block. Subsequent text
367 // deltas will start a fresh text block after this tool.
368 currentText = nil
369 return t
370 }
371
372 assembleMsg := func() Message {
373 content := []Content{}
374 for _, b := range blocks {
375 switch b.kind {
376 case "text":
377 if b.textBuf.Len() > 0 {
378 content = append(content, TextBlock{Text: b.textBuf.String()})
379 }
380 case "tool_use":
381 args := b.toolArgs.String()
382 if args == "" {
383 args = "{}"
384 }
385 content = append(content, ToolCallBlock{
386 ID: b.toolID, Name: b.toolName, Arguments: json.RawMessage(args),
387 })
388 }
389 }
390 return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
391 }
392
393 sendDone := func() {
394 usage.CostUSD = ComputeCost(model, usage)
395 out <- EventUsage{Usage: usage}
396 out <- EventDone{Stop: stop, Err: finalErr, Message: assembleMsg()}
397 }
398
399 for {
400 select {
401 case <-ctx.Done():
402 stop = StopAborted
403 finalErr = ctx.Err()
404 sendDone()
405 return
406 case ev, ok := <-raw:
407 if !ok {
408 sendDone()
409 return
410 }
411 if ev.Data == "[DONE]" {
412 sendDone()
413 return
414 }
415 var chunk struct {
416 Choices []struct {
417 Index int `json:"index"`
418 Delta struct {
419 Content string `json:"content"`
420 ToolCalls []struct {
421 Index int `json:"index"`
422 ID string `json:"id"`
423 Type string `json:"type"`
424 Function struct {
425 Name string `json:"name"`
426 Arguments string `json:"arguments"`
427 } `json:"function"`
428 } `json:"tool_calls"`
429 } `json:"delta"`
430 FinishReason string `json:"finish_reason"`
431 } `json:"choices"`
432 Usage *struct {
433 PromptTokens int `json:"prompt_tokens"`
434 CompletionTokens int `json:"completion_tokens"`
435 PromptTokensDetails struct {
436 CachedTokens int `json:"cached_tokens"`
437 } `json:"prompt_tokens_details"`
438 } `json:"usage"`
439 Error *struct {
440 Message string `json:"message"`
441 Type string `json:"type"`
442 } `json:"error"`
443 }
444 if err := json.Unmarshal([]byte(ev.Data), &chunk); err != nil {
445 continue
446 }
447 if chunk.Error != nil {
448 stop = StopError
449 finalErr = fmt.Errorf("openai: %s", chunk.Error.Message)
450 sendDone()
451 return
452 }
453 if chunk.Usage != nil {
454 usage.InputTokens = chunk.Usage.PromptTokens - chunk.Usage.PromptTokensDetails.CachedTokens
455 if usage.InputTokens < 0 {
456 usage.InputTokens = chunk.Usage.PromptTokens
457 }
458 usage.OutputTokens = chunk.Usage.CompletionTokens
459 usage.CacheReadTokens = chunk.Usage.PromptTokensDetails.CachedTokens
460 }
461 for _, ch := range chunk.Choices {
462 if ch.Delta.Content != "" {
463 appendText(ch.Delta.Content)
464 out <- EventTextDelta{Delta: ch.Delta.Content}
465 }
466 for _, tc := range ch.Delta.ToolCalls {
467 t := getOrCreateTool(tc.Index)
468 if tc.ID != "" {
469 t.toolID = tc.ID
470 }
471 if tc.Function.Name != "" {
472 t.toolName = tc.Function.Name
473 }
474 if !t.announced && t.toolID != "" && t.toolName != "" {
475 t.announced = true
476 out <- EventToolStart{ID: t.toolID, Name: t.toolName}
477 }
478 if tc.Function.Arguments != "" {
479 t.toolArgs.WriteString(tc.Function.Arguments)
480 if t.announced {
481 out <- EventToolArgs{ID: t.toolID, Delta: tc.Function.Arguments}
482 }
483 }
484 }
485 switch ch.FinishReason {
486 case "stop":
487 stop = StopEnd
488 case "length":
489 stop = StopLength
490 case "tool_calls", "function_call":
491 stop = StopToolUse
492 for _, b := range blocks {
493 if b.kind == "tool_use" && b.announced {
494 out <- EventToolEnd{ID: b.toolID}
495 }
496 }
497 }
498 }
499 }
500 }
501}