mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-05-13 01:27:46 +08:00
All streaming executors use bare channel sends (out <- chunk) inside goroutines
that process upstream SSE responses. When the downstream consumer disconnects
(client timeout, network drop, etc.), these sends block indefinitely, causing
the goroutine and all associated resources (HTTP response body, scanner buffers,
translation state) to leak permanently.
Over time, leaked goroutines accumulate monotonically, leading to RSS growth
from ~30MB to 3.7GB+ and eventual OOM kills on resource-constrained VPS hosts.
Fix: Replace all bare 'out <- ...' sends with:
select {
case out <- ...:
case <-ctx.Done():
return
}
This ensures goroutines terminate promptly when the request context is canceled,
allowing GC to reclaim all associated resources.
Affected executors (9 files, 36+ send sites):
- antigravity_executor.go (5 sites)
- gemini_cli_executor.go (6 sites)
- gemini_vertex_executor.go (6 sites)
- aistudio_executor.go (4 sites)
- gemini_executor.go (3 sites)
- openai_compat_executor.go (3 sites)
- claude_executor.go (4 sites)
- codex_executor.go (2 sites)
- kimi_executor.go (3 sites)
648 lines
20 KiB
Go
648 lines
20 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/v6/internal/auth/kimi"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
// 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{cfg: cfg} }
|
|
|
|
// Identifier returns the executor identifier.
|
|
func (e *KimiExecutor) Identifier() string { return "kimi" }
|
|
|
|
// 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
|
|
return e.ClaudeExecutor.Execute(ctx, auth, req, opts)
|
|
}
|
|
|
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
|
|
|
token := kimiCreds(auth)
|
|
|
|
reporter := helps.NewUsageReporter(ctx, e.Identifier(), 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 := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
|
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
|
|
|
// Strip kimi- prefix for upstream API
|
|
upstreamModel := stripKimiPrefix(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 = thinking.ApplyThinking(body, 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.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath)
|
|
body, err = normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
|
|
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)
|
|
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, from, req.Model, opts.OriginalRequest, body, data, ¶m)
|
|
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
|
|
return e.ClaudeExecutor.ExecuteStream(ctx, auth, req, opts)
|
|
}
|
|
|
|
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
|
token := kimiCreds(auth)
|
|
|
|
reporter := helps.NewUsageReporter(ctx, e.Identifier(), 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 := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
|
|
|
|
// Strip kimi- prefix for upstream API
|
|
upstreamModel := stripKimiPrefix(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 = thinking.ApplyThinking(body, 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.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel, requestPath)
|
|
body, err = normalizeKimiToolMessageLinks(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
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)
|
|
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
|
|
var param any
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
|
|
if detail, ok := helps.ParseOpenAIStreamUsage(line); ok {
|
|
reporter.Publish(ctx, detail)
|
|
}
|
|
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m)
|
|
for i := range chunks {
|
|
select {
|
|
case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
doneChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m)
|
|
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)
|
|
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.CountTokens(ctx, auth, req, opts)
|
|
}
|
|
|
|
func normalizeKimiToolMessageLinks(body []byte) ([]byte, error) {
|
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
|
return body, nil
|
|
}
|
|
|
|
messages := gjson.GetBytes(body, "messages")
|
|
if !messages.Exists() || !messages.IsArray() {
|
|
return body, nil
|
|
}
|
|
|
|
out := body
|
|
pending := make([]string, 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
|
|
}
|
|
}
|
|
|
|
msgs := messages.Array()
|
|
for msgIdx := range msgs {
|
|
msg := msgs[msgIdx]
|
|
role := strings.TrimSpace(msg.Get("role").String())
|
|
switch role {
|
|
case "assistant":
|
|
reasoning := msg.Get("reasoning_content")
|
|
if reasoning.Exists() {
|
|
reasoningText := reasoning.String()
|
|
if strings.TrimSpace(reasoningText) != "" {
|
|
latestReasoning = reasoningText
|
|
hasLatestReasoning = true
|
|
}
|
|
}
|
|
|
|
toolCalls := msg.Get("tool_calls")
|
|
if !toolCalls.Exists() || !toolCalls.IsArray() || len(toolCalls.Array()) == 0 {
|
|
continue
|
|
}
|
|
|
|
if !reasoning.Exists() || strings.TrimSpace(reasoning.String()) == "" {
|
|
reasoningText := fallbackAssistantReasoning(msg, hasLatestReasoning, latestReasoning)
|
|
path := fmt.Sprintf("messages.%d.reasoning_content", msgIdx)
|
|
next, err := sjson.SetBytes(out, path, reasoningText)
|
|
if err != nil {
|
|
return body, fmt.Errorf("kimi executor: failed to set assistant reasoning_content: %w", err)
|
|
}
|
|
out = next
|
|
patchedReasoning++
|
|
}
|
|
|
|
for _, tc := range toolCalls.Array() {
|
|
id := strings.TrimSpace(tc.Get("id").String())
|
|
if id == "" {
|
|
continue
|
|
}
|
|
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 != "" {
|
|
path := fmt.Sprintf("messages.%d.tool_call_id", msgIdx)
|
|
next, err := sjson.SetBytes(out, path, toolCallID)
|
|
if err != nil {
|
|
return body, fmt.Errorf("kimi executor: failed to set tool_call_id from call_id: %w", err)
|
|
}
|
|
out = next
|
|
patched++
|
|
}
|
|
}
|
|
if toolCallID == "" {
|
|
if len(pending) == 1 {
|
|
toolCallID = pending[0]
|
|
path := fmt.Sprintf("messages.%d.tool_call_id", msgIdx)
|
|
next, err := sjson.SetBytes(out, path, toolCallID)
|
|
if err != nil {
|
|
return body, fmt.Errorf("kimi executor: failed to infer tool_call_id: %w", err)
|
|
}
|
|
out = next
|
|
patched++
|
|
} else if len(pending) > 1 {
|
|
ambiguous++
|
|
}
|
|
}
|
|
if toolCallID != "" {
|
|
removePending(toolCallID)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 fallbackAssistantReasoning(msg gjson.Result, hasLatest bool, latest string) string {
|
|
if hasLatest && strings.TrimSpace(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 "[reasoning unavailable]"
|
|
}
|
|
|
|
// 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 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 match kimi-cli client for compatibility.
|
|
func applyKimiHeaders(r *http.Request, token string, stream bool) {
|
|
r.Header.Set("Content-Type", "application/json")
|
|
r.Header.Set("Authorization", "Bearer "+token)
|
|
// Match kimi-cli headers exactly
|
|
r.Header.Set("User-Agent", "KimiCLI/1.10.6")
|
|
r.Header.Set("X-Msh-Platform", "kimi_cli")
|
|
r.Header.Set("X-Msh-Version", "1.10.6")
|
|
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
|
|
}
|