v0.7.3: 重构 Provider 层 + 计算层隔离 + Cleaner/NoMemory 架构

- 删除 OpenAIProvider/OllamaProvider 死代码,LuaAdaptedProvider 独存
- DisableThinking 从 ExtraBody 移到 CompletionRequest 顶层字段
- ContextWindow 从 Provider 签名移到 BaseConfig/ModelContextWindow() 统管
- 确认 CleanText 仅做基本空白 trim,QQ 模板剥离归插件 Cleaner
- Cleaner/NoMemory 仅作用于向量计算和 jieba 分词层,原文不变
- context.ContextEvent/Doc.Content 始终保存原文
- 删除 nlp/download.go 死代码
- media.go: context.Background() -> a.ctx 级联
- clawhubadapter: HTTP 超时
- cut.go: 跨平台 mod cache 路径 (GOMODCACHE->GOPATH->HomeDir)
- bridge_e2e_test: 移除未用 runtime import
- lua 适配器: disable_thinking 传参
This commit is contained in:
JianFeeeee
2026-07-28 11:42:29 +08:00
parent 2c5f9ff262
commit f91b20ee16
24 changed files with 334 additions and 824 deletions

View File

@ -84,6 +84,7 @@ type CompletionRequest struct {
Stream bool `json:"stream,omitempty"`
Tools []interface{} `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
DisableThinking bool `json:"disable_thinking"`
ExtraBody map[string]interface{} `json:"-"`
}
@ -192,236 +193,12 @@ func ModelContextWindow(model string) int {
}
type BaseConfig struct {
Model string `json:"model"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
}
type OpenAIProvider struct {
cfg BaseConfig
client *http.Client
}
func NewOpenAIProvider(cfg BaseConfig) *OpenAIProvider {
if cfg.BaseURL == "" {
cfg.BaseURL = "https://api.openai.com/v1"
}
if cfg.Temperature == 0 {
cfg.Temperature = 0.7
}
if cfg.MaxTokens == 0 {
cfg.MaxTokens = 4096
}
return &OpenAIProvider{
cfg: cfg,
client: &http.Client{Timeout: 60 * time.Second},
}
}
func (p *OpenAIProvider) Name() string { return "openai" }
func (p *OpenAIProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
if req.Model == "" {
req.Model = p.cfg.Model
}
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequestWithContext(ctx, "POST", p.cfg.BaseURL+"/chat/completions", strings.NewReader(string(body)))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
resp, err := p.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("api call: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return nil, &ProviderError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("api error %d: %s", resp.StatusCode, string(respBody)),
}
}
var rawResult struct {
Choices []struct {
Message struct {
Content *string `json:"content"`
ReasoningContent *string `json:"reasoning_content"`
ToolCalls []rawToolCall `json:"tool_calls"`
Role string `json:"role"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := json.NewDecoder(resp.Body).Decode(&rawResult); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
if len(rawResult.Choices) == 0 {
return nil, fmt.Errorf("no choices returned")
}
ch := rawResult.Choices[0]
content := ""
if ch.Message.Content != nil {
content = *ch.Message.Content
}
var toolCalls []ToolCall
for _, tc := range ch.Message.ToolCalls {
tc := tc
args := make(map[string]interface{})
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
args["_raw"] = tc.Function.Arguments
}
toolCalls = append(toolCalls, ToolCall{
ID: tc.ID,
Type: tc.Type,
Name: tc.Function.Name,
Arguments: args,
})
}
return &CompletionResponse{
Content: content,
FinishReason: ch.FinishReason,
TokenUsage: TokenUsage{
Prompt: rawResult.Usage.PromptTokens,
Completion: rawResult.Usage.CompletionTokens,
Total: rawResult.Usage.TotalTokens,
},
ToolCalls: toolCalls,
}, nil
}
func (p *OpenAIProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
req.Stream = true
ch := make(chan StreamChunk, 64)
body, _ := json.Marshal(req)
httpReq, _ := http.NewRequestWithContext(ctx, "POST", p.cfg.BaseURL+"/chat/completions", strings.NewReader(string(body)))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
resp, err := p.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream api: %w", err)
}
go func() {
defer resp.Body.Close()
defer close(ch)
decoder := json.NewDecoder(resp.Body)
for {
var line struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
}
if err := decoder.Decode(&line); err != nil {
return
}
if len(line.Choices) > 0 {
select {
case ch <- StreamChunk{
Content: line.Choices[0].Delta.Content,
Done: line.Choices[0].FinishReason != nil,
}:
case <-ctx.Done():
return
}
}
}
}()
return ch, nil
}
type OllamaProvider struct {
cfg BaseConfig
client *http.Client
}
func NewOllamaProvider(cfg BaseConfig) *OllamaProvider {
if cfg.BaseURL == "" {
cfg.BaseURL = "http://localhost:11434"
}
if cfg.Temperature == 0 {
cfg.Temperature = 0.7
}
if cfg.MaxTokens == 0 {
cfg.MaxTokens = 4096
}
return &OllamaProvider{
cfg: cfg,
client: &http.Client{Timeout: 120 * time.Second},
}
}
func (p *OllamaProvider) Name() string { return "ollama" }
func (p *OllamaProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
ollamaReq := map[string]interface{}{
"model": req.Model,
"messages": req.Messages,
"stream": false,
"options": map[string]interface{}{
"temperature": req.Temperature,
"num_predict": req.MaxTokens,
},
}
body, _ := json.Marshal(ollamaReq)
httpReq, _ := http.NewRequestWithContext(ctx, "POST", p.cfg.BaseURL+"/api/chat", strings.NewReader(string(body)))
httpReq.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("ollama chat: %w", err)
}
defer resp.Body.Close()
var result struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
DoneReason string `json:"done_reason"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return &CompletionResponse{
Content: result.Message.Content,
FinishReason: result.DoneReason,
}, nil
}
func (p *OllamaProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
ch := make(chan StreamChunk, 64)
go func() {
defer close(ch)
ch <- StreamChunk{Done: true}
}()
return ch, nil
Model string `json:"model"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
ContextWindow int `json:"context_window"`
}
// LuaAdaptedProvider 使用 Lua 脚本做请求/响应变换,直接发起 HTTP 调用
@ -450,15 +227,10 @@ func NewLuaAdaptedProvider(cfg BaseConfig, vm *luaVM.VM, adapter string) *LuaAda
}
}
func (p *OpenAIProvider) MaxContextTokens() int {
return ModelContextWindow(p.cfg.Model)
}
func (p *OllamaProvider) MaxContextTokens() int {
return ModelContextWindow(p.cfg.Model)
}
func (p *LuaAdaptedProvider) MaxContextTokens() int {
if p.cfg.ContextWindow > 0 {
return p.cfg.ContextWindow
}
return ModelContextWindow(p.cfg.Model)
}
@ -642,6 +414,7 @@ func (s *SSEScanner) Text() string { return s.pending }
type providerStatus struct {
failCount int
unavailableUntil time.Time
permanent bool // 401/403 永久不可用,不自动恢复
}
type ProviderManager struct {
@ -748,12 +521,23 @@ func (m *ProviderManager) MarkUnavailable(name string) {
st.unavailableUntil = time.Now().Add(cooldown)
}
// ReportStatus records an HTTP status code for a provider, allowing auth errors
// (401/403) to be distinguished from transient failures.
// ReportStatus records an HTTP status code for a provider.
// 401/403 = credential error → permanently unavailable (never retry).
// Other codes → MarkUnavailable with exponential backoff.
func (m *ProviderManager) ReportStatus(name string, statusCode int) {
if statusCode == 401 || statusCode == 403 {
m.MarkUnavailable(name)
m.mu.Lock()
defer m.mu.Unlock()
st := m.status[name]
if st == nil {
st = &providerStatus{}
m.status[name] = st
}
st.permanent = true
st.unavailableUntil = time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC)
return
}
m.MarkUnavailable(name)
}
func (m *ProviderManager) ResetAvailability(name string) {
@ -762,6 +546,18 @@ func (m *ProviderManager) ResetAvailability(name string) {
delete(m.status, name)
}
func (m *ProviderManager) MarkPermanent(name string) {
m.mu.Lock()
defer m.mu.Unlock()
st := m.status[name]
if st == nil {
st = &providerStatus{}
m.status[name] = st
}
st.permanent = true
st.unavailableUntil = time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC)
}
func (m *ProviderManager) IsAvailable(name string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
@ -769,6 +565,9 @@ func (m *ProviderManager) IsAvailable(name string) bool {
if !ok {
return true
}
if st.permanent {
return false
}
return time.Now().After(st.unavailableUntil)
}