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