Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
140 changes: 42 additions & 98 deletions internal/providers/bailian/bailian.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,37 +31,49 @@ var Registration = providers.Registration{
}

// Provider implements the core.Provider interface for Alibaba Cloud Bailian.
// It wraps openai.CompatibleProvider and maps max_tokens to
// max_completion_tokens for every request (Bailian deprecated max_tokens
// in April 2026).
// Transport goes through the shared compatible provider; Bailian's only chat
// quirk is the max_tokens -> max_completion_tokens mapping (Bailian
// deprecated max_tokens in April 2026), applied via the AdaptChatRequest
// hook so every chat-derived path picks it up. Batch and file capabilities
// are exposed through the embedded facet surfaces; the remaining methods are
// delegated explicitly through the compatible provider rather than embedding
// it whole, because Bailian's upstream lacks parts of the full OpenAI
// surface (audio, native /responses) and embedding cannot subtract methods.
type Provider struct {
*openai.BatchSurface
*openai.FileSurface
compatible *openai.CompatibleProvider
apiKey string // retained to inject auth on the realtime websocket target
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// New creates a new Bailian provider from a resolved ProviderConfig.
func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider {
baseURL := providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL)
return &Provider{
compatible: openai.NewCompatibleProvider(cfg.APIKey, opts, openai.CompatibleProviderConfig{
ProviderName: "bailian",
BaseURL: baseURL,
SetHeaders: setHeaders,
}),
apiKey: cfg.APIKey,
}
compat := openai.NewCompatibleProvider(cfg.APIKey, opts, compatibleConfig(providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL)))
return newProvider(compat, cfg.APIKey)
}

// NewWithHTTPClient creates a new Bailian provider with a custom HTTP client.
// If httpClient is nil, http.DefaultClient is used.
func NewWithHTTPClient(apiKey string, httpClient *http.Client, hooks llmclient.Hooks) *Provider {
compat := openai.NewCompatibleProviderWithHTTPClient(apiKey, httpClient, hooks, compatibleConfig(defaultBaseURL))
return newProvider(compat, apiKey)
}

func newProvider(compat *openai.CompatibleProvider, apiKey string) *Provider {
return &Provider{
compatible: openai.NewCompatibleProviderWithHTTPClient(apiKey, httpClient, hooks, openai.CompatibleProviderConfig{
ProviderName: "bailian",
BaseURL: defaultBaseURL,
SetHeaders: setHeaders,
}),
apiKey: apiKey,
BatchSurface: openai.NewBatchSurface(compat),
FileSurface: openai.NewFileSurface(compat),
compatible: compat,
apiKey: apiKey,
}
}

func compatibleConfig(baseURL string) openai.CompatibleProviderConfig {
return openai.CompatibleProviderConfig{
ProviderName: "bailian",
BaseURL: baseURL,
SetHeaders: setHeaders,
AdaptChatRequest: adaptChatRequest,
}
}

Expand All @@ -74,15 +86,14 @@ func (p *Provider) SetBaseURL(url string) {
p.compatible.SetBaseURL(url)
}

// ChatCompletion maps max_tokens to max_completion_tokens for Bailian models.
// Bailian deprecated max_tokens in April 2026; all models now require max_completion_tokens.
// ChatCompletion sends a chat completion request to Bailian.
func (p *Provider) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) {
return p.compatible.ChatCompletion(ctx, adaptBailianRequest(req))
return p.compatible.ChatCompletion(ctx, req)
}

// StreamChatCompletion maps max_tokens to max_completion_tokens for streaming requests.
// StreamChatCompletion returns a raw response body for streaming.
func (p *Provider) StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) {
return p.compatible.StreamChatCompletion(ctx, adaptBailianRequest(req))
return p.compatible.StreamChatCompletion(ctx, req)
}

