diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml index 8e831d91..4fe24e32 100644 --- a/.github/workflows/update.yml +++ b/.github/workflows/update.yml @@ -40,6 +40,16 @@ jobs: - run: go run ./cmd/ionet/main.go continue-on-error: true + - run: go run ./cmd/moonshot/main.go + continue-on-error: true + env: + MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} + + - run: go run ./cmd/moonshot-cn/main.go + continue-on-error: true + env: + MOONSHOT_CN_API_KEY: ${{ secrets.MOONSHOT_CN_API_KEY }} + - run: go run ./cmd/opencode-go/main.go continue-on-error: true diff --git a/cmd/moonshot-cn/main.go b/cmd/moonshot-cn/main.go new file mode 100644 index 00000000..da1bbeb5 --- /dev/null +++ b/cmd/moonshot-cn/main.go @@ -0,0 +1,193 @@ +// Package main fetches models from the Moonshot China (api.moonshot.cn) API and writes moonshot-cn.json. +package main + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "slices" + "strings" + "time" + + "charm.land/catwalk/pkg/catwalk" +) + +const baseURL = "https://api.moonshot.cn/v1" + +// openAIListModelsResponse is the response shape for GET /v1/models. +type openAIListModelsResponse struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` +} + +func fetchModelIDs(ctx context.Context, apiKey string) ([]string, error) { + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", "Charm-Catwalk/1.0") + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() //nolint:errcheck + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + _ = os.MkdirAll("tmp", 0o700) + _ = os.WriteFile("tmp/moonshot-cn-models-response.json", body, 0o600) + + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("status %d: %s", resp.StatusCode, body) + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf( + "%w: use a key from platform.moonshot.cn for this command; for platform.moonshot.ai use ./cmd/moonshot", + err, + ) + } + return nil, err + } + + var parsed openAIListModelsResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("unmarshal models: %w", err) + } + + ids := make([]string, 0, len(parsed.Data)) + seen := make(map[string]struct{}) + for _, m := range parsed.Data { + id := strings.TrimSpace(m.ID) + if id == "" { + continue + } + if !strings.HasPrefix(strings.ToLower(id), "kimi-") { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + slices.Sort(ids) + return ids, nil +} + +func staticMetadata(id string) (catwalk.Model, bool) { + known := map[string]catwalk.Model{ + "kimi-k2.6": { + ID: "kimi-k2.6", + Name: "Kimi K2.6", + CostPer1MIn: 0.8, + CostPer1MOut: 4, + CostPer1MInCached: 0.2, + CostPer1MOutCached: 0, + ContextWindow: 262144, + DefaultMaxTokens: 26214, + CanReason: true, + ReasoningLevels: []string{"low", "medium", "high"}, + DefaultReasoningEffort: "medium", + SupportsImages: true, + }, + "kimi-k2.5": { + ID: "kimi-k2.5", + Name: "Kimi K2.5", + CostPer1MIn: 0.445, + CostPer1MOut: 2, + CostPer1MInCached: 0.225, + CostPer1MOutCached: 1.1, + ContextWindow: 262144, + DefaultMaxTokens: 26214, + CanReason: true, + ReasoningLevels: []string{"low", "medium", "high"}, + DefaultReasoningEffort: "medium", + SupportsImages: true, + }, + } + m, ok := known[id] + return m, ok +} + +func buildModel(id string) catwalk.Model { + if m, ok := staticMetadata(id); ok { + return m + } + return catwalk.Model{ + ID: id, + Name: id, + CostPer1MIn: 0, + CostPer1MOut: 0, + CostPer1MInCached: 0, + CostPer1MOutCached: 0, + ContextWindow: 262144, + DefaultMaxTokens: 26214, + CanReason: true, + ReasoningLevels: []string{"low", "medium", "high"}, + DefaultReasoningEffort: "medium", + SupportsImages: true, + } +} + +func main() { + apiKey := strings.TrimSpace(cmp.Or(os.Getenv("MOONSHOT_CN_API_KEY"), os.Getenv("KIMI_CN_API_KEY"))) + if apiKey == "" { + log.Fatal("Set MOONSHOT_CN_API_KEY (or KIMI_CN_API_KEY) for platform.moonshot.cn") + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + ids, err := fetchModelIDs(ctx, apiKey) + if err != nil { + log.Fatalf("Error fetching Moonshot China models: %v", err) + } + if len(ids) == 0 { + log.Fatal("No kimi-* models returned from the Moonshot China /v1/models API") + } + + provider := catwalk.Provider{ + Name: "Kimi (Moonshot, China)", + ID: catwalk.InferenceProviderMoonshotChina, + APIKey: "$MOONSHOT_CN_API_KEY", + APIEndpoint: baseURL, + Type: catwalk.TypeOpenAICompat, + } + + for _, id := range ids { + m := buildModel(id) + provider.Models = append(provider.Models, m) + fmt.Printf("Added model %s\n", id) + } + + provider.DefaultLargeModelID = "kimi-k2.6" + provider.DefaultSmallModelID = "kimi-k2.5" + if !slices.ContainsFunc(provider.Models, func(m catwalk.Model) bool { return m.ID == provider.DefaultLargeModelID }) { + provider.DefaultLargeModelID = provider.Models[len(provider.Models)-1].ID + } + if !slices.ContainsFunc(provider.Models, func(m catwalk.Model) bool { return m.ID == provider.DefaultSmallModelID }) { + provider.DefaultSmallModelID = provider.Models[0].ID + } + + data, err := json.MarshalIndent(provider, "", " ") + if err != nil { + log.Fatalf("Error marshaling Moonshot China provider: %v", err) + } + data = append(data, '\n') + + if err := os.WriteFile("internal/providers/configs/moonshot-cn.json", data, 0o600); err != nil { + log.Fatalf("Error writing moonshot-cn config: %v", err) + } + fmt.Printf("Generated moonshot-cn.json with %d models\n", len(provider.Models)) +} diff --git a/cmd/moonshot/main.go b/cmd/moonshot/main.go new file mode 100644 index 00000000..d6f55d43 --- /dev/null +++ b/cmd/moonshot/main.go @@ -0,0 +1,194 @@ +// Package main provides a command-line tool to fetch models from the Moonshot +// international (api.moonshot.ai) OpenAI-compatible API and generate moonshot.json. +package main + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "slices" + "strings" + "time" + + "charm.land/catwalk/pkg/catwalk" +) + +const baseURL = "https://api.moonshot.ai/v1" + +// openAIListModelsResponse is the response shape for GET /v1/models. +type openAIListModelsResponse struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` +} + +func fetchModelIDs(ctx context.Context, apiKey string) ([]string, error) { + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", "Charm-Catwalk/1.0") + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() //nolint:errcheck + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + _ = os.MkdirAll("tmp", 0o700) + _ = os.WriteFile("tmp/moonshot-models-response.json", body, 0o600) + + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("status %d: %s", resp.StatusCode, body) + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf( + "%w: use a key from platform.moonshot.ai for this command; for platform.moonshot.cn use ./cmd/moonshot-cn", + err, + ) + } + return nil, err + } + + var parsed openAIListModelsResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("unmarshal models: %w", err) + } + + ids := make([]string, 0, len(parsed.Data)) + seen := make(map[string]struct{}) + for _, m := range parsed.Data { + id := strings.TrimSpace(m.ID) + if id == "" { + continue + } + if !strings.HasPrefix(strings.ToLower(id), "kimi-") { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + slices.Sort(ids) + return ids, nil +} + +func staticMetadata(id string) (catwalk.Model, bool) { + known := map[string]catwalk.Model{ + "kimi-k2.6": { + ID: "kimi-k2.6", + Name: "Kimi K2.6", + CostPer1MIn: 0.8, + CostPer1MOut: 4, + CostPer1MInCached: 0.2, + CostPer1MOutCached: 0, + ContextWindow: 262144, + DefaultMaxTokens: 26214, + CanReason: true, + ReasoningLevels: []string{"low", "medium", "high"}, + DefaultReasoningEffort: "medium", + SupportsImages: true, + }, + "kimi-k2.5": { + ID: "kimi-k2.5", + Name: "Kimi K2.5", + CostPer1MIn: 0.445, + CostPer1MOut: 2, + CostPer1MInCached: 0.225, + CostPer1MOutCached: 1.1, + ContextWindow: 262144, + DefaultMaxTokens: 26214, + CanReason: true, + ReasoningLevels: []string{"low", "medium", "high"}, + DefaultReasoningEffort: "medium", + SupportsImages: true, + }, + } + m, ok := known[id] + return m, ok +} + +func buildModel(id string) catwalk.Model { + if m, ok := staticMetadata(id); ok { + return m + } + return catwalk.Model{ + ID: id, + Name: id, + CostPer1MIn: 0, + CostPer1MOut: 0, + CostPer1MInCached: 0, + CostPer1MOutCached: 0, + ContextWindow: 262144, + DefaultMaxTokens: 26214, + CanReason: true, + ReasoningLevels: []string{"low", "medium", "high"}, + DefaultReasoningEffort: "medium", + SupportsImages: true, + } +} + +func main() { + apiKey := strings.TrimSpace(cmp.Or(os.Getenv("MOONSHOT_API_KEY"), os.Getenv("KIMI_API_KEY"))) + if apiKey == "" { + log.Fatal("Set MOONSHOT_API_KEY (or KIMI_API_KEY) for platform.moonshot.ai") + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + ids, err := fetchModelIDs(ctx, apiKey) + if err != nil { + log.Fatalf("Error fetching Moonshot models: %v", err) + } + if len(ids) == 0 { + log.Fatal("No kimi-* models returned from the Moonshot /v1/models API") + } + + provider := catwalk.Provider{ + Name: "Kimi (Moonshot)", + ID: catwalk.InferenceProviderMoonshot, + APIKey: "$MOONSHOT_API_KEY", + APIEndpoint: baseURL, + Type: catwalk.TypeOpenAICompat, + } + + for _, id := range ids { + m := buildModel(id) + provider.Models = append(provider.Models, m) + fmt.Printf("Added model %s\n", id) + } + + provider.DefaultLargeModelID = "kimi-k2.6" + provider.DefaultSmallModelID = "kimi-k2.5" + if !slices.ContainsFunc(provider.Models, func(m catwalk.Model) bool { return m.ID == provider.DefaultLargeModelID }) { + provider.DefaultLargeModelID = provider.Models[len(provider.Models)-1].ID + } + if !slices.ContainsFunc(provider.Models, func(m catwalk.Model) bool { return m.ID == provider.DefaultSmallModelID }) { + provider.DefaultSmallModelID = provider.Models[0].ID + } + + data, err := json.MarshalIndent(provider, "", " ") + if err != nil { + log.Fatalf("Error marshaling Moonshot provider: %v", err) + } + data = append(data, '\n') + + if err := os.WriteFile("internal/providers/configs/moonshot.json", data, 0o600); err != nil { + log.Fatalf("Error writing moonshot config: %v", err) + } + fmt.Printf("Generated moonshot.json with %d models\n", len(provider.Models)) +} diff --git a/internal/providers/configs/moonshot-cn.json b/internal/providers/configs/moonshot-cn.json new file mode 100644 index 00000000..13685d1c --- /dev/null +++ b/internal/providers/configs/moonshot-cn.json @@ -0,0 +1,39 @@ +{ + "name": "Kimi (Moonshot, China)", + "id": "moonshot-cn", + "type": "openai-compat", + "api_key": "$MOONSHOT_CN_API_KEY", + "api_endpoint": "https://api.moonshot.cn/v1", + "default_large_model_id": "kimi-k2.6", + "default_small_model_id": "kimi-k2.5", + "models": [ + { + "id": "kimi-k2.5", + "name": "Kimi K2.5", + "cost_per_1m_in": 0.445, + "cost_per_1m_out": 2, + "cost_per_1m_in_cached": 0.225, + "cost_per_1m_out_cached": 1.1, + "context_window": 262144, + "default_max_tokens": 26214, + "can_reason": true, + "reasoning_levels": ["low", "medium", "high"], + "default_reasoning_effort": "medium", + "supports_attachments": true + }, + { + "id": "kimi-k2.6", + "name": "Kimi K2.6", + "cost_per_1m_in": 0.8, + "cost_per_1m_out": 4, + "cost_per_1m_in_cached": 0.2, + "cost_per_1m_out_cached": 0, + "context_window": 262144, + "default_max_tokens": 26214, + "can_reason": true, + "reasoning_levels": ["low", "medium", "high"], + "default_reasoning_effort": "medium", + "supports_attachments": true + } + ] +} diff --git a/internal/providers/configs/moonshot.json b/internal/providers/configs/moonshot.json new file mode 100644 index 00000000..cd7a3329 --- /dev/null +++ b/internal/providers/configs/moonshot.json @@ -0,0 +1,39 @@ +{ + "name": "Kimi (Moonshot)", + "id": "moonshot", + "type": "openai-compat", + "api_key": "$MOONSHOT_API_KEY", + "api_endpoint": "https://api.moonshot.ai/v1", + "default_large_model_id": "kimi-k2.6", + "default_small_model_id": "kimi-k2.5", + "models": [ + { + "id": "kimi-k2.5", + "name": "Kimi K2.5", + "cost_per_1m_in": 0.445, + "cost_per_1m_out": 2, + "cost_per_1m_in_cached": 0.225, + "cost_per_1m_out_cached": 1.1, + "context_window": 262144, + "default_max_tokens": 26214, + "can_reason": true, + "reasoning_levels": ["low", "medium", "high"], + "default_reasoning_effort": "medium", + "supports_attachments": true + }, + { + "id": "kimi-k2.6", + "name": "Kimi K2.6", + "cost_per_1m_in": 0.8, + "cost_per_1m_out": 4, + "cost_per_1m_in_cached": 0.2, + "cost_per_1m_out_cached": 0, + "context_window": 262144, + "default_max_tokens": 26214, + "can_reason": true, + "reasoning_levels": ["low", "medium", "high"], + "default_reasoning_effort": "medium", + "supports_attachments": true + } + ] +} diff --git a/internal/providers/providers.go b/internal/providers/providers.go index 4ba9d43f..d38413a6 100644 --- a/internal/providers/providers.go +++ b/internal/providers/providers.go @@ -60,6 +60,12 @@ var miniMaxConfig []byte //go:embed configs/minimax-china.json var miniMaxChinaConfig []byte +//go:embed configs/moonshot.json +var moonshotConfig []byte + +//go:embed configs/moonshot-cn.json +var moonshotCNConfig []byte + //go:embed configs/nebius.json var nebiusConfig []byte @@ -118,6 +124,8 @@ var providerRegistry = []ProviderFunc{ kimiCodingProvider, miniMaxProvider, miniMaxChinaProvider, + moonshotProvider, + moonshotCNProvider, syntheticProvider, // The remaining will be in alphabetical order. @@ -232,6 +240,14 @@ func miniMaxChinaProvider() catwalk.Provider { return loadProviderFromConfig(miniMaxChinaConfig) } +func moonshotProvider() catwalk.Provider { + return loadProviderFromConfig(moonshotConfig) +} + +func moonshotCNProvider() catwalk.Provider { + return loadProviderFromConfig(moonshotCNConfig) +} + func nebiusProvider() catwalk.Provider { return loadProviderFromConfig(nebiusConfig) } diff --git a/pkg/catwalk/provider.go b/pkg/catwalk/provider.go index f107df35..8dc55aa9 100644 --- a/pkg/catwalk/provider.go +++ b/pkg/catwalk/provider.go @@ -21,37 +21,39 @@ type InferenceProvider string // All the inference providers supported by the system. const ( - InferenceProviderOpenAI InferenceProvider = "openai" - InferenceProviderAnthropic InferenceProvider = "anthropic" - InferenceProviderSynthetic InferenceProvider = "synthetic" - InferenceProviderGemini InferenceProvider = "gemini" - InferenceProviderAzure InferenceProvider = "azure" - InferenceProviderBedrock InferenceProvider = "bedrock" - InferenceProviderVertexAI InferenceProvider = "vertexai" - InferenceProviderXAI InferenceProvider = "xai" - InferenceProviderZAI InferenceProvider = "zai" - InferenceProviderZhipu InferenceProvider = "zhipu" - InferenceProviderZhipuCoding InferenceProvider = "zhipu-coding" - InferenceProviderGROQ InferenceProvider = "groq" - InferenceProviderOpenRouter InferenceProvider = "openrouter" - InferenceProviderCerebras InferenceProvider = "cerebras" - InferenceProviderVenice InferenceProvider = "venice" - InferenceProviderChutes InferenceProvider = "chutes" - InferenceProviderHuggingFace InferenceProvider = "huggingface" - InferenceAIHubMix InferenceProvider = "aihubmix" - InferenceKimiCoding InferenceProvider = "kimi-coding" - InferenceProviderCopilot InferenceProvider = "copilot" - InferenceProviderCortecs InferenceProvider = "cortecs" - InferenceProviderVercel InferenceProvider = "vercel" - InferenceProviderMiniMax InferenceProvider = "minimax" - InferenceProviderMiniMaxChina InferenceProvider = "minimax-china" - InferenceProviderIoNet InferenceProvider = "ionet" - InferenceProviderQiniuCloud InferenceProvider = "qiniucloud" - InferenceProviderAvian InferenceProvider = "avian" - InferenceProviderNebius InferenceProvider = "nebius" - InferenceProviderNeuralwatt InferenceProvider = "neuralwatt" - InferenceProviderOpenCodeZen InferenceProvider = "opencode-zen" - InferenceProviderOpenCodeGo InferenceProvider = "opencode-go" + InferenceProviderOpenAI InferenceProvider = "openai" + InferenceProviderAnthropic InferenceProvider = "anthropic" + InferenceProviderSynthetic InferenceProvider = "synthetic" + InferenceProviderGemini InferenceProvider = "gemini" + InferenceProviderAzure InferenceProvider = "azure" + InferenceProviderBedrock InferenceProvider = "bedrock" + InferenceProviderVertexAI InferenceProvider = "vertexai" + InferenceProviderXAI InferenceProvider = "xai" + InferenceProviderZAI InferenceProvider = "zai" + InferenceProviderZhipu InferenceProvider = "zhipu" + InferenceProviderZhipuCoding InferenceProvider = "zhipu-coding" + InferenceProviderGROQ InferenceProvider = "groq" + InferenceProviderOpenRouter InferenceProvider = "openrouter" + InferenceProviderCerebras InferenceProvider = "cerebras" + InferenceProviderVenice InferenceProvider = "venice" + InferenceProviderChutes InferenceProvider = "chutes" + InferenceProviderHuggingFace InferenceProvider = "huggingface" + InferenceAIHubMix InferenceProvider = "aihubmix" + InferenceKimiCoding InferenceProvider = "kimi-coding" + InferenceProviderCopilot InferenceProvider = "copilot" + InferenceProviderCortecs InferenceProvider = "cortecs" + InferenceProviderVercel InferenceProvider = "vercel" + InferenceProviderMiniMax InferenceProvider = "minimax" + InferenceProviderMiniMaxChina InferenceProvider = "minimax-china" + InferenceProviderMoonshot InferenceProvider = "moonshot" + InferenceProviderMoonshotChina InferenceProvider = "moonshot-cn" + InferenceProviderIoNet InferenceProvider = "ionet" + InferenceProviderQiniuCloud InferenceProvider = "qiniucloud" + InferenceProviderAvian InferenceProvider = "avian" + InferenceProviderNebius InferenceProvider = "nebius" + InferenceProviderNeuralwatt InferenceProvider = "neuralwatt" + InferenceProviderOpenCodeZen InferenceProvider = "opencode-zen" + InferenceProviderOpenCodeGo InferenceProvider = "opencode-go" ) // Provider represents an AI provider configuration. @@ -121,6 +123,8 @@ func KnownProviders() []InferenceProvider { InferenceProviderVercel, InferenceProviderMiniMax, InferenceProviderMiniMaxChina, + InferenceProviderMoonshot, + InferenceProviderMoonshotChina, InferenceProviderQiniuCloud, InferenceProviderAvian, InferenceProviderNebius,