feat: add streaming support and custom base URL for Anthropic API

- Add streaming API support in anthropic provider for better compatibility
- Enable custom base URL configuration for Anthropic-compatible endpoints
- Add kimi-coding protocol support in factory provider

This allows users to configure custom Anthropic API endpoints via the
model_list configuration with api_base field.
This commit is contained in:
Edison-A-N 2026-02-27 09:02:54 +08:00
parent 3584c0c7be
commit 24182be571
2 changed files with 126 additions and 9 deletions

View file

@ -26,9 +26,10 @@ type (
const defaultBaseURL = "https://api.anthropic.com" const defaultBaseURL = "https://api.anthropic.com"
type Provider struct { type Provider struct {
client *anthropic.Client client *anthropic.Client
tokenSource func() (string, error) tokenSource func() (string, error)
baseURL string baseURL string
useStreaming bool
} }
func NewProvider(token string) *Provider { func NewProvider(token string) *Provider {
@ -42,15 +43,17 @@ func NewProviderWithBaseURL(token, apiBase string) *Provider {
option.WithBaseURL(baseURL), option.WithBaseURL(baseURL),
) )
return &Provider{ return &Provider{
client: &client, client: &client,
baseURL: baseURL, baseURL: baseURL,
useStreaming: true, // Enable streaming by default for kimi-coding compatibility
} }
} }
func NewProviderWithClient(client *anthropic.Client) *Provider { func NewProviderWithClient(client *anthropic.Client) *Provider {
return &Provider{ return &Provider{
client: client, client: client,
baseURL: defaultBaseURL, baseURL: defaultBaseURL,
useStreaming: true,
} }
} }
@ -85,6 +88,17 @@ func (p *Provider) Chat(
return nil, err return nil, err
} }
if p.useStreaming {
return p.chatStreaming(ctx, params, opts)
}
return p.chatNonStreaming(ctx, params, opts)
}
func (p *Provider) chatNonStreaming(
ctx context.Context,
params anthropic.MessageNewParams,
opts []option.RequestOption,
) (*LLMResponse, error) {
resp, err := p.client.Messages.New(ctx, params, opts...) resp, err := p.client.Messages.New(ctx, params, opts...)
if err != nil { if err != nil {
return nil, fmt.Errorf("claude API call: %w", err) return nil, fmt.Errorf("claude API call: %w", err)
@ -93,6 +107,97 @@ func (p *Provider) Chat(
return parseResponse(resp), nil return parseResponse(resp), nil
} }
func (p *Provider) chatStreaming(
ctx context.Context,
params anthropic.MessageNewParams,
opts []option.RequestOption,
) (*LLMResponse, error) {
stream := p.client.Messages.NewStreaming(ctx, params, opts...)
var content strings.Builder
var toolCalls []ToolCall
var usage UsageInfo
var finishReason string
for stream.Next() {
event := stream.Current()
switch event.Type {
case "message_start":
msg := event.AsMessageStart()
if msg.Message.Usage.InputTokens > 0 {
usage.PromptTokens = int(msg.Message.Usage.InputTokens)
}
if msg.Message.Usage.OutputTokens > 0 {
usage.CompletionTokens = int(msg.Message.Usage.OutputTokens)
}
case "content_block_start":
block := event.AsContentBlockStart()
if block.ContentBlock.Type == "tool_use" {
toolUse := block.ContentBlock.AsToolUse()
// Initialize tool call
toolCalls = append(toolCalls, ToolCall{
ID: toolUse.ID,
Name: toolUse.Name,
Arguments: map[string]any{},
})
}
case "content_block_delta":
delta := event.AsContentBlockDelta()
if delta.Delta.Type == "text_delta" {
content.WriteString(delta.Delta.Text)
} else if delta.Delta.Type == "input_json_delta" {
// Accumulate JSON for tool calls
if len(toolCalls) > 0 {
lastIdx := len(toolCalls) - 1
// Parse partial JSON
var args map[string]any
if err := json.Unmarshal([]byte(delta.Delta.PartialJSON), &args); err == nil {
for k, v := range args {
toolCalls[lastIdx].Arguments[k] = v
}
}
}
}
case "message_stop":
finishReason = "stop"
case "message_delta":
msgDelta := event.AsMessageDelta()
if msgDelta.Delta.StopReason != "" {
switch msgDelta.Delta.StopReason {
case "tool_use":
finishReason = "tool_calls"
case "max_tokens":
finishReason = "length"
default:
finishReason = "stop"
}
}
// Update usage from delta
if msgDelta.Usage.OutputTokens > 0 {
usage.CompletionTokens = int(msgDelta.Usage.OutputTokens)
}
}
}
if err := stream.Err(); err != nil {
return nil, fmt.Errorf("streaming error: %w", err)
}
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
return &LLMResponse{
Content: content.String(),
ToolCalls: toolCalls,
FinishReason: finishReason,
Usage: &usage,
}, nil
}
func (p *Provider) GetDefaultModel() string { func (p *Provider) GetDefaultModel() string {
return "claude-sonnet-4.6" return "claude-sonnet-4.6"
} }
@ -272,4 +377,4 @@ func normalizeBaseURL(apiBase string) string {
} }
return base return base
} }

View file

@ -10,6 +10,7 @@ import (
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic"
) )
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
@ -168,6 +169,17 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
} }
return provider, modelID, nil return provider, modelID, nil
case "kimi-coding", "kimicoding":
// Kimi for Coding uses Anthropic API format
apiBase := cfg.APIBase
if apiBase == "" {
apiBase = "https://api.kimi.com/coding"
}
if cfg.APIKey == "" {
return nil, "", fmt.Errorf("api_key is required for kimi-coding protocol (model: %s)", cfg.Model)
}
return anthropicprovider.NewProviderWithBaseURL(cfg.APIKey, apiBase), modelID, nil
default: default:
return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model) return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model)
} }
@ -209,4 +221,4 @@ func getDefaultAPIBase(protocol string) string {
default: default:
return "" return ""
} }
} }