Something went wrong. Try again.
sloproxy
Something went wrong. Try again.
36 kB · 1115 lines
Go
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116package executor
import ( "bufio" "bytes" "context" "fmt" "io" "net/http" "os" "path/filepath" "runtime" "strings" "time"
kimiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/kimi" "github.com/router-for-me/CLIProxyAPI/v7/internal/buildinfo" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" 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" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson")
const kimiReasoningUnavailable = "[reasoning unavailable]"
// KimiExecutor is a stateless executor for Kimi API using OpenAI-compatible chat completions.type KimiExecutor struct { ClaudeExecutor cfg *config.Config}
// NewKimiExecutor creates a new Kimi executor.func NewKimiExecutor(cfg *config.Config) *KimiExecutor { return &KimiExecutor{ ClaudeExecutor: ClaudeExecutor{ cfg: cfg, requestLogProvider: "kimi", upstreamModelNormalizer: normalizeKimiUpstreamModel, }, cfg: cfg, }}
// Identifier returns the executor identifier.func (e *KimiExecutor) Identifier() string { return "kimi" }
// RequestToFormat reports the upstream request format used after auth selection.func (e *KimiExecutor) RequestToFormat(_ cliproxyexecutor.Request, opts cliproxyexecutor.Options) sdktranslator.Format { if opts.SourceFormat == sdktranslator.FormatClaude { return sdktranslator.FormatClaude } return sdktranslator.FormatOpenAI}
// PrepareRequest injects Kimi credentials into the outgoing HTTP request.func (e *KimiExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { if req == nil { return nil } token := kimiCreds(auth) if strings.TrimSpace(token) != "" { req.Header.Set("Authorization", "Bearer "+token) } var attrs map[string]string if auth != nil { attrs = auth.Attributes } util.ApplyCustomHeadersFromAttrs(req, attrs) return nil}
// HttpRequest injects Kimi credentials into the request and executes it.func (e *KimiExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { if req == nil { return nil, fmt.Errorf("kimi executor: request is nil") } if ctx == nil { ctx = req.Context() } httpReq := req.WithContext(ctx) if err := e.PrepareRequest(httpReq, auth); err != nil { return nil, err } httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) return httpClient.Do(httpReq)}
// kimiUsesNativeMessages keeps Claude and compatible Responses tool calls on// Kimi's native endpoint. Kimi's OpenAI-compatible endpoint does not expose// the server-side web search tool as a client-visible tool call.func kimiUsesNativeMessages(from sdktranslator.Format, payload []byte) bool { if from.String() == "claude" { return true } if from != sdktranslator.FormatOpenAIResponse { return false } tools := gjson.GetBytes(payload, "tools") if !tools.IsArray() { return true } for _, tool := range tools.Array() { if tool.Get("type").String() == "web_search" { return false } } return true}
func kimiResponsesHistoryNeedsNative(payload []byte) bool { for _, item := range gjson.GetBytes(payload, "input").Array() { switch item.Get("type").String() { case "function_call", "function_call_output": return true } } return false}
// Execute performs a non-streaming chat completion request to Kimi.func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { from := opts.SourceFormat if from.String() == "claude" || kimiResponsesHistoryNeedsNative(req.Payload) { auth.Attributes["base_url"] = kimiauth.KimiAPIBaseURL preparedReq, replayScope := prepareKimiThinkingReplayRequest(ctx, req, opts) claudeResp, errExecute := e.ClaudeExecutor.Execute(ctx, auth, preparedReq, opts) if errExecute != nil { if replayScope.replayApplied && shouldClearKimiThinkingReplayAfterError(errExecute) { clearKimiThinkingReplayContent(ctx, replayScope) } return claudeResp, errExecute } cacheKimiThinkingReplayResponse(ctx, replayScope, claudeResp.Payload) return claudeResp, nil } responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
baseModel := thinking.ParseSuffix(req.Model).ModelName
token := kimiCreds(auth)
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth) defer reporter.TrackFailure(ctx, &err)
to := sdktranslator.FromString("openai") originalPayloadSource := req.Payload if len(opts.OriginalRequest) > 0 { originalPayloadSource = opts.OriginalRequest } originalPayload := bytes.Clone(originalPayloadSource) originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, false) body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, bytes.Clone(req.Payload), false)
// Strip kimi- prefix and any [1m] suffix for upstream API upstreamModel := normalizeKimiUpstreamModel(baseModel) body, err = sjson.SetBytes(body, "model", upstreamModel) if err != nil { return resp, fmt.Errorf("kimi executor: failed to set model in payload: %w", err) }
body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), "kimi", e.Identifier()) if err != nil { return resp, err }
requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body, err = normalizeKimiToolMessageLinks(body) if err != nil { return resp, err } if from == sdktranslator.FormatOpenAIResponse { body, err = moveKimiToolOutputImagesToUserMessages(body) if err != nil { return resp, err } } body = normalizeKimiTools(body) reporter.SetTranslatedReasoningEffort(body, e.Identifier())
url := kimiauth.KimiAPIBaseURL + "/v1/chat/completions" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return resp, err } applyKimiHeadersWithAuth(httpReq, token, false, auth) var attrs map[string]string if auth != nil { attrs = auth.Attributes } util.ApplyCustomHeadersFromAttrs(httpReq, attrs) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID authLabel = auth.Label authType, authValue = auth.AccountInfo() } helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{ URL: url, Method: http.MethodPost, Headers: httpReq.Header.Clone(), Body: body, Provider: e.Identifier(), AuthID: authID, AuthLabel: authLabel, AuthType: authType, AuthValue: authValue, })
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) httpClient = reporter.TrackHTTPClient(httpClient) httpResp, err := httpClient.Do(httpReq) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) return resp, err } defer func() { if errClose := httpResp.Body.Close(); errClose != nil { log.Errorf("kimi executor: close response body error: %v", errClose) } }() helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { b, _ := io.ReadAll(httpResp.Body) helps.AppendAPIResponseChunk(ctx, e.cfg, b) helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) err = statusErr{code: httpResp.StatusCode, msg: string(b)} return resp, err } data, err := io.ReadAll(httpResp.Body) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) return resp, err } helps.AppendAPIResponseChunk(ctx, e.cfg, data) reporter.Publish(ctx, helps.ParseOpenAIUsage(data)) var param any // Note: TranslateNonStream uses req.Model (original with suffix) to preserve // the original model name in the response for client compatibility. out := sdktranslator.TranslateNonStream(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, data, ¶m) if responseFormat == sdktranslator.FormatOpenAIResponse { out = helps.EnsureResponsesUsageDetails(out) } resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()} return resp, nil}
// ExecuteStream performs a streaming chat completion request to Kimi.func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) { from := opts.SourceFormat if from.String() == "claude" || kimiResponsesHistoryNeedsNative(req.Payload) { auth.Attributes["base_url"] = kimiauth.KimiAPIBaseURL preparedReq, replayScope := prepareKimiThinkingReplayRequest(ctx, req, opts) claudeResult, errExecute := e.ClaudeExecutor.ExecuteStream(ctx, auth, preparedReq, opts) if errExecute != nil { if replayScope.replayApplied && shouldClearKimiThinkingReplayAfterError(errExecute) { clearKimiThinkingReplayContent(ctx, replayScope) } return nil, errExecute } return wrapKimiThinkingReplayStream(ctx, claudeResult, replayScope), nil } responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
baseModel := thinking.ParseSuffix(req.Model).ModelName token := kimiCreds(auth)
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth) defer reporter.TrackFailure(ctx, &err)
to := sdktranslator.FromString("openai") originalPayloadSource := req.Payload if len(opts.OriginalRequest) > 0 { originalPayloadSource = opts.OriginalRequest } originalPayload := bytes.Clone(originalPayloadSource) originalTranslated := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, true) body := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, bytes.Clone(req.Payload), true)
// Strip kimi- prefix and any [1m] suffix for upstream API upstreamModel := normalizeKimiUpstreamModel(baseModel) body, err = sjson.SetBytes(body, "model", upstreamModel) if err != nil { return nil, fmt.Errorf("kimi executor: failed to set model in payload: %w", err) }
body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), "kimi", e.Identifier()) if err != nil { return nil, err }
body, err = sjson.SetBytes(body, "stream_options.include_usage", true) if err != nil { return nil, fmt.Errorf("kimi executor: failed to set stream_options in payload: %w", err) } requestedModel := helps.PayloadRequestedModel(opts, req.Model) requestPath := helps.PayloadRequestPath(opts) body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers) body, err = normalizeKimiToolMessageLinks(body) if err != nil { return nil, err } if from == sdktranslator.FormatOpenAIResponse { body, err = moveKimiToolOutputImagesToUserMessages(body) if err != nil { return nil, err } } body = normalizeKimiTools(body) reporter.SetTranslatedReasoningEffort(body, e.Identifier())
url := kimiauth.KimiAPIBaseURL + "/v1/chat/completions" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, err } applyKimiHeadersWithAuth(httpReq, token, true, auth) var attrs map[string]string if auth != nil { attrs = auth.Attributes } util.ApplyCustomHeadersFromAttrs(httpReq, attrs) var authID, authLabel, authType, authValue string if auth != nil { authID = auth.ID authLabel = auth.Label authType, authValue = auth.AccountInfo() } helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{ URL: url, Method: http.MethodPost, Headers: httpReq.Header.Clone(), Body: body, Provider: e.Identifier(), AuthID: authID, AuthLabel: authLabel, AuthType: authType, AuthValue: authValue, })
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0) httpClient = reporter.TrackHTTPClient(httpClient) httpResp, err := httpClient.Do(httpReq) if err != nil { helps.RecordAPIResponseError(ctx, e.cfg, err) return nil, err } helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { b, _ := io.ReadAll(httpResp.Body) helps.AppendAPIResponseChunk(ctx, e.cfg, b) helps.LogWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) if errClose := httpResp.Body.Close(); errClose != nil { log.Errorf("kimi executor: close response body error: %v", errClose) } err = statusErr{code: httpResp.StatusCode, msg: string(b)} return nil, err } out := make(chan cliproxyexecutor.StreamChunk) go func() { defer close(out) defer func() { if errClose := httpResp.Body.Close(); errClose != nil { log.Errorf("kimi executor: close response body error: %v", errClose) } }() scanner := bufio.NewScanner(httpResp.Body) scanner.Buffer(nil, 1_048_576) // 1MB claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload) var param any var streamUsage helps.StreamUsageBuffer defer streamUsage.Publish(ctx, reporter) for scanner.Scan() { line := scanner.Bytes() helps.AppendAPIResponseChunk(ctx, e.cfg, line) streamUsage.ObserveOpenAIStream(line) chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m, claudeInputTokens) for i := range chunks { select { case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}: case <-ctx.Done(): return } } } doneChunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m, claudeInputTokens) for i := range doneChunks { select { case out <- cliproxyexecutor.StreamChunk{Payload: doneChunks[i]}: case <-ctx.Done(): return } } if errScan := scanner.Err(); errScan != nil { helps.RecordAPIResponseError(ctx, e.cfg, errScan) reporter.PublishFailure(ctx, errScan) select { case out <- cliproxyexecutor.StreamChunk{Err: errScan}: case <-ctx.Done(): } } }() return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil}
// CountTokens estimates token count for Kimi requests.func (e *KimiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { auth.Attributes["base_url"] = kimiauth.KimiAPIBaseURL return e.ClaudeExecutor.countTokensUpstream(ctx, auth, req, opts)}
type kimiToolOutputImage struct { toolCallID string url string detail string}
// moveKimiToolOutputImagesToUserMessages keeps tool results as strings while// forwarding Responses API image parts through Kimi's native user image input.func moveKimiToolOutputImagesToUserMessages(body []byte) ([]byte, error) { if len(body) == 0 || !gjson.ValidBytes(body) { return body, nil } messages := util.GetGJSONBytesNoCopy(body, "messages") if !messages.Exists() || !messages.IsArray() { return body, nil } messageItems := make([]string, 0, len(messages.Array())) pendingImages := make([]kimiToolOutputImage, 0) movedImages := 0 flushImages := func() error { if len(pendingImages) == 0 { return nil } contentItems := make([]string, 0, len(pendingImages)*2) for _, image := range pendingImages { label := "Image output from the preceding tool call:" if image.toolCallID != "" { label = fmt.Sprintf("Image output from tool call %s:", image.toolCallID) } textPart := []byte(`{"type":"text","text":""}`) textPart, _ = sjson.SetBytes(textPart, "text", label) contentItems = append(contentItems, string(textPart)) imagePart := []byte(`{"type":"image_url","image_url":{"url":""}}`) imagePart, _ = sjson.SetBytes(imagePart, "image_url.url", image.url) if image.detail != "" { imagePart, _ = sjson.SetBytes(imagePart, "image_url.detail", image.detail) } contentItems = append(contentItems, string(imagePart)) } userMessage := []byte(`{"role":"user","content":[]}`) userMessage, errSet := sjson.SetRawBytes(userMessage, "content", helps.JoinRawJSONStrings(contentItems)) if errSet != nil { return fmt.Errorf("kimi executor: failed to attach tool output images: %w", errSet) } messageItems = append(messageItems, string(userMessage)) pendingImages = pendingImages[:0] return nil } for _, message := range messages.Array() { if strings.TrimSpace(message.Get("role").String()) != "tool" { if errFlush := flushImages(); errFlush != nil { return body, errFlush } messageItems = append(messageItems, message.Raw) continue } textValue, images, ok := splitKimiStructuredToolOutput(message.Get("content")) if !ok { messageItems = append(messageItems, message.Raw) continue } toolCallID := strings.TrimSpace(message.Get("tool_call_id").String()) for imageIndex := range images { images[imageIndex].toolCallID = toolCallID } pendingImages = append(pendingImages, images...) movedImages += len(images) updatedMessage, errSet := sjson.SetBytes([]byte(message.Raw), "content", textValue) if errSet != nil { return body, fmt.Errorf("kimi executor: failed to replace structured tool image output: %w", errSet) } messageItems = append(messageItems, string(updatedMessage)) } if errFlush := flushImages(); errFlush != nil { return body, errFlush } if movedImages == 0 { return body, nil } out, errSet := sjson.SetRawBytes(body, "messages", helps.JoinRawJSONStrings(messageItems)) if errSet != nil { return body, fmt.Errorf("kimi executor: failed to rebuild messages with tool output images: %w", errSet) } log.WithField("moved_tool_output_images", movedImages).Debug("kimi executor: moved tool output images to native user content") return out, nil}
func splitKimiStructuredToolOutput(content gjson.Result) (string, []kimiToolOutputImage, bool) { structured := content if content.Type == gjson.String { structured = gjson.Parse(content.String()) } var parts []gjson.Result switch { case structured.IsArray(): parts = structured.Array() case structured.IsObject(): parts = []gjson.Result{structured} default: return "", nil, false } textParts := make([]string, 0, len(parts)) images := make([]kimiToolOutputImage, 0) for _, part := range parts { switch strings.TrimSpace(part.Get("type").String()) { case "input_text", "output_text", "text": if textValue := part.Get("text").String(); textValue != "" { textParts = append(textParts, textValue) } case "input_image", "image_url", "image": imageURL := part.Get("image_url") url := imageURL.String() if imageURL.IsObject() { url = imageURL.Get("url").String() } if url == "" { url = part.Get("url").String() } if url == "" { textParts = append(textParts, part.Raw) continue } detail := part.Get("detail").String() if detail == "" { detail = part.Get("image_url.detail").String() } images = append(images, kimiToolOutputImage{url: url, detail: detail}) textParts = append(textParts, "[image output attached in the following user message]") default: textParts = append(textParts, part.Raw) } } if len(images) == 0 { return "", nil, false } return strings.Join(textParts, "\n"), images, true}
func normalizeKimiToolMessageLinks(body []byte) ([]byte, error) { if len(body) == 0 || !gjson.ValidBytes(body) { return body, nil }
messages := util.GetGJSONBytesNoCopy(body, "messages") if !messages.Exists() || !messages.IsArray() { return body, nil }
type messagePatch struct { index int path string value string errorContext string }
msgs := messages.Array() droppedMessages := make([]bool, len(msgs)) patches := make([]messagePatch, 0) pending := make([]string, 0) dropped := 0 patched := 0 patchedReasoning := 0 ambiguous := 0 latestReasoning := "" hasLatestReasoning := false
removePending := func(id string) { for idx := range pending { if pending[idx] != id { continue } pending = append(pending[:idx], pending[idx+1:]...) return } }
for msgIndex, msg := range msgs { if shouldDropKimiAssistantMessage(msg) { droppedMessages[msgIndex] = true dropped++ continue }
role := strings.TrimSpace(msg.Get("role").String()) switch role { case "assistant": reasoning := msg.Get("reasoning_content") if reasoning.Exists() { reasoningText := reasoning.String() if isUsableKimiReasoning(reasoningText) { latestReasoning = reasoningText hasLatestReasoning = true } }
toolCalls := msg.Get("tool_calls") if toolCalls.Exists() && toolCalls.IsArray() { toolCallItems := toolCalls.Array() if len(toolCallItems) > 0 { if !reasoning.Exists() || !isUsableKimiReasoning(reasoning.String()) { patches = append(patches, messagePatch{ index: msgIndex, path: "reasoning_content", value: fallbackAssistantReasoning(msg, hasLatestReasoning, latestReasoning), errorContext: "failed to set assistant reasoning_content", }) patchedReasoning++ } for _, toolCall := range toolCallItems { id := strings.TrimSpace(toolCall.Get("id").String()) if id != "" { pending = append(pending, id) } } } } case "tool": toolCallID := strings.TrimSpace(msg.Get("tool_call_id").String()) if toolCallID == "" { toolCallID = strings.TrimSpace(msg.Get("call_id").String()) if toolCallID != "" { patches = append(patches, messagePatch{index: msgIndex, path: "tool_call_id", value: toolCallID, errorContext: "failed to set tool_call_id from call_id"}) patched++ } } if toolCallID == "" { if len(pending) == 1 { toolCallID = pending[0] patches = append(patches, messagePatch{index: msgIndex, path: "tool_call_id", value: toolCallID, errorContext: "failed to infer tool_call_id"}) patched++ } else if len(pending) > 1 { ambiguous++ } } if toolCallID != "" { removePending(toolCallID) } } }
if dropped > 0 { log.WithField("dropped_assistant_messages", dropped).Debug("kimi executor: dropped empty assistant messages") } if dropped == 0 && len(patches) == 0 { if ambiguous > 0 { log.WithFields(log.Fields{ "ambiguous_tool_messages": ambiguous, "pending_tool_calls": len(pending), }).Warn("kimi executor: tool messages missing tool_call_id with ambiguous candidates") } return body, nil }
var out []byte if dropped == 0 && len(patches) == 1 { patch := patches[0] path := fmt.Sprintf("messages.%d.%s", patch.index, patch.path) updated, errSet := sjson.SetBytes(body, path, patch.value) if errSet != nil { return body, fmt.Errorf("kimi executor: %s: %w", patch.errorContext, errSet) } out = updated } else { messageItems := make([]string, 0, len(msgs)-dropped) patchIndex := 0 for msgIndex, msg := range msgs { if droppedMessages[msgIndex] { continue } messageJSON := msg.Raw for patchIndex < len(patches) && patches[patchIndex].index == msgIndex { patch := patches[patchIndex] next, errSet := sjson.SetBytes([]byte(messageJSON), patch.path, patch.value) if errSet != nil { return body, fmt.Errorf("kimi executor: %s: %w", patch.errorContext, errSet) } messageJSON = string(next) patchIndex++ } messageItems = append(messageItems, messageJSON) } updated, errSet := sjson.SetRawBytes(body, "messages", helps.JoinRawJSONStrings(messageItems)) if errSet != nil { if dropped > 0 { return body, fmt.Errorf("kimi executor: failed to drop empty assistant messages: %w", errSet) } return body, fmt.Errorf("kimi executor: %s: %w", patches[0].errorContext, errSet) } out = updated }
if patched > 0 || patchedReasoning > 0 { log.WithFields(log.Fields{ "patched_tool_messages": patched, "patched_reasoning_messages": patchedReasoning, }).Debug("kimi executor: normalized tool message fields") } if ambiguous > 0 { log.WithFields(log.Fields{ "ambiguous_tool_messages": ambiguous, "pending_tool_calls": len(pending), }).Warn("kimi executor: tool messages missing tool_call_id with ambiguous candidates") } return out, nil}
func shouldDropKimiAssistantMessage(msg gjson.Result) bool { if strings.TrimSpace(msg.Get("role").String()) != "assistant" { return false } if hasKimiToolCalls(msg) || hasKimiLegacyFunctionCall(msg) || hasKimiAssistantReasoning(msg) { return false } return isKimiAssistantContentEmpty(msg.Get("content"))}
func hasKimiToolCalls(msg gjson.Result) bool { toolCalls := msg.Get("tool_calls") return toolCalls.Exists() && toolCalls.IsArray() && len(toolCalls.Array()) > 0}
func hasKimiLegacyFunctionCall(msg gjson.Result) bool { functionCall := msg.Get("function_call") if !functionCall.Exists() || functionCall.Type == gjson.Null { return false } if functionCall.IsObject() && strings.TrimSpace(functionCall.Raw) == "{}" { return false } return strings.TrimSpace(functionCall.Raw) != ""}
func hasKimiAssistantReasoning(msg gjson.Result) bool { reasoning := msg.Get("reasoning_content") return reasoning.Exists() && strings.TrimSpace(reasoning.String()) != ""}
func isKimiAssistantContentEmpty(content gjson.Result) bool { if !content.Exists() || content.Type == gjson.Null { return true } if content.Type == gjson.String { return strings.TrimSpace(content.String()) == "" } if !content.IsArray() { return false } for _, part := range content.Array() { if !isKimiAssistantContentPartEmpty(part) { return false } } return true}
func isKimiAssistantContentPartEmpty(part gjson.Result) bool { if !part.Exists() || part.Type == gjson.Null { return true } if part.Type == gjson.String { return strings.TrimSpace(part.String()) == "" } if !part.IsObject() { return false } if text := part.Get("text"); text.Exists() { return strings.TrimSpace(text.String()) == "" } if strings.TrimSpace(part.Get("type").String()) == "text" { return true } return strings.TrimSpace(part.Raw) == "{}"}
func isUsableKimiReasoning(reasoning string) bool { trimmed := strings.TrimSpace(reasoning) return trimmed != "" && trimmed != kimiReasoningUnavailable}
func fallbackAssistantReasoning(msg gjson.Result, hasLatest bool, latest string) string { if hasLatest && isUsableKimiReasoning(latest) { return latest }
content := msg.Get("content") if content.Type == gjson.String { if text := strings.TrimSpace(content.String()); text != "" { return text } } if content.IsArray() { parts := make([]string, 0, len(content.Array())) for _, item := range content.Array() { text := strings.TrimSpace(item.Get("text").String()) if text == "" { continue } parts = append(parts, text) } if len(parts) > 0 { return strings.Join(parts, "\n") } }
return kimiReasoningUnavailable}
// Refresh refreshes the Kimi token using the refresh token.func (e *KimiExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { log.Debugf("kimi executor: refresh called") if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled { return refreshed, err } if auth == nil { return nil, fmt.Errorf("kimi executor: auth is nil") } // Expect refresh_token in metadata for OAuth-based accounts var refreshToken string if auth.Metadata != nil { if v, ok := auth.Metadata["refresh_token"].(string); ok && strings.TrimSpace(v) != "" { refreshToken = v } } if strings.TrimSpace(refreshToken) == "" { // Nothing to refresh return auth, nil }
client := kimiauth.NewDeviceFlowClientWithDeviceIDAndProxyURL(e.cfg, resolveKimiDeviceID(auth), auth.ProxyURL) td, err := client.RefreshToken(ctx, refreshToken) if err != nil { return nil, err } if auth.Metadata == nil { auth.Metadata = make(map[string]any) } auth.Metadata["access_token"] = td.AccessToken if td.RefreshToken != "" { auth.Metadata["refresh_token"] = td.RefreshToken } if td.ExpiresAt > 0 { exp := time.Unix(td.ExpiresAt, 0).UTC().Format(time.RFC3339) auth.Metadata["expired"] = exp } auth.Metadata["type"] = "kimi" now := time.Now().Format(time.RFC3339) auth.Metadata["last_refresh"] = now return auth, nil}
// applyKimiHeaders sets required headers for Kimi API requests.// Headers identify CLIProxyAPI with the current build version.func applyKimiHeaders(r *http.Request, token string, stream bool) { r.Header.Set("Content-Type", "application/json") r.Header.Set("Authorization", "Bearer "+token) // Identify requests with the current CLIProxyAPI version. r.Header.Set("User-Agent", "CLIProxyAPI/"+buildinfo.Version) r.Header.Set("X-Msh-Platform", "CLIProxyAPI") r.Header.Set("X-Msh-Version", buildinfo.Version) r.Header.Set("X-Msh-Device-Name", getKimiHostname()) r.Header.Set("X-Msh-Device-Model", getKimiDeviceModel()) r.Header.Set("X-Msh-Device-Id", getKimiDeviceID()) if stream { r.Header.Set("Accept", "text/event-stream") return } r.Header.Set("Accept", "application/json")}
func resolveKimiDeviceIDFromAuth(auth *cliproxyauth.Auth) string { if auth == nil || auth.Metadata == nil { return "" }
deviceIDRaw, ok := auth.Metadata["device_id"] if !ok { return "" }
deviceID, ok := deviceIDRaw.(string) if !ok { return "" }
return strings.TrimSpace(deviceID)}
func resolveKimiDeviceIDFromStorage(auth *cliproxyauth.Auth) string { if auth == nil { return "" }
storage, ok := auth.Storage.(*kimiauth.KimiTokenStorage) if !ok || storage == nil { return "" }
return strings.TrimSpace(storage.DeviceID)}
func resolveKimiDeviceID(auth *cliproxyauth.Auth) string { deviceID := resolveKimiDeviceIDFromAuth(auth) if deviceID != "" { return deviceID } return resolveKimiDeviceIDFromStorage(auth)}
func applyKimiHeadersWithAuth(r *http.Request, token string, stream bool, auth *cliproxyauth.Auth) { applyKimiHeaders(r, token, stream)
if deviceID := resolveKimiDeviceID(auth); deviceID != "" { r.Header.Set("X-Msh-Device-Id", deviceID) }}
// getKimiHostname returns the machine hostname.func getKimiHostname() string { hostname, err := os.Hostname() if err != nil { return "unknown" } return hostname}
// getKimiDeviceModel returns a device model string matching kimi-cli format.func getKimiDeviceModel() string { return fmt.Sprintf("%s %s", runtime.GOOS, runtime.GOARCH)}
// getKimiDeviceID returns a stable device ID, matching kimi-cli storage location.func getKimiDeviceID() string { homeDir, err := os.UserHomeDir() if err != nil { return "cli-proxy-api-device" } // Check kimi-cli's device_id location first (platform-specific) var kimiShareDir string switch runtime.GOOS { case "darwin": kimiShareDir = filepath.Join(homeDir, "Library", "Application Support", "kimi") case "windows": appData := os.Getenv("APPDATA") if appData == "" { appData = filepath.Join(homeDir, "AppData", "Roaming") } kimiShareDir = filepath.Join(appData, "kimi") default: // linux and other unix-like kimiShareDir = filepath.Join(homeDir, ".local", "share", "kimi") } deviceIDPath := filepath.Join(kimiShareDir, "device_id") if data, err := os.ReadFile(deviceIDPath); err == nil { return strings.TrimSpace(string(data)) } return "cli-proxy-api-device"}
// kimiCreds extracts the access token from auth.func kimiCreds(a *cliproxyauth.Auth) (token string) { if a == nil { return "" } // Check metadata first (OAuth flow stores tokens here) if a.Metadata != nil { if v, ok := a.Metadata["access_token"].(string); ok && strings.TrimSpace(v) != "" { return v } } // Fallback to attributes (API key style) if a.Attributes != nil { if v := a.Attributes["access_token"]; v != "" { return v } if v := a.Attributes["api_key"]; v != "" { return v } } return ""}
// stripKimiPrefix removes the "kimi-" prefix from model names for the upstream API.func stripKimiPrefix(model string) string { model = strings.TrimSpace(model) if strings.HasPrefix(strings.ToLower(model), "kimi-") { return model[5:] } return model}
// normalizeKimiUpstreamModel returns the canonical upstream model ID for Kimi.// It strips the CLIProxyAPI "kimi-" prefix and any Claude Code "[1m]" context// suffix while preserving a trailing thinking suffix (e.g. "(1024)"), so that// the upstream API receives IDs such as "k3(1024)" instead of "kimi-k3[1m](1024)".// K2.7 Code aliases are remapped to the official Kimi Code model IDs before// generic prefix stripping, so already-canonical IDs stay idempotent.func normalizeKimiUpstreamModel(model string) string { model = strings.TrimSpace(model) parsed := thinking.ParseSuffix(model) base := strings.ToLower(strings.TrimSpace(parsed.ModelName)) if strings.HasSuffix(base, "[1m]") { base = base[:len(base)-len("[1m]")] } var normalized string switch base { case "kimi-k2.7-code", "k2.7-code", "kimi-for-coding", "for-coding": normalized = "kimi-for-coding" case "kimi-k2.7-code-highspeed", "k2.7-code-highspeed", "kimi-for-coding-highspeed", "for-coding-highspeed": normalized = "kimi-for-coding-highspeed" default: normalized = stripKimiPrefix(base) } if parsed.HasSuffix { return normalized + "(" + parsed.RawSuffix + ")" } return normalized}
// normalizeKimiTools normalizes tool and legacy function parameter schemas for Moonshot.// It resolves and inlines local $ref pointers, strips $defs / definitions, and ensures// parameters root objects declare an explicit type: "object".func normalizeKimiTools(body []byte) []byte { if len(body) == 0 { return body } body = normalizeKimiToolList(body, "tools", true) body = normalizeKimiToolList(body, "functions", false) return body}
func normalizeKimiToolList(body []byte, arrayKey string, isTools bool) []byte { items := gjson.GetBytes(body, arrayKey) if !items.Exists() || !items.IsArray() { return body } arr := items.Array() if len(arr) == 0 { return body }
changed := false var updatedItems []string for _, item := range arr { itemRaw := item.Raw var paramPath string if isTools && item.Get("function.parameters").Exists() { paramPath = "function.parameters" } else if item.Get("parameters").Exists() { paramPath = "parameters" }
if paramPath != "" { rawParams := item.Get(paramPath) if rawParams.IsObject() { normalizedParams := normalizeKimiParametersSchema(rawParams.Raw) if normalizedParams != rawParams.Raw { if updated, errSet := sjson.SetRawBytes([]byte(itemRaw), paramPath, []byte(normalizedParams)); errSet == nil { itemRaw = string(updated) changed = true } } } } updatedItems = append(updatedItems, itemRaw) }
if !changed { return body }
out, errSetRaw := sjson.SetRawBytes(body, arrayKey, helps.JoinRawJSONStrings(updatedItems)) if errSetRaw != nil { return body } return out}
func normalizeKimiParametersSchema(paramsRaw string) string { if strings.TrimSpace(paramsRaw) == "" { return paramsRaw }
inlined := util.InlineLocalRefs(paramsRaw) paramBytes := []byte(inlined)
if inlinedDefs := gjson.GetBytes(paramBytes, "$defs"); inlinedDefs.Exists() { paramBytes, _ = sjson.DeleteBytes(paramBytes, "$defs") } if inlinedDefinitions := gjson.GetBytes(paramBytes, "definitions"); inlinedDefinitions.Exists() { paramBytes, _ = sjson.DeleteBytes(paramBytes, "definitions") }
if rootType := gjson.GetBytes(paramBytes, "type"); !rootType.Exists() { paramBytes, _ = sjson.SetBytes(paramBytes, "type", "object") }
return string(paramBytes)}