main.go

  1// Package main provides a command-line tool to fetch models from io.net
  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	"math"
 12	"net/http"
 13	"os"
 14	"slices"
 15	"strings"
 16	"time"
 17
 18	"charm.land/catwalk/pkg/catwalk"
 19	xstrings "github.com/charmbracelet/x/exp/strings"
 20)
 21
 22// Model represents a model from the io.net API.
 23type Model struct {
 24	ID                   string  `json:"id"`
 25	Name                 string  `json:"name"`
 26	ContextWindow        int     `json:"context_window"`
 27	SupportsImagesInput  bool    `json:"supports_images_input"`
 28	InputTokenPrice      float64 `json:"input_token_price"`
 29	OutputTokenPrice     float64 `json:"output_token_price"`
 30	CacheWriteTokenPrice float64 `json:"cache_write_token_price"`
 31	CacheReadTokenPrice  float64 `json:"cache_read_token_price"`
 32}
 33
 34// Response is the response structure for the io.net models API.
 35type Response struct {
 36	Data []Model `json:"data"`
 37}
 38
 39// This is used to generate the ionet.json config file.
 40func main() {
 41	provider := catwalk.Provider{
 42		Name:                "io.net",
 43		ID:                  "ionet",
 44		APIKey:              "$IONET_API_KEY",
 45		APIEndpoint:         "https://api.intelligence.io.solutions/api/v1",
 46		Type:                catwalk.TypeOpenAICompat,
 47		DefaultLargeModelID: "zai-org/GLM-4.7",
 48		DefaultSmallModelID: "zai-org/GLM-4.7-Flash",
 49	}
 50
 51	resp, err := fetchModels(provider.APIEndpoint)
 52	if err != nil {
 53		log.Fatal("Error fetching io.net models:", err)
 54	}
 55
 56	provider.Models = make([]catwalk.Model, 0, len(resp.Data))
 57
 58	modelIDSet := make(map[string]struct{})
 59
 60	for _, model := range resp.Data {
 61		// Avoid duplicate entries
 62		if _, ok := modelIDSet[model.ID]; ok {
 63			continue
 64		}
 65		modelIDSet[model.ID] = struct{}{}
 66
 67		if model.ContextWindow < 20000 {
 68			continue
 69		}
 70		if !supportsTools(model.ID) {
 71			continue
 72		}
 73
 74		canReason := isReasoningModel(model.ID)
 75		var reasoningLevels []string
 76		var defaultReasoning string
 77		if canReason {
 78			reasoningLevels = []string{"low", "medium", "high"}
 79			defaultReasoning = "medium"
 80		}
 81
 82		// Convert token prices (per token) to cost per 1M tokens
 83		roundCost := func(v float64) float64 { return math.Round(v*1e5) / 1e5 }
 84		costPer1MIn := roundCost(model.InputTokenPrice * 1_000_000)
 85		costPer1MOut := roundCost(model.OutputTokenPrice * 1_000_000)
 86		costPer1MInCached := roundCost(model.CacheReadTokenPrice * 1_000_000)
 87		costPer1MOutCached := roundCost(model.CacheWriteTokenPrice * 1_000_000)
 88
 89		m := catwalk.Model{
 90			ID:                     model.ID,
 91			Name:                   model.Name,
 92			CostPer1MIn:            costPer1MIn,
 93			CostPer1MOut:           costPer1MOut,
 94			CostPer1MInCached:      costPer1MInCached,
 95			CostPer1MOutCached:     costPer1MOutCached,
 96			ContextWindow:          int64(model.ContextWindow),
 97			DefaultMaxTokens:       int64(model.ContextWindow) / 10,
 98			CanReason:              canReason,
 99			ReasoningLevels:        reasoningLevels,
100			DefaultReasoningEffort: defaultReasoning,
101			SupportsImages:         model.SupportsImagesInput,
102		}
103
104		provider.Models = append(provider.Models, m)
105		fmt.Printf("Added model %s with context window %d\n", model.ID, model.ContextWindow)
106	}
107
108	slices.SortFunc(provider.Models, func(a catwalk.Model, b catwalk.Model) int {
109		return strings.Compare(a.Name, b.Name)
110	})
111
112	// Save the JSON in internal/providers/configs/ionet.json
113	data, err := json.MarshalIndent(provider, "", "  ")
114	if err != nil {
115		log.Fatal("Error marshaling io.net provider:", err)
116	}
117	data = append(data, '\n')
118
119	if err := os.WriteFile("internal/providers/configs/ionet.json", data, 0o600); err != nil {
120		log.Fatal("Error writing io.net provider config:", err)
121	}
122
123	fmt.Printf("Generated ionet.json with %d models\n", len(provider.Models))
124}
125
126func fetchModels(apiEndpoint string) (*Response, error) {
127	client := &http.Client{Timeout: 30 * time.Second}
128
129	req, err := http.NewRequestWithContext(context.Background(), "GET", apiEndpoint+"/models", nil)
130	if err != nil {
131		return nil, fmt.Errorf("failed to create http request: %w", err)
132	}
133	req.Header.Set("User-Agent", "Charm-Catwalk/1.0")
134
135	resp, err := client.Do(req)
136	if err != nil {
137		return nil, fmt.Errorf("failed to do http request: %w", err)
138	}
139	defer resp.Body.Close() //nolint:errcheck
140
141	body, _ := io.ReadAll(resp.Body)
142
143	// for debugging
144	_ = os.MkdirAll("tmp", 0o700)
145	_ = os.WriteFile("tmp/io-net-response.json", body, 0o600)
146
147	if resp.StatusCode != http.StatusOK {
148		return nil, fmt.Errorf("status %d: %s", resp.StatusCode, body)
149	}
150
151	var mr Response
152	if err := json.Unmarshal(body, &mr); err != nil {
153		return nil, fmt.Errorf("unable to unmarshal json: %w", err)
154	}
155	return &mr, nil
156}
157
158// isReasoningModel checks if the model ID indicates reasoning capability.
159func isReasoningModel(modelID string) bool {
160	return xstrings.ContainsAnyOf(
161		strings.ToLower(modelID),
162		"-thinking",
163		"deepseek",
164		"glm",
165		"gpt-oss",
166		"llama",
167	)
168}
169
170// supportsTools determines if a model supports tool calling based on its ID.
171func supportsTools(modelID string) bool {
172	return !xstrings.ContainsAnyOf(
173		strings.ToLower(modelID),
174		"deepseek",
175		"llama-4",
176		"mistral-nemo",
177		"qwen2.5",
178		"gpt-oss",
179	)
180}