Skip to content

Commit da6cbbd

Browse files
committed
feat: support all Freebuff models
1 parent 6b2750d commit da6cbbd

4 files changed

Lines changed: 135 additions & 41 deletions

File tree

‎config.example.yaml‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,12 +530,40 @@ nonstream-keepalive-interval: 0
530530
# models:
531531
# - name: "deepseek/deepseek-v4-flash"
532532
# alias: "deepseek-v4-flash"
533-
# display-name: "Freebuff DeepSeek V4 Flash"
533+
# display-name: "Freebuff DeepSeek V4 Flash 07/31"
534534
# max-context-length: 1048576
535535
# input-modalities: [text]
536536
# output-modalities: [text]
537537
# thinking:
538538
# levels: ["low", "high", "max"]
539+
# - name: "deepseek/deepseek-v4-pro"
540+
# alias: "deepseek-v4-pro"
541+
# display-name: "Freebuff DeepSeek V4 Pro 08/13"
542+
# max-context-length: 1048576
543+
# input-modalities: [text]
544+
# output-modalities: [text]
545+
# thinking:
546+
# levels: ["low", "high", "max"]
547+
# - name: "openai/gpt-5.6-luna"
548+
# alias: "gpt-5.6-luna"
549+
# display-name: "Freebuff GPT-5.6 Luna"
550+
# max-context-length: 1000000
551+
# input-modalities: [text, image]
552+
# output-modalities: [text]
553+
# thinking:
554+
# levels: ["low", "medium", "high", "xhigh", "max"]
555+
# - name: "minimax/minimax-m3"
556+
# alias: "minimax-m3"
557+
# display-name: "Freebuff MiniMax M3"
558+
# max-context-length: 524288
559+
# input-modalities: [text, image]
560+
# output-modalities: [text]
561+
# - name: "mimo/mimo-v2.5"
562+
# alias: "mimo-2.5"
563+
# display-name: "Freebuff MiMo 2.5"
564+
# max-context-length: 131072
565+
# input-modalities: [text, image]
566+
# output-modalities: [text]
539567

540568
# Vertex API keys (Vertex-compatible endpoints, base-url is optional)
541569
# vertex-api-key:

‎internal/runtime/executor/freebuff.go‎

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,23 @@ import (
1616
)
1717

1818
const (
19-
freebuffProvider = "freebuff"
20-
freebuffModel = "deepseek/deepseek-v4-flash"
21-
freebuffAgent = "base3-free-deepseek-flash"
22-
freebuffPrompt = "You are Buffy, the coding agent behind Codebuff."
19+
freebuffProvider = "freebuff"
20+
freebuffDeepSeekV4ProModel = "deepseek/deepseek-v4-pro"
21+
freebuffDeepSeekV4FlashModel = "deepseek/deepseek-v4-flash"
22+
freebuffGPT56LunaModel = "openai/gpt-5.6-luna"
23+
freebuffMiniMaxM3Model = "minimax/minimax-m3"
24+
freebuffMiMo25Model = "mimo/mimo-v2.5"
25+
freebuffPrompt = "You are Buffy, the coding agent behind Codebuff."
2326
)
2427

