2025-03-27 22:35:48 +01:00
package provider
import (
"context"
2025-04-16 20:06:23 +02:00
"encoding/json"
2025-03-27 22:35:48 +01:00
"errors"
2025-04-16 20:06:23 +02:00
"fmt"
"io"
"time"
2025-03-27 22:35:48 +01:00
2025-04-27 14:07:20 +02:00
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"
"github.com/openai/openai-go/shared"
2025-04-24 18:25:52 +02:00
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/llm/tools"
"github.com/opencode-ai/opencode/internal/logging"
"github.com/opencode-ai/opencode/internal/message"
2025-03-27 22:35:48 +01:00
)
2025-04-16 20:06:23 +02:00
type openaiOptions struct {
2025-04-18 20:17:38 +02:00
baseURL string
disableCache bool
reasoningEffort string
2025-04-29 12:56:49 +01:00
extraHeaders map [ string ] string
2025-03-27 22:35:48 +01:00
}
2025-04-16 20:06:23 +02:00
type OpenAIOption func ( * openaiOptions )
2025-03-27 22:35:48 +01:00
2025-04-16 20:06:23 +02:00
type openaiClient struct {
providerOptions providerClientOptions
options openaiOptions
client openai . Client
2025-03-27 22:35:48 +01:00
}
2025-04-16 20:06:23 +02:00
type OpenAIClient ProviderClient
2025-03-27 22:35:48 +01:00
2025-04-16 20:06:23 +02:00
func newOpenAIClient ( opts providerClientOptions ) OpenAIClient {
2025-04-18 20:17:38 +02:00
openaiOpts := openaiOptions {
reasoningEffort : "medium" ,
}
2025-04-16 20:06:23 +02:00
for _ , o := range opts . openaiOptions {
o ( & openaiOpts )
2025-03-27 22:35:48 +01:00
}
2025-04-16 20:06:23 +02:00
openaiClientOptions := [ ] option . RequestOption { }
if opts . apiKey != "" {
openaiClientOptions = append ( openaiClientOptions , option . WithAPIKey ( opts . apiKey ) )
2025-03-27 22:35:48 +01:00
}
2025-04-16 20:06:23 +02:00
if openaiOpts . baseURL != "" {
openaiClientOptions = append ( openaiClientOptions , option . WithBaseURL ( openaiOpts . baseURL ) )
2025-03-27 22:35:48 +01:00
}
2025-04-29 12:56:49 +01:00
if openaiOpts . extraHeaders != nil {
for key , value := range openaiOpts . extraHeaders {
openaiClientOptions = append ( openaiClientOptions , option . WithHeader ( key , value ) )
}
}
2025-04-16 20:06:23 +02:00
client := openai . NewClient ( openaiClientOptions ... )
return & openaiClient {
providerOptions : opts ,
options : openaiOpts ,
client : client ,
2025-03-27 22:35:48 +01:00
}
}
2025-04-16 20:06:23 +02:00
func ( o * openaiClient ) convertMessages ( messages [ ] message . Message ) ( openaiMessages [ ] openai . ChatCompletionMessageParamUnion ) {
// Add system message first
openaiMessages = append ( openaiMessages , openai . SystemMessage ( o . providerOptions . systemMessage ) )
2025-03-27 22:35:48 +01:00
for _ , msg := range messages {
switch msg . Role {
case message . User :
2025-04-16 20:06:23 +02:00
openaiMessages = append ( openaiMessages , openai . UserMessage ( msg . Content ( ) . String ( ) ) )
2025-03-27 22:35:48 +01:00
case message . Assistant :
assistantMsg := openai . ChatCompletionAssistantMessageParam {
Role : "assistant" ,
}
2025-04-03 15:20:15 +02:00
if msg . Content ( ) . String ( ) != "" {
2025-03-27 22:35:48 +01:00
assistantMsg . Content = openai . ChatCompletionAssistantMessageParamContentUnion {
2025-04-03 15:20:15 +02:00
OfString : openai . String ( msg . Content ( ) . String ( ) ) ,
2025-03-27 22:35:48 +01:00
}
}
2025-04-03 15:20:15 +02:00
if len ( msg . ToolCalls ( ) ) > 0 {
assistantMsg . ToolCalls = make ( [ ] openai . ChatCompletionMessageToolCallParam , len ( msg . ToolCalls ( ) ) )
for i , call := range msg . ToolCalls ( ) {
2025-03-27 22:35:48 +01:00
assistantMsg . ToolCalls [ i ] = openai . ChatCompletionMessageToolCallParam {
ID : call . ID ,
Type : "function" ,
Function : openai . ChatCompletionMessageToolCallFunctionParam {
Name : call . Name ,
Arguments : call . Input ,
} ,
}
}
}
2025-04-16 20:06:23 +02:00
openaiMessages = append ( openaiMessages , openai . ChatCompletionMessageParamUnion {
2025-03-27 22:35:48 +01:00
OfAssistant : & assistantMsg ,
} )
case message . Tool :
2025-04-03 15:20:15 +02:00
for _ , result := range msg . ToolResults ( ) {
2025-04-16 20:06:23 +02:00
openaiMessages = append ( openaiMessages ,
2025-03-27 22:35:48 +01:00
openai . ToolMessage ( result . Content , result . ToolCallID ) ,
)
}
}
}
2025-04-16 20:06:23 +02:00
return
2025-03-27 22:35:48 +01:00
}
2025-04-16 20:06:23 +02:00
func ( o * openaiClient ) convertTools ( tools [ ] tools . BaseTool ) [ ] openai . ChatCompletionToolParam {
2025-03-27 22:35:48 +01:00
openaiTools := make ( [ ] openai . ChatCompletionToolParam , len ( tools ) )
for i , tool := range tools {
info := tool . Info ( )
openaiTools [ i ] = openai . ChatCompletionToolParam {
Function : openai . FunctionDefinitionParam {
Name : info . Name ,
Description : openai . String ( info . Description ) ,
Parameters : openai . FunctionParameters {
"type" : "object" ,
"properties" : info . Parameters ,
"required" : info . Required ,
} ,
} ,
}
}
return openaiTools
}
2025-04-16 20:06:23 +02:00
func ( o * openaiClient ) finishReason ( reason string ) message . FinishReason {
switch reason {
case "stop" :
return message . FinishReasonEndTurn
case "length" :
return message . FinishReasonMaxTokens
case "tool_calls" :
return message . FinishReasonToolUse
default :
return message . FinishReasonUnknown
2025-03-27 22:35:48 +01:00
}
}
2025-04-16 20:06:23 +02:00
func ( o * openaiClient ) preparedParams ( messages [ ] openai . ChatCompletionMessageParamUnion , tools [ ] openai . ChatCompletionToolParam ) openai . ChatCompletionNewParams {
2025-04-18 20:17:38 +02:00
params := openai . ChatCompletionNewParams {
Model : openai . ChatModel ( o . providerOptions . model . APIModel ) ,
Messages : messages ,
Tools : tools ,
2025-03-27 22:35:48 +01:00
}
2025-04-18 20:17:38 +02:00
if o . providerOptions . model . CanReason == true {
params . MaxCompletionTokens = openai . Int ( o . providerOptions . maxTokens )
switch o . options . reasoningEffort {
case "low" :
params . ReasoningEffort = shared . ReasoningEffortLow
case "medium" :
params . ReasoningEffort = shared . ReasoningEffortMedium
case "high" :
params . ReasoningEffort = shared . ReasoningEffortHigh
default :
params . ReasoningEffort = shared . ReasoningEffortMedium
}
} else {
params . MaxTokens = openai . Int ( o . providerOptions . maxTokens )
}
return params
2025-04-16 20:06:23 +02:00
}
2025-03-27 22:35:48 +01:00
2025-04-16 20:06:23 +02:00
func ( o * openaiClient ) send ( ctx context . Context , messages [ ] message . Message , tools [ ] tools . BaseTool ) ( response * ProviderResponse , err error ) {
params := o . preparedParams ( o . convertMessages ( messages ) , o . convertTools ( tools ) )
cfg := config . Get ( )
if cfg . Debug {
jsonData , _ := json . Marshal ( params )
logging . Debug ( "Prepared messages" , "messages" , string ( jsonData ) )
2025-03-27 22:35:48 +01:00
}
2025-04-16 20:06:23 +02:00
attempts := 0
for {
attempts ++
openaiResponse , err := o . client . Chat . Completions . New (
ctx ,
params ,
)
// If there is an error we are going to see if we can retry the call
if err != nil {
retry , after , retryErr := o . shouldRetry ( attempts , err )
if retryErr != nil {
return nil , retryErr
2025-03-27 22:35:48 +01:00
}
2025-04-16 20:06:23 +02:00
if retry {
2025-04-27 14:07:20 +02:00
logging . WarnPersist ( fmt . Sprintf ( "Retrying due to rate limit... attempt %d of %d" , attempts , maxRetries ) , logging . PersistTimeArg , time . Millisecond * time . Duration ( after + 100 ) )
2025-04-16 20:06:23 +02:00
select {
case <- ctx . Done ( ) :
return nil , ctx . Err ( )
case <- time . After ( time . Duration ( after ) * time . Millisecond ) :
continue
}
}
return nil , retryErr
2025-03-27 22:35:48 +01:00
}
2025-04-16 20:06:23 +02:00
content := ""
if openaiResponse . Choices [ 0 ] . Message . Content != "" {
content = openaiResponse . Choices [ 0 ] . Message . Content
}
2025-03-27 22:35:48 +01:00
2025-04-29 12:56:49 +01:00
toolCalls := o . toolCalls ( * openaiResponse )
finishReason := o . finishReason ( string ( openaiResponse . Choices [ 0 ] . FinishReason ) )
if len ( toolCalls ) > 0 {
finishReason = message . FinishReasonToolUse
}
2025-04-16 20:06:23 +02:00
return & ProviderResponse {
Content : content ,
2025-04-29 12:56:49 +01:00
ToolCalls : toolCalls ,
2025-04-16 20:06:23 +02:00
Usage : o . usage ( * openaiResponse ) ,
2025-04-29 12:56:49 +01:00
FinishReason : finishReason ,
2025-04-16 20:06:23 +02:00
} , nil
}
2025-03-27 22:35:48 +01:00
}
2025-04-16 20:06:23 +02:00
func ( o * openaiClient ) stream ( ctx context . Context , messages [ ] message . Message , tools [ ] tools . BaseTool ) <- chan ProviderEvent {
params := o . preparedParams ( o . convertMessages ( messages ) , o . convertTools ( tools ) )
params . StreamOptions = openai . ChatCompletionStreamOptionsParam {
IncludeUsage : openai . Bool ( true ) ,
2025-03-27 22:35:48 +01:00
}
2025-04-16 20:06:23 +02:00
cfg := config . Get ( )
if cfg . Debug {
jsonData , _ := json . Marshal ( params )
logging . Debug ( "Prepared messages" , "messages" , string ( jsonData ) )
}
2025-03-27 22:35:48 +01:00
2025-04-16 20:06:23 +02:00
attempts := 0
2025-03-27 22:35:48 +01:00
eventChan := make ( chan ProviderEvent )
go func ( ) {
2025-04-16 20:06:23 +02:00
for {
attempts ++
openaiStream := o . client . Chat . Completions . NewStreaming (
ctx ,
params ,
)
acc := openai . ChatCompletionAccumulator { }
currentContent := ""
toolCalls := make ( [ ] message . ToolCall , 0 )
for openaiStream . Next ( ) {
chunk := openaiStream . Current ( )
acc . AddChunk ( chunk )
if tool , ok := acc . JustFinishedToolCall ( ) ; ok {
toolCalls = append ( toolCalls , message . ToolCall {
ID : tool . Id ,
Name : tool . Name ,
Input : tool . Arguments ,
Type : "function" ,
} )
}
2025-03-27 22:35:48 +01:00
2025-04-16 20:06:23 +02:00
for _ , choice := range chunk . Choices {
if choice . Delta . Content != "" {
eventChan <- ProviderEvent {
Type : EventContentDelta ,
Content : choice . Delta . Content ,
}
currentContent += choice . Delta . Content
2025-03-27 22:35:48 +01:00
}
}
}
2025-04-16 20:06:23 +02:00
err := openaiStream . Err ( )
if err == nil || errors . Is ( err , io . EOF ) {
// Stream completed successfully
2025-04-29 12:56:49 +01:00
finishReason := o . finishReason ( string ( acc . ChatCompletion . Choices [ 0 ] . FinishReason ) )
if len ( toolCalls ) > 0 {
finishReason = message . FinishReasonToolUse
}
2025-04-16 20:06:23 +02:00
eventChan <- ProviderEvent {
Type : EventComplete ,
Response : & ProviderResponse {
Content : currentContent ,
ToolCalls : toolCalls ,
Usage : o . usage ( acc . ChatCompletion ) ,
2025-04-29 12:56:49 +01:00
FinishReason : finishReason ,
2025-04-16 20:06:23 +02:00
} ,
}
close ( eventChan )
return
2025-03-27 22:35:48 +01:00
}
2025-04-16 20:06:23 +02:00
// If there is an error we are going to see if we can retry the call
retry , after , retryErr := o . shouldRetry ( attempts , err )
if retryErr != nil {
eventChan <- ProviderEvent { Type : EventError , Error : retryErr }
close ( eventChan )
return
}
if retry {
2025-04-27 14:07:20 +02:00
logging . WarnPersist ( fmt . Sprintf ( "Retrying due to rate limit... attempt %d of %d" , attempts , maxRetries ) , logging . PersistTimeArg , time . Millisecond * time . Duration ( after + 100 ) )
2025-04-16 20:06:23 +02:00
select {
case <- ctx . Done ( ) :
// context cancelled
if ctx . Err ( ) == nil {
eventChan <- ProviderEvent { Type : EventError , Error : ctx . Err ( ) }
}
close ( eventChan )
return
case <- time . After ( time . Duration ( after ) * time . Millisecond ) :
continue
}
}
eventChan <- ProviderEvent { Type : EventError , Error : retryErr }
close ( eventChan )
2025-03-27 22:35:48 +01:00
return
}
2025-04-16 20:06:23 +02:00
} ( )
2025-03-27 22:35:48 +01:00
2025-04-16 20:06:23 +02:00
return eventChan
}
2025-03-27 22:35:48 +01:00
2025-04-16 20:06:23 +02:00
func ( o * openaiClient ) shouldRetry ( attempts int , err error ) ( bool , int64 , error ) {
var apierr * openai . Error
if ! errors . As ( err , & apierr ) {
return false , 0 , err
}
if apierr . StatusCode != 429 && apierr . StatusCode != 500 {
return false , 0 , err
}
if attempts > maxRetries {
return false , 0 , fmt . Errorf ( "maximum retry attempts reached for rate limit: %d retries" , maxRetries )
}
retryMs := 0
retryAfterValues := apierr . Response . Header . Values ( "Retry-After" )
backoffMs := 2000 * ( 1 << ( attempts - 1 ) )
jitterMs := int ( float64 ( backoffMs ) * 0.2 )
retryMs = backoffMs + jitterMs
if len ( retryAfterValues ) > 0 {
if _ , err := fmt . Sscanf ( retryAfterValues [ 0 ] , "%d" , & retryMs ) ; err == nil {
retryMs = retryMs * 1000
2025-03-27 22:35:48 +01:00
}
2025-04-16 20:06:23 +02:00
}
return true , int64 ( retryMs ) , nil
}
2025-03-27 22:35:48 +01:00
2025-04-16 20:06:23 +02:00
func ( o * openaiClient ) toolCalls ( completion openai . ChatCompletion ) [ ] message . ToolCall {
var toolCalls [ ] message . ToolCall
if len ( completion . Choices ) > 0 && len ( completion . Choices [ 0 ] . Message . ToolCalls ) > 0 {
for _ , call := range completion . Choices [ 0 ] . Message . ToolCalls {
toolCall := message . ToolCall {
2025-04-19 16:35:45 +02:00
ID : call . ID ,
Name : call . Function . Name ,
Input : call . Function . Arguments ,
Type : "function" ,
Finished : true ,
2025-04-16 20:06:23 +02:00
}
toolCalls = append ( toolCalls , toolCall )
}
}
return toolCalls
2025-03-27 22:35:48 +01:00
}
2025-04-16 20:06:23 +02:00
func ( o * openaiClient ) usage ( completion openai . ChatCompletion ) TokenUsage {
cachedTokens := completion . Usage . PromptTokensDetails . CachedTokens
inputTokens := completion . Usage . PromptTokens - cachedTokens
return TokenUsage {
InputTokens : inputTokens ,
OutputTokens : completion . Usage . CompletionTokens ,
CacheCreationTokens : 0 , // OpenAI doesn't provide this directly
CacheReadTokens : cachedTokens ,
}
}
func WithOpenAIBaseURL ( baseURL string ) OpenAIOption {
return func ( options * openaiOptions ) {
options . baseURL = baseURL
}
}
2025-04-29 12:56:49 +01:00
func WithOpenAIExtraHeaders ( headers map [ string ] string ) OpenAIOption {
return func ( options * openaiOptions ) {
options . extraHeaders = headers
}
}
2025-04-16 20:06:23 +02:00
func WithOpenAIDisableCache ( ) OpenAIOption {
return func ( options * openaiOptions ) {
options . disableCache = true
}
}
2025-04-18 20:17:38 +02:00
func WithReasoningEffort ( effort string ) OpenAIOption {
return func ( options * openaiOptions ) {
defaultReasoningEffort := "medium"
switch effort {
case "low" , "medium" , "high" :
defaultReasoningEffort = effort
default :
logging . Warn ( "Invalid reasoning effort, using default: medium" )
}
options . reasoningEffort = defaultReasoningEffort
}
}