main.go

  1// Package main generates the OpenCode Zen provider configuration.
  2package main
  3
  4import (
  5	"cmp"
  6	"context"
  7	"encoding/json"
  8	"fmt"
  9	"io"
 10	"log"
 11	"math"
 12	"net/http"
 13	"os"
 14	"slices"
 15	"strings"
 16	"time"
 17
 18	"charm.land/catwalk/pkg/catwalk"
 19)
 20
 21type ZenModel struct {
 22	ID      string `json:"id"`
 23	Object  string `json:"object"`
 24	Created int64  `json:"created"`
 25	OwnedBy string `json:"owned_by"`
 26}
 27
 28type ZenModelsResponse struct {
 29	Object string     `json:"object"`
 30	Data   []ZenModel `json:"data"`
 31}
 32
 33type PricingData struct {
 34	Input      float64 `json:"input"`
 35	Output     float64 `json:"output"`
 36	CacheRead  float64 `json:"cache_read,omitempty"`
 37	CacheWrite float64 `json:"cache_write,omitempty"`
 38}
 39
 40type ModelLimit struct {
 41	Context int64 `json:"context"`
 42	Output  int64 `json:"output"`
 43}
 44
 45type ModelEnrichment struct {
 46	Name       string      `json:"name"`
 47	Attachment bool        `json:"attachment"`
 48	Reasoning  bool        `json:"reasoning"`
 49	Cost       PricingData `json:"cost"`
 50	Limit      ModelLimit  `json:"limit"`
 51}
 52
 53func fetchZenModels() ([]ZenModel, error) {
 54	apiKey := cmp.Or(os.Getenv("OPENCODE_API_KEY"), "public")
 55
 56	client := &http.Client{Timeout: 30 * time.Second}
 57	req, _ := http.NewRequestWithContext(
 58		context.Background(),
 59		"GET",
 60		"https://opencode.ai/zen/v1/models",
 61		nil,
 62	)
 63	req.Header.Set("User-Agent", "Catwalk/1.0")
 64	req.Header.Set("Authorization", "Bearer "+apiKey)
 65
 66	resp, err := client.Do(req)
 67	if err != nil {
 68		return nil, fmt.Errorf("failed to fetch zen models: %w", err)
 69	}
 70	defer func() { _ = resp.Body.Close() }()
 71	if resp.StatusCode != 200 {
 72		body, _ := io.ReadAll(resp.Body)
 73		return nil, fmt.Errorf("status %d: %s", resp.StatusCode, body)
 74	}
 75
 76	var mr ZenModelsResponse
 77	if err := json.NewDecoder(resp.Body).Decode(&mr); err != nil {
 78		return nil, fmt.Errorf("failed to decode zen models: %w", err)
 79	}
 80
 81	return mr.Data, nil
 82}
 83
 84func fetchEnrichmentData() (map[string]ModelEnrichment, error) {
 85	client := &http.Client{Timeout: 30 * time.Second}
 86	req, _ := http.NewRequestWithContext(
 87		context.Background(),
 88		"GET",
 89		"https://models.dev/api.json",
 90		nil,
 91	)
 92	req.Header.Set("User-Agent", "Catwalk/1.0")
 93
 94	resp, err := client.Do(req)
 95	if err != nil {
 96		return nil, fmt.Errorf("failed fetching enrichment data: %w", err)
 97	}
 98	defer func() { _ = resp.Body.Close() }()
 99	if resp.StatusCode != 200 {
100		body, _ := io.ReadAll(resp.Body)
101		return nil, fmt.Errorf("status %d: %s", resp.StatusCode, body)
102	}
103
104	var fullData map[string]json.RawMessage
105	if err := json.NewDecoder(resp.Body).Decode(&fullData); err != nil {
106		return nil, fmt.Errorf("failed to decode when fetching enrichment data: %w", err)
107	}
108
109	rawOpenCode, ok := fullData["opencode"]
110	if !ok {
111		return nil, fmt.Errorf("opencode provider not found in models.dev/api.json")
112	}
113
114	var openCodeData struct {
115		Models map[string]ModelEnrichment `json:"models"`
116	}
117	if err := json.Unmarshal(rawOpenCode, &openCodeData); err != nil {
118		return nil, fmt.Errorf("failed to unmarshal when fetching enrichment data: %w", err)
119	}
120
121	return openCodeData.Models, nil
122}
123
124func main() {
125	zenModels, err := fetchZenModels()
126	if err != nil {
127		log.Fatal("Error fetching OpenCode Zen models:", err)
128	}
129
130	enrichmentData, err := fetchEnrichmentData()
131	if err != nil {
132		log.Fatal("Error fetching enrichment data:", err)
133	}
134
135	zenProvider := catwalk.Provider{
136		Name:                "OpenCode Zen",
137		ID:                  catwalk.InferenceProviderOpenCodeZen,
138		APIKey:              "$OPENCODE_API_KEY",
139		APIEndpoint:         "https://opencode.ai/zen/v1",
140		Type:                catwalk.TypeOpenAICompat,
141		DefaultLargeModelID: "minimax-m2.5-free",
142		DefaultSmallModelID: "minimax-m2.5-free",
143	}
144
145	for _, zenModel := range zenModels {
146		enrichment, hasEnrichment := enrichmentData[zenModel.ID]
147
148		var costPer1MIn, costPer1MOut, costPer1MInCached, costPer1MOutCached float64
149		var contextWindow, defaultMaxTokens int64 = 200000, 20000
150		var supportsImages bool
151		var canReason bool
152		var reasoningLevels []string
153		var defaultReasoningEffort string
154		modelName := zenModel.ID
155
156		if hasEnrichment {
157			costPer1MIn = math.Round(enrichment.Cost.Input*100) / 100
158			costPer1MOut = math.Round(enrichment.Cost.Output*100) / 100
159			costPer1MInCached = math.Round(enrichment.Cost.CacheRead*100) / 100
160			costPer1MOutCached = math.Round(enrichment.Cost.CacheWrite*100) / 100
161			contextWindow = enrichment.Limit.Context
162			defaultMaxTokens = enrichment.Limit.Output
163			supportsImages = enrichment.Attachment
164			modelName = enrichment.Name
165
166			if enrichment.Reasoning {
167				reasoningLevels = []string{"low", "medium", "high"}
168				defaultReasoningEffort = "medium"
169				canReason = true
170			}
171		} else {
172			log.Printf("WARNING: No enrichment found for model %s, using defaults\n", zenModel.ID)
173		}
174
175		m := catwalk.Model{
176			ID:                     zenModel.ID,
177			Name:                   modelName,
178			CostPer1MIn:            costPer1MIn,
179			CostPer1MOut:           costPer1MOut,
180			CostPer1MInCached:      costPer1MInCached,
181			CostPer1MOutCached:     costPer1MOutCached,
182			ContextWindow:          contextWindow,
183			DefaultMaxTokens:       defaultMaxTokens,
184			SupportsImages:         supportsImages,
185			CanReason:              canReason,
186			ReasoningLevels:        reasoningLevels,
187			DefaultReasoningEffort: defaultReasoningEffort,
188		}
189
190		zenProvider.Models = append(zenProvider.Models, m)
191		fmt.Printf("Added model %s (%s)\n", zenModel.ID, modelName)
192	}
193
194	slices.SortFunc(zenProvider.Models, func(a catwalk.Model, b catwalk.Model) int {
195		return strings.Compare(a.Name, b.Name)
196	})
197
198	data, err := json.MarshalIndent(zenProvider, "", "  ")
199	if err != nil {
200		log.Fatal("Error marshaling provider:", err)
201	}
202	data = append(data, '\n')
203
204	if err := os.WriteFile("internal/providers/configs/opencode-zen.json", data, 0o600); err != nil {
205		log.Fatal("Error writing provider config:", err)
206	}
207
208	fmt.Printf("Generated opencode-zen.json with %d models\n", len(zenProvider.Models))
209}