850 lines
27 KiB
Go
850 lines
27 KiB
Go
package 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)
|
|
}
|
|
|
|
// 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" {
|
|
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.ApplyThinkingWithSourcePayload(body, req.Payload, originalPayloadSource, req.Model, 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
|
|
}
|
|
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" {
|
|
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.ApplyThinkingWithSourcePayload(body, req.Payload, originalPayloadSource, req.Model, 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
|
|
}
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|