Spaces:
Paused
Paused
| 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) | |
| body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) | |
| 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) | |
| body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel) | |
| 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 { | |
| out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]} | |
| } | |
| } | |
| doneChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m) | |
| for i := range doneChunks { | |
| out <- cliproxyexecutor.StreamChunk{Payload: doneChunks[i]} | |
| } | |
| if errScan := scanner.Err(); errScan != nil { | |
| helps.RecordAPIResponseError(ctx, e.cfg, errScan) | |
| reporter.PublishFailure(ctx) | |
| out <- cliproxyexecutor.StreamChunk{Err: errScan} | |
| } | |
| }() | |
| 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 | |
| } | |