on main help sign in
Merge pull request #505 from charmbracelet/fix-openai-provider

Fix openai compatible api for certain providers

commit989e56eb6f1873593791adb8230c6d403372c1e1 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 5 Aug 2025, 15:58 (1 year ago) parentd72bac2dd11ff0
7 files changed+90−119
go.mod+2−2 go.sum+4−4 internal/llm/provider/openai.go+71−67 internal/tui/components/chat/messages/messages.go+3 internal/tui/components/chat/splash/splash.go−22 internal/tui/components/dialogs/models/list.go+10−5 internal/tui/components/dialogs/models/models.go−19
go.mod+2−2
@@ -14,7 +14,7 @@ require (
1414 github.com/charlievieth/fastwalk v1.0.11
1515 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
1616 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d
17− github.com/charmbracelet/catwalk v0.3.5
17+ github.com/charmbracelet/catwalk v0.4.5
1818 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
1919 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
2020 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0
@@ -56,7 +56,7 @@ require (
5656 github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
5757 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
5858 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
59− golang.org/x/oauth2 v0.25.0 // indirect
59+ golang.org/x/oauth2 v0.30.0 // indirect
6060 golang.org/x/time v0.8.0 // indirect
6161 google.golang.org/api v0.211.0 // indirect
6262)
go.sum+4−4
@@ -78,8 +78,8 @@ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
7878github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
7979github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d h1:YMXLZHSo8DjytVY/b5dK8LDuyQsVUmBK3ydQMpu2Ui4=
8080github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
81−github.com/charmbracelet/catwalk v0.3.5 h1:ChMvA5ooTNZhDKFagmGNQgIZvZp8XjpdaJ+cDmhgCgA=
82−github.com/charmbracelet/catwalk v0.3.5/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
81+github.com/charmbracelet/catwalk v0.4.5 h1:Kv3PadDe8IF8gpcYTfAJdCee5Bv4HufvtNT61FXtq5g=
82+github.com/charmbracelet/catwalk v0.4.5/go.mod h1:WnKgNPmQHuMyk7GtwAQwl+ezHusfH40IvzML2qwUGwc=
8383github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
8484github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
8585github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
@@ -332,8 +332,8 @@ golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
332332golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
333333golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
334334golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
335−golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
336−golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
335+golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
336+golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
337337golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
338338golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
339339golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
internal/llm/provider/openai.go+71−67
@@ -2,10 +2,12 @@ package provider
22
33import (
44 "context"
5+ "encoding/json"
56 "errors"
67 "fmt"
78 "io"
89 "log/slog"
10+ "slices"
911 "strings"
1012 "time"
1113
@@ -14,6 +16,7 @@ import (
1416 "github.com/charmbracelet/crush/internal/llm/tools"
1517 "github.com/charmbracelet/crush/internal/log"
1618 "github.com/charmbracelet/crush/internal/message"
19+ "github.com/google/uuid"
1720 "github.com/openai/openai-go"
1821 "github.com/openai/openai-go/option"
1922 "github.com/openai/openai-go/packages/param"
@@ -70,8 +73,9 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
7073 systemMessage = o.providerOptions.systemPromptPrefix + "\n" + systemMessage
7174 }
7275
73− systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage}
76+ system := openai.SystemMessage(systemMessage)
7477 if isAnthropicModel && !o.providerOptions.disableCache {
78+ systemTextBlock := openai.ChatCompletionContentPartTextParam{Text: systemMessage}
7579 systemTextBlock.SetExtraFields(
7680 map[string]any{
7781 "cache_control": map[string]string{
@@ -79,10 +83,10 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
7983 },
8084 },
8185 )
86+ var content []openai.ChatCompletionContentPartTextParam
87+ content = append(content, systemTextBlock)
88+ system = openai.SystemMessage(content)
8289 }
83− var content []openai.ChatCompletionContentPartTextParam
84− content = append(content, systemTextBlock)
85− system := openai.SystemMessage(content)
8690 openaiMessages = append(openaiMessages, system)
8791
8892 for i, msg := range messages {
@@ -93,9 +97,12 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
9397 switch msg.Role {
9498 case message.User:
9599 var content []openai.ChatCompletionContentPartUnionParam
100+
96101 textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
97102 content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
103+ hasBinaryContent := false
98104 for _, binaryContent := range msg.BinaryContent() {
105+ hasBinaryContent = true
99106 imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(catwalk.InferenceProviderOpenAI)}
100107 imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
101108
@@ -108,8 +115,11 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
108115 },
109116 })
110117 }
111−
112− openaiMessages = append(openaiMessages, openai.UserMessage(content))
118+ if hasBinaryContent || (isAnthropicModel && !o.providerOptions.disableCache) {
119+ openaiMessages = append(openaiMessages, openai.UserMessage(content))
120+ } else {
121+ openaiMessages = append(openaiMessages, openai.UserMessage(msg.Content().String()))
122+ }
113123
114124 case message.Assistant:
115125 assistantMsg := openai.ChatCompletionAssistantMessageParam{
@@ -134,13 +144,15 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
134144 },
135145 },
136146 }
147+ if !isAnthropicModel {
148+ assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
149+ OfString: param.NewOpt(msg.Content().String()),
150+ }
151+ }
137152 }
138153
139154 if len(msg.ToolCalls()) > 0 {
140155 hasContent = true
141− assistantMsg.Content = openai.ChatCompletionAssistantMessageParamContentUnion{
142− OfString: param.NewOpt(msg.Content().String()),
143− }
144156 assistantMsg.ToolCalls = make([]openai.ChatCompletionMessageToolCallParam, len(msg.ToolCalls()))
145157 for i, call := range msg.ToolCalls() {
146158 assistantMsg.ToolCalls[i] = openai.ChatCompletionMessageToolCallParam{
@@ -329,21 +341,26 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
329341 acc := openai.ChatCompletionAccumulator{}
330342 currentContent := ""
331343 toolCalls := make([]message.ToolCall, 0)
332−
333− var currentToolCallID string
334− var currentToolCall openai.ChatCompletionMessageToolCall
335344 var msgToolCalls []openai.ChatCompletionMessageToolCall
336− currentToolIndex := 0
337345 for openaiStream.Next() {
338346 chunk := openaiStream.Current()
339347 // Kujtim: this is an issue with openrouter qwen, its sending -1 for the tool index
340348 if len(chunk.Choices) > 0 && len(chunk.Choices[0].Delta.ToolCalls) > 0 && chunk.Choices[0].Delta.ToolCalls[0].Index == -1 {
341− chunk.Choices[0].Delta.ToolCalls[0].Index = int64(currentToolIndex)
342− currentToolIndex++
349+ chunk.Choices[0].Delta.ToolCalls[0].Index = 0
343350 }
344351 acc.AddChunk(chunk)
345− // This fixes multiple tool calls for some providers
346− for _, choice := range chunk.Choices {
352+ for i, choice := range chunk.Choices {
353+ reasoning, ok := choice.Delta.JSON.ExtraFields["reasoning"]
354+ if ok && reasoning.Raw() != "" {
355+ reasoningStr := ""
356+ json.Unmarshal([]byte(reasoning.Raw()), &reasoningStr)
357+ if reasoningStr != "" {
358+ eventChan <- ProviderEvent{
359+ Type: EventThinkingDelta,
360+ Thinking: reasoningStr,
361+ }
362+ }
363+ }
347364 if choice.Delta.Content != "" {
348365 eventChan <- ProviderEvent{
349366 Type: EventContentDelta,
@@ -352,63 +369,50 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
352369 currentContent += choice.Delta.Content
353370 } else if len(choice.Delta.ToolCalls) > 0 {
354371 toolCall := choice.Delta.ToolCalls[0]
355− // Detect tool use start
356− if currentToolCallID == "" {
357− if toolCall.ID != "" {
358− currentToolCallID = toolCall.ID
359− eventChan <- ProviderEvent{
360− Type: EventToolUseStart,
361− ToolCall: &message.ToolCall{
362− ID: toolCall.ID,
363− Name: toolCall.Function.Name,
364− Finished: false,
365− },
372+ newToolCall := false
373+ if len(msgToolCalls)-1 >= int(toolCall.Index) { // tool call exists
374+ existingToolCall := msgToolCalls[toolCall.Index]
375+ if toolCall.ID != "" && toolCall.ID != existingToolCall.ID {
376+ found := false
377+ // try to find the tool based on the ID
378+ for i, tool := range msgToolCalls {
379+ if tool.ID == toolCall.ID {
380+ msgToolCalls[i].Function.Arguments += toolCall.Function.Arguments
381+ found = true
382+ }
366383 }
367− currentToolCall = openai.ChatCompletionMessageToolCall{
368− ID: toolCall.ID,
369− Type: "function",
370− Function: openai.ChatCompletionMessageToolCallFunction{
371− Name: toolCall.Function.Name,
372− Arguments: toolCall.Function.Arguments,
373− },
384+ if !found {
385+ newToolCall = true
374386 }
375− }
376− } else {
377− // Delta tool use
378− if toolCall.ID == "" || toolCall.ID == currentToolCallID {
379− currentToolCall.Function.Arguments += toolCall.Function.Arguments
380387 } else {
381− // Detect new tool use
382− if toolCall.ID != currentToolCallID {
383− msgToolCalls = append(msgToolCalls, currentToolCall)
384− currentToolCallID = toolCall.ID
385− eventChan <- ProviderEvent{
386− Type: EventToolUseStart,
387− ToolCall: &message.ToolCall{
388− ID: toolCall.ID,
389− Name: toolCall.Function.Name,
390− Finished: false,
391− },
392− }
393− currentToolCall = openai.ChatCompletionMessageToolCall{
394− ID: toolCall.ID,
395− Type: "function",
396− Function: openai.ChatCompletionMessageToolCallFunction{
397− Name: toolCall.Function.Name,
398− Arguments: toolCall.Function.Arguments,
399− },
400− }
401− }
388+ msgToolCalls[toolCall.Index].Function.Arguments += toolCall.Function.Arguments
402389 }
390+ } else {
391+ newToolCall = true
403392 }
404− }
405− // Kujtim: some models send finish stop even for tool calls
406− if choice.FinishReason == "tool_calls" || (choice.FinishReason == "stop" && currentToolCallID != "") {
407− msgToolCalls = append(msgToolCalls, currentToolCall)
408− if len(acc.Choices) > 0 {
409− acc.Choices[0].Message.ToolCalls = msgToolCalls
393+ if newToolCall { // new tool call
394+ if toolCall.ID == "" {
395+ toolCall.ID = uuid.NewString()
396+ }
397+ eventChan <- ProviderEvent{
398+ Type: EventToolUseStart,
399+ ToolCall: &message.ToolCall{
400+ ID: toolCall.ID,
401+ Name: toolCall.Function.Name,
402+ Finished: false,
403+ },
404+ }
405+ msgToolCalls = append(msgToolCalls, openai.ChatCompletionMessageToolCall{
406+ ID: toolCall.ID,
407+ Type: "function",
408+ Function: openai.ChatCompletionMessageToolCallFunction{
409+ Name: toolCall.Function.Name,
410+ Arguments: toolCall.Function.Arguments,
411+ },
412+ })
410413 }
411414 }
415+ acc.Choices[i].Message.ToolCalls = slices.Clone(msgToolCalls)
412416 }
413417 }
414418
internal/tui/components/chat/messages/messages.go+3
@@ -274,6 +274,9 @@ func (m *messageCmp) renderThinkingContent() string {
274274 if reasoningContent.StartedAt > 0 {
275275 duration := m.message.ThinkingDuration()
276276 if reasoningContent.FinishedAt > 0 {
277+ if duration.String() == "0s" {
278+ return ""
279+ }
277280 m.anim.SetLabel("")
278281 opts := core.StatusOpts{
279282 Title: "Thought for",
internal/tui/components/chat/splash/splash.go−22
@@ -3,7 +3,6 @@ package splash
33import (
44 "fmt"
55 "os"
6− "slices"
76 "strings"
87 "time"
98
@@ -103,27 +102,6 @@ func New() Splash {
103102
104103func (s *splashCmp) SetOnboarding(onboarding bool) {
105104 s.isOnboarding = onboarding
106− if onboarding {
107− providers, err := config.Providers()
108− if err != nil {
109− return
110− }
111− filteredProviders := []catwalk.Provider{}
112− simpleProviders := []string{
113− "anthropic",
114− "openai",
115− "gemini",
116− "xai",
117− "groq",
118− "openrouter",
119− }
120− for _, p := range providers {
121− if slices.Contains(simpleProviders, string(p.ID)) {
122− filteredProviders = append(filteredProviders, p)
123− }
124− }
125− s.modelList.SetProviders(filteredProviders)
126− }
127105}
128106
129107func (s *splashCmp) SetProjectInit(needsInit bool) {
internal/tui/components/dialogs/models/list.go+10−5
@@ -3,6 +3,7 @@ package models
33import (
44 "fmt"
55 "slices"
6+ "strings"
67
78 tea "github.com/charmbracelet/bubbletea/v2"
89 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -49,7 +50,15 @@ func (m *ModelListComponent) Init() tea.Cmd {
4950 var cmds []tea.Cmd
5051 if len(m.providers) == 0 {
5152 providers, err := config.Providers()
52− m.providers = providers
53+ filteredProviders := []catwalk.Provider{}
54+ for _, p := range providers {
55+ hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$")
56+ if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure {
57+ filteredProviders = append(filteredProviders, p)
58+ }
59+ }
60+
61+ m.providers = filteredProviders
5362 if err != nil {
5463 cmds = append(cmds, util.ReportError(err))
5564 }
@@ -242,7 +251,3 @@ func (m *ModelListComponent) GetModelType() int {
242251func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
243252 m.list.SetInputPlaceholder(placeholder)
244253}
245−
246−func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) {
247− m.providers = providers
248−}
internal/tui/components/dialogs/models/models.go−19
@@ -2,7 +2,6 @@ package models
22
33import (
44 "fmt"
5− "slices"
65 "time"
76
87 "github.com/charmbracelet/bubbles/v2/help"
@@ -96,24 +95,6 @@ func NewModelDialogCmp() ModelDialog {
9695}
9796
9897func (m *modelDialogCmp) Init() tea.Cmd {
99− providers, err := config.Providers()
100− if err == nil {
101− filteredProviders := []catwalk.Provider{}
102− simpleProviders := []string{
103− "anthropic",
104− "openai",
105− "gemini",
106− "xai",
107− "groq",
108− "openrouter",
109− }
110− for _, p := range providers {
111− if slices.Contains(simpleProviders, string(p.ID)) {
112− filteredProviders = append(filteredProviders, p)
113− }
114− }
115− m.modelList.SetProviders(filteredProviders)
116− }
11798 return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
11899}
119100