// ListModels returns the list of available models from Bailian.
Expand Down Expand Up @@ -110,7 +121,7 @@ func (p *Provider) Embeddings(ctx context.Context, req *core.EmbeddingRequest) (

// Passthrough routes an opaque provider-native request to Bailian.
// It also adapts max_tokens -> max_completion_tokens in the raw body,
// mirroring the adaptation done in ChatCompletion/StreamChatCompletion.
// mirroring the adaptation done on typed chat requests.
func (p *Provider) Passthrough(ctx context.Context, req *core.PassthroughRequest) (*core.PassthroughResponse, error) {
if req == nil {
return nil, core.NewInvalidRequestError("passthrough request is required", nil)
Expand All @@ -127,74 +138,7 @@ func (p *Provider) Passthrough(ctx context.Context, req *core.PassthroughRequest
return p.compatible.Passthrough(ctx, req)
}

// CreateBatch creates a native Bailian batch job.
func (p *Provider) CreateBatch(ctx context.Context, req *core.BatchRequest) (*core.BatchResponse, error) {
return p.compatible.CreateBatch(ctx, req)
}

// GetBatch retrieves a Bailian batch job by ID.
func (p *Provider) GetBatch(ctx context.Context, id string) (*core.BatchResponse, error) {
return p.compatible.GetBatch(ctx, id)
}

// ListBatches lists Bailian batch jobs with pagination.
func (p *Provider) ListBatches(ctx context.Context, limit int, after string) (*core.BatchListResponse, error) {
return p.compatible.ListBatches(ctx, limit, after)
}

// CancelBatch cancels a pending Bailian batch job.
func (p *Provider) CancelBatch(ctx context.Context, id string) (*core.BatchResponse, error) {
return p.compatible.CancelBatch(ctx, id)
}

// GetBatchResults fetches Bailian batch results via the output file API.
func (p *Provider) GetBatchResults(ctx context.Context, id string) (*core.BatchResultsResponse, error) {
return p.compatible.GetBatchResults(ctx, id)
}

// CreateFile uploads a file through Bailian's OpenAI-compatible /files API.
func (p *Provider) CreateFile(ctx context.Context, req *core.FileCreateRequest) (*core.FileObject, error) {
resp, err := p.compatible.CreateFile(ctx, req)
if err != nil {
return nil, err
}
resp.Provider = "bailian"
return resp, nil
}

// ListFiles lists files through Bailian's OpenAI-compatible /files API.
func (p *Provider) ListFiles(ctx context.Context, purpose string, limit int, after string) (*core.FileListResponse, error) {
resp, err := p.compatible.ListFiles(ctx, purpose, limit, after)
if err != nil {
return nil, err
}
for i := range resp.Data {
resp.Data[i].Provider = "bailian"
}
return resp, nil
}

// GetFile retrieves a file object through Bailian's OpenAI-compatible /files API.
func (p *Provider) GetFile(ctx context.Context, id string) (*core.FileObject, error) {
resp, err := p.compatible.GetFile(ctx, id)
if err != nil {
return nil, err
}
resp.Provider = "bailian"
return resp, nil
}

// DeleteFile deletes a file through Bailian's OpenAI-compatible /files API.
func (p *Provider) DeleteFile(ctx context.Context, id string) (*core.FileDeleteResponse, error) {
return p.compatible.DeleteFile(ctx, id)
}

// GetFileContent fetches raw file bytes through Bailian's /files/{id}/content API.
func (p *Provider) GetFileContent(ctx context.Context, id string) (*core.FileContentResponse, error) {
return p.compatible.GetFileContent(ctx, id)
}

// adaptBailianRequest maps max_tokens -> max_completion_tokens in the request.
// adaptChatRequest maps max_tokens -> max_completion_tokens in the request.
// Bailian deprecated max_tokens in April 2026.
// It moves MaxTokens into ExtraFields as max_completion_tokens so that when
// ChatRequest is serialized, max_tokens is omitted and max_completion_tokens
Expand All @@ -206,34 +150,34 @@ func (p *Provider) GetFileContent(ctx context.Context, id string) (*core.FileCon
//
// If either operation fails, the original request is returned unmodified
// and a warning is logged so operators can diagnose the issue.
func adaptBailianRequest(req *core.ChatRequest) *core.ChatRequest {
func adaptChatRequest(req *core.ChatRequest) (*core.ChatRequest, error) {
if req == nil || req.MaxTokens == nil {
return req
return req, nil
}
// If the caller already set max_completion_tokens explicitly, respect it.
if existing := req.ExtraFields.Lookup("max_completion_tokens"); existing != nil {
cloned := *req
cloned.MaxTokens = nil
return &cloned
return &cloned, nil
}
maxTokensJSON, err := json.Marshal(*req.MaxTokens)
if err != nil {
slog.Warn("bailian: failed to marshal MaxTokens for adaptation, forwarding original request",
"error", err)
return req
return req, nil
}
extra, err := core.MergeUnknownJSONFields(req.ExtraFields, map[string]json.RawMessage{
"max_completion_tokens": maxTokensJSON,
})
if err != nil {
slog.Warn("bailian: failed to merge ExtraFields for adaptation, forwarding original request",
"error", err)
return req
return req, nil
}
cloned := *req
cloned.ExtraFields = extra
cloned.MaxTokens = nil
return &cloned
return &cloned, nil
}

// adaptPassthroughBody adapts max_tokens -> max_completion_tokens in a raw
Expand Down
21 changes: 17 additions & 4 deletions internal/providers/bailian/bailian_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,14 +409,21 @@ func TestPassthrough_ReadError(t *testing.T) {
}

func TestAdaptBailianRequest_Nil(t *testing.T) {
if r := adaptBailianRequest(nil); r != nil {
r, err := adaptChatRequest(nil)
if err != nil {
t.Fatalf("adaptChatRequest(nil) error = %v", err)
}
if r != nil {
t.Fatal("expected nil")
}
}

func TestAdaptBailianRequest_NoMaxTokens(t *testing.T) {
req := &core.ChatRequest{Model: "qwen3-max"}
r := adaptBailianRequest(req)
r, err := adaptChatRequest(req)
if err != nil {
t.Fatalf("adaptChatRequest() error = %v", err)
}
if r.MaxTokens != nil {
t.Fatal("should not set max_completion_tokens when request had none")
}
Expand Down Expand Up @@ -474,7 +481,10 @@ func TestAdaptBailianRequest_PreservesOtherFields(t *testing.T) {
Messages: []core.Message{{Role: "user", Content: "hi"}},
MaxTokens: &maxTokens,
}
r := adaptBailianRequest(req)
r, err := adaptChatRequest(req)
if err != nil {
t.Fatalf("adaptChatRequest() error = %v", err)
}
if r == req {
t.Fatal("should return a clone, not the original")
}
Expand All @@ -497,7 +507,10 @@ func TestAdaptBailianRequest_RespectsExistingMaxCompletionTokens(t *testing.T) {
MaxTokens: &maxTokens,
ExtraFields: extra,
}
r := adaptBailianRequest(req)
r, err := adaptChatRequest(req)
if err != nil {
t.Fatalf("adaptChatRequest() error = %v", err)
}
if r == req {
t.Fatal("should return a clone")
}
Expand Down
88 changes: 24 additions & 64 deletions internal/providers/groq/groq.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,32 +27,42 @@ const (

// Provider implements the core.Provider interface for Groq. Groq's API is
// OpenAI-compatible, so all transport goes through the shared compatible
// provider; only the Responses API differs (translated via chat) because the
// gateway does not use Groq's native /responses endpoints.
// provider; the Responses API is translated via chat because the gateway
// does not use Groq's native /responses endpoints. Methods are delegated
// explicitly (and batch/files via facet surfaces) rather than embedding the
// full compatible provider, because Groq's upstream lacks passthrough and
// native response lifecycle management and embedding cannot subtract
// methods.
type Provider struct {
*openai.BatchSurface
*openai.FileSurface
compat *openai.CompatibleProvider
}

// New creates a new Groq provider.
func New(providerCfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider {
return &Provider{
compat: openai.NewCompatibleProvider(providerCfg.APIKey, opts, openai.CompatibleProviderConfig{
ProviderName: "groq",
BaseURL: providers.ResolveBaseURL(providerCfg.BaseURL, defaultBaseURL),
SetHeaders: setHeaders,
}),
}
return newProvider(openai.NewCompatibleProvider(providerCfg.APIKey, opts, compatibleConfig(providers.ResolveBaseURL(providerCfg.BaseURL, defaultBaseURL))))
}

// NewWithHTTPClient creates a new Groq provider with a custom HTTP client.
// If httpClient is nil, http.DefaultClient is used.
func NewWithHTTPClient(apiKey string, httpClient *http.Client, hooks llmclient.Hooks) *Provider {
return newProvider(openai.NewCompatibleProviderWithHTTPClient(apiKey, httpClient, hooks, compatibleConfig(defaultBaseURL)))
}

func newProvider(compat *openai.CompatibleProvider) *Provider {
return &Provider{
compat: openai.NewCompatibleProviderWithHTTPClient(apiKey, httpClient, hooks, openai.CompatibleProviderConfig{
ProviderName: "groq",
BaseURL: defaultBaseURL,
SetHeaders: setHeaders,
}),
BatchSurface: openai.NewBatchSurface(compat),
FileSurface: openai.NewFileSurface(compat),
compat: compat,
}
}

func compatibleConfig(baseURL string) openai.CompatibleProviderConfig {
return openai.CompatibleProviderConfig{
ProviderName: "groq",
BaseURL: baseURL,
SetHeaders: setHeaders,
}
}

Expand Down Expand Up @@ -109,53 +119,3 @@ func (p *Provider) CreateSpeech(ctx context.Context, req *core.AudioSpeechReques
func (p *Provider) CreateTranscription(ctx context.Context, req *core.AudioTranscriptionRequest) (*core.AudioResponse, error) {
return p.compat.CreateTranscription(ctx, req)
}

// CreateBatch creates a native Groq batch job.
func (p *Provider) CreateBatch(ctx context.Context, req *core.BatchRequest) (*core.BatchResponse, error) {
return p.compat.CreateBatch(ctx, req)
}

// GetBatch retrieves a native Groq batch job.
func (p *Provider) GetBatch(ctx context.Context, id string) (*core.BatchResponse, error) {
return p.compat.GetBatch(ctx, id)
}

// ListBatches lists native Groq batch jobs.
func (p *Provider) ListBatches(ctx context.Context, limit int, after string) (*core.BatchListResponse, error) {
return p.compat.ListBatches(ctx, limit, after)
}

// CancelBatch cancels a native Groq batch job.
func (p *Provider) CancelBatch(ctx context.Context, id string) (*core.BatchResponse, error) {
return p.compat.CancelBatch(ctx, id)
}

// GetBatchResults fetches Groq batch results via the output file API.
func (p *Provider) GetBatchResults(ctx context.Context, id string) (*core.BatchResultsResponse, error) {
return p.compat.GetBatchResults(ctx, id)
}

// CreateFile uploads a file through Groq's OpenAI-compatible /files API.
func (p *Provider) CreateFile(ctx context.Context, req *core.FileCreateRequest) (*core.FileObject, error) {
return p.compat.CreateFile(ctx, req)
}

// ListFiles lists files through Groq's OpenAI-compatible /files API.
func (p *Provider) ListFiles(ctx context.Context, purpose string, limit int, after string) (*core.FileListResponse, error) {
return p.compat.ListFiles(ctx, purpose, limit, after)
}

// GetFile retrieves one file object through Groq's OpenAI-compatible /files API.
func (p *Provider) GetFile(ctx context.Context, id string) (*core.FileObject, error) {
return p.compat.GetFile(ctx, id)
}

// DeleteFile deletes a file object through Groq's OpenAI-compatible /files API.
func (p *Provider) DeleteFile(ctx context.Context, id string) (*core.FileDeleteResponse, error) {
return p.compat.DeleteFile(ctx, id)
}

// GetFileContent fetches raw file bytes through Groq's /files/{id}/content API.
func (p *Provider) GetFileContent(ctx context.Context, id string) (*core.FileContentResponse, error) {
return p.compat.GetFileContent(ctx, id)
}
Loading