vkproxy.go

v0.7.0
Doc Versions Source
1
package vkproxy
2
3
import (
4
	"archive/zip"
5
	"fmt"
6
	"io"
7
	"os"
8
	"path/filepath"
9
	"regexp"
10
	"strconv"
11
	"strings"
12
13
	"sourcecraft.dev/bigbes/claudio/config"
14
)
15
16
const (
17
	zipFileName   = "claude-code-config.zip"
18
	shellFileName = "install_claude_aliases.sh"
19
)
20
21
// ProxyModel holds a parsed model configuration from the VK LLM Proxy zip.
22
type ProxyModel struct {
23
	Name          string
24
	ProviderName  string
25
	BaseURL       string
26
	AuthToken     string
27
	ModelID       string
28
	ContextWindow int
29
	ProxyUser     string
30
	CustomHeaders string
31
	// Env is the full set of KEY=value assignments declared in the function body
32
	// of the install script (e.g. ENABLE_LSP_TOOL, API_TIMEOUT_MS, the ANTHROPIC_*
33
	// model family, AI_LOG_ENDPOINT, ...). It always includes BaseURL/AuthToken/
34
	// ModelID/ProxyUser/CustomHeaders/ContextWindow under their canonical keys
35
	// so callers can forward everything verbatim.
36
	Env map[string]string
37
}
38
39
// FindZip looks for claude-code-config.zip in standard locations.
40
func FindZip() string {
41
	var candidates []string
42
	cfgDir := filepath.Dir(config.Path())
43
	candidates = append(candidates, filepath.Join(cfgDir, zipFileName))
44
	if cwd, err := os.Getwd(); err == nil {
45
		candidates = append(candidates, filepath.Join(cwd, zipFileName))
46
	}
47
	if home, err := os.UserHomeDir(); err == nil {
48
		candidates = append(candidates, filepath.Join(home, zipFileName))
49
	}
50
	for _, p := range candidates {
51
		if _, err := os.Stat(p); err == nil {
52
			return p
53
		}
54
	}
55
	return ""
56
}
57
58
const funcCommentPrefix = "# Функция для "
59
60
var (
61
	commentNameRe = regexp.MustCompile(`name='([^']+)'`)
62
	providerRe    = regexp.MustCompile(`provider_name='([^']+)'`)
63
	ctxRe         = regexp.MustCompile(`context_window=(\d+)`)
64
	envStrRe      = regexp.MustCompile(`^(\w+)="([^"]*)"`)
65
	envBareRe     = regexp.MustCompile(`^(\w+)=(\S+)`)
66
)
67
68
// ParseZip reads a zip file, extracts install_claude_aliases.sh, and parses model configs.
69
func ParseZip(path string) ([]ProxyModel, error) {
70
	r, err := zip.OpenReader(path)
71
	if err != nil {
72
		return nil, fmt.Errorf("opening zip: %w", err)
73
	}
74
	defer r.Close()
75
76
	for _, f := range r.File {
77
		if filepath.Base(f.Name) == shellFileName {
78
			rc, err := f.Open()
79
			if err != nil {
80
				return nil, err
81
			}
82
			defer rc.Close()
83
			data, err := io.ReadAll(rc)
84
			if err != nil {
85
				return nil, err
86
			}
87
			return parseScript(string(data)), nil
88
		}
89
	}
90
	return nil, fmt.Errorf("%s not found in zip", shellFileName)
91
}
92
93
func parseScript(script string) []ProxyModel {
94
	lines := strings.Split(script, "\n")
95
	var models []ProxyModel
96
97
	for i := 0; i < len(lines); i++ {
98
		line := strings.TrimSpace(lines[i])
99
100
		if !strings.HasPrefix(line, funcCommentPrefix) {
101
			continue
102
		}
103
104
		var m ProxyModel
105
		// Old format: "# Функция для name='X' provider_name='Y' context_window=N"
106
		if matches := commentNameRe.FindStringSubmatch(line); len(matches) > 1 {
107
			m.Name = matches[1]
108
		} else {
109
			// New format: "# Функция для <model-id>" — first whitespace-delimited
110
			// token is the model id; longer-form descriptive comments
111
			// (e.g. "# Функция для отображения ...") are filtered later by
112
			// requiring ANTHROPIC_MODEL in the body.
113
			rest := strings.TrimSpace(strings.TrimPrefix(line, funcCommentPrefix))
114
			if idx := strings.IndexAny(rest, " \t"); idx >= 0 {
115
				rest = rest[:idx]
116
			}
117
			m.Name = rest
118
		}
119
		if matches := providerRe.FindStringSubmatch(line); len(matches) > 1 {
120
			m.ProviderName = matches[1]
121
		}
122
		if matches := ctxRe.FindStringSubmatch(line); len(matches) > 1 {
123
			if v, err := strconv.Atoi(matches[1]); err == nil {
124
				m.ContextWindow = v
125
			}
126
		}
127
128
		// Parse the function body — capture every KEY=val (quoted or bare) into
129
		// m.Env, and mirror the well-known keys onto dedicated fields.
130
		m.Env = make(map[string]string)
131
		for i++; i < len(lines); i++ {
132
			bodyLine := strings.TrimSpace(lines[i])
133
			if bodyLine == "}" {
134
				break
135
			}
136
			bodyLine = strings.TrimSuffix(bodyLine, "\\")
137
			bodyLine = strings.TrimSpace(bodyLine)
138
139
			var key, val string
140
			if matches := envStrRe.FindStringSubmatch(bodyLine); len(matches) > 2 {
141
				key, val = matches[1], matches[2]
142
			} else if matches := envBareRe.FindStringSubmatch(bodyLine); len(matches) > 2 {
143
				key, val = matches[1], matches[2]
144
			} else {
145
				continue
146
			}
147
148
			m.Env[key] = val
149
			switch key {
150
			case "ANTHROPIC_BASE_URL":
151
				m.BaseURL = val
152
			case "ANTHROPIC_AUTH_TOKEN":
153
				m.AuthToken = val
154
			case "ANTHROPIC_MODEL":
155
				m.ModelID = val
156
			case "VK_LLM_PROXY_USER":
157
				m.ProxyUser = val
158
			case "ANTHROPIC_CUSTOM_HEADERS":
159
				m.CustomHeaders = val
160
			case "CLAUDE_CODE_CONTEXT_LIMIT":
161
				if m.ContextWindow == 0 {
162
					if v, err := strconv.Atoi(val); err == nil {
163
						m.ContextWindow = v
164
					}
165
				}
166
			}
167
		}
168
169
		if m.ModelID == "" {
170
			continue
171
		}
172
		if m.ProviderName == "" {
173
			m.ProviderName = deriveProviderName(m.ModelID)
174
		}
175
		models = append(models, m)
176
	}
177
178
	return models
179
}
180
181
// GroupByProvider groups models by their cleaned provider name, returning
182
// the provider order and grouped models.
183
func GroupByProvider(models []ProxyModel) ([]string, map[string][]ProxyModel) {
184
	var order []string
185
	seen := make(map[string]bool)
186
	grouped := make(map[string][]ProxyModel)
187
188
	for _, m := range models {
189
		name := CleanProviderName(m.ProviderName)
190
		if !seen[name] {
191
			seen[name] = true
192
			order = append(order, name)
193
		}
194
		grouped[name] = append(grouped[name], m)
195
	}
196
	return order, grouped
197
}
198
199
// deriveProviderName guesses a provider label from a model id when the script
200
// does not declare one (the post-2026 zip format dropped the metadata header).
201
func deriveProviderName(modelID string) string {
202
	lower := strings.ToLower(modelID)
203
	switch {
204
	case strings.HasPrefix(lower, "deepseek"):
205
		return "DeepSeek"
206
	case strings.HasPrefix(lower, "kimi"):
207
		return "Kimi"
208
	case strings.HasPrefix(lower, "glm"):
209
		return "GLM"
210
	case strings.HasPrefix(lower, "minimax"):
211
		return "MiniMax"
212
	case strings.HasPrefix(lower, "qwen"):
213
		return "Qwen"
214
	case strings.HasPrefix(lower, "diona"):
215
		return "Diona"
216
	}
217
	if idx := strings.IndexAny(modelID, "-."); idx > 0 {
218
		return modelID[:idx]
219
	}
220
	return modelID
221
}
222
223
// CleanProviderName strips trailing _N suffixes (e.g. "DeepSeek_1" → "DeepSeek").
224
func CleanProviderName(raw string) string {
225
	parts := strings.Split(raw, "_")
226
	if len(parts) > 1 {
227
		last := parts[len(parts)-1]
228
		if _, err := strconv.Atoi(last); err == nil {
229
			return strings.Join(parts[:len(parts)-1], "_")
230
		}
231
	}
232
	return raw
233
}
234

Source Files