Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions bifrost/bifrost.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const missingContentIndex = -1
// LLM implements the llm.LanguageModel interface using the Bifrost gateway.
type LLM struct {
client *bifrostcore.Bifrost
account *multiProviderAccount // kept for diagnostics; nil after the constructor returns a fully-constructed *LLM
provider schemas.ModelProvider
apiKey string // used only to redact configured secrets from provider error surfaces
fallbackAPIKeys []string // fallback provider keys, redacted from error surfaces alongside apiKey
Expand Down Expand Up @@ -355,11 +356,24 @@ func readFileData(file llm.File) ([]byte, error) {
}

// New creates a new LLM instance with the given configuration. It errors when
// a fallback cannot be registered, so a misconfigured fallback chain fails at
// a fallback cannot be registered, so a misconfigured chain fails at
// setup instead of silently shrinking.
func New(cfg Config) (*LLM, error) {
primaryEntry := &providerAccount{ProviderSettings: cfg.ProviderSettings}

// Wrap an OpenAI-base primary that does not speak the Responses API
// (e.g. OpenCode Go, or any openaicompatible with UseResponsesAPI=false)
// under its own custom-provider slot so the chat-only AllowedRequests gate
// is attached by GetConfigForProvider. With only ChatCompletion and
// ChatCompletionStream set to true, Bifrost refuses POSTs to /v1/responses
// and /v1/responses/input_tokens (CountTokens returns "unsupported_operation",
// which the plugin maps to llm.ErrUnsupportedTokenCount → llm.EstimateTokens
// fallback). Mirrors the fallback-wrap branch below.
if cfg.Provider == schemas.OpenAI && !cfg.UseResponsesAPI && isCustomCapableProvider(cfg.Provider) {
primaryEntry.name = customProviderName(cfg.Provider, "primary")
primaryEntry.chatOnly = true
}

account := newMultiProviderAccount()
account.addProvider(primaryEntry)

Expand Down Expand Up @@ -422,7 +436,8 @@ func New(cfg Config) (*LLM, error) {

return &LLM{
client: client,
provider: cfg.Provider,
account: account,
provider: primaryEntry.registeredName(),
apiKey: cfg.APIKey,
fallbackAPIKeys: redactKeys[1:],
defaultModel: cfg.DefaultModel,
Expand Down
142 changes: 142 additions & 0 deletions bifrost/bifrost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2128,6 +2128,35 @@ func TestServiceConfigToFallbackEntry(t *testing.T) {
expectedAPIURL: "https://api.mistral.ai/v1",
expectedChatOnly: false,
},
{
name: "OpenCode Go service gets default URL and is chat-only",
svc: llm.ServiceConfig{
Type: llm.ServiceTypeOpenCodeGo,
APIKey: "key",
DefaultModel: "kimi-k3",
},
expectedProvider: schemas.OpenAI,
expectedModel: "kimi-k3",
// The injected /v1 suffix is stripped by normalizeOpenAIBaseURL so
// Bifrost can prepend /v1/chat/completions without doubling up.
expectedAPIURL: "https://opencode.ai/zen/go",
// OpenCode Go does not speak the OpenAI Responses API; it must be
// registered chat-only so Bifrost downgrades Responses-API requests.
expectedChatOnly: true,
},
{
name: "OpenCode Go service with explicit API URL is left alone",
svc: llm.ServiceConfig{
Type: llm.ServiceTypeOpenCodeGo,
APIKey: "key",
APIURL: "https://proxy.example.com/go/v1",
DefaultModel: "kimi-k3",
},
expectedProvider: schemas.OpenAI,
expectedModel: "kimi-k3",
expectedAPIURL: "https://proxy.example.com/go",
expectedChatOnly: true,
},
{
name: "OpenAI Compatible normalizes URL and is chat-only",
svc: llm.ServiceConfig{
Expand Down Expand Up @@ -2341,6 +2370,10 @@ func TestProviderAccount_ChatOnlyCustomConfig(t *testing.T) {
assert.True(t, cfg.CustomProviderConfig.AllowedRequests.ChatCompletionStream)
assert.False(t, cfg.CustomProviderConfig.AllowedRequests.Responses)
assert.False(t, cfg.CustomProviderConfig.AllowedRequests.ResponsesStream)
// CountTokens must be blocked too — a gateway that omits /v1/responses
// cannot serve /v1/responses/input_tokens either (it is the same
// auxiliary endpoint Bifrost pings before every chat request).
assert.False(t, cfg.CustomProviderConfig.AllowedRequests.CountTokens)

// A custom provider that does support the Responses API leaves AllowedRequests
// unset so all operations remain available.
Expand All @@ -2357,6 +2390,108 @@ func TestProviderAccount_ChatOnlyCustomConfig(t *testing.T) {
assert.Nil(t, cfg.CustomProviderConfig.AllowedRequests)
}

// TestNew_OpenCodeGoPrimaryIsWrappedAsChatOnlyCustom pins the contract that an
// OpenCode Go primary service is registered under a custom-provider slot whose
// AllowedRequests gate refuses POSTs to /v1/responses and
// /v1/responses/input_tokens (which the gateway does not implement). Without
// this gate the Bifrost preflight would 404 on the count-tokens endpoint and
// crash its HTML-404 response parser.
func TestNew_OpenCodeGoPrimaryIsWrappedAsChatOnlyCustom(t *testing.T) {
primary := llm.ServiceConfig{
ID: "opencodego-primary",
Type: llm.ServiceTypeOpenCodeGo,
APIKey: "key",
DefaultModel: "kimi-k3",
}

llmInstance, err := NewFromServiceConfig(primary, llm.BotConfig{}, nil)
require.NoError(t, err)
defer llmInstance.client.Shutdown()

// b.provider should now be the registered custom-provider name
// "openai::primary", not bare schemas.OpenAI — requests routed through
// req.Provider = b.provider must target the wrapped slot.
expectedName := customProviderName(schemas.OpenAI, "primary")
assert.Equal(t, expectedName, llmInstance.provider,
"primary must be registered under the custom-provider name so requests route to the wrapped slot")

// The bare schemas.OpenAI slot must NOT be registered — only the wrapped
// one is. This is the proof that the wrap actually moved the registration.
providers, err := llmInstance.account.GetConfiguredProviders()
require.NoError(t, err)
assert.ElementsMatch(t, []schemas.ModelProvider{expectedName}, providers,
"bare schemas.OpenAI must not be registered alongside the wrapped primary slot")

// GetConfigForProvider on the wrapped slot must attach a CustomProviderConfig
// whose AllowedRequests allow only ChatCompletion / ChatCompletionStream.
wrappedCfg, err := llmInstance.account.GetConfigForProvider(expectedName)
require.NoError(t, err)
require.NotNil(t, wrappedCfg.CustomProviderConfig,
"wrapped primary must declare CustomProviderConfig to attach AllowedRequests")
require.NotNil(t, wrappedCfg.CustomProviderConfig.AllowedRequests,
"wrapped primary must declare AllowedRequests so /v1/responses and /v1/responses/input_tokens are refused")
assert.Equal(t, schemas.OpenAI, wrappedCfg.CustomProviderConfig.BaseProviderType)
assert.True(t, wrappedCfg.CustomProviderConfig.AllowedRequests.ChatCompletion)
assert.True(t, wrappedCfg.CustomProviderConfig.AllowedRequests.ChatCompletionStream)
assert.False(t, wrappedCfg.CustomProviderConfig.AllowedRequests.Responses)
assert.False(t, wrappedCfg.CustomProviderConfig.AllowedRequests.ResponsesStream)
assert.False(t, wrappedCfg.CustomProviderConfig.AllowedRequests.CountTokens,
"CountTokens must be disabled so the Bifrost preflight does not POST to /v1/responses/input_tokens")
assert.Equal(t, "https://opencode.ai/zen/go", wrappedCfg.NetworkConfig.BaseURL,
"the wrapped slot's base URL must be the auto-applied OpenCode Go default")
}

// TestNew_DirectOpenAIAndAzurePrimariesRemainStandard guards the inverse of
// TestNew_OpenCodeGoPrimaryIsWrappedAsChatOnlyCustom: real OpenAI / Azure
// services continue to register on the bare schemas.OpenAI / schemas.Azure
// slot (not a custom slot) so all of Bifrost's per-provider quirks — including
// the Responses-API path — remain available. Only OpenAI-base services with
// cfg.UseResponsesAPI == false get wrapped.
func TestNew_DirectOpenAIAndAzurePrimariesRemainStandard(t *testing.T) {
cases := []struct {
name string
service llm.ServiceConfig
}{
{
name: "direct OpenAI primary keeps the bare schemas.OpenAI slot",
service: llm.ServiceConfig{
ID: "openai-direct",
Type: llm.ServiceTypeOpenAI,
APIKey: "key",
DefaultModel: "gpt-4o",
},
},
{
name: "Azure primary keeps the bare schemas.Azure slot",
service: llm.ServiceConfig{
ID: "azure",
Type: llm.ServiceTypeAzure,
APIKey: "key",
APIURL: "https://example.openai.azure.com",
DefaultModel: "gpt-4o",
},
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
llmInstance, err := NewFromServiceConfig(tc.service, llm.BotConfig{}, nil)
require.NoError(t, err)
defer llmInstance.client.Shutdown()

expectedName, err := MapServiceTypeToProvider(tc.service.Type)
require.NoError(t, err)
assert.Equal(t, expectedName, llmInstance.provider,
"standard providers must keep the bare base-type slot so they remain standard")

cfg, err := llmInstance.account.GetConfigForProvider(expectedName)
require.NoError(t, err)
assert.Nil(t, cfg.CustomProviderConfig,
"standard providers must not be wrapped as custom providers")
})
}
}

func TestConvertToBifrostResponsesRequestStructuredOutputStringEnum(t *testing.T) {
b := &LLM{
provider: schemas.OpenAI,
Expand Down Expand Up @@ -2664,6 +2799,13 @@ func TestCountTokensOmitsMaxOutputTokens(t *testing.T) {
StreamingTimeout: 10 * time.Second,
},
OutputTokenLimit: 8192, // produces MaxGeneratedTokens > 0 → MaxOutputTokens in the request
// UseResponsesAPI reflects the production value for direct OpenAI, which
// is always forced to true by NewFromServiceConfig (see
// llm.ServiceUsesResponsesAPI). The chat-only custom wrap that protects
// gateways without /v1/responses (OpenCode Go, openaicompatible with the
// toggle off) only fires when UseResponsesAPI is false, so this keeps
// CountTokens reaching the backend so the body-shape checks below run.
UseResponsesAPI: true,
})
require.NoError(t, err)
defer llmClient.client.Shutdown()
Expand Down
50 changes: 39 additions & 11 deletions bifrost/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ func MapServiceTypeToProvider(serviceType string) (schemas.ModelProvider, error)
return schemas.Gemini, nil
case llm.ServiceTypeVertex:
return schemas.Vertex, nil
case llm.ServiceTypeOpenCodeGo:
return schemas.OpenAI, nil
default:
return "", fmt.Errorf("unsupported service type: %s", serviceType)
}
Expand All @@ -44,6 +46,12 @@ func MapServiceTypeToProvider(serviceType string) (schemas.ModelProvider, error)
// and the effective-behavior checks used by built-in Mattermost tools so that
// built-in fallbacks do not get suppressed when native tools would be stripped.
func SupportsNativeTools(serviceType string) bool {
// OpenCode Go is OpenAI-base, but the gateway is a thin proxy that does
// not implement native OpenAI tools; declare them unsupported so we
// do not try to send web_search to /v1/chat/completions.
if serviceType == llm.ServiceTypeOpenCodeGo {
return false
}
provider, err := MapServiceTypeToProvider(serviceType)
if err != nil {
return false
Expand Down Expand Up @@ -88,6 +96,12 @@ func NewFromServiceConfig(serviceConfig llm.ServiceConfig, botConfig llm.BotConf
return nil, err
}

// Managed providers whose endpoint is fixed and known need their base URL
// filled in by us; otherwise the wrapped custom-provider slot Bifrost sees
// has an empty BaseURL. The fallback path (serviceConfigToFallbackEntry)
// applies the same defaults.
applyProviderURLDefaults(&serviceConfig)

settings := providerSettingsFromService(provider, serviceConfig)
if settings.StreamingTimeout <= 0 {
settings.StreamingTimeout = DefaultStreamingTimeout
Expand Down Expand Up @@ -124,6 +138,24 @@ func NewFromServiceConfig(serviceConfig llm.ServiceConfig, botConfig llm.BotConf
return New(cfg)
}

// applyProviderURLDefaults fills in the base URL for managed providers whose
// endpoint is fixed and known. Mutates svc in place. Called from both the
// primary path (NewFromServiceConfig) and the fallback path
// (serviceConfigToFallbackEntry) so the two stay in sync.
func applyProviderURLDefaults(svc *llm.ServiceConfig) {
if svc.APIURL != "" {
return
}
switch svc.Type {
case llm.ServiceTypeCohere:
svc.APIURL = "https://api.cohere.ai/compatibility/v1"
case llm.ServiceTypeMistral:
svc.APIURL = "https://api.mistral.ai/v1"
case llm.ServiceTypeOpenCodeGo:
svc.APIURL = "https://opencode.ai/zen/go/v1"
}
}

// providerSettingsFromService maps a ServiceConfig's provider connection fields
// onto ProviderSettings. It does not fill in per-provider URL defaults; bifrost
// has its own and they drift.
Expand Down Expand Up @@ -152,16 +184,11 @@ func serviceConfigToFallbackEntry(svc llm.ServiceConfig) (FallbackEntry, error)
return FallbackEntry{}, err
}

// Unlike the primary path, fallbacks pin explicit base URLs for Cohere and
// Mistral when none is configured.
if svc.APIURL == "" {
switch svc.Type {
case llm.ServiceTypeCohere:
svc.APIURL = "https://api.cohere.ai/compatibility/v1"
case llm.ServiceTypeMistral:
svc.APIURL = "https://api.mistral.ai/v1"
}
}
// Unlike the primary path, fallbacks pin explicit base URLs for Cohere,
// Mistral, and OpenCode Go when none is configured. The OpenCode Go URL
// has a /v1 suffix; normalizeOpenAIBaseURL below strips it so Bifrost
// can prepend /v1/chat/completions (or /v1/responses) without doubling.
applyProviderURLDefaults(&svc)

return FallbackEntry{
ProviderSettings: providerSettingsFromService(provider, svc),
Expand Down Expand Up @@ -201,7 +228,8 @@ func IsSupported(serviceType string) bool {
llm.ServiceTypeCohere,
llm.ServiceTypeMistral,
llm.ServiceTypeGemini,
llm.ServiceTypeVertex:
llm.ServiceTypeVertex,
llm.ServiceTypeOpenCodeGo:
return true
default:
return false
Expand Down
2 changes: 2 additions & 0 deletions bifrost/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func TestSupportsNativeTools(t *testing.T) {
{llm.ServiceTypeCohere, false},
{llm.ServiceTypeMistral, false},
{llm.ServiceTypeScale, false},
{llm.ServiceTypeOpenCodeGo, false},
{"unknown", false},
}
for _, tt := range tests {
Expand All @@ -53,6 +54,7 @@ func TestFilterNativeToolsForServiceType(t *testing.T) {
{"Bedrock drops tools", llm.ServiceTypeBedrock, tools, []string{}},
{"Cohere drops tools", llm.ServiceTypeCohere, tools, []string{}},
{"Mistral drops tools", llm.ServiceTypeMistral, tools, []string{}},
{"OpenCode Go drops tools", llm.ServiceTypeOpenCodeGo, tools, []string{}},
{"nil tools stay nil", llm.ServiceTypeOpenAI, nil, nil},
{"empty tools stay empty", llm.ServiceTypeOpenAI, []string{}, []string{}},
}
Expand Down
4 changes: 4 additions & 0 deletions llm/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,10 @@ func IsValidService(service ServiceConfig) bool {
return service.APIKey != "" && service.APIURL != ""
case ServiceTypeGemini:
return service.APIKey != ""
case ServiceTypeOpenCodeGo:
// OpenCode Go uses an OpenAI-compatible endpoint with standard Bearer
// auth; only the API key is required — the base URL is auto-applied.
return service.APIKey != ""
case ServiceTypeVertex:
// Auth credentials optional — empty means ADC / attached IAM role.
if service.VertexProjectID == "" || service.Region == "" {
Expand Down
28 changes: 28 additions & 0 deletions llm/configuration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,34 @@ func TestIsValidService(t *testing.T) {
},
want: false,
},
{
name: "Valid OpenCode Go service with API key",
service: ServiceConfig{
ID: "service-12",
Type: ServiceTypeOpenCodeGo,
APIKey: "opencodego-key",
},
want: true,
},
{
name: "OpenCode Go service missing API key",
service: ServiceConfig{
ID: "service-12",
Type: ServiceTypeOpenCodeGo,
APIKey: "", // bad - only the API key is required; base URL is auto-defaulted
},
want: false,
},
{
name: "OpenCode Go service with explicit API URL is still valid",
service: ServiceConfig{
ID: "service-12",
Type: ServiceTypeOpenCodeGo,
APIKey: "opencodego-key",
APIURL: "https://opencode.ai/zen/go/v1", // operator override is allowed
},
want: true,
},
{
name: "Valid Scale service with API key and API URL",
service: ServiceConfig{
Expand Down
12 changes: 12 additions & 0 deletions llm/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ var openAICompatibleProviders = map[string]OpenAICompatibleProvider{
}
},
},
// OpenCode Go is the managed subscription tier of the OpenCode coding
// agent. It exposes an OpenAI-compatible chat-completions endpoint at
// https://opencode.ai/zen/go/v1 using standard Bearer auth, so the
// default transport is sufficient — no CreateTransport hook is needed.
// The 16 model IDs the gateway actually serves (Grok 4.5, GLM-5.2,
// GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5,
// Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3,
// DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3) are lowercase-hyphenated —
// e.g. "kimi-k3" — never "gpt-4o" or other upstream-native names.
ServiceTypeOpenCodeGo: {
DefaultModel: "kimi-k3",
},
}

// GetOpenAICompatibleProvider returns the provider configuration for the given
Expand Down
Loading