shinnmen / internal /runtime /executor /kimi_provider.go
shinmentakezo07
refactor(executor): split providers and preserve legacy entrypoints
3e5ab39
Raw
History Blame Contribute Delete
6.19 kB
package executor
import (
"fmt"
"net/http"
"strings"
kimiauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// KimiProvider implements ProviderConfig for Kimi API
type KimiProvider struct{}
func (p *KimiProvider) GetIdentifier() string {
return "kimi"
}
func (p *KimiProvider) GetCredentials(auth *cliproxyauth.Auth) (apiKey, baseURL string) {
if auth == nil {
return "", kimiauth.KimiAPIBaseURL
}
// Check metadata first (OAuth flow stores tokens here)
if auth.Metadata != nil {
if v, ok := auth.Metadata["access_token"].(string); ok && strings.TrimSpace(v) != "" {
return v, kimiauth.KimiAPIBaseURL
}
}
// Fallback to attributes (API key style)
if auth.Attributes != nil {
if v := auth.Attributes["access_token"]; v != "" {
return v, kimiauth.KimiAPIBaseURL
}
if v := auth.Attributes["api_key"]; v != "" {
return v, kimiauth.KimiAPIBaseURL
}
}
return "", kimiauth.KimiAPIBaseURL
}
func (p *KimiProvider) GetEndpoint(baseURL, model, action string, stream bool) string {
return baseURL + "/v1/chat/completions"
}
func (p *KimiProvider) ApplyHeaders(req *http.Request, auth *cliproxyauth.Auth, apiKey string, stream bool) {
applyKimiHeadersWithAuth(req, apiKey, stream, auth)
}
func (p *KimiProvider) GetTranslatorFormat() string {
return "openai"
}
func (p *KimiProvider) TransformRequestBody(body []byte, model string, stream bool) ([]byte, error) {
// Strip kimi- prefix for upstream API
upstreamModel := stripKimiPrefix(model)
body, err := sjson.SetBytes(body, "model", upstreamModel)
if err != nil {
return body, fmt.Errorf("kimi executor: failed to set model in payload: %w", err)
}
// Normalize tool message links
body, err = normalizeKimiToolMessageLinks(body)
if err != nil {
return body, err
}
// Set stream_options for streaming requests
if stream {
body, err = sjson.SetBytes(body, "stream_options.include_usage", true)
if err != nil {
return body, fmt.Errorf("kimi executor: failed to set stream_options in payload: %w", err)
}
}
return body, nil
}
func (p *KimiProvider) TransformResponseBody(body []byte) []byte {
// No transformation needed for Kimi responses
return body
}
func (p *KimiProvider) ParseUsage(data []byte, stream bool) usageDetail {
if stream {
if detail, ok := parseOpenAIStreamUsage(data); ok {
return detail
}
return usageDetail{}
}
return parseOpenAIUsage(data)
}
// 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
}
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)
}
}
}
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]"
}