28+
var freebuffAgentByModel = map[string]string{
29+
freebuffDeepSeekV4ProModel: "base3-free-deepseek",
30+
freebuffDeepSeekV4FlashModel: "base3-free-deepseek-flash",
31+
freebuffGPT56LunaModel: "base3-free-luna",
32+
freebuffMiniMaxM3Model: "base3-free-minimax-m3",
33+
freebuffMiMo25Model: "base3-free-mimo",
34+
}
35+
2536
type freebuffSession struct {
2637
Status string `json:"status"`
2738
Model string `json:"model"`
@@ -37,7 +48,8 @@ func (e *OpenAICompatExecutor) isFreebuff() bool {
3748
}
3849

3950
func (e *OpenAICompatExecutor) prepareFreebuffRequest(ctx context.Context, client *http.Client, auth *cliproxyauth.Auth, baseURL, apiKey, model string, payload []byte) ([]byte, string, error) {
40-
if model != freebuffModel {
51+
agentID, ok := freebuffAgentByModel[model]
52+
if !ok {
4153
return nil, "", fmt.Errorf("freebuff: unsupported model %q", model)
4254
}
4355

@@ -62,7 +74,7 @@ func (e *OpenAICompatExecutor) prepareFreebuffRequest(ctx context.Context, clien
6274
if err != nil {
6375
return nil, "", err
6476
}
65-
runID, err := e.startFreebuffRun(ctx, client, auth, baseURL, apiKey)
77+
runID, err := e.startFreebuffRun(ctx, client, auth, baseURL, apiKey, agentID)
6678
if err != nil {
6779
return nil, "", err
6880
}
@@ -80,20 +92,30 @@ func (e *OpenAICompatExecutor) prepareFreebuffRequest(ctx context.Context, clien
8092
}
8193

8294
func (e *OpenAICompatExecutor) freebuffInstance(ctx context.Context, client *http.Client, auth *cliproxyauth.Auth, baseURL, apiKey, model string) (string, error) {
83-
// ponytail: one account owns one session; shard locks by credential only if acquisition latency matters.
84-
e.freebuffMu.Lock()
85-
defer e.freebuffMu.Unlock()
8695
var session freebuffSession
87-
if err := e.freebuffJSON(ctx, client, auth, http.MethodGet, strings.TrimSuffix(baseURL, "/")+"/freebuff/session", apiKey, nil, nil, &session); err != nil {
96+
sessionURL := strings.TrimSuffix(baseURL, "/") + "/freebuff/session"
97+
if err := e.freebuffJSON(ctx, client, auth, http.MethodGet, sessionURL, apiKey, nil, nil, &session); err != nil {
8898
var statusError statusErr
8999
if !errors.As(err, &statusError) || statusError.StatusCode() != http.StatusNotFound {
90100
return "", err
91101
}
92102
session.Status = "none"
93103
}
104+
if session.Status == "active" && session.Model == model {
105+
if session.InstanceID == "" {
106+
return "", fmt.Errorf("freebuff: active session returned no instanceId")
107+
}
108+
return session.InstanceID, nil
109+
}
110+
if session.Status == "active" || session.Status == "ended" {
111+
if err := e.freebuffJSON(ctx, client, auth, http.MethodDelete, sessionURL, apiKey, nil, nil, nil); err != nil {
112+
return "", err
113+
}
114+
session.Status = "none"
115+
}
94116
if session.Status == "none" {
95117
headers := http.Header{"x-freebuff-model": []string{model}}
96-
if err := e.freebuffJSON(ctx, client, auth, http.MethodPost, strings.TrimSuffix(baseURL, "/")+"/freebuff/session", apiKey, headers, nil, &session); err != nil {
118+
if err := e.freebuffJSON(ctx, client, auth, http.MethodPost, sessionURL, apiKey, headers, nil, &session); err != nil {
97119
return "", err
98120
}
99121
}
@@ -103,8 +125,8 @@ func (e *OpenAICompatExecutor) freebuffInstance(ctx context.Context, client *htt
103125
return session.InstanceID, nil
104126
}
105127

106-
func (e *OpenAICompatExecutor) startFreebuffRun(ctx context.Context, client *http.Client, auth *cliproxyauth.Auth, baseURL, apiKey string) (string, error) {
107-
body, err := json.Marshal(map[string]any{"action": "START", "agentId": freebuffAgent, "ancestorRunIds": []string{}})
128+
func (e *OpenAICompatExecutor) startFreebuffRun(ctx context.Context, client *http.Client, auth *cliproxyauth.Auth, baseURL, apiKey, agentID string) (string, error) {
129+
body, err := json.Marshal(map[string]any{"action": "START", "agentId": agentID, "ancestorRunIds": []string{}})
108130
if err != nil {
109131
return "", err
110132
}

‎internal/runtime/executor/freebuff_test.go‎

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"net/http"
77
"net/http/httptest"
8+
"reflect"
89
"testing"
910

1011
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
@@ -15,7 +16,20 @@ import (
1516
)
1617

1718
func TestFreebuffRequestLifecycle(t *testing.T) {
18-
var finished bool
19+
wantAgents := map[string]string{
20+
"deepseek/deepseek-v4-pro": "base3-free-deepseek",
21+
"deepseek/deepseek-v4-flash": "base3-free-deepseek-flash",
22+
"openai/gpt-5.6-luna": "base3-free-luna",
23+
"minimax/minimax-m3": "base3-free-minimax-m3",
24+
"mimo/mimo-v2.5": "base3-free-mimo",
25+
}
26+
if !reflect.DeepEqual(freebuffAgentByModel, wantAgents) {
27+
t.Fatalf("freebuff agents = %#v", freebuffAgentByModel)
28+
}
29+
30+
var session freebuffSession
31+
var deleted, finished int
32+
var startedAgents []string
1933
var chatBody struct {
2034
Messages []map[string]any `json:"messages"`
2135
CodebuffMetadata map[string]string `json:"codebuff_metadata"`
@@ -26,30 +40,39 @@ func TestFreebuffRequestLifecycle(t *testing.T) {
2640
}
2741
switch r.URL.Path {
2842
case "/api/v1/freebuff/session":
29-
if r.Method == http.MethodGet {
30-
http.NotFound(w, r)
31-
return
32-
}
33-
if r.Header.Get("x-freebuff-model") != freebuffModel {
34-
t.Fatalf("x-freebuff-model = %q", r.Header.Get("x-freebuff-model"))
43+
switch r.Method {
44+
case http.MethodGet:
45+
if session.Status == "" {
46+
http.NotFound(w, r)
47+
return
48+
}
49+
json.NewEncoder(w).Encode(session)
50+
case http.MethodDelete:
51+
deleted++
52+
session = freebuffSession{}
53+
w.WriteHeader(http.StatusNoContent)
54+
case http.MethodPost:
55+
model := r.Header.Get("x-freebuff-model")
56+
session = freebuffSession{Status: "active", Model: model, InstanceID: "instance-1"}
57+
json.NewEncoder(w).Encode(session)
3558
}
36-
json.NewEncoder(w).Encode(freebuffSession{Status: "active", Model: freebuffModel, InstanceID: "instance-1"})
3759
case "/api/v1/agent-runs":
3860
var body map[string]any
3961
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
4062
t.Fatal(err)
4163
}
4264
if body["action"] == "FINISH" {
43-
finished = true
65+
finished++
4466
return
4567
}
68+
startedAgents = append(startedAgents, body["agentId"].(string))
4669
json.NewEncoder(w).Encode(freebuffRun{RunID: "run-1"})
4770
case "/api/v1/chat/completions":
4871
if err := json.NewDecoder(r.Body).Decode(&chatBody); err != nil {
4972
t.Fatal(err)
5073
}
5174
json.NewEncoder(w).Encode(map[string]any{
52-
"id": "chat-1", "model": freebuffModel,
75+
"id": "chat-1", "model": session.Model,
5376
"choices": []any{map[string]any{"index": 0, "message": map[string]string{"role": "assistant", "content": "pong"}, "finish_reason": "stop"}},
5477
})
5578
default:
@@ -60,20 +83,23 @@ func TestFreebuffRequestLifecycle(t *testing.T) {
6083

6184
executor := NewOpenAICompatExecutor(util.OpenAICompatibleProviderKey(freebuffProvider), &config.Config{})
6285
auth := &cliproxyauth.Auth{Attributes: map[string]string{"base_url": server.URL + "/api/v1", "api_key": "token"}}
63-
response, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
64-
Model: freebuffModel,
65-
Payload: []byte(`{"model":"deepseek/deepseek-v4-flash","messages":[{"role":"user","content":"ping"}]}`),
66-
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAI})
67-
if err != nil {
68-
t.Fatal(err)
69-
}
70-
if len(response.Payload) == 0 {
71-
t.Fatal("empty response")
86+
for _, model := range []string{freebuffDeepSeekV4FlashModel, freebuffDeepSeekV4ProModel} {
87+
payload, err := json.Marshal(map[string]any{"model": model, "messages": []map[string]string{{"role": "user", "content": "ping"}}})
88+
if err != nil {
89+
t.Fatal(err)
90+
}
91+
response, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{Model: model, Payload: payload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAI})
92+
if err != nil {
93+
t.Fatal(err)
94+
}
95+
if len(response.Payload) == 0 {
96+
t.Fatal("empty response")
97+
}
7298
}
7399
if chatBody.Messages[0]["content"] != freebuffPrompt || chatBody.CodebuffMetadata["freebuff_instance_id"] != "instance-1" || chatBody.CodebuffMetadata["cost_mode"] != "free" {
74100
t.Fatalf("prepared body = %+v", chatBody)
75101
}
76-
if !finished {
77-
t.Fatal("run was not finished")
102+
if !reflect.DeepEqual(startedAgents, []string{"base3-free-deepseek-flash", "base3-free-deepseek"}) || deleted != 1 || finished != 2 {
103+
t.Fatalf("started agents = %v, deleted = %d, finished = %d", startedAgents, deleted, finished)
78104
}
79105
}

‎internal/runtime/executor/openai_compat_executor.go‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ const (
4242
// It performs request/response translation and executes against the provider base URL
4343
// using per-auth credentials (API key) and per-auth HTTP transport (proxy) from context.
4444
type OpenAICompatExecutor struct {
45-
provider string
46-
cfg *config.Config
45+
provider string
46+
cfg *config.Config
47+
// ponytail: all Freebuff traffic is serial; shard locks by credential if multiple accounts need throughput.
4748
freebuffMu sync.Mutex
4849
}
4950

@@ -146,7 +147,10 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
146147
}
147148
reporter.SetTranslatedReasoningEffort(translated, to.String())
148149
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
149-
if e.isFreebuff() {
150+
freebuffRequest := e.isFreebuff()
151+
if freebuffRequest {
152+
e.freebuffMu.Lock()
153+
defer e.freebuffMu.Unlock()
150154
var runID string
151155
translated, runID, err = e.prepareFreebuffRequest(ctx, httpClient, auth, baseURL, apiKey, baseModel, translated)
152156
if err != nil {
@@ -170,7 +174,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
170174
if apiKey != "" {
171175
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
172176
}
173-
if e.isFreebuff() {
177+
if freebuffRequest {
174178
httpReq.Header.Set("User-Agent", "ai-sdk/openai-compatible/codebuff")
175179
} else {
176180
httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat")
@@ -377,7 +381,16 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
377381
reporter.SetTranslatedReasoningEffort(translated, to.String())
378382
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
379383
var freebuffRunID string
380-
if e.isFreebuff() {
384+
freebuffRequest := e.isFreebuff()
385+
freebuffLocked := false
386+
if freebuffRequest {
387+
e.freebuffMu.Lock()
388+
freebuffLocked = true
389+
defer func() {
390+
if freebuffLocked {
391+
e.freebuffMu.Unlock()
392+
}
393+
}()
381394
translated, freebuffRunID, err = e.prepareFreebuffRequest(ctx, httpClient, auth, baseURL, apiKey, baseModel, translated)
382395
if err != nil {
383396
return nil, err
@@ -396,7 +409,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
396409
if apiKey != "" {
397410
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
398411
}
399-
if e.isFreebuff() {
412+
if freebuffRequest {
400413
httpReq.Header.Set("User-Agent", "ai-sdk/openai-compatible/codebuff")
401414
} else {
402415
httpReq.Header.Set("User-Agent", "cli-proxy-openai-compat")
@@ -450,7 +463,12 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
450463
return nil, err
451464
}
452465
out := make(chan cliproxyexecutor.StreamChunk)
466+
unlockFreebuffInStream := freebuffLocked
467+
freebuffLocked = false
453468
go func() {
469+
if unlockFreebuffInStream {
470+
defer e.freebuffMu.Unlock()
471+
}
454472
defer close(out)
455473
defer func() {
456474
if errClose := httpResp.Body.Close(); errClose != nil {

0 commit comments

Comments
 (0)