705 lines
26 KiB
Go
705 lines
26 KiB
Go
package executor
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestNewKimiExecutorInitializesDelegatedClaudeConfig(t *testing.T) {
|
|
cfg := &config.Config{SDKConfig: config.SDKConfig{RequestLog: true}}
|
|
executor := NewKimiExecutor(cfg)
|
|
|
|
if executor.cfg != cfg {
|
|
t.Fatal("Kimi executor config was not initialized")
|
|
}
|
|
if executor.ClaudeExecutor.cfg != cfg {
|
|
t.Fatal("delegated Claude executor config was not initialized")
|
|
}
|
|
}
|
|
|
|
func TestKimiExecutorRequestToFormatMatchesWireProtocol(t *testing.T) {
|
|
type requestToFormatReporter interface {
|
|
RequestToFormat(cliproxyexecutor.Request, cliproxyexecutor.Options) sdktranslator.Format
|
|
}
|
|
|
|
executor := NewKimiExecutor(&config.Config{})
|
|
reporter, ok := any(executor).(requestToFormatReporter)
|
|
if !ok {
|
|
t.Fatal("Kimi executor does not report its upstream request format")
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
stream bool
|
|
source sdktranslator.Format
|
|
want sdktranslator.Format
|
|
}{
|
|
{name: "Claude non-streaming", source: sdktranslator.FormatClaude, want: sdktranslator.FormatClaude},
|
|
{name: "Claude streaming", stream: true, source: sdktranslator.FormatClaude, want: sdktranslator.FormatClaude},
|
|
{name: "OpenAI non-streaming", source: sdktranslator.FormatOpenAI, want: sdktranslator.FormatOpenAI},
|
|
{name: "OpenAI streaming", stream: true, source: sdktranslator.FormatOpenAI, want: sdktranslator.FormatOpenAI},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := reporter.RequestToFormat(cliproxyexecutor.Request{}, cliproxyexecutor.Options{
|
|
SourceFormat: tt.source,
|
|
Stream: tt.stream,
|
|
})
|
|
if got != tt.want {
|
|
t.Fatalf("RequestToFormat() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestKimiExecutorClaudeRequestPreservesInternalModelSemantics(t *testing.T) {
|
|
var upstreamBody []byte
|
|
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
var errRead error
|
|
upstreamBody, errRead = io.ReadAll(req.Body)
|
|
if errRead != nil {
|
|
return nil, errRead
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(
|
|
`{"id":"msg_test","type":"message","role":"assistant","model":"k2.5","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`,
|
|
)),
|
|
}, nil
|
|
}))
|
|
|
|
executor := NewKimiExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
Attributes: map[string]string{},
|
|
Metadata: map[string]any{"access_token": "test-token"},
|
|
}
|
|
const model = "kimi-k2.5(max)"
|
|
payload := []byte(`{"model":"kimi-k2.5(max)","max_tokens":32,"messages":[{"role":"user","content":"hello"}]}`)
|
|
response, err := executor.Execute(ctx, auth, cliproxyexecutor.Request{
|
|
Model: model,
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatClaude,
|
|
OriginalRequest: payload,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
if got := gjson.GetBytes(upstreamBody, "model").String(); got != "k2.5" {
|
|
t.Fatalf("upstream model = %q, want k2.5", got)
|
|
}
|
|
if got := gjson.GetBytes(upstreamBody, "output_config.effort").String(); got != "high" {
|
|
t.Fatalf("upstream output_config.effort = %q, want high", got)
|
|
}
|
|
if got := gjson.GetBytes(response.Payload, "model").String(); got != model {
|
|
t.Fatalf("response model = %q, want %q", got, model)
|
|
}
|
|
}
|
|
|
|
func TestKimiExecutorPreservesAssistantContentAndToolCallsFromResponsesHistory(t *testing.T) {
|
|
var upstreamBody []byte
|
|
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
var errRead error
|
|
upstreamBody, errRead = io.ReadAll(req.Body)
|
|
if errRead != nil {
|
|
return nil, errRead
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(
|
|
`{"id":"chatcmpl_test","object":"chat.completion","created":1,"model":"k3","choices":[{"index":0,"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`,
|
|
)),
|
|
}, nil
|
|
}))
|
|
|
|
executor := NewKimiExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
Attributes: map[string]string{},
|
|
Metadata: map[string]any{"access_token": "test-token"},
|
|
}
|
|
payload := []byte(`{
|
|
"model":"kimi-k3",
|
|
"input":[
|
|
{"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"inspect the next step"}]},
|
|
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Step 3 completed; continue to step 4."}]},
|
|
{"type":"function_call","call_id":"call_4","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}"},
|
|
{"type":"function_call_output","call_id":"call_4","output":"ok"}
|
|
]
|
|
}`)
|
|
|
|
_, err := executor.Execute(ctx, auth, cliproxyexecutor.Request{
|
|
Model: "kimi-k3",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
|
OriginalRequest: payload,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Execute() error = %v", err)
|
|
}
|
|
|
|
messages := gjson.GetBytes(upstreamBody, "messages").Array()
|
|
if got := len(messages); got != 2 {
|
|
t.Fatalf("upstream messages count = %d, want 2; body=%s", got, upstreamBody)
|
|
}
|
|
assistant := messages[0]
|
|
if got := assistant.Get("content.0.text").String(); got != "Step 3 completed; continue to step 4." {
|
|
t.Fatalf("assistant content = %q, want preserved text; body=%s", got, upstreamBody)
|
|
}
|
|
if got := assistant.Get("reasoning_content").String(); got != "inspect the next step" {
|
|
t.Fatalf("assistant reasoning_content = %q, want inspect the next step; body=%s", got, upstreamBody)
|
|
}
|
|
if got := assistant.Get("tool_calls.0.id").String(); got != "call_4" {
|
|
t.Fatalf("assistant tool call ID = %q, want call_4; body=%s", got, upstreamBody)
|
|
}
|
|
if got := messages[1].Get("tool_call_id").String(); got != "call_4" {
|
|
t.Fatalf("tool output call ID = %q, want call_4; body=%s", got, upstreamBody)
|
|
}
|
|
}
|
|
|
|
func TestKimiExecutorCountTokensUsesCanonicalUpstreamModel(t *testing.T) {
|
|
var upstreamRequest *http.Request
|
|
var upstreamBody []byte
|
|
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
upstreamRequest = req.Clone(req.Context())
|
|
var errRead error
|
|
upstreamBody, errRead = io.ReadAll(req.Body)
|
|
if errRead != nil {
|
|
return nil, errRead
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(`{"input_tokens":42}`)),
|
|
}, nil
|
|
}))
|
|
|
|
executor := NewKimiExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
Attributes: map[string]string{},
|
|
Metadata: map[string]any{"access_token": "test-token"},
|
|
}
|
|
payload := []byte(`{"model":"kimi-k3[1m](high)","messages":[{"role":"user","content":"hello"}]}`)
|
|
_, err := executor.CountTokens(ctx, auth, cliproxyexecutor.Request{
|
|
Model: "kimi-k3[1m](high)",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
if err != nil {
|
|
t.Fatalf("CountTokens() error = %v", err)
|
|
}
|
|
if upstreamRequest == nil {
|
|
t.Fatal("upstream request was not captured")
|
|
}
|
|
if got := upstreamRequest.URL.String(); got != "https://api.kimi.com/coding/v1/messages/count_tokens?beta=true" {
|
|
t.Fatalf("upstream URL = %q, want Kimi count tokens endpoint", got)
|
|
}
|
|
if got := gjson.GetBytes(upstreamBody, "model").String(); got != "k3" {
|
|
t.Fatalf("upstream model = %q, want k3", got)
|
|
}
|
|
}
|
|
|
|
func TestKimiExecutorCountTokensInvalidGzipErrorBodyReturnsDecodeMessage(t *testing.T) {
|
|
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusBadRequest,
|
|
Header: http.Header{"Content-Encoding": []string{"gzip"}},
|
|
Body: io.NopCloser(strings.NewReader("not-a-valid-gzip-stream")),
|
|
}, nil
|
|
}))
|
|
|
|
executor := NewKimiExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
Attributes: map[string]string{},
|
|
Metadata: map[string]any{"access_token": "test-token"},
|
|
}
|
|
payload := []byte(`{"model":"kimi-k3","messages":[{"role":"user","content":"hello"}]}`)
|
|
_, err := executor.CountTokens(ctx, auth, cliproxyexecutor.Request{
|
|
Model: "kimi-k3",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
|
|
assertStatusErr(t, err, http.StatusBadRequest)
|
|
if !strings.Contains(err.Error(), "failed to decode error response body") {
|
|
t.Fatalf("CountTokens() error = %q, want decode failure", err)
|
|
}
|
|
}
|
|
|
|
func TestKimiExecutorClaudeStreamForwardsAnthropicBetaAndLogsUpstream(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
recorder := httptest.NewRecorder()
|
|
ginCtx, _ := gin.CreateTestContext(recorder)
|
|
ginCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages?beta=true", nil)
|
|
|
|
var upstreamRequest *http.Request
|
|
ctx := context.WithValue(context.Background(), "gin", ginCtx)
|
|
ctx = context.WithValue(ctx, "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
upstreamRequest = req.Clone(req.Context())
|
|
upstreamRequest.Header = req.Header.Clone()
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
|
Body: io.NopCloser(strings.NewReader(
|
|
"event: message_start\n" +
|
|
`data: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","model":"k3","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1,"output_tokens":0}}}` + "\n\n" +
|
|
"event: message_stop\n" +
|
|
`data: {"type":"message_stop"}` + "\n\n",
|
|
)),
|
|
}, nil
|
|
}))
|
|
|
|
cfg := &config.Config{SDKConfig: config.SDKConfig{RequestLog: true}}
|
|
executor := NewKimiExecutor(cfg)
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "kimi-test-auth",
|
|
Attributes: map[string]string{},
|
|
Metadata: map[string]any{"access_token": "test-token"},
|
|
}
|
|
payload := []byte(`{"model":"kimi-k3","max_tokens":32,"messages":[{"role":"user","content":"hello"}]}`)
|
|
result, err := executor.ExecuteStream(ctx, auth, cliproxyexecutor.Request{
|
|
Model: "kimi-k3",
|
|
Payload: payload,
|
|
}, cliproxyexecutor.Options{
|
|
Stream: true,
|
|
SourceFormat: sdktranslator.FormatClaude,
|
|
OriginalRequest: payload,
|
|
Headers: http.Header{
|
|
"Anthropic-Beta": []string{"client-beta-one", "client-beta-two"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteStream() error = %v", err)
|
|
}
|
|
var output strings.Builder
|
|
for chunk := range result.Chunks {
|
|
if chunk.Err != nil {
|
|
t.Fatalf("stream chunk error = %v", chunk.Err)
|
|
}
|
|
output.Write(chunk.Payload)
|
|
}
|
|
if !strings.Contains(output.String(), `"model":"kimi-k3"`) {
|
|
t.Fatalf("stream output = %q, want requested model kimi-k3", output.String())
|
|
}
|
|
if upstreamRequest == nil {
|
|
t.Fatal("upstream request was not captured")
|
|
}
|
|
if got := upstreamRequest.URL.String(); got != "https://api.kimi.com/coding/v1/messages?beta=true" {
|
|
t.Fatalf("upstream URL = %q, want Kimi messages endpoint", got)
|
|
}
|
|
upstreamBetas := upstreamRequest.Header.Get("Anthropic-Beta")
|
|
if upstreamBetas != "client-beta-one,client-beta-two" {
|
|
t.Fatalf("Anthropic-Beta = %q, want caller beta values only", upstreamBetas)
|
|
}
|
|
|
|
rawAPIRequest, existsRequest := ginCtx.Get("API_REQUEST")
|
|
apiRequest, okRequest := rawAPIRequest.([]byte)
|
|
if !existsRequest || !okRequest {
|
|
t.Fatalf("API_REQUEST = %#v, want captured bytes", rawAPIRequest)
|
|
}
|
|
apiRequestText := string(apiRequest)
|
|
for _, want := range []string{
|
|
"=== API REQUEST 1 ===",
|
|
"Upstream URL: https://api.kimi.com/coding/v1/messages?beta=true",
|
|
"Auth: provider=kimi",
|
|
"Anthropic-Beta: " + upstreamBetas,
|
|
`"model":"k3"`,
|
|
} {
|
|
if !strings.Contains(apiRequestText, want) {
|
|
t.Fatalf("API_REQUEST = %q, want %q", apiRequestText, want)
|
|
}
|
|
}
|
|
if strings.Contains(apiRequestText, "<missing>") {
|
|
t.Fatalf("API_REQUEST = %q, want captured upstream request", apiRequestText)
|
|
}
|
|
|
|
rawAPIResponse, existsResponse := ginCtx.Get("API_RESPONSE")
|
|
apiResponse, okResponse := rawAPIResponse.([]byte)
|
|
if !existsResponse || !okResponse {
|
|
t.Fatalf("API_RESPONSE = %#v, want captured bytes", rawAPIResponse)
|
|
}
|
|
apiResponseText := string(apiResponse)
|
|
for _, want := range []string{"=== API RESPONSE 1 ===", "Status: 200", `data: {"type":"message_stop"}`} {
|
|
if !strings.Contains(apiResponseText, want) {
|
|
t.Fatalf("API_RESPONSE = %q, want %q", apiResponseText, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
type kimiRoundTripperFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f kimiRoundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return f(req)
|
|
}
|
|
|
|
func TestNormalizeKimiToolMessageLinks_UsesCallIDFallback(t *testing.T) {
|
|
body := []byte(`{
|
|
"messages":[
|
|
{"role":"assistant","tool_calls":[{"id":"list_directory:1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]},
|
|
{"role":"tool","call_id":"list_directory:1","content":"[]"}
|
|
]
|
|
}`)
|
|
|
|
out, err := normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
|
}
|
|
|
|
got := gjson.GetBytes(out, "messages.1.tool_call_id").String()
|
|
if got != "list_directory:1" {
|
|
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "list_directory:1")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKimiToolMessageLinks_InferSinglePendingID(t *testing.T) {
|
|
body := []byte(`{
|
|
"messages":[
|
|
{"role":"assistant","tool_calls":[{"id":"call_123","type":"function","function":{"name":"read_file","arguments":"{}"}}]},
|
|
{"role":"tool","content":"file-content"}
|
|
]
|
|
}`)
|
|
|
|
out, err := normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
|
}
|
|
|
|
got := gjson.GetBytes(out, "messages.1.tool_call_id").String()
|
|
if got != "call_123" {
|
|
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_123")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKimiToolMessageLinks_AmbiguousMissingIDIsNotInferred(t *testing.T) {
|
|
body := []byte(`{
|
|
"messages":[
|
|
{"role":"assistant","tool_calls":[
|
|
{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}},
|
|
{"id":"call_2","type":"function","function":{"name":"read_file","arguments":"{}"}}
|
|
]},
|
|
{"role":"tool","content":"result-without-id"}
|
|
]
|
|
}`)
|
|
|
|
out, err := normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
|
}
|
|
|
|
if gjson.GetBytes(out, "messages.1.tool_call_id").Exists() {
|
|
t.Fatalf("messages.1.tool_call_id should be absent for ambiguous case, got %q", gjson.GetBytes(out, "messages.1.tool_call_id").String())
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKimiToolMessageLinks_PreservesExistingToolCallID(t *testing.T) {
|
|
body := []byte(`{
|
|
"messages":[
|
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]},
|
|
{"role":"tool","tool_call_id":"call_1","call_id":"different-id","content":"result"}
|
|
]
|
|
}`)
|
|
|
|
out, err := normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
|
}
|
|
|
|
got := gjson.GetBytes(out, "messages.1.tool_call_id").String()
|
|
if got != "call_1" {
|
|
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_1")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKimiToolMessageLinks_InheritsPreviousReasoningForAssistantToolCalls(t *testing.T) {
|
|
body := []byte(`{
|
|
"messages":[
|
|
{"role":"assistant","content":"plan","reasoning_content":"previous reasoning"},
|
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]}
|
|
]
|
|
}`)
|
|
|
|
out, err := normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
|
}
|
|
|
|
got := gjson.GetBytes(out, "messages.1.reasoning_content").String()
|
|
if got != "previous reasoning" {
|
|
t.Fatalf("messages.1.reasoning_content = %q, want %q", got, "previous reasoning")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKimiToolMessageLinks_InsertsFallbackReasoningWhenMissing(t *testing.T) {
|
|
body := []byte(`{
|
|
"messages":[
|
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]}
|
|
]
|
|
}`)
|
|
|
|
out, err := normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
|
}
|
|
|
|
reasoning := gjson.GetBytes(out, "messages.0.reasoning_content")
|
|
if !reasoning.Exists() {
|
|
t.Fatalf("messages.0.reasoning_content should exist")
|
|
}
|
|
if reasoning.String() != "[reasoning unavailable]" {
|
|
t.Fatalf("messages.0.reasoning_content = %q, want %q", reasoning.String(), "[reasoning unavailable]")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKimiToolMessageLinks_DoesNotReuseUnavailableReasoning(t *testing.T) {
|
|
body := []byte(`{
|
|
"messages":[
|
|
{"role":"assistant","reasoning_content":"[reasoning unavailable]"},
|
|
{"role":"assistant","content":"current summary","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]}
|
|
]
|
|
}`)
|
|
|
|
out, err := normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
|
}
|
|
|
|
got := gjson.GetBytes(out, "messages.1.reasoning_content").String()
|
|
if got != "current summary" {
|
|
t.Fatalf("messages.1.reasoning_content = %q, want %q", got, "current summary")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKimiToolMessageLinks_UnavailableReasoningDoesNotOverridePreviousReasoning(t *testing.T) {
|
|
body := []byte(`{
|
|
"messages":[
|
|
{"role":"assistant","reasoning_content":"real reasoning"},
|
|
{"role":"assistant","reasoning_content":"[reasoning unavailable]"},
|
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]}
|
|
]
|
|
}`)
|
|
|
|
out, err := normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
|
}
|
|
|
|
got := gjson.GetBytes(out, "messages.2.reasoning_content").String()
|
|
if got != "real reasoning" {
|
|
t.Fatalf("messages.2.reasoning_content = %q, want %q", got, "real reasoning")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKimiToolMessageLinks_ReplacesUnavailableReasoningContent(t *testing.T) {
|
|
body := []byte(`{
|
|
"messages":[
|
|
{"role":"assistant","content":"assistant summary","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}],"reasoning_content":"[reasoning unavailable]"}
|
|
]
|
|
}`)
|
|
|
|
out, err := normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
|
}
|
|
|
|
got := gjson.GetBytes(out, "messages.0.reasoning_content").String()
|
|
if got != "assistant summary" {
|
|
t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "assistant summary")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKimiToolMessageLinks_UsesContentAsReasoningFallback(t *testing.T) {
|
|
body := []byte(`{
|
|
"messages":[
|
|
{"role":"assistant","content":[{"type":"text","text":"first line"},{"type":"text","text":"second line"}],"tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]}
|
|
]
|
|
}`)
|
|
|
|
out, err := normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
|
}
|
|
|
|
got := gjson.GetBytes(out, "messages.0.reasoning_content").String()
|
|
if got != "first line\nsecond line" {
|
|
t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "first line\nsecond line")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKimiToolMessageLinks_ReplacesEmptyReasoningContent(t *testing.T) {
|
|
body := []byte(`{
|
|
"messages":[
|
|
{"role":"assistant","content":"assistant summary","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}],"reasoning_content":""}
|
|
]
|
|
}`)
|
|
|
|
out, err := normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
|
}
|
|
|
|
got := gjson.GetBytes(out, "messages.0.reasoning_content").String()
|
|
if got != "assistant summary" {
|
|
t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "assistant summary")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKimiToolMessageLinks_PreservesExistingAssistantReasoning(t *testing.T) {
|
|
body := []byte(`{
|
|
"messages":[
|
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}],"reasoning_content":"keep me"}
|
|
]
|
|
}`)
|
|
|
|
out, err := normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
|
}
|
|
|
|
got := gjson.GetBytes(out, "messages.0.reasoning_content").String()
|
|
if got != "keep me" {
|
|
t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "keep me")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKimiToolMessageLinks_RepairsIDsAndReasoningTogether(t *testing.T) {
|
|
body := []byte(`{
|
|
"messages":[
|
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}],"reasoning_content":"r1"},
|
|
{"role":"tool","call_id":"call_1","content":"[]"},
|
|
{"role":"assistant","tool_calls":[{"id":"call_2","type":"function","function":{"name":"read_file","arguments":"{}"}}]},
|
|
{"role":"tool","call_id":"call_2","content":"file"}
|
|
]
|
|
}`)
|
|
|
|
out, err := normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
|
}
|
|
|
|
if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "call_1" {
|
|
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_1")
|
|
}
|
|
if got := gjson.GetBytes(out, "messages.3.tool_call_id").String(); got != "call_2" {
|
|
t.Fatalf("messages.3.tool_call_id = %q, want %q", got, "call_2")
|
|
}
|
|
if got := gjson.GetBytes(out, "messages.2.reasoning_content").String(); got != "r1" {
|
|
t.Fatalf("messages.2.reasoning_content = %q, want %q", got, "r1")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKimiToolMessageLinks_DropsEmptyAssistantWithoutToolLink(t *testing.T) {
|
|
body := []byte(`{
|
|
"messages":[
|
|
{"role":"user","content":"start"},
|
|
{"role":"assistant","content":""},
|
|
{"role":"assistant","content":" "},
|
|
{"role":"assistant","content":"","tool_calls":null},
|
|
{"role":"assistant","content":[{"type":"text","text":" "}]},
|
|
{"role":"assistant"},
|
|
{"role":"assistant","content":"keep"},
|
|
{"role":"user","content":"next"}
|
|
]
|
|
}`)
|
|
|
|
out, err := normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
|
}
|
|
|
|
messages := gjson.GetBytes(out, "messages").Array()
|
|
if len(messages) != 3 {
|
|
t.Fatalf("messages length = %d, want 3, raw = %s", len(messages), gjson.GetBytes(out, "messages").Raw)
|
|
}
|
|
if got := messages[0].Get("content").String(); got != "start" {
|
|
t.Fatalf("messages.0.content = %q, want %q", got, "start")
|
|
}
|
|
if got := messages[1].Get("content").String(); got != "keep" {
|
|
t.Fatalf("messages.1.content = %q, want %q", got, "keep")
|
|
}
|
|
if got := messages[2].Get("content").String(); got != "next" {
|
|
t.Fatalf("messages.2.content = %q, want %q", got, "next")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKimiToolMessageLinks_PreservesAssistantWithToolLinkOrReasoning(t *testing.T) {
|
|
body := []byte(`{
|
|
"messages":[
|
|
{"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]},
|
|
{"role":"assistant","content":"","function_call":{"name":"legacy_call","arguments":"{}"}},
|
|
{"role":"assistant","content":"","reasoning_content":"thought"},
|
|
{"role":"assistant","content":[{"type":"text","text":" visible "}]}
|
|
]
|
|
}`)
|
|
|
|
out, err := normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
|
}
|
|
|
|
messages := gjson.GetBytes(out, "messages").Array()
|
|
if len(messages) != 4 {
|
|
t.Fatalf("messages length = %d, want 4, raw = %s", len(messages), gjson.GetBytes(out, "messages").Raw)
|
|
}
|
|
if !messages[0].Get("tool_calls").Exists() {
|
|
t.Fatalf("messages.0.tool_calls should exist")
|
|
}
|
|
if !messages[1].Get("function_call").Exists() {
|
|
t.Fatalf("messages.1.function_call should exist")
|
|
}
|
|
if got := messages[2].Get("reasoning_content").String(); got != "thought" {
|
|
t.Fatalf("messages.2.reasoning_content = %q, want %q", got, "thought")
|
|
}
|
|
if got := messages[3].Get("content.0.text").String(); got != " visible " {
|
|
t.Fatalf("messages.3.content.0.text = %q, want %q", got, " visible ")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeKimiUpstreamModel(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want string
|
|
}{
|
|
{"kimi-k3[1m]", "k3"},
|
|
{"kimi-k3", "k3"},
|
|
{"Kimi-K3[1M]", "k3"},
|
|
{"k3[1m]", "k3"},
|
|
{"k3", "k3"},
|
|
{"kimi-k2.6", "k2.6"},
|
|
{"kimi-k2.6[1m]", "k2.6"},
|
|
{"kimi-k3(1024)", "k3(1024)"},
|
|
{"kimi-k3[1m](1024)", "k3(1024)"},
|
|
{"kimi-k2.6(high)", "k2.6(high)"},
|
|
{"kimi-k2.6[1m](high)", "k2.6(high)"},
|
|
{"kimi-k2.7-code", "kimi-for-coding"},
|
|
{"kimi-k2.7-code-highspeed", "kimi-for-coding-highspeed"},
|
|
{"Kimi-K2.7-Code", "kimi-for-coding"},
|
|
{"kimi-k2.7-code-highspeed(high)", "kimi-for-coding-highspeed(high)"},
|
|
{"kimi-k2.7-code[1m](high)", "kimi-for-coding(high)"},
|
|
{"k2.7-code", "kimi-for-coding"},
|
|
{"k2.7-code-highspeed", "kimi-for-coding-highspeed"},
|
|
{"kimi-for-coding", "kimi-for-coding"},
|
|
{"kimi-for-coding-highspeed", "kimi-for-coding-highspeed"},
|
|
{"Kimi-For-Coding", "kimi-for-coding"},
|
|
{"kimi-for-coding-highspeed(high)", "kimi-for-coding-highspeed(high)"},
|
|
{"kimi-for-coding[1m]", "kimi-for-coding"},
|
|
{"for-coding", "kimi-for-coding"},
|
|
{"for-coding-highspeed", "kimi-for-coding-highspeed"},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
got := normalizeKimiUpstreamModel(c.in)
|
|
if got != c.want {
|
|
t.Errorf("normalizeKimiUpstreamModel(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|