diff --git a/Taskfile.yaml b/Taskfile.yaml index 07ba2dd9..9aea116a 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -64,6 +64,7 @@ tasks: - task: gen:cortecs - task: gen:huggingface - task: gen:ionet + - task: gen:mistral - task: gen:nebius - task: gen:neuralwatt - task: gen:opencode-go @@ -109,6 +110,11 @@ tasks: cmds: - go run cmd/ionet/main.go + gen:mistral: + desc: Generate Mistral AI provider configurations + cmds: + - go run cmd/mistral/main.go + gen:nebius: desc: Generate Nebius provider configurations cmds: diff --git a/cmd/mistral/main.go b/cmd/mistral/main.go new file mode 100644 index 00000000..3d7e644b --- /dev/null +++ b/cmd/mistral/main.go @@ -0,0 +1,227 @@ +// Package main provides a command-line tool to fetch models from Mistral AI +// and generate a configuration file for the provider. +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "slices" + "strings" + "time" + + "charm.land/catwalk/pkg/catwalk" +) + +type ModelsResponse struct { + Data []MistralModel `json:"data"` +} + +type MistralModel struct { + ID string `json:"id"` + Name string `json:"name"` + MaxContextLength int64 `json:"max_context_length"` + Capabilities MistralCapability `json:"capabilities"` + Aliases []string `json:"aliases"` + Deprecation *string `json:"deprecation"` +} + +type MistralCapability struct { + CompletionChat bool `json:"completion_chat"` + CompletionFIM bool `json:"completion_fim"` + FunctionCalling bool `json:"function_calling"` + Vision bool `json:"vision"` +} + +type pricing struct { + in float64 + out float64 +} + +// Pricing per 1M tokens (USD), from https://mistral.ai/pricing +// Keyed by the canonical model ID we want to emit. +var modelPricing = map[string]pricing{ + "mistral-medium-latest": {1.50, 7.50}, + "mistral-small-latest": {0.10, 0.30}, + "mistral-large-latest": {0.50, 1.50}, + "ministral-3b-latest": {0.10, 0.10}, + "ministral-8b-latest": {0.15, 0.15}, + "ministral-14b-latest": {0.20, 0.20}, + "devstral-medium-latest": {0.40, 2.00}, + "codestral-latest": {0.30, 0.90}, + "mistral-tiny-latest": {0.15, 0.15}, + "magistral-medium-latest": {2.00, 5.00}, + "magistral-small-latest": {0.50, 1.50}, +} + +// The set of model IDs we want to include in the generated config. +// These are the canonical, user-facing identifiers. +var wantedModels = map[string]bool{ + "mistral-large-latest": true, + "mistral-medium-latest": true, + "mistral-small-latest": true, + "mistral-tiny-latest": true, + "devstral-medium-latest": true, + "codestral-latest": true, + "magistral-medium-latest": true, + "magistral-small-latest": true, + "ministral-3b-latest": true, + "ministral-8b-latest": true, + "ministral-14b-latest": true, +} + +var prettyNames = map[string]string{ + "mistral-large-latest": "Mistral Large 3", + "mistral-medium-latest": "Mistral Medium 3.5", + "mistral-small-latest": "Mistral Small 4", + "mistral-tiny-latest": "Mistral NeMo", + "devstral-medium-latest": "Devstral 2", + "codestral-latest": "Codestral", + "magistral-medium-latest": "Magistral Medium", + "magistral-small-latest": "Magistral Small", + "ministral-3b-latest": "Ministral 3B", + "ministral-8b-latest": "Ministral 8B", + "ministral-14b-latest": "Ministral 14B", +} + +func prettyName(id string) string { + if name, ok := prettyNames[id]; ok { + return name + } + return id +} + +func fetchMistralModels() (*ModelsResponse, error) { + apiKey := os.Getenv("MISTRAL_API_KEY") + if apiKey == "" { + return nil, fmt.Errorf("MISTRAL_API_KEY environment variable is not set") + } + + client := &http.Client{Timeout: 30 * time.Second} + req, _ := http.NewRequestWithContext( + context.Background(), + "GET", + "https://api.mistral.ai/v1/models", + nil, + ) + req.Header.Set("User-Agent", "Crush-Client/1.0") + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(req) + if err != nil { + return nil, err //nolint:wrapcheck + } + defer resp.Body.Close() //nolint:errcheck + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("unable to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status %d: %s", resp.StatusCode, body) + } + + _ = os.MkdirAll("tmp", 0o700) + _ = os.WriteFile("tmp/mistral-response.json", body, 0o600) + + var mr ModelsResponse + if err := json.Unmarshal(body, &mr); err != nil { + return nil, err //nolint:wrapcheck + } + return &mr, nil +} + +func main() { + modelsResp, err := fetchMistralModels() + if err != nil { + log.Fatal("Error fetching Mistral models:", err) + } + + provider := catwalk.Provider{ + Name: "Mistral AI", + ID: catwalk.InferenceProviderMistral, + APIKey: "$MISTRAL_API_KEY", + APIEndpoint: "https://api.mistral.ai/v1", + Type: catwalk.TypeOpenAICompat, + DefaultLargeModelID: "mistral-medium-latest", + DefaultSmallModelID: "mistral-small-latest", + } + + // Index models by ID for quick lookup + modelByID := make(map[string]MistralModel) + for _, m := range modelsResp.Data { + modelByID[m.ID] = m + } + + // Only emit the canonical IDs we want, deduplicated + seen := make(map[string]bool) + for _, model := range modelsResp.Data { + if !wantedModels[model.ID] { + continue + } + if seen[model.ID] { + continue + } + seen[model.ID] = true + + if model.Deprecation != nil { + continue + } + if !model.Capabilities.CompletionChat { + continue + } + + p := modelPricing[model.ID] + ctxWindow := model.MaxContextLength + defaultMaxTokens := ctxWindow / 10 + + canReason := strings.Contains(model.ID, "magistral") + + var ( + reasoningLevels []string + defaultReasoning string + ) + if canReason { + reasoningLevels = []string{"low", "medium", "high"} + defaultReasoning = "medium" + } + + m := catwalk.Model{ + ID: model.ID, + Name: prettyName(model.ID), + CostPer1MIn: p.in, + CostPer1MOut: p.out, + CostPer1MInCached: 0, + CostPer1MOutCached: 0, + ContextWindow: ctxWindow, + DefaultMaxTokens: defaultMaxTokens, + CanReason: canReason, + ReasoningLevels: reasoningLevels, + DefaultReasoningEffort: defaultReasoning, + SupportsImages: model.Capabilities.Vision, + } + + provider.Models = append(provider.Models, m) + } + + slices.SortFunc(provider.Models, func(a, b catwalk.Model) int { + return strings.Compare(a.ID, b.ID) + }) + + data, err := json.MarshalIndent(provider, "", " ") + if err != nil { + log.Fatal("Error marshaling Mistral provider:", err) + } + data = append(data, '\n') + + if err := os.WriteFile("internal/providers/configs/mistral.json", data, 0o600); err != nil { + log.Fatal("Error writing Mistral provider config:", err) + } + + fmt.Printf("Generated mistral.json with %d models\n", len(provider.Models)) +} diff --git a/internal/providers/configs/mistral.json b/internal/providers/configs/mistral.json new file mode 100644 index 00000000..f54839cf --- /dev/null +++ b/internal/providers/configs/mistral.json @@ -0,0 +1,155 @@ +{ + "name": "Mistral AI", + "id": "mistral", + "api_key": "$MISTRAL_API_KEY", + "api_endpoint": "https://api.mistral.ai/v1", + "type": "openai-compat", + "default_large_model_id": "mistral-medium-latest", + "default_small_model_id": "mistral-small-latest", + "models": [ + { + "id": "codestral-latest", + "name": "Codestral", + "cost_per_1m_in": 0.3, + "cost_per_1m_out": 0.9, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 256000, + "default_max_tokens": 25600, + "can_reason": false, + "supports_attachments": false + }, + { + "id": "devstral-medium-latest", + "name": "Devstral 2", + "cost_per_1m_in": 0.4, + "cost_per_1m_out": 2, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 262144, + "default_max_tokens": 26214, + "can_reason": false, + "supports_attachments": false + }, + { + "id": "magistral-medium-latest", + "name": "Magistral Medium", + "cost_per_1m_in": 2, + "cost_per_1m_out": 5, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 131072, + "default_max_tokens": 13107, + "can_reason": true, + "reasoning_levels": [ + "low", + "medium", + "high" + ], + "default_reasoning_effort": "medium", + "supports_attachments": true + }, + { + "id": "magistral-small-latest", + "name": "Magistral Small", + "cost_per_1m_in": 0.5, + "cost_per_1m_out": 1.5, + "cost_per_1m_in_cached": 0, + "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 + }, + { + "id": "ministral-14b-latest", + "name": "Ministral 14B", + "cost_per_1m_in": 0.2, + "cost_per_1m_out": 0.2, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 262144, + "default_max_tokens": 26214, + "can_reason": false, + "supports_attachments": true + }, + { + "id": "ministral-3b-latest", + "name": "Ministral 3B", + "cost_per_1m_in": 0.1, + "cost_per_1m_out": 0.1, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 131072, + "default_max_tokens": 13107, + "can_reason": false, + "supports_attachments": true + }, + { + "id": "ministral-8b-latest", + "name": "Ministral 8B", + "cost_per_1m_in": 0.15, + "cost_per_1m_out": 0.15, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 262144, + "default_max_tokens": 26214, + "can_reason": false, + "supports_attachments": true + }, + { + "id": "mistral-large-latest", + "name": "Mistral Large 3", + "cost_per_1m_in": 0.5, + "cost_per_1m_out": 1.5, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 262144, + "default_max_tokens": 26214, + "can_reason": false, + "supports_attachments": true + }, + { + "id": "mistral-medium-latest", + "name": "Mistral Medium 3.5", + "cost_per_1m_in": 1.5, + "cost_per_1m_out": 7.5, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 131072, + "default_max_tokens": 13107, + "can_reason": false, + "supports_attachments": true + }, + { + "id": "mistral-small-latest", + "name": "Mistral Small 4", + "cost_per_1m_in": 0.1, + "cost_per_1m_out": 0.3, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 262144, + "default_max_tokens": 26214, + "can_reason": false, + "supports_attachments": true + }, + { + "id": "mistral-tiny-latest", + "name": "Mistral NeMo", + "cost_per_1m_in": 0.15, + "cost_per_1m_out": 0.15, + "cost_per_1m_in_cached": 0, + "cost_per_1m_out_cached": 0, + "context_window": 131072, + "default_max_tokens": 13107, + "can_reason": false, + "supports_attachments": false + } + ] +} diff --git a/internal/providers/providers.go b/internal/providers/providers.go index c2cd88df..292e8a31 100644 --- a/internal/providers/providers.go +++ b/internal/providers/providers.go @@ -63,6 +63,9 @@ var kimiCodingConfig []byte //go:embed configs/minimax.json var miniMaxConfig []byte +//go:embed configs/mistral.json +var mistralConfig []byte + //go:embed configs/minimax-china.json var miniMaxChinaConfig []byte @@ -144,6 +147,7 @@ var providerRegistry = []ProviderFunc{ groqProvider, huggingFaceProvider, ioNetProvider, + mistralProvider, nebiusProvider, neuralwattProvider, openCodeGoProvider, @@ -252,6 +256,10 @@ func miniMaxChinaProvider() catwalk.Provider { return loadProviderFromConfig(miniMaxChinaConfig) } +func mistralProvider() catwalk.Provider { + return loadProviderFromConfig(mistralConfig) +} + func nebiusProvider() catwalk.Provider { return loadProviderFromConfig(nebiusConfig) } diff --git a/pkg/catwalk/provider.go b/pkg/catwalk/provider.go index 55835825..3ceb9aaf 100644 --- a/pkg/catwalk/provider.go +++ b/pkg/catwalk/provider.go @@ -55,6 +55,7 @@ const ( InferenceProviderOpenCodeZen InferenceProvider = "opencode-zen" InferenceProviderOpenCodeGo InferenceProvider = "opencode-go" InferenceProviderAlibabaSingapore InferenceProvider = "alibaba-singapore" + InferenceProviderMistral InferenceProvider = "mistral" ) // Provider represents an AI provider configuration. @@ -131,6 +132,7 @@ func KnownProviders() []InferenceProvider { InferenceProviderNeuralwatt, InferenceProviderOpenCodeZen, InferenceProviderOpenCodeGo, + InferenceProviderMistral, } }