main.go

  1// Package main provides a command-line tool to fetch models from Synthetic
  2// and generate a configuration file for the provider.
  3package main
  4
  5import (
  6	"context"
  7	"encoding/json"
  8	"fmt"
  9	"io"
 10	"log"
 11	"net/http"
 12	"os"
 13	"slices"
 14	"strconv"
 15	"strings"
 16	"time"
 17
 18	"charm.land/catwalk/pkg/catwalk"
 19)
 20
 21// Model represents a model from the Synthetic API.
 22type Model struct {
 23	ID                string   `json:"id"`
 24	Name              string   `json:"name"`
 25	InputModalities   []string `json:"input_modalities"`
 26	OutputModalities  []string `json:"output_modalities"`
 27	ContextLength     int64    `json:"context_length"`
 28	MaxOutputLength   int64    `json:"max_output_length,omitempty"`
 29	Pricing           Pricing  `json:"pricing"`
 30	SupportedFeatures []string `json:"supported_features,omitempty"`
 31}
 32
 33// Pricing contains the pricing information for different operations.
 34type Pricing struct {
 35	Prompt           string `json:"prompt"`
 36	Completion       string `json:"completion"`
 37	Image            string `json:"image"`
 38	Request          string `json:"request"`
 39	InputCacheReads  string `json:"input_cache_reads"`
 40	InputCacheWrites string `json:"input_cache_writes"`
 41}
 42
 43// ModelsResponse is the response structure for the Synthetic models API.
 44type ModelsResponse struct {
 45	Data []Model `json:"data"`
 46}
 47
 48// ModelPricing is the pricing structure for a model, detailing costs per
 49// million tokens for input and output, both cached and uncached.
 50type ModelPricing struct {
 51	CostPer1MIn        float64 `json:"cost_per_1m_in"`
 52	CostPer1MOut       float64 `json:"cost_per_1m_out"`
 53	CostPer1MInCached  float64 `json:"cost_per_1m_in_cached"`
 54	CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
 55}
 56
 57// parsePrice extracts a float from Synthetic's price format (e.g. "$0.00000055").
 58func parsePrice(s string) float64 {
 59	s = strings.TrimPrefix(s, "$")
 60	v, err := strconv.ParseFloat(s, 64)
 61	if err != nil {
 62		return 0.0
 63	}
 64	return v
 65}
 66
 67func getPricing(model Model) ModelPricing {
 68	return ModelPricing{
 69		CostPer1MIn:        parsePrice(model.Pricing.Prompt) * 1_000_000,
 70		CostPer1MOut:       parsePrice(model.Pricing.Completion) * 1_000_000,
 71		CostPer1MInCached:  parsePrice(model.Pricing.InputCacheReads) * 1_000_000,
 72		CostPer1MOutCached: parsePrice(model.Pricing.InputCacheReads) * 1_000_000,
 73	}
 74}
 75
 76// applyModelOverrides sets supported_features for models where Synthetic
 77// omits this metadata.
 78// TODO: Remove this when they add the missing metadata.
 79func applyModelOverrides(model *Model) {
 80	switch {
 81	// All of llama support tools, none do reasoning yet
 82	case strings.HasPrefix(model.ID, "hf:meta-llama/Llama-"):
 83		model.SupportedFeatures = []string{"tools"}
 84
 85	case strings.HasPrefix(model.ID, "hf:deepseek-ai/DeepSeek-R1"):
 86		model.SupportedFeatures = []string{"tools", "reasoning"}
 87
 88	case strings.HasPrefix(model.ID, "hf:deepseek-ai/DeepSeek-V3.1"):
 89		model.SupportedFeatures = []string{"tools", "reasoning"}
 90
 91	case strings.HasPrefix(model.ID, "hf:deepseek-ai/DeepSeek-V3.2"):
 92		model.SupportedFeatures = []string{"tools", "reasoning"}
 93
 94	case strings.HasPrefix(model.ID, "hf:deepseek-ai/DeepSeek-V3"):
 95		model.SupportedFeatures = []string{"tools"}
 96
 97	case strings.HasPrefix(model.ID, "hf:Qwen/Qwen3-235B-A22B-Thinking"):
 98		model.SupportedFeatures = []string{"tools", "reasoning"}
 99
100	case strings.HasPrefix(model.ID, "hf:Qwen/Qwen3-235B-A22B-Instruct"):
101		model.SupportedFeatures = []string{"tools", "reasoning"}
102
103	// The rest of Qwen3 don't support reasoning but do tools
104	case strings.HasPrefix(model.ID, "hf:Qwen/Qwen3"):
105		model.SupportedFeatures = []string{"tools"}
106
107	// Has correct metadata already, but the following k2 matchers would
108	// override it to omit reasoning
109	case strings.HasPrefix(model.ID, "hf:moonshotai/Kimi-K2-Thinking"):
110		model.SupportedFeatures = []string{"tools", "reasoning"}
111
112	case strings.HasPrefix(model.ID, "hf:moonshotai/Kimi-K2.5"):
113		model.SupportedFeatures = []string{"tools", "reasoning"}
114
115	case strings.HasPrefix(model.ID, "hf:moonshotai/Kimi-K2"):
116		model.SupportedFeatures = []string{"tools"}
117
118	case strings.HasPrefix(model.ID, "hf:zai-org/GLM-4.5"):
119		model.SupportedFeatures = []string{"tools"}
120
121	case strings.HasPrefix(model.ID, "hf:openai/gpt-oss"):
122		model.SupportedFeatures = []string{"tools", "reasoning"}
123
124	case strings.HasPrefix(model.ID, "hf:MiniMaxAI/MiniMax-M2.1"):
125		model.SupportedFeatures = []string{"tools", "reasoning"}
126	}
127}
128
129func fetchSyntheticModels(apiEndpoint string) (*ModelsResponse, error) {
130	client := &http.Client{Timeout: 30 * time.Second}
131	req, _ := http.NewRequestWithContext(context.Background(), "GET", apiEndpoint+"/models", nil)
132	req.Header.Set("User-Agent", "Crush-Client/1.0")
133	resp, err := client.Do(req)
134	if err != nil {
135		return nil, err //nolint:wrapcheck
136	}
137	defer resp.Body.Close() //nolint:errcheck
138	if resp.StatusCode != 200 {
139		body, _ := io.ReadAll(resp.Body)
140		return nil, fmt.Errorf("status %d: %s", resp.StatusCode, body)
141	}
142	var mr ModelsResponse
143	if err := json.NewDecoder(resp.Body).Decode(&mr); err != nil {
144		return nil, err //nolint:wrapcheck
145	}
146	return &mr, nil
147}
148
149// This is used to generate the synthetic.json config file.
150func main() {
151	syntheticProvider := catwalk.Provider{
152		Name:                "Synthetic",
153		ID:                  "synthetic",
154		APIKey:              "$SYNTHETIC_API_KEY",
155		APIEndpoint:         "https://api.synthetic.new/openai/v1",
156		Type:                catwalk.TypeOpenAICompat,
157		DefaultLargeModelID: "hf:zai-org/GLM-4.7",
158		DefaultSmallModelID: "hf:deepseek-ai/DeepSeek-V3.1-Terminus",
159		Models:              []catwalk.Model{},
160	}
161
162	modelsResp, err := fetchSyntheticModels(syntheticProvider.APIEndpoint)
163	if err != nil {
164		log.Fatal("Error fetching Synthetic models:", err)
165	}
166
167	// Apply overrides for models missing supported_features metadata
168	for i := range modelsResp.Data {
169		applyModelOverrides(&modelsResp.Data[i])
170	}
171
172	for _, model := range modelsResp.Data {
173		// Skip models with small context windows
174		if model.ContextLength < 20000 {
175			continue
176		}
177
178		// Skip non-text models
179		if !slices.Contains(model.InputModalities, "text") ||
180			!slices.Contains(model.OutputModalities, "text") {
181			continue
182		}
183
184		// Ensure they support tools
185		supportsTools := slices.Contains(model.SupportedFeatures, "tools")
186		if !supportsTools {
187			continue
188		}
189
190		pricing := getPricing(model)
191		supportsImages := slices.Contains(model.InputModalities, "image")
192
193		// Check if model supports reasoning
194		canReason := slices.Contains(model.SupportedFeatures, "reasoning")
195		var reasoningLevels []string
196		var defaultReasoning string
197		if canReason {
198			reasoningLevels = []string{"low", "medium", "high"}
199			defaultReasoning = "medium"
200		}
201
202		// Strip everything before the first / for a cleaner name
203		modelName := model.Name
204		if idx := strings.Index(model.Name, "/"); idx != -1 {
205			modelName = model.Name[idx+1:]
206		}
207		// Replace hyphens with spaces
208		modelName = strings.ReplaceAll(modelName, "-", " ")
209
210		m := catwalk.Model{
211			ID:                     model.ID,
212			Name:                   modelName,
213			CostPer1MIn:            pricing.CostPer1MIn,
214			CostPer1MOut:           pricing.CostPer1MOut,
215			CostPer1MInCached:      pricing.CostPer1MInCached,
216			CostPer1MOutCached:     pricing.CostPer1MOutCached,
217			ContextWindow:          model.ContextLength,
218			CanReason:              canReason,
219			DefaultReasoningEffort: defaultReasoning,
220			ReasoningLevels:        reasoningLevels,
221			SupportsImages:         supportsImages,
222		}
223
224		// Set max tokens based on max_output_length if available, but cap at
225		// 15% of context length
226		maxFromOutput := model.MaxOutputLength / 2
227		maxAt15Pct := (model.ContextLength * 15) / 100
228		if model.MaxOutputLength > 0 && maxFromOutput <= maxAt15Pct {
229			m.DefaultMaxTokens = maxFromOutput
230		} else {
231			m.DefaultMaxTokens = model.ContextLength / 10
232		}
233
234		syntheticProvider.Models = append(syntheticProvider.Models, m)
235		fmt.Printf("Added model %s with context window %d\n",
236			model.ID, model.ContextLength)
237	}
238
239	slices.SortFunc(syntheticProvider.Models, func(a catwalk.Model, b catwalk.Model) int {
240		return strings.Compare(a.Name, b.Name)
241	})
242
243	// Save the JSON in internal/providers/configs/synthetic.json
244	data, err := json.MarshalIndent(syntheticProvider, "", "  ")
245	if err != nil {
246		log.Fatal("Error marshaling Synthetic provider:", err)
247	}
248
249	if err := os.WriteFile("internal/providers/configs/synthetic.json", data, 0o600); err != nil {
250		log.Fatal("Error writing Synthetic provider config:", err)
251	}
252
253	fmt.Printf("Generated synthetic.json with %d models\n", len(syntheticProvider.Models))
254}