sshinmen Claude commited on
Commit
13d6015
·
1 Parent(s): bd749dd

Refactor config and management API handlers

Browse files

- Split config.go into separate files (migration, sanitizer, yaml_utils)
- Add bootstrap.go for server initialization
- Add generics.go for generic API handler utilities
- Refactor gemini_vertex_executor.go
- Update management config_lists.go with new patterns

Co-Authored-By: Claude <noreply@anthropic.com>

cmd/server/bootstrap.go ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "io/fs"
8
+ "net/url"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "github.com/joho/godotenv"
13
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
14
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
15
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/store"
16
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
17
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
18
+ coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
19
+ log "github.com/sirupsen/logrus"
20
+ )
21
+
22
+ // BootstrapResult holds the outcome of the application bootstrap process.
23
+ type BootstrapResult struct {
24
+ Config *config.Config
25
+ ConfigFilePath string
26
+ TokenStore coreauth.Store
27
+ }
28
+
29
+ // Bootstrap initializes the environment, determines the storage backend,
30
+ // loads the configuration, and sets up the token store.
31
+ func Bootstrap(ctx context.Context, configPathOverride string, isCloudDeploy bool) (*BootstrapResult, error) {
32
+ var (
33
+ err error
34
+ cfg *config.Config
35
+ configFilePath string
36
+ tokenStore coreauth.Store
37
+ usePostgresStore bool
38
+ pgStoreDSN string
39
+ pgStoreSchema string
40
+ pgStoreLocalPath string
41
+ useGitStore bool
42
+ gitStoreRemoteURL string
43
+ gitStoreUser string
44
+ gitStorePassword string
45
+ gitStoreLocalPath string
46
+ useObjectStore bool
47
+ objectStoreEndpoint string
48
+ objectStoreAccess string
49
+ objectStoreSecret string
50
+ objectStoreBucket string
51
+ objectStoreLocalPath string
52
+ )
53
+
54
+ wd, err := os.Getwd()
55
+ if err != nil {
56
+ return nil, fmt.Errorf("failed to get working directory: %w", err)
57
+ }
58
+
59
+ // Load environment variables from .env if present.
60
+ if errLoad := godotenv.Load(filepath.Join(wd, ".env")); errLoad != nil {
61
+ if !errors.Is(errLoad, os.ErrNotExist) {
62
+ log.WithError(errLoad).Warn("failed to load .env file")
63
+ }
64
+ }
65
+
66
+ lookupEnv := func(keys ...string) (string, bool) {
67
+ for _, key := range keys {
68
+ if value, ok := os.LookupEnv(key); ok {
69
+ if trimmed := strings.TrimSpace(value); trimmed != "" {
70
+ return trimmed, true
71
+ }
72
+ }
73
+ }
74
+ return "", false
75
+ }
76
+
77
+ writableBase := util.WritablePath()
78
+ if value, ok := lookupEnv("PGSTORE_DSN", "pgstore_dsn"); ok {
79
+ usePostgresStore = true
80
+ pgStoreDSN = value
81
+ }
82
+ if usePostgresStore {
83
+ if value, ok := lookupEnv("PGSTORE_SCHEMA", "pgstore_schema"); ok {
84
+ pgStoreSchema = value
85
+ }
86
+ if value, ok := lookupEnv("PGSTORE_LOCAL_PATH", "pgstore_local_path"); ok {
87
+ pgStoreLocalPath = value
88
+ }
89
+ if pgStoreLocalPath == "" {
90
+ if writableBase != "" {
91
+ pgStoreLocalPath = writableBase
92
+ } else {
93
+ pgStoreLocalPath = wd
94
+ }
95
+ }
96
+ useGitStore = false
97
+ }
98
+ if value, ok := lookupEnv("GITSTORE_GIT_URL", "gitstore_git_url"); ok {
99
+ useGitStore = true
100
+ gitStoreRemoteURL = value
101
+ }
102
+ if value, ok := lookupEnv("GITSTORE_GIT_USERNAME", "gitstore_git_username"); ok {
103
+ gitStoreUser = value
104
+ }
105
+ if value, ok := lookupEnv("GITSTORE_GIT_TOKEN", "gitstore_git_token"); ok {
106
+ gitStorePassword = value
107
+ }
108
+ if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok {
109
+ gitStoreLocalPath = value
110
+ }
111
+ if value, ok := lookupEnv("OBJECTSTORE_ENDPOINT", "objectstore_endpoint"); ok {
112
+ useObjectStore = true
113
+ objectStoreEndpoint = value
114
+ }
115
+ if value, ok := lookupEnv("OBJECTSTORE_ACCESS_KEY", "objectstore_access_key"); ok {
116
+ objectStoreAccess = value
117
+ }
118
+ if value, ok := lookupEnv("OBJECTSTORE_SECRET_KEY", "objectstore_secret_key"); ok {
119
+ objectStoreSecret = value
120
+ }
121
+ if value, ok := lookupEnv("OBJECTSTORE_BUCKET", "objectstore_bucket"); ok {
122
+ objectStoreBucket = value
123
+ }
124
+ if value, ok := lookupEnv("OBJECTSTORE_LOCAL_PATH", "objectstore_local_path"); ok {
125
+ objectStoreLocalPath = value
126
+ }
127
+
128
+ // Determine and load the configuration file.
129
+ // Prefer the Postgres store when configured, otherwise fallback to git or local files.
130
+ if usePostgresStore {
131
+ if pgStoreLocalPath == "" {
132
+ pgStoreLocalPath = wd
133
+ }
134
+ pgStoreLocalPath = filepath.Join(pgStoreLocalPath, "pgstore")
135
+ pgStoreInst, err := store.NewPostgresStore(ctx, store.PostgresStoreConfig{
136
+ DSN: pgStoreDSN,
137
+ Schema: pgStoreSchema,
138
+ SpoolDir: pgStoreLocalPath,
139
+ })
140
+ if err != nil {
141
+ return nil, fmt.Errorf("failed to initialize postgres token store: %w", err)
142
+ }
143
+ tokenStore = pgStoreInst
144
+
145
+ examplePath := filepath.Join(wd, "config.example.yaml")
146
+ if errBootstrap := pgStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
147
+ return nil, fmt.Errorf("failed to bootstrap postgres-backed config: %w", errBootstrap)
148
+ }
149
+ configFilePath = pgStoreInst.ConfigPath()
150
+ cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
151
+ if err == nil {
152
+ cfg.AuthDir = pgStoreInst.AuthDir()
153
+ log.Infof("postgres-backed token store enabled, workspace path: %s", pgStoreInst.WorkDir())
154
+ }
155
+ } else if useObjectStore {
156
+ if objectStoreLocalPath == "" {
157
+ if writableBase != "" {
158
+ objectStoreLocalPath = writableBase
159
+ } else {
160
+ objectStoreLocalPath = wd
161
+ }
162
+ }
163
+ objectStoreRoot := filepath.Join(objectStoreLocalPath, "objectstore")
164
+ resolvedEndpoint := strings.TrimSpace(objectStoreEndpoint)
165
+ useSSL := true
166
+ if strings.Contains(resolvedEndpoint, "://") {
167
+ parsed, errParse := url.Parse(resolvedEndpoint)
168
+ if errParse != nil {
169
+ return nil, fmt.Errorf("failed to parse object store endpoint %q: %w", objectStoreEndpoint, errParse)
170
+ }
171
+ switch strings.ToLower(parsed.Scheme) {
172
+ case "http":
173
+ useSSL = false
174
+ case "https":
175
+ useSSL = true
176
+ default:
177
+ return nil, fmt.Errorf("unsupported object store scheme %q (only http and https are allowed)", parsed.Scheme)
178
+ }
179
+ if parsed.Host == "" {
180
+ return nil, fmt.Errorf("object store endpoint %q is missing host information", objectStoreEndpoint)
181
+ }
182
+ resolvedEndpoint = parsed.Host
183
+ if parsed.Path != "" && parsed.Path != "/" {
184
+ resolvedEndpoint = strings.TrimSuffix(parsed.Host+parsed.Path, "/")
185
+ }
186
+ }
187
+ resolvedEndpoint = strings.TrimRight(resolvedEndpoint, "/")
188
+ objCfg := store.ObjectStoreConfig{
189
+ Endpoint: resolvedEndpoint,
190
+ Bucket: objectStoreBucket,
191
+ AccessKey: objectStoreAccess,
192
+ SecretKey: objectStoreSecret,
193
+ LocalRoot: objectStoreRoot,
194
+ UseSSL: useSSL,
195
+ PathStyle: true,
196
+ }
197
+ objectStoreInst, err := store.NewObjectTokenStore(objCfg)
198
+ if err != nil {
199
+ return nil, fmt.Errorf("failed to initialize object token store: %w", err)
200
+ }
201
+ tokenStore = objectStoreInst
202
+
203
+ examplePath := filepath.Join(wd, "config.example.yaml")
204
+ if errBootstrap := objectStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
205
+ return nil, fmt.Errorf("failed to bootstrap object-backed config: %w", errBootstrap)
206
+ }
207
+ configFilePath = objectStoreInst.ConfigPath()
208
+ cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
209
+ if err == nil {
210
+ if cfg == nil {
211
+ cfg = &config.Config{}
212
+ }
213
+ cfg.AuthDir = objectStoreInst.AuthDir()
214
+ log.Infof("object-backed token store enabled, bucket: %s", objectStoreBucket)
215
+ }
216
+ } else if useGitStore {
217
+ if gitStoreLocalPath == "" {
218
+ if writableBase != "" {
219
+ gitStoreLocalPath = writableBase
220
+ } else {
221
+ gitStoreLocalPath = wd
222
+ }
223
+ }
224
+ gitStoreRoot := filepath.Join(gitStoreLocalPath, "gitstore")
225
+ authDir := filepath.Join(gitStoreRoot, "auths")
226
+ gitStoreInst := store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword)
227
+ gitStoreInst.SetBaseDir(authDir)
228
+ tokenStore = gitStoreInst
229
+
230
+ if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil {
231
+ return nil, fmt.Errorf("failed to prepare git token store: %w", errRepo)
232
+ }
233
+ configFilePath = gitStoreInst.ConfigPath()
234
+ if configFilePath == "" {
235
+ configFilePath = filepath.Join(gitStoreRoot, "config", "config.yaml")
236
+ }
237
+ if _, statErr := os.Stat(configFilePath); errors.Is(statErr, fs.ErrNotExist) {
238
+ examplePath := filepath.Join(wd, "config.example.yaml")
239
+ if _, errExample := os.Stat(examplePath); errExample != nil {
240
+ return nil, fmt.Errorf("failed to find template config file: %w", errExample)
241
+ }
242
+ if errCopy := misc.CopyConfigTemplate(examplePath, configFilePath); errCopy != nil {
243
+ return nil, fmt.Errorf("failed to bootstrap git-backed config: %w", errCopy)
244
+ }
245
+ if errCommit := gitStoreInst.PersistConfig(context.Background()); errCommit != nil {
246
+ return nil, fmt.Errorf("failed to commit initial git-backed config: %w", errCommit)
247
+ }
248
+ log.Infof("git-backed config initialized from template: %s", configFilePath)
249
+ } else if statErr != nil {
250
+ return nil, fmt.Errorf("failed to inspect git-backed config: %w", statErr)
251
+ }
252
+ cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
253
+ if err == nil {
254
+ cfg.AuthDir = gitStoreInst.AuthDir()
255
+ log.Infof("git-backed token store enabled, repository path: %s", gitStoreRoot)
256
+ }
257
+ } else if configPathOverride != "" {
258
+ configFilePath = configPathOverride
259
+ cfg, err = config.LoadConfigOptional(configPathOverride, isCloudDeploy)
260
+ } else {
261
+ wd, err = os.Getwd()
262
+ if err != nil {
263
+ return nil, fmt.Errorf("failed to get working directory: %w", err)
264
+ }
265
+ configFilePath = filepath.Join(wd, "config.yaml")
266
+ cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
267
+ }
268
+
269
+ if err != nil {
270
+ return nil, fmt.Errorf("failed to load config: %w", err)
271
+ }
272
+ if cfg == nil {
273
+ cfg = &config.Config{}
274
+ }
275
+
276
+ // If tokenStore is still nil (local file mode), use the file token store
277
+ if tokenStore == nil {
278
+ tokenStore = sdkAuth.NewFileTokenStore()
279
+ }
280
+
281
+ return &BootstrapResult{
282
+ Config: cfg,
283
+ ConfigFilePath: configFilePath,
284
+ TokenStore: tokenStore,
285
+ }, nil
286
+ }
cmd/server/main.go CHANGED
@@ -5,25 +5,15 @@ package main
5
 
6
  import (
7
  "context"
8
- "errors"
9
  "flag"
10
  "fmt"
11
- "io/fs"
12
- "net/url"
13
  "os"
14
- "path/filepath"
15
- "strings"
16
- "time"
17
 
18
- "github.com/joho/godotenv"
19
  configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
20
  "github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
21
  "github.com/router-for-me/CLIProxyAPI/v6/internal/cmd"
22
- "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
23
  "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
24
  "github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
25
- "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
26
- "github.com/router-for-me/CLIProxyAPI/v6/internal/store"
27
  _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
28
  "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
29
  "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
@@ -113,276 +103,19 @@ func main() {
113
  // Parse the command-line flags.
114
  flag.Parse()
115
 
116
- // Core application variables.
117
- var err error
118
- var cfg *config.Config
119
- var isCloudDeploy bool
120
- var (
121
- usePostgresStore bool
122
- pgStoreDSN string
123
- pgStoreSchema string
124
- pgStoreLocalPath string
125
- pgStoreInst *store.PostgresStore
126
- useGitStore bool
127
- gitStoreRemoteURL string
128
- gitStoreUser string
129
- gitStorePassword string
130
- gitStoreLocalPath string
131
- gitStoreInst *store.GitTokenStore
132
- gitStoreRoot string
133
- useObjectStore bool
134
- objectStoreEndpoint string
135
- objectStoreAccess string
136
- objectStoreSecret string
137
- objectStoreBucket string
138
- objectStoreLocalPath string
139
- objectStoreInst *store.ObjectTokenStore
140
- )
141
-
142
- wd, err := os.Getwd()
143
- if err != nil {
144
- log.Errorf("failed to get working directory: %v", err)
145
- return
146
- }
147
-
148
- // Load environment variables from .env if present.
149
- if errLoad := godotenv.Load(filepath.Join(wd, ".env")); errLoad != nil {
150
- if !errors.Is(errLoad, os.ErrNotExist) {
151
- log.WithError(errLoad).Warn("failed to load .env file")
152
- }
153
- }
154
-
155
- lookupEnv := func(keys ...string) (string, bool) {
156
- for _, key := range keys {
157
- if value, ok := os.LookupEnv(key); ok {
158
- if trimmed := strings.TrimSpace(value); trimmed != "" {
159
- return trimmed, true
160
- }
161
- }
162
- }
163
- return "", false
164
- }
165
- writableBase := util.WritablePath()
166
- if value, ok := lookupEnv("PGSTORE_DSN", "pgstore_dsn"); ok {
167
- usePostgresStore = true
168
- pgStoreDSN = value
169
- }
170
- if usePostgresStore {
171
- if value, ok := lookupEnv("PGSTORE_SCHEMA", "pgstore_schema"); ok {
172
- pgStoreSchema = value
173
- }
174
- if value, ok := lookupEnv("PGSTORE_LOCAL_PATH", "pgstore_local_path"); ok {
175
- pgStoreLocalPath = value
176
- }
177
- if pgStoreLocalPath == "" {
178
- if writableBase != "" {
179
- pgStoreLocalPath = writableBase
180
- } else {
181
- pgStoreLocalPath = wd
182
- }
183
- }
184
- useGitStore = false
185
- }
186
- if value, ok := lookupEnv("GITSTORE_GIT_URL", "gitstore_git_url"); ok {
187
- useGitStore = true
188
- gitStoreRemoteURL = value
189
- }
190
- if value, ok := lookupEnv("GITSTORE_GIT_USERNAME", "gitstore_git_username"); ok {
191
- gitStoreUser = value
192
- }
193
- if value, ok := lookupEnv("GITSTORE_GIT_TOKEN", "gitstore_git_token"); ok {
194
- gitStorePassword = value
195
- }
196
- if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok {
197
- gitStoreLocalPath = value
198
- }
199
- if value, ok := lookupEnv("OBJECTSTORE_ENDPOINT", "objectstore_endpoint"); ok {
200
- useObjectStore = true
201
- objectStoreEndpoint = value
202
- }
203
- if value, ok := lookupEnv("OBJECTSTORE_ACCESS_KEY", "objectstore_access_key"); ok {
204
- objectStoreAccess = value
205
- }
206
- if value, ok := lookupEnv("OBJECTSTORE_SECRET_KEY", "objectstore_secret_key"); ok {
207
- objectStoreSecret = value
208
- }
209
- if value, ok := lookupEnv("OBJECTSTORE_BUCKET", "objectstore_bucket"); ok {
210
- objectStoreBucket = value
211
- }
212
- if value, ok := lookupEnv("OBJECTSTORE_LOCAL_PATH", "objectstore_local_path"); ok {
213
- objectStoreLocalPath = value
214
- }
215
-
216
  // Check for cloud deploy mode only on first execution
217
- // Read env var name in uppercase: DEPLOY
218
  deployEnv := os.Getenv("DEPLOY")
219
- if deployEnv == "cloud" {
220
- isCloudDeploy = true
221
- }
222
 
223
- // Determine and load the configuration file.
224
- // Prefer the Postgres store when configured, otherwise fallback to git or local files.
225
- var configFilePath string
226
- if usePostgresStore {
227
- if pgStoreLocalPath == "" {
228
- pgStoreLocalPath = wd
229
- }
230
- pgStoreLocalPath = filepath.Join(pgStoreLocalPath, "pgstore")
231
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
232
- pgStoreInst, err = store.NewPostgresStore(ctx, store.PostgresStoreConfig{
233
- DSN: pgStoreDSN,
234
- Schema: pgStoreSchema,
235
- SpoolDir: pgStoreLocalPath,
236
- })
237
- cancel()
238
- if err != nil {
239
- log.Errorf("failed to initialize postgres token store: %v", err)
240
- return
241
- }
242
- examplePath := filepath.Join(wd, "config.example.yaml")
243
- ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
244
- if errBootstrap := pgStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
245
- cancel()
246
- log.Errorf("failed to bootstrap postgres-backed config: %v", errBootstrap)
247
- return
248
- }
249
- cancel()
250
- configFilePath = pgStoreInst.ConfigPath()
251
- cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
252
- if err == nil {
253
- cfg.AuthDir = pgStoreInst.AuthDir()
254
- log.Infof("postgres-backed token store enabled, workspace path: %s", pgStoreInst.WorkDir())
255
- }
256
- } else if useObjectStore {
257
- if objectStoreLocalPath == "" {
258
- if writableBase != "" {
259
- objectStoreLocalPath = writableBase
260
- } else {
261
- objectStoreLocalPath = wd
262
- }
263
- }
264
- objectStoreRoot := filepath.Join(objectStoreLocalPath, "objectstore")
265
- resolvedEndpoint := strings.TrimSpace(objectStoreEndpoint)
266
- useSSL := true
267
- if strings.Contains(resolvedEndpoint, "://") {
268
- parsed, errParse := url.Parse(resolvedEndpoint)
269
- if errParse != nil {
270
- log.Errorf("failed to parse object store endpoint %q: %v", objectStoreEndpoint, errParse)
271
- return
272
- }
273
- switch strings.ToLower(parsed.Scheme) {
274
- case "http":
275
- useSSL = false
276
- case "https":
277
- useSSL = true
278
- default:
279
- log.Errorf("unsupported object store scheme %q (only http and https are allowed)", parsed.Scheme)
280
- return
281
- }
282
- if parsed.Host == "" {
283
- log.Errorf("object store endpoint %q is missing host information", objectStoreEndpoint)
284
- return
285
- }
286
- resolvedEndpoint = parsed.Host
287
- if parsed.Path != "" && parsed.Path != "/" {
288
- resolvedEndpoint = strings.TrimSuffix(parsed.Host+parsed.Path, "/")
289
- }
290
- }
291
- resolvedEndpoint = strings.TrimRight(resolvedEndpoint, "/")
292
- objCfg := store.ObjectStoreConfig{
293
- Endpoint: resolvedEndpoint,
294
- Bucket: objectStoreBucket,
295
- AccessKey: objectStoreAccess,
296
- SecretKey: objectStoreSecret,
297
- LocalRoot: objectStoreRoot,
298
- UseSSL: useSSL,
299
- PathStyle: true,
300
- }
301
- objectStoreInst, err = store.NewObjectTokenStore(objCfg)
302
- if err != nil {
303
- log.Errorf("failed to initialize object token store: %v", err)
304
- return
305
- }
306
- examplePath := filepath.Join(wd, "config.example.yaml")
307
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
308
- if errBootstrap := objectStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
309
- cancel()
310
- log.Errorf("failed to bootstrap object-backed config: %v", errBootstrap)
311
- return
312
- }
313
- cancel()
314
- configFilePath = objectStoreInst.ConfigPath()
315
- cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
316
- if err == nil {
317
- if cfg == nil {
318
- cfg = &config.Config{}
319
- }
320
- cfg.AuthDir = objectStoreInst.AuthDir()
321
- log.Infof("object-backed token store enabled, bucket: %s", objectStoreBucket)
322
- }
323
- } else if useGitStore {
324
- if gitStoreLocalPath == "" {
325
- if writableBase != "" {
326
- gitStoreLocalPath = writableBase
327
- } else {
328
- gitStoreLocalPath = wd
329
- }
330
- }
331
- gitStoreRoot = filepath.Join(gitStoreLocalPath, "gitstore")
332
- authDir := filepath.Join(gitStoreRoot, "auths")
333
- gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword)
334
- gitStoreInst.SetBaseDir(authDir)
335
- if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil {
336
- log.Errorf("failed to prepare git token store: %v", errRepo)
337
- return
338
- }
339
- configFilePath = gitStoreInst.ConfigPath()
340
- if configFilePath == "" {
341
- configFilePath = filepath.Join(gitStoreRoot, "config", "config.yaml")
342
- }
343
- if _, statErr := os.Stat(configFilePath); errors.Is(statErr, fs.ErrNotExist) {
344
- examplePath := filepath.Join(wd, "config.example.yaml")
345
- if _, errExample := os.Stat(examplePath); errExample != nil {
346
- log.Errorf("failed to find template config file: %v", errExample)
347
- return
348
- }
349
- if errCopy := misc.CopyConfigTemplate(examplePath, configFilePath); errCopy != nil {
350
- log.Errorf("failed to bootstrap git-backed config: %v", errCopy)
351
- return
352
- }
353
- if errCommit := gitStoreInst.PersistConfig(context.Background()); errCommit != nil {
354
- log.Errorf("failed to commit initial git-backed config: %v", errCommit)
355
- return
356
- }
357
- log.Infof("git-backed config initialized from template: %s", configFilePath)
358
- } else if statErr != nil {
359
- log.Errorf("failed to inspect git-backed config: %v", statErr)
360
- return
361
- }
362
- cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
363
- if err == nil {
364
- cfg.AuthDir = gitStoreInst.AuthDir()
365
- log.Infof("git-backed token store enabled, repository path: %s", gitStoreRoot)
366
- }
367
- } else if configPath != "" {
368
- configFilePath = configPath
369
- cfg, err = config.LoadConfigOptional(configPath, isCloudDeploy)
370
- } else {
371
- wd, err = os.Getwd()
372
- if err != nil {
373
- log.Errorf("failed to get working directory: %v", err)
374
- return
375
- }
376
- configFilePath = filepath.Join(wd, "config.yaml")
377
- cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
378
- }
379
  if err != nil {
380
- log.Errorf("failed to load config: %v", err)
381
  return
382
  }
383
- if cfg == nil {
384
- cfg = &config.Config{}
385
- }
386
 
387
  // In cloud deploy mode, check if we have a valid configuration
388
  var configFileExists bool
@@ -432,15 +165,7 @@ func main() {
432
  }
433
 
434
  // Register the shared token store once so all components use the same persistence backend.
435
- if usePostgresStore {
436
- sdkAuth.RegisterTokenStore(pgStoreInst)
437
- } else if useObjectStore {
438
- sdkAuth.RegisterTokenStore(objectStoreInst)
439
- } else if useGitStore {
440
- sdkAuth.RegisterTokenStore(gitStoreInst)
441
- } else {
442
- sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
443
- }
444
 
445
  // Register built-in access providers before constructing services.
446
  configaccess.Register()
@@ -463,6 +188,7 @@ func main() {
463
  // Handle Claude login
464
  cmd.DoClaudeLogin(cfg, options)
465
  } else if qwenLogin {
 
466
  cmd.DoQwenLogin(cfg, options)
467
  } else if iflowLogin {
468
  cmd.DoIFlowLogin(cfg, options)
 
5
 
6
  import (
7
  "context"
 
8
  "flag"
9
  "fmt"
 
 
10
  "os"
 
 
 
11
 
 
12
  configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
13
  "github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
14
  "github.com/router-for-me/CLIProxyAPI/v6/internal/cmd"
 
15
  "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
16
  "github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
 
 
17
  _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
18
  "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
19
  "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
 
103
  // Parse the command-line flags.
104
  flag.Parse()
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  // Check for cloud deploy mode only on first execution
 
107
  deployEnv := os.Getenv("DEPLOY")
108
+ isCloudDeploy := deployEnv == "cloud"
 
 
109
 
110
+ // Bootstrap the application (load config, init token store)
111
+ bootstrap, err := Bootstrap(context.Background(), configPath, isCloudDeploy)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  if err != nil {
113
+ log.Errorf("failed to bootstrap application: %v", err)
114
  return
115
  }
116
+
117
+ cfg := bootstrap.Config
118
+ configFilePath := bootstrap.ConfigFilePath
119
 
120
  // In cloud deploy mode, check if we have a valid configuration
121
  var configFileExists bool
 
165
  }
166
 
167
  // Register the shared token store once so all components use the same persistence backend.
168
+ sdkAuth.RegisterTokenStore(bootstrap.TokenStore)
 
 
 
 
 
 
 
 
169
 
170
  // Register built-in access providers before constructing services.
171
  configaccess.Register()
 
188
  // Handle Claude login
189
  cmd.DoClaudeLogin(cfg, options)
190
  } else if qwenLogin {
191
+ // Handle Qwen login
192
  cmd.DoQwenLogin(cfg, options)
193
  } else if iflowLogin {
194
  cmd.DoIFlowLogin(cfg, options)
internal/api/handlers/management/config_lists.go CHANGED
@@ -9,31 +9,70 @@ import (
9
  "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
10
  )
11
 
12
- // Generic helpers for list[string]
13
- func (h *Handler) putStringList(c *gin.Context, set func([]string), after func()) {
14
- data, err := c.GetRawData()
15
- if err != nil {
16
- c.JSON(400, gin.H{"error": "failed to read body"})
17
- return
18
- }
19
- var arr []string
20
- if err = json.Unmarshal(data, &arr); err != nil {
21
- var obj struct {
22
- Items []string `json:"items"`
23
- }
24
- if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
25
- c.JSON(400, gin.H{"error": "invalid body"})
26
- return
27
- }
28
- arr = obj.Items
29
- }
30
- set(arr)
31
- if after != nil {
32
- after()
33
- }
34
- h.persist(c)
35
- }
36
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func()) {
38
  var body struct {
39
  Old *string `json:"old"`
@@ -74,49 +113,28 @@ func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func()
74
  c.JSON(400, gin.H{"error": "missing fields"})
75
  }
76
 
77
- func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after func()) {
78
- if idxStr := c.Query("index"); idxStr != "" {
79
- var idx int
80
- _, err := fmt.Sscanf(idxStr, "%d", &idx)
81
- if err == nil && idx >= 0 && idx < len(*target) {
82
- *target = append((*target)[:idx], (*target)[idx+1:]...)
83
- if after != nil {
84
- after()
85
- }
86
- h.persist(c)
87
- return
88
- }
89
- }
90
- if val := strings.TrimSpace(c.Query("value")); val != "" {
91
- out := make([]string, 0, len(*target))
92
- for _, v := range *target {
93
- if strings.TrimSpace(v) != val {
94
- out = append(out, v)
95
- }
96
- }
97
- *target = out
98
- if after != nil {
99
- after()
100
- }
101
- h.persist(c)
102
- return
103
- }
104
- c.JSON(400, gin.H{"error": "missing index or value"})
105
- }
106
-
107
  // api-keys
108
  func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.cfg.APIKeys}) }
109
  func (h *Handler) PutAPIKeys(c *gin.Context) {
110
- h.putStringList(c, func(v []string) {
111
  h.cfg.APIKeys = append([]string(nil), v...)
112
  h.cfg.Access.Providers = nil
113
- }, nil)
114
  }
115
  func (h *Handler) PatchAPIKeys(c *gin.Context) {
116
  h.patchStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
117
  }
118
  func (h *Handler) DeleteAPIKeys(c *gin.Context) {
119
- h.deleteFromStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
 
 
 
 
 
 
 
 
 
120
  }
121
 
122
  // gemini-api-key: []GeminiKey
@@ -124,122 +142,53 @@ func (h *Handler) GetGeminiKeys(c *gin.Context) {
124
  c.JSON(200, gin.H{"gemini-api-key": h.cfg.GeminiKey})
125
  }
126
  func (h *Handler) PutGeminiKeys(c *gin.Context) {
127
- data, err := c.GetRawData()
128
- if err != nil {
129
- c.JSON(400, gin.H{"error": "failed to read body"})
130
- return
131
- }
132
- var arr []config.GeminiKey
133
- if err = json.Unmarshal(data, &arr); err != nil {
134
- var obj struct {
135
- Items []config.GeminiKey `json:"items"`
136
- }
137
- if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
138
- c.JSON(400, gin.H{"error": "invalid body"})
139
- return
140
- }
141
- arr = obj.Items
142
- }
143
- h.cfg.GeminiKey = append([]config.GeminiKey(nil), arr...)
144
- h.cfg.SanitizeGeminiKeys()
145
- h.persist(c)
146
  }
147
  func (h *Handler) PatchGeminiKey(c *gin.Context) {
148
- type geminiKeyPatch struct {
149
- APIKey *string `json:"api-key"`
150
- Prefix *string `json:"prefix"`
151
- BaseURL *string `json:"base-url"`
152
- ProxyURL *string `json:"proxy-url"`
153
- Headers *map[string]string `json:"headers"`
154
- ExcludedModels *[]string `json:"excluded-models"`
155
- }
156
- var body struct {
157
- Index *int `json:"index"`
158
- Match *string `json:"match"`
159
- Value *geminiKeyPatch `json:"value"`
160
- }
161
- if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
162
- c.JSON(400, gin.H{"error": "invalid body"})
163
- return
164
- }
165
- targetIndex := -1
166
- if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) {
167
- targetIndex = *body.Index
168
- }
169
- if targetIndex == -1 && body.Match != nil {
170
- match := strings.TrimSpace(*body.Match)
171
- if match != "" {
172
- for i := range h.cfg.GeminiKey {
173
- if h.cfg.GeminiKey[i].APIKey == match {
174
- targetIndex = i
175
- break
176
  }
 
177
  }
178
- }
179
- }
180
- if targetIndex == -1 {
181
- c.JSON(404, gin.H{"error": "item not found"})
182
- return
183
- }
184
-
185
- entry := h.cfg.GeminiKey[targetIndex]
186
- if body.Value.APIKey != nil {
187
- trimmed := strings.TrimSpace(*body.Value.APIKey)
188
- if trimmed == "" {
189
- h.cfg.GeminiKey = append(h.cfg.GeminiKey[:targetIndex], h.cfg.GeminiKey[targetIndex+1:]...)
190
- h.cfg.SanitizeGeminiKeys()
191
- h.persist(c)
192
- return
193
- }
194
- entry.APIKey = trimmed
195
- }
196
- if body.Value.Prefix != nil {
197
- entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
198
- }
199
- if body.Value.BaseURL != nil {
200
- entry.BaseURL = strings.TrimSpace(*body.Value.BaseURL)
201
- }
202
- if body.Value.ProxyURL != nil {
203
- entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
204
- }
205
- if body.Value.Headers != nil {
206
- entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
207
- }
208
- if body.Value.ExcludedModels != nil {
209
- entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
210
- }
211
- h.cfg.GeminiKey[targetIndex] = entry
212
- h.cfg.SanitizeGeminiKeys()
213
- h.persist(c)
214
  }
215
 
216
  func (h *Handler) DeleteGeminiKey(c *gin.Context) {
217
- if val := strings.TrimSpace(c.Query("api-key")); val != "" {
218
- out := make([]config.GeminiKey, 0, len(h.cfg.GeminiKey))
219
- for _, v := range h.cfg.GeminiKey {
220
- if v.APIKey != val {
221
- out = append(out, v)
222
- }
223
- }
224
- if len(out) != len(h.cfg.GeminiKey) {
225
- h.cfg.GeminiKey = out
226
- h.cfg.SanitizeGeminiKeys()
227
- h.persist(c)
228
- } else {
229
- c.JSON(404, gin.H{"error": "item not found"})
230
- }
231
- return
232
- }
233
- if idxStr := c.Query("index"); idxStr != "" {
234
- var idx int
235
- if _, err := fmt.Sscanf(idxStr, "%d", &idx); err == nil && idx >= 0 && idx < len(h.cfg.GeminiKey) {
236
- h.cfg.GeminiKey = append(h.cfg.GeminiKey[:idx], h.cfg.GeminiKey[idx+1:]...)
237
- h.cfg.SanitizeGeminiKeys()
238
- h.persist(c)
239
- return
240
- }
241
- }
242
- c.JSON(400, gin.H{"error": "missing api-key or index"})
243
  }
244
 
245
  // kiro-api-key: []KiroKey
@@ -247,127 +196,61 @@ func (h *Handler) GetKiroKeys(c *gin.Context) {
247
  c.JSON(200, gin.H{"kiro-api-key": h.cfg.KiroKey})
248
  }
249
  func (h *Handler) PutKiroKeys(c *gin.Context) {
250
- data, err := c.GetRawData()
251
- if err != nil {
252
- c.JSON(400, gin.H{"error": "failed to read body"})
253
- return
254
- }
255
- var arr []config.KiroKey
256
- if err = json.Unmarshal(data, &arr); err != nil {
257
- var obj struct {
258
- Items []config.KiroKey `json:"items"`
259
- }
260
- if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
261
- c.JSON(400, gin.H{"error": "invalid body"})
262
- return
263
- }
264
- arr = obj.Items
265
- }
266
- for i := range arr {
267
- normalizeKiroKey(&arr[i])
268
- }
269
- h.cfg.KiroKey = arr
270
- // h.cfg.SanitizeKiroKeys() // Add sanitizer if needed
271
- h.persist(c)
272
  }
273
  func (h *Handler) PatchKiroKey(c *gin.Context) {
274
- type kiroKeyPatch struct {
275
- RefreshToken *string `json:"refresh-token"`
276
- ProfileARN *string `json:"profile-arn"`
277
- Region *string `json:"region"`
278
- Prefix *string `json:"prefix"`
279
- ProxyURL *string `json:"proxy-url"`
280
- CredentialsFile *string `json:"credentials-file"`
281
- KiroCliDBFile *string `json:"kiro-cli-db-file"`
282
- Models *[]config.KiroModel `json:"models"`
283
- Headers *map[string]string `json:"headers"`
284
- ExcludedModels *[]string `json:"excluded-models"`
285
- }
286
- var body struct {
287
- Index *int `json:"index"`
288
- Match *string `json:"match"`
289
- Value *kiroKeyPatch `json:"value"`
290
- }
291
- if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
292
- c.JSON(400, gin.H{"error": "invalid body"})
293
- return
294
- }
295
- targetIndex := -1
296
- if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.KiroKey) {
297
- targetIndex = *body.Index
298
- }
299
- if targetIndex == -1 && body.Match != nil {
300
- match := strings.TrimSpace(*body.Match)
301
- for i := range h.cfg.KiroKey {
302
- if h.cfg.KiroKey[i].RefreshToken == match {
303
- targetIndex = i
304
- break
305
  }
306
- }
307
- }
308
- if targetIndex == -1 {
309
- c.JSON(404, gin.H{"error": "item not found"})
310
- return
311
- }
312
-
313
- entry := h.cfg.KiroKey[targetIndex]
314
- if body.Value.RefreshToken != nil {
315
- entry.RefreshToken = strings.TrimSpace(*body.Value.RefreshToken)
316
- }
317
- if body.Value.ProfileARN != nil {
318
- entry.ProfileARN = strings.TrimSpace(*body.Value.ProfileARN)
319
- }
320
- if body.Value.Region != nil {
321
- entry.Region = strings.TrimSpace(*body.Value.Region)
322
- }
323
- if body.Value.Prefix != nil {
324
- entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
325
- }
326
- if body.Value.ProxyURL != nil {
327
- entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
328
- }
329
- if body.Value.CredentialsFile != nil {
330
- entry.CredentialsFile = strings.TrimSpace(*body.Value.CredentialsFile)
331
- }
332
- if body.Value.KiroCliDBFile != nil {
333
- entry.KiroCliDBFile = strings.TrimSpace(*body.Value.KiroCliDBFile)
334
- }
335
- if body.Value.Models != nil {
336
- entry.Models = append([]config.KiroModel(nil), (*body.Value.Models)...)
337
- }
338
- if body.Value.Headers != nil {
339
- entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
340
- }
341
- if body.Value.ExcludedModels != nil {
342
- entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
343
- }
344
- normalizeKiroKey(&entry)
345
- h.cfg.KiroKey[targetIndex] = entry
346
- h.persist(c)
347
  }
348
 
349
  func (h *Handler) DeleteKiroKey(c *gin.Context) {
350
- if val := c.Query("refresh-token"); val != "" {
351
- out := make([]config.KiroKey, 0, len(h.cfg.KiroKey))
352
- for _, v := range h.cfg.KiroKey {
353
- if v.RefreshToken != val {
354
- out = append(out, v)
355
- }
356
- }
357
- h.cfg.KiroKey = out
358
- h.persist(c)
359
- return
360
- }
361
- if idxStr := c.Query("index"); idxStr != "" {
362
- var idx int
363
- _, err := fmt.Sscanf(idxStr, "%d", &idx)
364
- if err == nil && idx >= 0 && idx < len(h.cfg.KiroKey) {
365
- h.cfg.KiroKey = append(h.cfg.KiroKey[:idx], h.cfg.KiroKey[idx+1:]...)
366
- h.persist(c)
367
- return
368
- }
369
- }
370
- c.JSON(400, gin.H{"error": "missing refresh-token or index"})
371
  }
372
 
373
  // claude-api-key: []ClaudeKey
@@ -375,109 +258,63 @@ func (h *Handler) GetClaudeKeys(c *gin.Context) {
375
  c.JSON(200, gin.H{"claude-api-key": h.cfg.ClaudeKey})
376
  }
377
  func (h *Handler) PutClaudeKeys(c *gin.Context) {
378
- data, err := c.GetRawData()
379
- if err != nil {
380
- c.JSON(400, gin.H{"error": "failed to read body"})
381
- return
382
- }
383
- var arr []config.ClaudeKey
384
- if err = json.Unmarshal(data, &arr); err != nil {
385
- var obj struct {
386
- Items []config.ClaudeKey `json:"items"`
387
- }
388
- if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
389
- c.JSON(400, gin.H{"error": "invalid body"})
390
- return
391
- }
392
- arr = obj.Items
393
- }
394
- for i := range arr {
395
- normalizeClaudeKey(&arr[i])
396
- }
397
- h.cfg.ClaudeKey = arr
398
- h.cfg.SanitizeClaudeKeys()
399
- h.persist(c)
400
  }
401
  func (h *Handler) PatchClaudeKey(c *gin.Context) {
402
- type claudeKeyPatch struct {
403
- APIKey *string `json:"api-key"`
404
- APIKeyEntries *[]config.ClaudeAPIKeyEntry `json:"api-key-entries"`
405
- Prefix *string `json:"prefix"`
406
- BaseURL *string `json:"base-url"`
407
- ProxyURL *string `json:"proxy-url"`
408
- Models *[]config.ClaudeModel `json:"models"`
409
- Headers *map[string]string `json:"headers"`
410
- ExcludedModels *[]string `json:"excluded-models"`
411
- }
412
- var body struct {
413
- Index *int `json:"index"`
414
- Match *string `json:"match"`
415
- Value *claudeKeyPatch `json:"value"`
416
- }
417
- if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
418
- c.JSON(400, gin.H{"error": "invalid body"})
419
- return
420
- }
421
- targetIndex := -1
422
- if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) {
423
- targetIndex = *body.Index
424
- }
425
- if targetIndex == -1 && body.Match != nil {
426
- match := strings.TrimSpace(*body.Match)
427
- for i := range h.cfg.ClaudeKey {
428
- // Match by single API key or check if match exists in APIKeyEntries
429
- if h.cfg.ClaudeKey[i].APIKey == match {
430
- targetIndex = i
431
- break
432
  }
433
- for _, entry := range h.cfg.ClaudeKey[i].APIKeyEntries {
434
  if entry.APIKey == match {
435
- targetIndex = i
436
- break
437
  }
438
  }
439
- if targetIndex != -1 {
440
- break
 
 
 
441
  }
442
- }
443
- }
444
- if targetIndex == -1 {
445
- c.JSON(404, gin.H{"error": "item not found"})
446
- return
447
- }
448
-
449
- entry := h.cfg.ClaudeKey[targetIndex]
450
- if body.Value.APIKey != nil {
451
- entry.APIKey = strings.TrimSpace(*body.Value.APIKey)
452
- }
453
- if body.Value.APIKeyEntries != nil {
454
- entry.APIKeyEntries = append([]config.ClaudeAPIKeyEntry(nil), (*body.Value.APIKeyEntries)...)
455
- }
456
- if body.Value.Prefix != nil {
457
- entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
458
- }
459
- if body.Value.BaseURL != nil {
460
- entry.BaseURL = strings.TrimSpace(*body.Value.BaseURL)
461
- }
462
- if body.Value.ProxyURL != nil {
463
- entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
464
- }
465
- if body.Value.Models != nil {
466
- entry.Models = append([]config.ClaudeModel(nil), (*body.Value.Models)...)
467
- }
468
- if body.Value.Headers != nil {
469
- entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
470
- }
471
- if body.Value.ExcludedModels != nil {
472
- entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
473
- }
474
- normalizeClaudeKey(&entry)
475
- h.cfg.ClaudeKey[targetIndex] = entry
476
- h.cfg.SanitizeClaudeKeys()
477
- h.persist(c)
478
  }
479
 
480
  func (h *Handler) DeleteClaudeKey(c *gin.Context) {
 
 
 
 
 
481
  if val := c.Query("api-key"); val != "" {
482
  out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))
483
  for _, v := range h.cfg.ClaudeKey {
@@ -502,6 +339,11 @@ func (h *Handler) DeleteClaudeKey(c *gin.Context) {
502
  h.persist(c)
503
  return
504
  }
 
 
 
 
 
505
  if idxStr := c.Query("index"); idxStr != "" {
506
  var idx int
507
  _, err := fmt.Sscanf(idxStr, "%d", &idx)
@@ -520,125 +362,63 @@ func (h *Handler) GetOpenAICompat(c *gin.Context) {
520
  c.JSON(200, gin.H{"openai-compatibility": normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)})
521
  }
522
  func (h *Handler) PutOpenAICompat(c *gin.Context) {
523
- data, err := c.GetRawData()
524
- if err != nil {
525
- c.JSON(400, gin.H{"error": "failed to read body"})
526
- return
527
- }
528
- var arr []config.OpenAICompatibility
529
- if err = json.Unmarshal(data, &arr); err != nil {
530
- var obj struct {
531
- Items []config.OpenAICompatibility `json:"items"`
532
- }
533
- if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
534
- c.JSON(400, gin.H{"error": "invalid body"})
535
- return
536
- }
537
- arr = obj.Items
538
- }
539
- filtered := make([]config.OpenAICompatibility, 0, len(arr))
540
- for i := range arr {
541
- normalizeOpenAICompatibilityEntry(&arr[i])
542
- if strings.TrimSpace(arr[i].BaseURL) != "" {
543
- filtered = append(filtered, arr[i])
544
- }
545
- }
546
- h.cfg.OpenAICompatibility = filtered
547
- h.cfg.SanitizeOpenAICompatibility()
548
- h.persist(c)
549
  }
550
  func (h *Handler) PatchOpenAICompat(c *gin.Context) {
551
- type openAICompatPatch struct {
552
- Name *string `json:"name"`
553
- Prefix *string `json:"prefix"`
554
- BaseURL *string `json:"base-url"`
555
- APIKeyEntries *[]config.OpenAICompatibilityAPIKey `json:"api-key-entries"`
556
- Models *[]config.OpenAICompatibilityModel `json:"models"`
557
- Headers *map[string]string `json:"headers"`
558
- }
559
- var body struct {
560
- Name *string `json:"name"`
561
- Index *int `json:"index"`
562
- Value *openAICompatPatch `json:"value"`
563
- }
564
- if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
565
- c.JSON(400, gin.H{"error": "invalid body"})
566
- return
567
- }
568
- targetIndex := -1
569
- if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
570
- targetIndex = *body.Index
571
- }
572
- if targetIndex == -1 && body.Name != nil {
573
- match := strings.TrimSpace(*body.Name)
574
- for i := range h.cfg.OpenAICompatibility {
575
- if h.cfg.OpenAICompatibility[i].Name == match {
576
- targetIndex = i
577
- break
578
  }
579
- }
580
- }
581
- if targetIndex == -1 {
582
- c.JSON(404, gin.H{"error": "item not found"})
583
- return
584
- }
585
-
586
- entry := h.cfg.OpenAICompatibility[targetIndex]
587
- if body.Value.Name != nil {
588
- entry.Name = strings.TrimSpace(*body.Value.Name)
589
- }
590
- if body.Value.Prefix != nil {
591
- entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
592
- }
593
- if body.Value.BaseURL != nil {
594
- trimmed := strings.TrimSpace(*body.Value.BaseURL)
595
- if trimmed == "" {
596
- h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:targetIndex], h.cfg.OpenAICompatibility[targetIndex+1:]...)
597
- h.cfg.SanitizeOpenAICompatibility()
598
- h.persist(c)
599
- return
600
- }
601
- entry.BaseURL = trimmed
602
- }
603
- if body.Value.APIKeyEntries != nil {
604
- entry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), (*body.Value.APIKeyEntries)...)
605
- }
606
- if body.Value.Models != nil {
607
- entry.Models = append([]config.OpenAICompatibilityModel(nil), (*body.Value.Models)...)
608
- }
609
- if body.Value.Headers != nil {
610
- entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
611
- }
612
- normalizeOpenAICompatibilityEntry(&entry)
613
- h.cfg.OpenAICompatibility[targetIndex] = entry
614
- h.cfg.SanitizeOpenAICompatibility()
615
- h.persist(c)
616
  }
617
 
618
  func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
619
- if name := c.Query("name"); name != "" {
620
- out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility))
621
- for _, v := range h.cfg.OpenAICompatibility {
622
- if v.Name != name {
623
- out = append(out, v)
624
- }
625
- }
626
- h.cfg.OpenAICompatibility = out
627
- h.cfg.SanitizeOpenAICompatibility()
628
- h.persist(c)
629
- return
630
- }
631
- if idxStr := c.Query("index"); idxStr != "" {
632
- var idx int
633
- _, err := fmt.Sscanf(idxStr, "%d", &idx)
634
- if err == nil && idx >= 0 && idx < len(h.cfg.OpenAICompatibility) {
635
- h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:idx], h.cfg.OpenAICompatibility[idx+1:]...)
636
- h.cfg.SanitizeOpenAICompatibility()
637
- h.persist(c)
638
- return
639
- }
640
- }
641
- c.JSON(400, gin.H{"error": "missing name or index"})
642
  }
643
 
644
  // vertex-api-key: []VertexCompatKey
@@ -646,133 +426,61 @@ func (h *Handler) GetVertexCompatKeys(c *gin.Context) {
646
  c.JSON(200, gin.H{"vertex-api-key": h.cfg.VertexCompatAPIKey})
647
  }
648
  func (h *Handler) PutVertexCompatKeys(c *gin.Context) {
649
- data, err := c.GetRawData()
650
- if err != nil {
651
- c.JSON(400, gin.H{"error": "failed to read body"})
652
- return
653
- }
654
- var arr []config.VertexCompatKey
655
- if err = json.Unmarshal(data, &arr); err != nil {
656
- var obj struct {
657
- Items []config.VertexCompatKey `json:"items"`
658
- }
659
- if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
660
- c.JSON(400, gin.H{"error": "invalid body"})
661
- return
662
- }
663
- arr = obj.Items
664
- }
665
- for i := range arr {
666
- normalizeVertexCompatKey(&arr[i])
667
- }
668
- h.cfg.VertexCompatAPIKey = arr
669
- h.cfg.SanitizeVertexCompatKeys()
670
- h.persist(c)
671
  }
672
  func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
673
- type vertexCompatPatch struct {
674
- APIKey *string `json:"api-key"`
675
- Prefix *string `json:"prefix"`
676
- BaseURL *string `json:"base-url"`
677
- ProxyURL *string `json:"proxy-url"`
678
- Headers *map[string]string `json:"headers"`
679
- Models *[]config.VertexCompatModel `json:"models"`
680
- }
681
- var body struct {
682
- Index *int `json:"index"`
683
- Match *string `json:"match"`
684
- Value *vertexCompatPatch `json:"value"`
685
- }
686
- if errBindJSON := c.ShouldBindJSON(&body); errBindJSON != nil || body.Value == nil {
687
- c.JSON(400, gin.H{"error": "invalid body"})
688
- return
689
- }
690
- targetIndex := -1
691
- if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.VertexCompatAPIKey) {
692
- targetIndex = *body.Index
693
- }
694
- if targetIndex == -1 && body.Match != nil {
695
- match := strings.TrimSpace(*body.Match)
696
- if match != "" {
697
- for i := range h.cfg.VertexCompatAPIKey {
698
- if h.cfg.VertexCompatAPIKey[i].APIKey == match {
699
- targetIndex = i
700
- break
701
  }
 
702
  }
703
- }
704
- }
705
- if targetIndex == -1 {
706
- c.JSON(404, gin.H{"error": "item not found"})
707
- return
708
- }
709
-
710
- entry := h.cfg.VertexCompatAPIKey[targetIndex]
711
- if body.Value.APIKey != nil {
712
- trimmed := strings.TrimSpace(*body.Value.APIKey)
713
- if trimmed == "" {
714
- h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)
715
- h.cfg.SanitizeVertexCompatKeys()
716
- h.persist(c)
717
- return
718
- }
719
- entry.APIKey = trimmed
720
- }
721
- if body.Value.Prefix != nil {
722
- entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
723
- }
724
- if body.Value.BaseURL != nil {
725
- trimmed := strings.TrimSpace(*body.Value.BaseURL)
726
- if trimmed == "" {
727
- h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:targetIndex], h.cfg.VertexCompatAPIKey[targetIndex+1:]...)
728
- h.cfg.SanitizeVertexCompatKeys()
729
- h.persist(c)
730
- return
731
- }
732
- entry.BaseURL = trimmed
733
- }
734
- if body.Value.ProxyURL != nil {
735
- entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
736
- }
737
- if body.Value.Headers != nil {
738
- entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
739
- }
740
- if body.Value.Models != nil {
741
- entry.Models = append([]config.VertexCompatModel(nil), (*body.Value.Models)...)
742
- }
743
- normalizeVertexCompatKey(&entry)
744
- h.cfg.VertexCompatAPIKey[targetIndex] = entry
745
- h.cfg.SanitizeVertexCompatKeys()
746
- h.persist(c)
747
  }
748
 
749
  func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
750
- if val := strings.TrimSpace(c.Query("api-key")); val != "" {
751
- out := make([]config.VertexCompatKey, 0, len(h.cfg.VertexCompatAPIKey))
752
- for _, v := range h.cfg.VertexCompatAPIKey {
753
- if v.APIKey != val {
754
- out = append(out, v)
755
- }
756
- }
757
- h.cfg.VertexCompatAPIKey = out
758
- h.cfg.SanitizeVertexCompatKeys()
759
- h.persist(c)
760
- return
761
- }
762
- if idxStr := c.Query("index"); idxStr != "" {
763
- var idx int
764
- _, errScan := fmt.Sscanf(idxStr, "%d", &idx)
765
- if errScan == nil && idx >= 0 && idx < len(h.cfg.VertexCompatAPIKey) {
766
- h.cfg.VertexCompatAPIKey = append(h.cfg.VertexCompatAPIKey[:idx], h.cfg.VertexCompatAPIKey[idx+1:]...)
767
- h.cfg.SanitizeVertexCompatKeys()
768
- h.persist(c)
769
- return
770
- }
771
- }
772
- c.JSON(400, gin.H{"error": "missing api-key or index"})
773
  }
774
 
775
  // oauth-excluded-models: map[string][]string
 
776
  func (h *Handler) GetOAuthExcludedModels(c *gin.Context) {
777
  c.JSON(200, gin.H{"oauth-excluded-models": config.NormalizeOAuthExcludedModels(h.cfg.OAuthExcludedModels)})
778
  }
@@ -858,6 +566,7 @@ func (h *Handler) DeleteOAuthExcludedModels(c *gin.Context) {
858
  }
859
 
860
  // oauth-model-alias: map[string][]OAuthModelAlias
 
861
  func (h *Handler) GetOAuthModelAlias(c *gin.Context) {
862
  c.JSON(200, gin.H{"oauth-model-alias": sanitizedOAuthModelAlias(h.cfg.OAuthModelAlias)})
863
  }
@@ -959,132 +668,56 @@ func (h *Handler) GetCodexKeys(c *gin.Context) {
959
  c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey})
960
  }
961
  func (h *Handler) PutCodexKeys(c *gin.Context) {
962
- data, err := c.GetRawData()
963
- if err != nil {
964
- c.JSON(400, gin.H{"error": "failed to read body"})
965
- return
966
- }
967
- var arr []config.CodexKey
968
- if err = json.Unmarshal(data, &arr); err != nil {
969
- var obj struct {
970
- Items []config.CodexKey `json:"items"`
971
- }
972
- if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
973
- c.JSON(400, gin.H{"error": "invalid body"})
974
- return
975
- }
976
- arr = obj.Items
977
- }
978
- // Filter out codex entries with empty base-url (treat as removed)
979
- filtered := make([]config.CodexKey, 0, len(arr))
980
- for i := range arr {
981
- entry := arr[i]
982
- normalizeCodexKey(&entry)
983
- if entry.BaseURL == "" {
984
- continue
985
- }
986
- filtered = append(filtered, entry)
987
- }
988
- h.cfg.CodexKey = filtered
989
- h.cfg.SanitizeCodexKeys()
990
- h.persist(c)
991
  }
992
  func (h *Handler) PatchCodexKey(c *gin.Context) {
993
- type codexKeyPatch struct {
994
- APIKey *string `json:"api-key"`
995
- Prefix *string `json:"prefix"`
996
- BaseURL *string `json:"base-url"`
997
- ProxyURL *string `json:"proxy-url"`
998
- Models *[]config.CodexModel `json:"models"`
999
- Headers *map[string]string `json:"headers"`
1000
- ExcludedModels *[]string `json:"excluded-models"`
1001
- }
1002
- var body struct {
1003
- Index *int `json:"index"`
1004
- Match *string `json:"match"`
1005
- Value *codexKeyPatch `json:"value"`
1006
- }
1007
- if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
1008
- c.JSON(400, gin.H{"error": "invalid body"})
1009
- return
1010
- }
1011
- targetIndex := -1
1012
- if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
1013
- targetIndex = *body.Index
1014
- }
1015
- if targetIndex == -1 && body.Match != nil {
1016
- match := strings.TrimSpace(*body.Match)
1017
- for i := range h.cfg.CodexKey {
1018
- if h.cfg.CodexKey[i].APIKey == match {
1019
- targetIndex = i
1020
- break
1021
  }
1022
- }
1023
- }
1024
- if targetIndex == -1 {
1025
- c.JSON(404, gin.H{"error": "item not found"})
1026
- return
1027
- }
1028
-
1029
- entry := h.cfg.CodexKey[targetIndex]
1030
- if body.Value.APIKey != nil {
1031
- entry.APIKey = strings.TrimSpace(*body.Value.APIKey)
1032
- }
1033
- if body.Value.Prefix != nil {
1034
- entry.Prefix = strings.TrimSpace(*body.Value.Prefix)
1035
- }
1036
- if body.Value.BaseURL != nil {
1037
- trimmed := strings.TrimSpace(*body.Value.BaseURL)
1038
- if trimmed == "" {
1039
- h.cfg.CodexKey = append(h.cfg.CodexKey[:targetIndex], h.cfg.CodexKey[targetIndex+1:]...)
1040
- h.cfg.SanitizeCodexKeys()
1041
- h.persist(c)
1042
- return
1043
- }
1044
- entry.BaseURL = trimmed
1045
- }
1046
- if body.Value.ProxyURL != nil {
1047
- entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL)
1048
- }
1049
- if body.Value.Models != nil {
1050
- entry.Models = append([]config.CodexModel(nil), (*body.Value.Models)...)
1051
- }
1052
- if body.Value.Headers != nil {
1053
- entry.Headers = config.NormalizeHeaders(*body.Value.Headers)
1054
- }
1055
- if body.Value.ExcludedModels != nil {
1056
- entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
1057
- }
1058
- normalizeCodexKey(&entry)
1059
- h.cfg.CodexKey[targetIndex] = entry
1060
- h.cfg.SanitizeCodexKeys()
1061
- h.persist(c)
1062
  }
1063
 
1064
  func (h *Handler) DeleteCodexKey(c *gin.Context) {
1065
- if val := c.Query("api-key"); val != "" {
1066
- out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
1067
- for _, v := range h.cfg.CodexKey {
1068
- if v.APIKey != val {
1069
- out = append(out, v)
1070
- }
1071
- }
1072
- h.cfg.CodexKey = out
1073
- h.cfg.SanitizeCodexKeys()
1074
- h.persist(c)
1075
- return
1076
- }
1077
- if idxStr := c.Query("index"); idxStr != "" {
1078
- var idx int
1079
- _, err := fmt.Sscanf(idxStr, "%d", &idx)
1080
- if err == nil && idx >= 0 && idx < len(h.cfg.CodexKey) {
1081
- h.cfg.CodexKey = append(h.cfg.CodexKey[:idx], h.cfg.CodexKey[idx+1:]...)
1082
- h.cfg.SanitizeCodexKeys()
1083
- h.persist(c)
1084
- return
1085
- }
1086
- }
1087
- c.JSON(400, gin.H{"error": "missing api-key or index"})
1088
  }
1089
 
1090
  func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) {
 
9
  "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
10
  )
11
 
12
+ // Patch structs for generic handlers
13
+
14
+ type geminiKeyPatch struct {
15
+ APIKey *string `json:"api-key"`
16
+ Prefix *string `json:"prefix"`
17
+ BaseURL *string `json:"base-url"`
18
+ ProxyURL *string `json:"proxy-url"`
19
+ Headers *map[string]string `json:"headers"`
20
+ ExcludedModels *[]string `json:"excluded-models"`
21
+ }
22
+
23
+ type kiroKeyPatch struct {
24
+ RefreshToken *string `json:"refresh-token"`
25
+ ProfileARN *string `json:"profile-arn"`
26
+ Region *string `json:"region"`
27
+ Prefix *string `json:"prefix"`
28
+ ProxyURL *string `json:"proxy-url"`
29
+ CredentialsFile *string `json:"credentials-file"`
30
+ KiroCliDBFile *string `json:"kiro-cli-db-file"`
31
+ Models *[]config.KiroModel `json:"models"`
32
+ Headers *map[string]string `json:"headers"`
33
+ ExcludedModels *[]string `json:"excluded-models"`
34
+ }
35
+
36
+ type claudeKeyPatch struct {
37
+ APIKey *string `json:"api-key"`
38
+ APIKeyEntries *[]config.ClaudeAPIKeyEntry `json:"api-key-entries"`
39
+ Prefix *string `json:"prefix"`
40
+ BaseURL *string `json:"base-url"`
41
+ ProxyURL *string `json:"proxy-url"`
42
+ Models *[]config.ClaudeModel `json:"models"`
43
+ Headers *map[string]string `json:"headers"`
44
+ ExcludedModels *[]string `json:"excluded-models"`
45
+ }
46
+
47
+ type openAICompatPatch struct {
48
+ Name *string `json:"name"`
49
+ Prefix *string `json:"prefix"`
50
+ BaseURL *string `json:"base-url"`
51
+ APIKeyEntries *[]config.OpenAICompatibilityAPIKey `json:"api-key-entries"`
52
+ Models *[]config.OpenAICompatibilityModel `json:"models"`
53
+ Headers *map[string]string `json:"headers"`
54
+ }
55
+
56
+ type vertexCompatPatch struct {
57
+ APIKey *string `json:"api-key"`
58
+ Prefix *string `json:"prefix"`
59
+ BaseURL *string `json:"base-url"`
60
+ ProxyURL *string `json:"proxy-url"`
61
+ Headers *map[string]string `json:"headers"`
62
+ Models *[]config.VertexCompatModel `json:"models"`
63
+ }
64
+
65
+ type codexKeyPatch struct {
66
+ APIKey *string `json:"api-key"`
67
+ Prefix *string `json:"prefix"`
68
+ BaseURL *string `json:"base-url"`
69
+ ProxyURL *string `json:"proxy-url"`
70
+ Models *[]config.CodexModel `json:"models"`
71
+ Headers *map[string]string `json:"headers"`
72
+ ExcludedModels *[]string `json:"excluded-models"`
73
+ }
74
+
75
+ // Special helper for string list patch (API Keys) due to unique Old/New format support
76
  func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func()) {
77
  var body struct {
78
  Old *string `json:"old"`
 
113
  c.JSON(400, gin.H{"error": "missing fields"})
114
  }
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  // api-keys
117
  func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.cfg.APIKeys}) }
118
  func (h *Handler) PutAPIKeys(c *gin.Context) {
119
+ genericPutList(h, c, func(v []string) {
120
  h.cfg.APIKeys = append([]string(nil), v...)
121
  h.cfg.Access.Providers = nil
122
+ }, nil, nil)
123
  }
124
  func (h *Handler) PatchAPIKeys(c *gin.Context) {
125
  h.patchStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
126
  }
127
  func (h *Handler) DeleteAPIKeys(c *gin.Context) {
128
+ genericDeleteList(h, c,
129
+ func() []string { return h.cfg.APIKeys },
130
+ func(v []string) {
131
+ h.cfg.APIKeys = v
132
+ h.cfg.Access.Providers = nil
133
+ },
134
+ func(item string, val string) bool { return strings.TrimSpace(item) == val },
135
+ "value",
136
+ nil,
137
+ )
138
  }
139
 
140
  // gemini-api-key: []GeminiKey
 
142
  c.JSON(200, gin.H{"gemini-api-key": h.cfg.GeminiKey})
143
  }
144
  func (h *Handler) PutGeminiKeys(c *gin.Context) {
145
+ genericPutList(h, c, func(v []config.GeminiKey) {
146
+ h.cfg.GeminiKey = append([]config.GeminiKey(nil), v...)
147
+ }, nil, h.cfg.SanitizeGeminiKeys)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  }
149
  func (h *Handler) PatchGeminiKey(c *gin.Context) {
150
+ genericPatchList(h, c,
151
+ func() []config.GeminiKey { return h.cfg.GeminiKey },
152
+ func(v []config.GeminiKey) { h.cfg.GeminiKey = v },
153
+ func(item config.GeminiKey, match string) bool { return item.APIKey == match },
154
+ func(item *config.GeminiKey, patch geminiKeyPatch) bool {
155
+ if patch.APIKey != nil {
156
+ trimmed := strings.TrimSpace(*patch.APIKey)
157
+ if trimmed == "" {
158
+ return false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  }
160
+ item.APIKey = trimmed
161
  }
162
+ if patch.Prefix != nil {
163
+ item.Prefix = strings.TrimSpace(*patch.Prefix)
164
+ }
165
+ if patch.BaseURL != nil {
166
+ item.BaseURL = strings.TrimSpace(*patch.BaseURL)
167
+ }
168
+ if patch.ProxyURL != nil {
169
+ item.ProxyURL = strings.TrimSpace(*patch.ProxyURL)
170
+ }
171
+ if patch.Headers != nil {
172
+ item.Headers = config.NormalizeHeaders(*patch.Headers)
173
+ }
174
+ if patch.ExcludedModels != nil {
175
+ item.ExcludedModels = config.NormalizeExcludedModels(*patch.ExcludedModels)
176
+ }
177
+ return true
178
+ },
179
+ nil,
180
+ h.cfg.SanitizeGeminiKeys,
181
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  }
183
 
184
  func (h *Handler) DeleteGeminiKey(c *gin.Context) {
185
+ genericDeleteList(h, c,
186
+ func() []config.GeminiKey { return h.cfg.GeminiKey },
187
+ func(v []config.GeminiKey) { h.cfg.GeminiKey = v },
188
+ func(item config.GeminiKey, val string) bool { return item.APIKey == val },
189
+ "api-key",
190
+ h.cfg.SanitizeGeminiKeys,
191
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  }
193
 
194
  // kiro-api-key: []KiroKey
 
196
  c.JSON(200, gin.H{"kiro-api-key": h.cfg.KiroKey})
197
  }
198
  func (h *Handler) PutKiroKeys(c *gin.Context) {
199
+ genericPutList(h, c, func(v []config.KiroKey) {
200
+ h.cfg.KiroKey = v
201
+ }, normalizeKiroKey, nil)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  }
203
  func (h *Handler) PatchKiroKey(c *gin.Context) {
204
+ genericPatchList(h, c,
205
+ func() []config.KiroKey { return h.cfg.KiroKey },
206
+ func(v []config.KiroKey) { h.cfg.KiroKey = v },
207
+ func(item config.KiroKey, match string) bool { return item.RefreshToken == match },
208
+ func(item *config.KiroKey, patch kiroKeyPatch) bool {
209
+ if patch.RefreshToken != nil {
210
+ item.RefreshToken = strings.TrimSpace(*patch.RefreshToken)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  }
212
+ if patch.ProfileARN != nil {
213
+ item.ProfileARN = strings.TrimSpace(*patch.ProfileARN)
214
+ }
215
+ if patch.Region != nil {
216
+ item.Region = strings.TrimSpace(*patch.Region)
217
+ }
218
+ if patch.Prefix != nil {
219
+ item.Prefix = strings.TrimSpace(*patch.Prefix)
220
+ }
221
+ if patch.ProxyURL != nil {
222
+ item.ProxyURL = strings.TrimSpace(*patch.ProxyURL)
223
+ }
224
+ if patch.CredentialsFile != nil {
225
+ item.CredentialsFile = strings.TrimSpace(*patch.CredentialsFile)
226
+ }
227
+ if patch.KiroCliDBFile != nil {
228
+ item.KiroCliDBFile = strings.TrimSpace(*patch.KiroCliDBFile)
229
+ }
230
+ if patch.Models != nil {
231
+ item.Models = append([]config.KiroModel(nil), (*patch.Models)...)
232
+ }
233
+ if patch.Headers != nil {
234
+ item.Headers = config.NormalizeHeaders(*patch.Headers)
235
+ }
236
+ if patch.ExcludedModels != nil {
237
+ item.ExcludedModels = config.NormalizeExcludedModels(*patch.ExcludedModels)
238
+ }
239
+ return true
240
+ },
241
+ normalizeKiroKey,
242
+ nil,
243
+ )
 
 
 
 
 
 
 
 
 
244
  }
245
 
246
  func (h *Handler) DeleteKiroKey(c *gin.Context) {
247
+ genericDeleteList(h, c,
248
+ func() []config.KiroKey { return h.cfg.KiroKey },
249
+ func(v []config.KiroKey) { h.cfg.KiroKey = v },
250
+ func(item config.KiroKey, val string) bool { return item.RefreshToken == val },
251
+ "refresh-token",
252
+ nil,
253
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  }
255
 
256
  // claude-api-key: []ClaudeKey
 
258
  c.JSON(200, gin.H{"claude-api-key": h.cfg.ClaudeKey})
259
  }
260
  func (h *Handler) PutClaudeKeys(c *gin.Context) {
261
+ genericPutList(h, c, func(v []config.ClaudeKey) {
262
+ h.cfg.ClaudeKey = v
263
+ }, normalizeClaudeKey, h.cfg.SanitizeClaudeKeys)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  }
265
  func (h *Handler) PatchClaudeKey(c *gin.Context) {
266
+ genericPatchList(h, c,
267
+ func() []config.ClaudeKey { return h.cfg.ClaudeKey },
268
+ func(v []config.ClaudeKey) { h.cfg.ClaudeKey = v },
269
+ func(item config.ClaudeKey, match string) bool {
270
+ if item.APIKey == match {
271
+ return true
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  }
273
+ for _, entry := range item.APIKeyEntries {
274
  if entry.APIKey == match {
275
+ return true
 
276
  }
277
  }
278
+ return false
279
+ },
280
+ func(item *config.ClaudeKey, patch claudeKeyPatch) bool {
281
+ if patch.APIKey != nil {
282
+ item.APIKey = strings.TrimSpace(*patch.APIKey)
283
  }
284
+ if patch.APIKeyEntries != nil {
285
+ item.APIKeyEntries = append([]config.ClaudeAPIKeyEntry(nil), (*patch.APIKeyEntries)...)
286
+ }
287
+ if patch.Prefix != nil {
288
+ item.Prefix = strings.TrimSpace(*patch.Prefix)
289
+ }
290
+ if patch.BaseURL != nil {
291
+ item.BaseURL = strings.TrimSpace(*patch.BaseURL)
292
+ }
293
+ if patch.ProxyURL != nil {
294
+ item.ProxyURL = strings.TrimSpace(*patch.ProxyURL)
295
+ }
296
+ if patch.Models != nil {
297
+ item.Models = append([]config.ClaudeModel(nil), (*patch.Models)...)
298
+ }
299
+ if patch.Headers != nil {
300
+ item.Headers = config.NormalizeHeaders(*patch.Headers)
301
+ }
302
+ if patch.ExcludedModels != nil {
303
+ item.ExcludedModels = config.NormalizeExcludedModels(*patch.ExcludedModels)
304
+ }
305
+ return true
306
+ },
307
+ normalizeClaudeKey,
308
+ h.cfg.SanitizeClaudeKeys,
309
+ )
 
 
 
 
 
 
 
 
 
 
310
  }
311
 
312
  func (h *Handler) DeleteClaudeKey(c *gin.Context) {
313
+ // Custom logic needed for APIKeyEntries deletion support
314
+ // existing handler removes either the whole key OR just an entry from APIKeyEntries
315
+ // This is not supported by genericDeleteList (which removes items from the list).
316
+ // So we keep custom implementation for now, but cleaner.
317
+
318
  if val := c.Query("api-key"); val != "" {
319
  out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))
320
  for _, v := range h.cfg.ClaudeKey {
 
339
  h.persist(c)
340
  return
341
  }
342
+ // Index deletion can use generic logic, but mix and match is messy.
343
+ // Let's fallback to genericDeleteList for index, and manually handle api-key above.
344
+ // Actually, if we use genericDeleteList, we can't handle the "partial delete" of APIKeyEntries.
345
+ // So we keep the custom implementation for Claude Delete.
346
+
347
  if idxStr := c.Query("index"); idxStr != "" {
348
  var idx int
349
  _, err := fmt.Sscanf(idxStr, "%d", &idx)
 
362
  c.JSON(200, gin.H{"openai-compatibility": normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)})
363
  }
364
  func (h *Handler) PutOpenAICompat(c *gin.Context) {
365
+ // Custom Put because of filter logic (BaseURL check)
366
+ // We can use genericPutList with normalize, but we need to remove items if BaseURL is empty.
367
+ // genericPutList normalize works on *T. If we empty it, it stays.
368
+ // So we can't use genericPutList if we need to filter *during* Put.
369
+ // Let's keep custom implementation.
370
+ // Or we can rely on SanitizeOpenAICompatibility to remove empty BaseURL entries?
371
+ // Existing SanitizeOpenAICompatibility:
372
+ // "SanitizeOpenAICompatibility providers: drop entries without base-url"
373
+ // So yes, we can use genericPutList + Sanitize!
374
+
375
+ genericPutList(h, c, func(v []config.OpenAICompatibility) {
376
+ h.cfg.OpenAICompatibility = v
377
+ }, normalizeOpenAICompatibilityEntry, h.cfg.SanitizeOpenAICompatibility)
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  }
379
  func (h *Handler) PatchOpenAICompat(c *gin.Context) {
380
+ genericPatchList(h, c,
381
+ func() []config.OpenAICompatibility { return h.cfg.OpenAICompatibility },
382
+ func(v []config.OpenAICompatibility) { h.cfg.OpenAICompatibility = v },
383
+ func(item config.OpenAICompatibility, match string) bool { return item.Name == match },
384
+ func(item *config.OpenAICompatibility, patch openAICompatPatch) bool {
385
+ if patch.Name != nil {
386
+ item.Name = strings.TrimSpace(*patch.Name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  }
388
+ if patch.Prefix != nil {
389
+ item.Prefix = strings.TrimSpace(*patch.Prefix)
390
+ }
391
+ if patch.BaseURL != nil {
392
+ trimmed := strings.TrimSpace(*patch.BaseURL)
393
+ if trimmed == "" {
394
+ return false // Remove if base URL is cleared
395
+ }
396
+ item.BaseURL = trimmed
397
+ }
398
+ if patch.APIKeyEntries != nil {
399
+ item.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), (*patch.APIKeyEntries)...)
400
+ }
401
+ if patch.Models != nil {
402
+ item.Models = append([]config.OpenAICompatibilityModel(nil), (*patch.Models)...)
403
+ }
404
+ if patch.Headers != nil {
405
+ item.Headers = config.NormalizeHeaders(*patch.Headers)
406
+ }
407
+ return true
408
+ },
409
+ normalizeOpenAICompatibilityEntry,
410
+ h.cfg.SanitizeOpenAICompatibility,
411
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  }
413
 
414
  func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
415
+ genericDeleteList(h, c,
416
+ func() []config.OpenAICompatibility { return h.cfg.OpenAICompatibility },
417
+ func(v []config.OpenAICompatibility) { h.cfg.OpenAICompatibility = v },
418
+ func(item config.OpenAICompatibility, val string) bool { return item.Name == val },
419
+ "name",
420
+ h.cfg.SanitizeOpenAICompatibility,
421
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  }
423
 
424
  // vertex-api-key: []VertexCompatKey
 
426
  c.JSON(200, gin.H{"vertex-api-key": h.cfg.VertexCompatAPIKey})
427
  }
428
  func (h *Handler) PutVertexCompatKeys(c *gin.Context) {
429
+ genericPutList(h, c, func(v []config.VertexCompatKey) {
430
+ h.cfg.VertexCompatAPIKey = v
431
+ }, normalizeVertexCompatKey, h.cfg.SanitizeVertexCompatKeys)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  }
433
  func (h *Handler) PatchVertexCompatKey(c *gin.Context) {
434
+ genericPatchList(h, c,
435
+ func() []config.VertexCompatKey { return h.cfg.VertexCompatAPIKey },
436
+ func(v []config.VertexCompatKey) { h.cfg.VertexCompatAPIKey = v },
437
+ func(item config.VertexCompatKey, match string) bool { return item.APIKey == match },
438
+ func(item *config.VertexCompatKey, patch vertexCompatPatch) bool {
439
+ if patch.APIKey != nil {
440
+ trimmed := strings.TrimSpace(*patch.APIKey)
441
+ if trimmed == "" {
442
+ return false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
  }
444
+ item.APIKey = trimmed
445
  }
446
+ if patch.Prefix != nil {
447
+ item.Prefix = strings.TrimSpace(*patch.Prefix)
448
+ }
449
+ if patch.BaseURL != nil {
450
+ trimmed := strings.TrimSpace(*patch.BaseURL)
451
+ if trimmed == "" {
452
+ return false
453
+ }
454
+ item.BaseURL = trimmed
455
+ }
456
+ if patch.ProxyURL != nil {
457
+ item.ProxyURL = strings.TrimSpace(*patch.ProxyURL)
458
+ }
459
+ if patch.Headers != nil {
460
+ item.Headers = config.NormalizeHeaders(*patch.Headers)
461
+ }
462
+ if patch.Models != nil {
463
+ item.Models = append([]config.VertexCompatModel(nil), (*patch.Models)...)
464
+ }
465
+ return true
466
+ },
467
+ normalizeVertexCompatKey,
468
+ h.cfg.SanitizeVertexCompatKeys,
469
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  }
471
 
472
  func (h *Handler) DeleteVertexCompatKey(c *gin.Context) {
473
+ genericDeleteList(h, c,
474
+ func() []config.VertexCompatKey { return h.cfg.VertexCompatAPIKey },
475
+ func(v []config.VertexCompatKey) { h.cfg.VertexCompatAPIKey = v },
476
+ func(item config.VertexCompatKey, val string) bool { return item.APIKey == val },
477
+ "api-key",
478
+ h.cfg.SanitizeVertexCompatKeys,
479
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
480
  }
481
 
482
  // oauth-excluded-models: map[string][]string
483
+ // ... (Keeping as is, it's a map)
484
  func (h *Handler) GetOAuthExcludedModels(c *gin.Context) {
485
  c.JSON(200, gin.H{"oauth-excluded-models": config.NormalizeOAuthExcludedModels(h.cfg.OAuthExcludedModels)})
486
  }
 
566
  }
567
 
568
  // oauth-model-alias: map[string][]OAuthModelAlias
569
+ // ... (Keeping as is, it's a map)
570
  func (h *Handler) GetOAuthModelAlias(c *gin.Context) {
571
  c.JSON(200, gin.H{"oauth-model-alias": sanitizedOAuthModelAlias(h.cfg.OAuthModelAlias)})
572
  }
 
668
  c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey})
669
  }
670
  func (h *Handler) PutCodexKeys(c *gin.Context) {
671
+ genericPutList(h, c, func(v []config.CodexKey) {
672
+ h.cfg.CodexKey = v
673
+ }, normalizeCodexKey, h.cfg.SanitizeCodexKeys)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
674
  }
675
  func (h *Handler) PatchCodexKey(c *gin.Context) {
676
+ genericPatchList(h, c,
677
+ func() []config.CodexKey { return h.cfg.CodexKey },
678
+ func(v []config.CodexKey) { h.cfg.CodexKey = v },
679
+ func(item config.CodexKey, match string) bool { return item.APIKey == match },
680
+ func(item *config.CodexKey, patch codexKeyPatch) bool {
681
+ if patch.APIKey != nil {
682
+ item.APIKey = strings.TrimSpace(*patch.APIKey)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
683
  }
684
+ if patch.Prefix != nil {
685
+ item.Prefix = strings.TrimSpace(*patch.Prefix)
686
+ }
687
+ if patch.BaseURL != nil {
688
+ trimmed := strings.TrimSpace(*patch.BaseURL)
689
+ if trimmed == "" {
690
+ return false
691
+ }
692
+ item.BaseURL = trimmed
693
+ }
694
+ if patch.ProxyURL != nil {
695
+ item.ProxyURL = strings.TrimSpace(*patch.ProxyURL)
696
+ }
697
+ if patch.Models != nil {
698
+ item.Models = append([]config.CodexModel(nil), (*patch.Models)...)
699
+ }
700
+ if patch.Headers != nil {
701
+ item.Headers = config.NormalizeHeaders(*patch.Headers)
702
+ }
703
+ if patch.ExcludedModels != nil {
704
+ item.ExcludedModels = config.NormalizeExcludedModels(*patch.ExcludedModels)
705
+ }
706
+ return true
707
+ },
708
+ normalizeCodexKey,
709
+ h.cfg.SanitizeCodexKeys,
710
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
711
  }
712
 
713
  func (h *Handler) DeleteCodexKey(c *gin.Context) {
714
+ genericDeleteList(h, c,
715
+ func() []config.CodexKey { return h.cfg.CodexKey },
716
+ func(v []config.CodexKey) { h.cfg.CodexKey = v },
717
+ func(item config.CodexKey, val string) bool { return item.APIKey == val },
718
+ "api-key",
719
+ h.cfg.SanitizeCodexKeys,
720
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
721
  }
722
 
723
  func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) {
internal/api/handlers/management/generics.go ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package management
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "strings"
7
+
8
+ "github.com/gin-gonic/gin"
9
+ )
10
+
11
+ // genericPutList handles PUT requests for a list of items.
12
+ func genericPutList[T any](h *Handler, c *gin.Context, setList func([]T), normalize func(*T), sanitize func()) {
13
+ data, err := c.GetRawData()
14
+ if err != nil {
15
+ c.JSON(400, gin.H{"error": "failed to read body"})
16
+ return
17
+ }
18
+ var arr []T
19
+ if err = json.Unmarshal(data, &arr); err != nil {
20
+ var obj struct {
21
+ Items []T `json:"items"`
22
+ }
23
+ if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
24
+ c.JSON(400, gin.H{"error": "invalid body"})
25
+ return
26
+ }
27
+ arr = obj.Items
28
+ }
29
+
30
+ if normalize != nil {
31
+ for i := range arr {
32
+ normalize(&arr[i])
33
+ }
34
+ }
35
+
36
+ setList(arr)
37
+ if sanitize != nil {
38
+ sanitize()
39
+ }
40
+ h.persist(c)
41
+ }
42
+
43
+ // genericPatchList handles PATCH requests for a list item.
44
+ // T is the item type (e.g. config.GeminiKey).
45
+ // P is the patch struct type (e.g. geminiKeyPatch).
46
+ func genericPatchList[T any, P any](
47
+ h *Handler,
48
+ c *gin.Context,
49
+ getList func() []T,
50
+ setList func([]T),
51
+ matchFunc func(T, string) bool, // returns true if item matches the match string
52
+ updateFunc func(*T, P) bool, // applies patch P to item T, returns false if item should be removed
53
+ normalize func(*T),
54
+ sanitize func(),
55
+ ) {
56
+ var body struct {
57
+ Index *int `json:"index"`
58
+ Match *string `json:"match"`
59
+ Value *P `json:"value"`
60
+ }
61
+ if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
62
+ c.JSON(400, gin.H{"error": "invalid body"})
63
+ return
64
+ }
65
+
66
+ list := getList()
67
+ targetIndex := -1
68
+ if body.Index != nil && *body.Index >= 0 && *body.Index < len(list) {
69
+ targetIndex = *body.Index
70
+ }
71
+ if targetIndex == -1 && body.Match != nil {
72
+ match := strings.TrimSpace(*body.Match)
73
+ if match != "" && matchFunc != nil {
74
+ for i := range list {
75
+ if matchFunc(list[i], match) {
76
+ targetIndex = i
77
+ break
78
+ }
79
+ }
80
+ }
81
+ }
82
+
83
+ if targetIndex == -1 {
84
+ c.JSON(404, gin.H{"error": "item not found"})
85
+ return
86
+ }
87
+
88
+ // Work on a copy of the list to be safe, but we need to modify the list in place or reassign.
89
+ // Since getList returns a slice, modifications to elements reflect in the underlying array usually,
90
+ // but appending/slicing does not.
91
+ // We will create a new slice for assignment.
92
+
93
+ // Use a copy of the slice to avoid modifying the config in place before persisting (though persist calls save, config is in memory).
94
+ // Actually we want to modify h.cfg in place then persist.
95
+
96
+ entry := list[targetIndex]
97
+ keep := updateFunc(&entry, *body.Value)
98
+
99
+ if !keep {
100
+ // Remove item
101
+ list = append(list[:targetIndex], list[targetIndex+1:]...)
102
+ } else {
103
+ if normalize != nil {
104
+ normalize(&entry)
105
+ }
106
+ list[targetIndex] = entry
107
+ }
108
+
109
+ setList(list)
110
+ if sanitize != nil {
111
+ sanitize()
112
+ }
113
+ h.persist(c)
114
+ }
115
+
116
+ // genericDeleteList handles DELETE requests for a list item.
117
+ func genericDeleteList[T any](
118
+ h *Handler,
119
+ c *gin.Context,
120
+ getList func() []T,
121
+ setList func([]T),
122
+ shouldDelete func(T, string) bool, // returns true if item matches criteria and should be deleted
123
+ queryParamName string,
124
+ sanitize func(),
125
+ ) {
126
+ if val := strings.TrimSpace(c.Query(queryParamName)); val != "" {
127
+ list := getList()
128
+ out := make([]T, 0, len(list))
129
+ deleted := false
130
+ for _, v := range list {
131
+ if shouldDelete(v, val) {
132
+ deleted = true
133
+ continue
134
+ }
135
+ out = append(out, v)
136
+ }
137
+
138
+ if deleted {
139
+ setList(out)
140
+ if sanitize != nil {
141
+ sanitize()
142
+ }
143
+ h.persist(c)
144
+ } else {
145
+ c.JSON(404, gin.H{"error": "item not found"})
146
+ }
147
+ return
148
+ }
149
+
150
+ if idxStr := c.Query("index"); idxStr != "" {
151
+ list := getList()
152
+ var idx int
153
+ if _, err := fmt.Sscanf(idxStr, "%d", &idx); err == nil && idx >= 0 && idx < len(list) {
154
+ list = append(list[:idx], list[idx+1:]...)
155
+ setList(list)
156
+ if sanitize != nil {
157
+ sanitize()
158
+ }
159
+ h.persist(c)
160
+ return
161
+ }
162
+ }
163
+ c.JSON(400, gin.H{"error": fmt.Sprintf("missing %s or index", queryParamName)})
164
+ }
internal/api/handlers/management/generics_test.go ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package management
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "os"
9
+ "testing"
10
+
11
+ "github.com/gin-gonic/gin"
12
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
13
+ "github.com/stretchr/testify/assert"
14
+ )
15
+
16
+ // setupTestHandler creates a temporary config file and a Handler for testing.
17
+ // It returns the handler, the config object, the cleanup function, and an error if any.
18
+ func setupTestHandler(t *testing.T) (*Handler, *config.Config, func(), error) {
19
+ // Create a temporary file for config
20
+ tmpFile, err := os.CreateTemp("", "config_test_*.yaml")
21
+ if err != nil {
22
+ return nil, nil, nil, err
23
+ }
24
+
25
+ // Write initial valid YAML content (empty object)
26
+ if _, err := tmpFile.WriteString("{}\n"); err != nil {
27
+ tmpFile.Close()
28
+ os.Remove(tmpFile.Name())
29
+ return nil, nil, nil, err
30
+ }
31
+ tmpFile.Close()
32
+
33
+ cfg := &config.Config{
34
+ SDKConfig: config.SDKConfig{
35
+ Access: config.AccessConfig{
36
+ Providers: nil,
37
+ },
38
+ },
39
+ }
40
+
41
+ h := NewHandler(cfg, tmpFile.Name(), nil)
42
+
43
+ cleanup := func() {
44
+ os.Remove(tmpFile.Name())
45
+ }
46
+
47
+ return h, cfg, cleanup, nil
48
+ }
49
+
50
+ func TestPutAPIKeys(t *testing.T) {
51
+ gin.SetMode(gin.TestMode)
52
+
53
+ h, cfg, cleanup, err := setupTestHandler(t)
54
+ assert.NoError(t, err)
55
+ defer cleanup()
56
+
57
+ // Initial state
58
+ cfg.APIKeys = []string{"initial-key"}
59
+
60
+ // Test case: Put a new list of keys
61
+ newKeys := []string{"key1", "key2", "key3"}
62
+ body, _ := json.Marshal(newKeys)
63
+
64
+ w := httptest.NewRecorder()
65
+ c, _ := gin.CreateTestContext(w)
66
+ c.Request, _ = http.NewRequest(http.MethodPut, "/api-keys", bytes.NewBuffer(body))
67
+
68
+ h.PutAPIKeys(c)
69
+
70
+ assert.Equal(t, http.StatusOK, w.Code)
71
+
72
+ // Verify memory update
73
+ assert.Equal(t, newKeys, cfg.APIKeys)
74
+
75
+ // Verify Access.Providers is cleared (as per PutAPIKeys logic)
76
+ assert.Nil(t, cfg.Access.Providers)
77
+
78
+ // Test case: Put using object wrapper { "items": [...] }
79
+ wrapperBody := `{"items": ["wrapped-key-1", "wrapped-key-2"]}`
80
+ w = httptest.NewRecorder()
81
+ c, _ = gin.CreateTestContext(w)
82
+ c.Request, _ = http.NewRequest(http.MethodPut, "/api-keys", bytes.NewBufferString(wrapperBody))
83
+
84
+ h.PutAPIKeys(c)
85
+
86
+ assert.Equal(t, http.StatusOK, w.Code)
87
+ assert.Equal(t, []string{"wrapped-key-1", "wrapped-key-2"}, cfg.APIKeys)
88
+ }
89
+
90
+ func TestDeleteAPIKeys(t *testing.T) {
91
+ gin.SetMode(gin.TestMode)
92
+
93
+ h, cfg, cleanup, err := setupTestHandler(t)
94
+ assert.NoError(t, err)
95
+ defer cleanup()
96
+
97
+ // Initial state
98
+ cfg.APIKeys = []string{"key1", "key2", "key3", "key4"}
99
+
100
+ // Test case: Delete by value (query param "value")
101
+ w := httptest.NewRecorder()
102
+ c, _ := gin.CreateTestContext(w)
103
+ c.Request, _ = http.NewRequest(http.MethodDelete, "/api-keys?value=key2", nil)
104
+
105
+ h.DeleteAPIKeys(c)
106
+
107
+ assert.Equal(t, http.StatusOK, w.Code)
108
+ assert.Equal(t, []string{"key1", "key3", "key4"}, cfg.APIKeys)
109
+
110
+ // Test case: Delete by index
111
+ w = httptest.NewRecorder()
112
+ c, _ = gin.CreateTestContext(w)
113
+ c.Request, _ = http.NewRequest(http.MethodDelete, "/api-keys?index=1", nil) // Deleting index 1 (key3 now, since key2 is gone)
114
+
115
+ h.DeleteAPIKeys(c)
116
+
117
+ assert.Equal(t, http.StatusOK, w.Code)
118
+ assert.Equal(t, []string{"key1", "key4"}, cfg.APIKeys)
119
+
120
+ // Test case: Delete non-existent value
121
+ w = httptest.NewRecorder()
122
+ c, _ = gin.CreateTestContext(w)
123
+ c.Request, _ = http.NewRequest(http.MethodDelete, "/api-keys?value=non-existent", nil)
124
+
125
+ h.DeleteAPIKeys(c)
126
+
127
+ assert.Equal(t, http.StatusNotFound, w.Code)
128
+ assert.Equal(t, []string{"key1", "key4"}, cfg.APIKeys)
129
+
130
+ // Test case: Delete invalid index
131
+ w = httptest.NewRecorder()
132
+ c, _ = gin.CreateTestContext(w)
133
+ c.Request, _ = http.NewRequest(http.MethodDelete, "/api-keys?index=99", nil)
134
+
135
+ h.DeleteAPIKeys(c)
136
+
137
+ // GenericDeleteList returns 400 if index is out of bounds but parsed correctly?
138
+ // The code says: if _, err := fmt.Sscanf(idxStr, "%d", &idx); err == nil && idx >= 0 && idx < len(list)
139
+ // If that condition fails, it falls through to c.JSON(400, "missing ... or index")
140
+ assert.Equal(t, http.StatusBadRequest, w.Code)
141
+ assert.Equal(t, []string{"key1", "key4"}, cfg.APIKeys)
142
+ }
internal/config/config.go CHANGED
@@ -5,16 +5,12 @@
5
  package config
6
 
7
  import (
8
- "bytes"
9
- "encoding/json"
10
  "errors"
11
  "fmt"
12
  "os"
13
  "strings"
14
  "syscall"
15
 
16
- log "github.com/sirupsen/logrus"
17
- "golang.org/x/crypto/bcrypt"
18
  "gopkg.in/yaml.v3"
19
  )
20
 
@@ -664,1104 +660,3 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
664
  // Return the populated configuration struct.
665
  return &cfg, nil
666
  }
667
-
668
- // SanitizePayloadRules validates raw JSON payload rule params and drops invalid rules.
669
- func (cfg *Config) SanitizePayloadRules() {
670
- if cfg == nil {
671
- return
672
- }
673
- cfg.Payload.DefaultRaw = sanitizePayloadRawRules(cfg.Payload.DefaultRaw, "default-raw")
674
- cfg.Payload.OverrideRaw = sanitizePayloadRawRules(cfg.Payload.OverrideRaw, "override-raw")
675
- }
676
-
677
- func sanitizePayloadRawRules(rules []PayloadRule, section string) []PayloadRule {
678
- if len(rules) == 0 {
679
- return rules
680
- }
681
- out := make([]PayloadRule, 0, len(rules))
682
- for i := range rules {
683
- rule := rules[i]
684
- if len(rule.Params) == 0 {
685
- continue
686
- }
687
- invalid := false
688
- for path, value := range rule.Params {
689
- raw, ok := payloadRawString(value)
690
- if !ok {
691
- continue
692
- }
693
- trimmed := bytes.TrimSpace(raw)
694
- if len(trimmed) == 0 || !json.Valid(trimmed) {
695
- log.WithFields(log.Fields{
696
- "section": section,
697
- "rule_index": i + 1,
698
- "param": path,
699
- }).Warn("payload rule dropped: invalid raw JSON")
700
- invalid = true
701
- break
702
- }
703
- }
704
- if invalid {
705
- continue
706
- }
707
- out = append(out, rule)
708
- }
709
- return out
710
- }
711
-
712
- func payloadRawString(value any) ([]byte, bool) {
713
- switch typed := value.(type) {
714
- case string:
715
- return []byte(typed), true
716
- case []byte:
717
- return typed, true
718
- default:
719
- return nil, false
720
- }
721
- }
722
-
723
- // SanitizeOAuthModelAlias normalizes and deduplicates global OAuth model name aliases.
724
- // It trims whitespace, normalizes channel keys to lower-case, drops empty entries,
725
- // allows multiple aliases per upstream name, and ensures aliases are unique within each channel.
726
- func (cfg *Config) SanitizeOAuthModelAlias() {
727
- if cfg == nil || len(cfg.OAuthModelAlias) == 0 {
728
- return
729
- }
730
- out := make(map[string][]OAuthModelAlias, len(cfg.OAuthModelAlias))
731
- for rawChannel, aliases := range cfg.OAuthModelAlias {
732
- channel := strings.ToLower(strings.TrimSpace(rawChannel))
733
- if channel == "" || len(aliases) == 0 {
734
- continue
735
- }
736
- seenAlias := make(map[string]struct{}, len(aliases))
737
- clean := make([]OAuthModelAlias, 0, len(aliases))
738
- for _, entry := range aliases {
739
- name := strings.TrimSpace(entry.Name)
740
- alias := strings.TrimSpace(entry.Alias)
741
- if name == "" || alias == "" {
742
- continue
743
- }
744
- if strings.EqualFold(name, alias) {
745
- continue
746
- }
747
- aliasKey := strings.ToLower(alias)
748
- if _, ok := seenAlias[aliasKey]; ok {
749
- continue
750
- }
751
- seenAlias[aliasKey] = struct{}{}
752
- clean = append(clean, OAuthModelAlias{Name: name, Alias: alias, Fork: entry.Fork})
753
- }
754
- if len(clean) > 0 {
755
- out[channel] = clean
756
- }
757
- }
758
- cfg.OAuthModelAlias = out
759
- }
760
-
761
- // SanitizeOpenAICompatibility removes OpenAI-compatibility provider entries that are
762
- // not actionable, specifically those missing a BaseURL. It trims whitespace before
763
- // evaluation and preserves the relative order of remaining entries.
764
- func (cfg *Config) SanitizeOpenAICompatibility() {
765
- if cfg == nil || len(cfg.OpenAICompatibility) == 0 {
766
- return
767
- }
768
- out := make([]OpenAICompatibility, 0, len(cfg.OpenAICompatibility))
769
- for i := range cfg.OpenAICompatibility {
770
- e := cfg.OpenAICompatibility[i]
771
- e.Name = strings.TrimSpace(e.Name)
772
- e.Prefix = normalizeModelPrefix(e.Prefix)
773
- e.BaseURL = strings.TrimSpace(e.BaseURL)
774
- e.Headers = NormalizeHeaders(e.Headers)
775
- if e.BaseURL == "" {
776
- // Skip providers with no base-url; treated as removed
777
- continue
778
- }
779
- out = append(out, e)
780
- }
781
- cfg.OpenAICompatibility = out
782
- }
783
-
784
- // SanitizeCodexKeys removes Codex API key entries missing a BaseURL.
785
- // It trims whitespace and preserves order for remaining entries.
786
- func (cfg *Config) SanitizeCodexKeys() {
787
- if cfg == nil || len(cfg.CodexKey) == 0 {
788
- return
789
- }
790
- out := make([]CodexKey, 0, len(cfg.CodexKey))
791
- for i := range cfg.CodexKey {
792
- e := cfg.CodexKey[i]
793
- e.Prefix = normalizeModelPrefix(e.Prefix)
794
- e.BaseURL = strings.TrimSpace(e.BaseURL)
795
- e.Headers = NormalizeHeaders(e.Headers)
796
- e.ExcludedModels = NormalizeExcludedModels(e.ExcludedModels)
797
- if e.BaseURL == "" {
798
- continue
799
- }
800
- out = append(out, e)
801
- }
802
- cfg.CodexKey = out
803
- }
804
-
805
- // SanitizeClaudeKeys normalizes headers and API key entries for Claude credentials.
806
- func (cfg *Config) SanitizeClaudeKeys() {
807
- if cfg == nil || len(cfg.ClaudeKey) == 0 {
808
- return
809
- }
810
- for i := range cfg.ClaudeKey {
811
- entry := &cfg.ClaudeKey[i]
812
- entry.Prefix = normalizeModelPrefix(entry.Prefix)
813
- entry.Headers = NormalizeHeaders(entry.Headers)
814
- entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
815
- entry.BaseURL = strings.TrimSpace(entry.BaseURL)
816
- entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
817
- entry.APIKey = strings.TrimSpace(entry.APIKey)
818
- // Sanitize APIKeyEntries
819
- for j := range entry.APIKeyEntries {
820
- apiKeyEntry := &entry.APIKeyEntries[j]
821
- apiKeyEntry.APIKey = strings.TrimSpace(apiKeyEntry.APIKey)
822
- apiKeyEntry.ProxyURL = strings.TrimSpace(apiKeyEntry.ProxyURL)
823
- }
824
- }
825
- }
826
-
827
- // SanitizeGeminiKeys deduplicates and normalizes Gemini credentials.
828
- func (cfg *Config) SanitizeGeminiKeys() {
829
- if cfg == nil {
830
- return
831
- }
832
-
833
- seen := make(map[string]struct{}, len(cfg.GeminiKey))
834
- out := cfg.GeminiKey[:0]
835
- for i := range cfg.GeminiKey {
836
- entry := cfg.GeminiKey[i]
837
- entry.APIKey = strings.TrimSpace(entry.APIKey)
838
- if entry.APIKey == "" {
839
- continue
840
- }
841
- entry.Prefix = normalizeModelPrefix(entry.Prefix)
842
- entry.BaseURL = strings.TrimSpace(entry.BaseURL)
843
- entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
844
- entry.Headers = NormalizeHeaders(entry.Headers)
845
- entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
846
- if _, exists := seen[entry.APIKey]; exists {
847
- continue
848
- }
849
- seen[entry.APIKey] = struct{}{}
850
- out = append(out, entry)
851
- }
852
- cfg.GeminiKey = out
853
- }
854
-
855
- func normalizeModelPrefix(prefix string) string {
856
- trimmed := strings.TrimSpace(prefix)
857
- trimmed = strings.Trim(trimmed, "/")
858
- if trimmed == "" {
859
- return ""
860
- }
861
- if strings.Contains(trimmed, "/") {
862
- return ""
863
- }
864
- return trimmed
865
- }
866
-
867
- func syncInlineAccessProvider(cfg *Config) {
868
- if cfg == nil {
869
- return
870
- }
871
- if len(cfg.APIKeys) == 0 {
872
- if provider := cfg.ConfigAPIKeyProvider(); provider != nil && len(provider.APIKeys) > 0 {
873
- cfg.APIKeys = append([]string(nil), provider.APIKeys...)
874
- }
875
- }
876
- cfg.Access.Providers = nil
877
- }
878
-
879
- // looksLikeBcrypt returns true if the provided string appears to be a bcrypt hash.
880
- func looksLikeBcrypt(s string) bool {
881
- return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$")
882
- }
883
-
884
- // NormalizeHeaders trims header keys and values and removes empty pairs.
885
- func NormalizeHeaders(headers map[string]string) map[string]string {
886
- if len(headers) == 0 {
887
- return nil
888
- }
889
- clean := make(map[string]string, len(headers))
890
- for k, v := range headers {
891
- key := strings.TrimSpace(k)
892
- val := strings.TrimSpace(v)
893
- if key == "" || val == "" {
894
- continue
895
- }
896
- clean[key] = val
897
- }
898
- if len(clean) == 0 {
899
- return nil
900
- }
901
- return clean
902
- }
903
-
904
- // NormalizeExcludedModels trims, lowercases, and deduplicates model exclusion patterns.
905
- // It preserves the order of first occurrences and drops empty entries.
906
- func NormalizeExcludedModels(models []string) []string {
907
- if len(models) == 0 {
908
- return nil
909
- }
910
- seen := make(map[string]struct{}, len(models))
911
- out := make([]string, 0, len(models))
912
- for _, raw := range models {
913
- trimmed := strings.ToLower(strings.TrimSpace(raw))
914
- if trimmed == "" {
915
- continue
916
- }
917
- if _, exists := seen[trimmed]; exists {
918
- continue
919
- }
920
- seen[trimmed] = struct{}{}
921
- out = append(out, trimmed)
922
- }
923
- if len(out) == 0 {
924
- return nil
925
- }
926
- return out
927
- }
928
-
929
- // NormalizeOAuthExcludedModels cleans provider -> excluded models mappings by normalizing provider keys
930
- // and applying model exclusion normalization to each entry.
931
- func NormalizeOAuthExcludedModels(entries map[string][]string) map[string][]string {
932
- if len(entries) == 0 {
933
- return nil
934
- }
935
- out := make(map[string][]string, len(entries))
936
- for provider, models := range entries {
937
- key := strings.ToLower(strings.TrimSpace(provider))
938
- if key == "" {
939
- continue
940
- }
941
- normalized := NormalizeExcludedModels(models)
942
- if len(normalized) == 0 {
943
- continue
944
- }
945
- out[key] = normalized
946
- }
947
- if len(out) == 0 {
948
- return nil
949
- }
950
- return out
951
- }
952
-
953
- // hashSecret hashes the given secret using bcrypt.
954
- func hashSecret(secret string) (string, error) {
955
- // Use default cost for simplicity.
956
- hashedBytes, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost)
957
- if err != nil {
958
- return "", err
959
- }
960
- return string(hashedBytes), nil
961
- }
962
-
963
- // SaveConfigPreserveComments writes the config back to YAML while preserving existing comments
964
- // and key ordering by loading the original file into a yaml.Node tree and updating values in-place.
965
- func SaveConfigPreserveComments(configFile string, cfg *Config) error {
966
- persistCfg := sanitizeConfigForPersist(cfg)
967
- // Load original YAML as a node tree to preserve comments and ordering.
968
- data, err := os.ReadFile(configFile)
969
- if err != nil {
970
- return err
971
- }
972
-
973
- var original yaml.Node
974
- if err = yaml.Unmarshal(data, &original); err != nil {
975
- return err
976
- }
977
- if original.Kind != yaml.DocumentNode || len(original.Content) == 0 {
978
- return fmt.Errorf("invalid yaml document structure")
979
- }
980
- if original.Content[0] == nil || original.Content[0].Kind != yaml.MappingNode {
981
- return fmt.Errorf("expected root mapping node")
982
- }
983
-
984
- // Marshal the current cfg to YAML, then unmarshal to a yaml.Node we can merge from.
985
- rendered, err := yaml.Marshal(persistCfg)
986
- if err != nil {
987
- return err
988
- }
989
- var generated yaml.Node
990
- if err = yaml.Unmarshal(rendered, &generated); err != nil {
991
- return err
992
- }
993
- if generated.Kind != yaml.DocumentNode || len(generated.Content) == 0 || generated.Content[0] == nil {
994
- return fmt.Errorf("invalid generated yaml structure")
995
- }
996
- if generated.Content[0].Kind != yaml.MappingNode {
997
- return fmt.Errorf("expected generated root mapping node")
998
- }
999
-
1000
- // Remove deprecated sections before merging back the sanitized config.
1001
- removeLegacyAuthBlock(original.Content[0])
1002
- removeLegacyOpenAICompatAPIKeys(original.Content[0])
1003
- removeLegacyAmpKeys(original.Content[0])
1004
- removeLegacyGenerativeLanguageKeys(original.Content[0])
1005
-
1006
- pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-excluded-models")
1007
-
1008
- // Merge generated into original in-place, preserving comments/order of existing nodes.
1009
- mergeMappingPreserve(original.Content[0], generated.Content[0])
1010
- normalizeCollectionNodeStyles(original.Content[0])
1011
-
1012
- // Write back.
1013
- f, err := os.Create(configFile)
1014
- if err != nil {
1015
- return err
1016
- }
1017
- defer func() { _ = f.Close() }()
1018
- var buf bytes.Buffer
1019
- enc := yaml.NewEncoder(&buf)
1020
- enc.SetIndent(2)
1021
- if err = enc.Encode(&original); err != nil {
1022
- _ = enc.Close()
1023
- return err
1024
- }
1025
- if err = enc.Close(); err != nil {
1026
- return err
1027
- }
1028
- data = NormalizeCommentIndentation(buf.Bytes())
1029
- _, err = f.Write(data)
1030
- return err
1031
- }
1032
-
1033
- func sanitizeConfigForPersist(cfg *Config) *Config {
1034
- if cfg == nil {
1035
- return nil
1036
- }
1037
- clone := *cfg
1038
- clone.SDKConfig = cfg.SDKConfig
1039
- clone.SDKConfig.Access = AccessConfig{}
1040
- return &clone
1041
- }
1042
-
1043
- // SaveConfigPreserveCommentsUpdateNestedScalar updates a nested scalar key path like ["a","b"]
1044
- // while preserving comments and positions.
1045
- func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error {
1046
- data, err := os.ReadFile(configFile)
1047
- if err != nil {
1048
- return err
1049
- }
1050
- var root yaml.Node
1051
- if err = yaml.Unmarshal(data, &root); err != nil {
1052
- return err
1053
- }
1054
- if root.Kind != yaml.DocumentNode || len(root.Content) == 0 {
1055
- return fmt.Errorf("invalid yaml document structure")
1056
- }
1057
- node := root.Content[0]
1058
- // descend mapping nodes following path
1059
- for i, key := range path {
1060
- if i == len(path)-1 {
1061
- // set final scalar
1062
- v := getOrCreateMapValue(node, key)
1063
- v.Kind = yaml.ScalarNode
1064
- v.Tag = "!!str"
1065
- v.Value = value
1066
- } else {
1067
- next := getOrCreateMapValue(node, key)
1068
- if next.Kind != yaml.MappingNode {
1069
- next.Kind = yaml.MappingNode
1070
- next.Tag = "!!map"
1071
- }
1072
- node = next
1073
- }
1074
- }
1075
- f, err := os.Create(configFile)
1076
- if err != nil {
1077
- return err
1078
- }
1079
- defer func() { _ = f.Close() }()
1080
- var buf bytes.Buffer
1081
- enc := yaml.NewEncoder(&buf)
1082
- enc.SetIndent(2)
1083
- if err = enc.Encode(&root); err != nil {
1084
- _ = enc.Close()
1085
- return err
1086
- }
1087
- if err = enc.Close(); err != nil {
1088
- return err
1089
- }
1090
- data = NormalizeCommentIndentation(buf.Bytes())
1091
- _, err = f.Write(data)
1092
- return err
1093
- }
1094
-
1095
- // NormalizeCommentIndentation removes indentation from standalone YAML comment lines to keep them left aligned.
1096
- func NormalizeCommentIndentation(data []byte) []byte {
1097
- lines := bytes.Split(data, []byte("\n"))
1098
- changed := false
1099
- for i, line := range lines {
1100
- trimmed := bytes.TrimLeft(line, " \t")
1101
- if len(trimmed) == 0 || trimmed[0] != '#' {
1102
- continue
1103
- }
1104
- if len(trimmed) == len(line) {
1105
- continue
1106
- }
1107
- lines[i] = append([]byte(nil), trimmed...)
1108
- changed = true
1109
- }
1110
- if !changed {
1111
- return data
1112
- }
1113
- return bytes.Join(lines, []byte("\n"))
1114
- }
1115
-
1116
- // getOrCreateMapValue finds the value node for a given key in a mapping node.
1117
- // If not found, it appends a new key/value pair and returns the new value node.
1118
- func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node {
1119
- if mapNode.Kind != yaml.MappingNode {
1120
- mapNode.Kind = yaml.MappingNode
1121
- mapNode.Tag = "!!map"
1122
- mapNode.Content = nil
1123
- }
1124
- for i := 0; i+1 < len(mapNode.Content); i += 2 {
1125
- k := mapNode.Content[i]
1126
- if k.Value == key {
1127
- return mapNode.Content[i+1]
1128
- }
1129
- }
1130
- // append new key/value
1131
- mapNode.Content = append(mapNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key})
1132
- val := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ""}
1133
- mapNode.Content = append(mapNode.Content, val)
1134
- return val
1135
- }
1136
-
1137
- // mergeMappingPreserve merges keys from src into dst mapping node while preserving
1138
- // key order and comments of existing keys in dst. New keys are only added if their
1139
- // value is non-zero to avoid polluting the config with defaults.
1140
- func mergeMappingPreserve(dst, src *yaml.Node) {
1141
- if dst == nil || src == nil {
1142
- return
1143
- }
1144
- if dst.Kind != yaml.MappingNode || src.Kind != yaml.MappingNode {
1145
- // If kinds do not match, prefer replacing dst with src semantics in-place
1146
- // but keep dst node object to preserve any attached comments at the parent level.
1147
- copyNodeShallow(dst, src)
1148
- return
1149
- }
1150
- for i := 0; i+1 < len(src.Content); i += 2 {
1151
- sk := src.Content[i]
1152
- sv := src.Content[i+1]
1153
- idx := findMapKeyIndex(dst, sk.Value)
1154
- if idx >= 0 {
1155
- // Merge into existing value node (always update, even to zero values)
1156
- dv := dst.Content[idx+1]
1157
- mergeNodePreserve(dv, sv)
1158
- } else {
1159
- // New key: only add if value is non-zero to avoid polluting config with defaults
1160
- if isZeroValueNode(sv) {
1161
- continue
1162
- }
1163
- dst.Content = append(dst.Content, deepCopyNode(sk), deepCopyNode(sv))
1164
- }
1165
- }
1166
- }
1167
-
1168
- // mergeNodePreserve merges src into dst for scalars, mappings and sequences while
1169
- // reusing destination nodes to keep comments and anchors. For sequences, it updates
1170
- // in-place by index.
1171
- func mergeNodePreserve(dst, src *yaml.Node) {
1172
- if dst == nil || src == nil {
1173
- return
1174
- }
1175
- switch src.Kind {
1176
- case yaml.MappingNode:
1177
- if dst.Kind != yaml.MappingNode {
1178
- copyNodeShallow(dst, src)
1179
- }
1180
- mergeMappingPreserve(dst, src)
1181
- case yaml.SequenceNode:
1182
- // Preserve explicit null style if dst was null and src is empty sequence
1183
- if dst.Kind == yaml.ScalarNode && dst.Tag == "!!null" && len(src.Content) == 0 {
1184
- // Keep as null to preserve original style
1185
- return
1186
- }
1187
- if dst.Kind != yaml.SequenceNode {
1188
- dst.Kind = yaml.SequenceNode
1189
- dst.Tag = "!!seq"
1190
- dst.Content = nil
1191
- }
1192
- reorderSequenceForMerge(dst, src)
1193
- // Update elements in place
1194
- minContent := len(dst.Content)
1195
- if len(src.Content) < minContent {
1196
- minContent = len(src.Content)
1197
- }
1198
- for i := 0; i < minContent; i++ {
1199
- if dst.Content[i] == nil {
1200
- dst.Content[i] = deepCopyNode(src.Content[i])
1201
- continue
1202
- }
1203
- mergeNodePreserve(dst.Content[i], src.Content[i])
1204
- if dst.Content[i] != nil && src.Content[i] != nil &&
1205
- dst.Content[i].Kind == yaml.MappingNode && src.Content[i].Kind == yaml.MappingNode {
1206
- pruneMissingMapKeys(dst.Content[i], src.Content[i])
1207
- }
1208
- }
1209
- // Append any extra items from src
1210
- for i := len(dst.Content); i < len(src.Content); i++ {
1211
- dst.Content = append(dst.Content, deepCopyNode(src.Content[i]))
1212
- }
1213
- // Truncate if dst has extra items not in src
1214
- if len(src.Content) < len(dst.Content) {
1215
- dst.Content = dst.Content[:len(src.Content)]
1216
- }
1217
- case yaml.ScalarNode, yaml.AliasNode:
1218
- // For scalars, update Tag and Value but keep Style from dst to preserve quoting
1219
- dst.Kind = src.Kind
1220
- dst.Tag = src.Tag
1221
- dst.Value = src.Value
1222
- // Keep dst.Style as-is intentionally
1223
- case 0:
1224
- // Unknown/empty kind; do nothing
1225
- default:
1226
- // Fallback: replace shallowly
1227
- copyNodeShallow(dst, src)
1228
- }
1229
- }
1230
-
1231
- // findMapKeyIndex returns the index of key node in dst mapping (index of key, not value).
1232
- // Returns -1 when not found.
1233
- func findMapKeyIndex(mapNode *yaml.Node, key string) int {
1234
- if mapNode == nil || mapNode.Kind != yaml.MappingNode {
1235
- return -1
1236
- }
1237
- for i := 0; i+1 < len(mapNode.Content); i += 2 {
1238
- if mapNode.Content[i] != nil && mapNode.Content[i].Value == key {
1239
- return i
1240
- }
1241
- }
1242
- return -1
1243
- }
1244
-
1245
- // isZeroValueNode returns true if the YAML node represents a zero/default value
1246
- // that should not be written as a new key to preserve config cleanliness.
1247
- // For mappings and sequences, recursively checks if all children are zero values.
1248
- func isZeroValueNode(node *yaml.Node) bool {
1249
- if node == nil {
1250
- return true
1251
- }
1252
- switch node.Kind {
1253
- case yaml.ScalarNode:
1254
- switch node.Tag {
1255
- case "!!bool":
1256
- return node.Value == "false"
1257
- case "!!int", "!!float":
1258
- return node.Value == "0" || node.Value == "0.0"
1259
- case "!!str":
1260
- return node.Value == ""
1261
- case "!!null":
1262
- return true
1263
- }
1264
- case yaml.SequenceNode:
1265
- if len(node.Content) == 0 {
1266
- return true
1267
- }
1268
- // Check if all elements are zero values
1269
- for _, child := range node.Content {
1270
- if !isZeroValueNode(child) {
1271
- return false
1272
- }
1273
- }
1274
- return true
1275
- case yaml.MappingNode:
1276
- if len(node.Content) == 0 {
1277
- return true
1278
- }
1279
- // Check if all values are zero values (values are at odd indices)
1280
- for i := 1; i < len(node.Content); i += 2 {
1281
- if !isZeroValueNode(node.Content[i]) {
1282
- return false
1283
- }
1284
- }
1285
- return true
1286
- }
1287
- return false
1288
- }
1289
-
1290
- // deepCopyNode creates a deep copy of a yaml.Node graph.
1291
- func deepCopyNode(n *yaml.Node) *yaml.Node {
1292
- if n == nil {
1293
- return nil
1294
- }
1295
- cp := *n
1296
- if len(n.Content) > 0 {
1297
- cp.Content = make([]*yaml.Node, len(n.Content))
1298
- for i := range n.Content {
1299
- cp.Content[i] = deepCopyNode(n.Content[i])
1300
- }
1301
- }
1302
- return &cp
1303
- }
1304
-
1305
- // copyNodeShallow copies type/tag/value and resets content to match src, but
1306
- // keeps the same destination node pointer to preserve parent relations/comments.
1307
- func copyNodeShallow(dst, src *yaml.Node) {
1308
- if dst == nil || src == nil {
1309
- return
1310
- }
1311
- dst.Kind = src.Kind
1312
- dst.Tag = src.Tag
1313
- dst.Value = src.Value
1314
- // Replace content with deep copy from src
1315
- if len(src.Content) > 0 {
1316
- dst.Content = make([]*yaml.Node, len(src.Content))
1317
- for i := range src.Content {
1318
- dst.Content[i] = deepCopyNode(src.Content[i])
1319
- }
1320
- } else {
1321
- dst.Content = nil
1322
- }
1323
- }
1324
-
1325
- func reorderSequenceForMerge(dst, src *yaml.Node) {
1326
- if dst == nil || src == nil {
1327
- return
1328
- }
1329
- if len(dst.Content) == 0 {
1330
- return
1331
- }
1332
- if len(src.Content) == 0 {
1333
- return
1334
- }
1335
- original := append([]*yaml.Node(nil), dst.Content...)
1336
- used := make([]bool, len(original))
1337
- ordered := make([]*yaml.Node, len(src.Content))
1338
- for i := range src.Content {
1339
- if idx := matchSequenceElement(original, used, src.Content[i]); idx >= 0 {
1340
- ordered[i] = original[idx]
1341
- used[idx] = true
1342
- }
1343
- }
1344
- dst.Content = ordered
1345
- }
1346
-
1347
- func matchSequenceElement(original []*yaml.Node, used []bool, target *yaml.Node) int {
1348
- if target == nil {
1349
- return -1
1350
- }
1351
- switch target.Kind {
1352
- case yaml.MappingNode:
1353
- id := sequenceElementIdentity(target)
1354
- if id != "" {
1355
- for i := range original {
1356
- if used[i] || original[i] == nil || original[i].Kind != yaml.MappingNode {
1357
- continue
1358
- }
1359
- if sequenceElementIdentity(original[i]) == id {
1360
- return i
1361
- }
1362
- }
1363
- }
1364
- case yaml.ScalarNode:
1365
- val := strings.TrimSpace(target.Value)
1366
- if val != "" {
1367
- for i := range original {
1368
- if used[i] || original[i] == nil || original[i].Kind != yaml.ScalarNode {
1369
- continue
1370
- }
1371
- if strings.TrimSpace(original[i].Value) == val {
1372
- return i
1373
- }
1374
- }
1375
- }
1376
- default:
1377
- }
1378
- // Fallback to structural equality to preserve nodes lacking explicit identifiers.
1379
- for i := range original {
1380
- if used[i] || original[i] == nil {
1381
- continue
1382
- }
1383
- if nodesStructurallyEqual(original[i], target) {
1384
- return i
1385
- }
1386
- }
1387
- return -1
1388
- }
1389
-
1390
- func sequenceElementIdentity(node *yaml.Node) string {
1391
- if node == nil || node.Kind != yaml.MappingNode {
1392
- return ""
1393
- }
1394
- identityKeys := []string{"id", "name", "alias", "api-key", "api_key", "apikey", "key", "provider", "model"}
1395
- for _, k := range identityKeys {
1396
- if v := mappingScalarValue(node, k); v != "" {
1397
- return k + "=" + v
1398
- }
1399
- }
1400
- for i := 0; i+1 < len(node.Content); i += 2 {
1401
- keyNode := node.Content[i]
1402
- valNode := node.Content[i+1]
1403
- if keyNode == nil || valNode == nil || valNode.Kind != yaml.ScalarNode {
1404
- continue
1405
- }
1406
- val := strings.TrimSpace(valNode.Value)
1407
- if val != "" {
1408
- return strings.ToLower(strings.TrimSpace(keyNode.Value)) + "=" + val
1409
- }
1410
- }
1411
- return ""
1412
- }
1413
-
1414
- func mappingScalarValue(node *yaml.Node, key string) string {
1415
- if node == nil || node.Kind != yaml.MappingNode {
1416
- return ""
1417
- }
1418
- lowerKey := strings.ToLower(key)
1419
- for i := 0; i+1 < len(node.Content); i += 2 {
1420
- keyNode := node.Content[i]
1421
- valNode := node.Content[i+1]
1422
- if keyNode == nil || valNode == nil || valNode.Kind != yaml.ScalarNode {
1423
- continue
1424
- }
1425
- if strings.ToLower(strings.TrimSpace(keyNode.Value)) == lowerKey {
1426
- return strings.TrimSpace(valNode.Value)
1427
- }
1428
- }
1429
- return ""
1430
- }
1431
-
1432
- func nodesStructurallyEqual(a, b *yaml.Node) bool {
1433
- if a == nil || b == nil {
1434
- return a == b
1435
- }
1436
- if a.Kind != b.Kind {
1437
- return false
1438
- }
1439
- switch a.Kind {
1440
- case yaml.MappingNode:
1441
- if len(a.Content) != len(b.Content) {
1442
- return false
1443
- }
1444
- for i := 0; i+1 < len(a.Content); i += 2 {
1445
- if !nodesStructurallyEqual(a.Content[i], b.Content[i]) {
1446
- return false
1447
- }
1448
- if !nodesStructurallyEqual(a.Content[i+1], b.Content[i+1]) {
1449
- return false
1450
- }
1451
- }
1452
- return true
1453
- case yaml.SequenceNode:
1454
- if len(a.Content) != len(b.Content) {
1455
- return false
1456
- }
1457
- for i := range a.Content {
1458
- if !nodesStructurallyEqual(a.Content[i], b.Content[i]) {
1459
- return false
1460
- }
1461
- }
1462
- return true
1463
- case yaml.ScalarNode:
1464
- return strings.TrimSpace(a.Value) == strings.TrimSpace(b.Value)
1465
- case yaml.AliasNode:
1466
- return nodesStructurallyEqual(a.Alias, b.Alias)
1467
- default:
1468
- return strings.TrimSpace(a.Value) == strings.TrimSpace(b.Value)
1469
- }
1470
- }
1471
-
1472
- func removeMapKey(mapNode *yaml.Node, key string) {
1473
- if mapNode == nil || mapNode.Kind != yaml.MappingNode || key == "" {
1474
- return
1475
- }
1476
- for i := 0; i+1 < len(mapNode.Content); i += 2 {
1477
- if mapNode.Content[i] != nil && mapNode.Content[i].Value == key {
1478
- mapNode.Content = append(mapNode.Content[:i], mapNode.Content[i+2:]...)
1479
- return
1480
- }
1481
- }
1482
- }
1483
-
1484
- func pruneMappingToGeneratedKeys(dstRoot, srcRoot *yaml.Node, key string) {
1485
- if key == "" || dstRoot == nil || srcRoot == nil {
1486
- return
1487
- }
1488
- if dstRoot.Kind != yaml.MappingNode || srcRoot.Kind != yaml.MappingNode {
1489
- return
1490
- }
1491
- dstIdx := findMapKeyIndex(dstRoot, key)
1492
- if dstIdx < 0 || dstIdx+1 >= len(dstRoot.Content) {
1493
- return
1494
- }
1495
- srcIdx := findMapKeyIndex(srcRoot, key)
1496
- if srcIdx < 0 {
1497
- removeMapKey(dstRoot, key)
1498
- return
1499
- }
1500
- if srcIdx+1 >= len(srcRoot.Content) {
1501
- return
1502
- }
1503
- srcVal := srcRoot.Content[srcIdx+1]
1504
- dstVal := dstRoot.Content[dstIdx+1]
1505
- if srcVal == nil {
1506
- dstRoot.Content[dstIdx+1] = nil
1507
- return
1508
- }
1509
- if srcVal.Kind != yaml.MappingNode {
1510
- dstRoot.Content[dstIdx+1] = deepCopyNode(srcVal)
1511
- return
1512
- }
1513
- if dstVal == nil || dstVal.Kind != yaml.MappingNode {
1514
- dstRoot.Content[dstIdx+1] = deepCopyNode(srcVal)
1515
- return
1516
- }
1517
- pruneMissingMapKeys(dstVal, srcVal)
1518
- }
1519
-
1520
- func pruneMissingMapKeys(dstMap, srcMap *yaml.Node) {
1521
- if dstMap == nil || srcMap == nil || dstMap.Kind != yaml.MappingNode || srcMap.Kind != yaml.MappingNode {
1522
- return
1523
- }
1524
- keep := make(map[string]struct{}, len(srcMap.Content)/2)
1525
- for i := 0; i+1 < len(srcMap.Content); i += 2 {
1526
- keyNode := srcMap.Content[i]
1527
- if keyNode == nil {
1528
- continue
1529
- }
1530
- key := strings.TrimSpace(keyNode.Value)
1531
- if key == "" {
1532
- continue
1533
- }
1534
- keep[key] = struct{}{}
1535
- }
1536
- for i := 0; i+1 < len(dstMap.Content); {
1537
- keyNode := dstMap.Content[i]
1538
- if keyNode == nil {
1539
- i += 2
1540
- continue
1541
- }
1542
- key := strings.TrimSpace(keyNode.Value)
1543
- if _, ok := keep[key]; !ok {
1544
- dstMap.Content = append(dstMap.Content[:i], dstMap.Content[i+2:]...)
1545
- continue
1546
- }
1547
- i += 2
1548
- }
1549
- }
1550
-
1551
- // normalizeCollectionNodeStyles forces YAML collections to use block notation, keeping
1552
- // lists and maps readable. Empty sequences retain flow style ([]) so empty list markers
1553
- // remain compact.
1554
- func normalizeCollectionNodeStyles(node *yaml.Node) {
1555
- if node == nil {
1556
- return
1557
- }
1558
- switch node.Kind {
1559
- case yaml.MappingNode:
1560
- node.Style = 0
1561
- for i := range node.Content {
1562
- normalizeCollectionNodeStyles(node.Content[i])
1563
- }
1564
- case yaml.SequenceNode:
1565
- if len(node.Content) == 0 {
1566
- node.Style = yaml.FlowStyle
1567
- } else {
1568
- node.Style = 0
1569
- }
1570
- for i := range node.Content {
1571
- normalizeCollectionNodeStyles(node.Content[i])
1572
- }
1573
- default:
1574
- // Scalars keep their existing style to preserve quoting
1575
- }
1576
- }
1577
-
1578
- // Legacy migration helpers (move deprecated config keys into structured fields).
1579
- type legacyConfigData struct {
1580
- LegacyGeminiKeys []string `yaml:"generative-language-api-key"`
1581
- OpenAICompat []legacyOpenAICompatibility `yaml:"openai-compatibility"`
1582
- AmpUpstreamURL string `yaml:"amp-upstream-url"`
1583
- AmpUpstreamAPIKey string `yaml:"amp-upstream-api-key"`
1584
- AmpRestrictManagement *bool `yaml:"amp-restrict-management-to-localhost"`
1585
- AmpModelMappings []AmpModelMapping `yaml:"amp-model-mappings"`
1586
- }
1587
-
1588
- type legacyOpenAICompatibility struct {
1589
- Name string `yaml:"name"`
1590
- BaseURL string `yaml:"base-url"`
1591
- APIKeys []string `yaml:"api-keys"`
1592
- }
1593
-
1594
- func (cfg *Config) migrateLegacyGeminiKeys(legacy []string) bool {
1595
- if cfg == nil || len(legacy) == 0 {
1596
- return false
1597
- }
1598
- changed := false
1599
- seen := make(map[string]struct{}, len(cfg.GeminiKey))
1600
- for i := range cfg.GeminiKey {
1601
- key := strings.TrimSpace(cfg.GeminiKey[i].APIKey)
1602
- if key == "" {
1603
- continue
1604
- }
1605
- seen[key] = struct{}{}
1606
- }
1607
- for _, raw := range legacy {
1608
- key := strings.TrimSpace(raw)
1609
- if key == "" {
1610
- continue
1611
- }
1612
- if _, exists := seen[key]; exists {
1613
- continue
1614
- }
1615
- cfg.GeminiKey = append(cfg.GeminiKey, GeminiKey{APIKey: key})
1616
- seen[key] = struct{}{}
1617
- changed = true
1618
- }
1619
- return changed
1620
- }
1621
-
1622
- func (cfg *Config) migrateLegacyOpenAICompatibilityKeys(legacy []legacyOpenAICompatibility) bool {
1623
- if cfg == nil || len(cfg.OpenAICompatibility) == 0 || len(legacy) == 0 {
1624
- return false
1625
- }
1626
- changed := false
1627
- for _, legacyEntry := range legacy {
1628
- if len(legacyEntry.APIKeys) == 0 {
1629
- continue
1630
- }
1631
- target := findOpenAICompatTarget(cfg.OpenAICompatibility, legacyEntry.Name, legacyEntry.BaseURL)
1632
- if target == nil {
1633
- continue
1634
- }
1635
- if mergeLegacyOpenAICompatAPIKeys(target, legacyEntry.APIKeys) {
1636
- changed = true
1637
- }
1638
- }
1639
- return changed
1640
- }
1641
-
1642
- func mergeLegacyOpenAICompatAPIKeys(entry *OpenAICompatibility, keys []string) bool {
1643
- if entry == nil || len(keys) == 0 {
1644
- return false
1645
- }
1646
- changed := false
1647
- existing := make(map[string]struct{}, len(entry.APIKeyEntries))
1648
- for i := range entry.APIKeyEntries {
1649
- key := strings.TrimSpace(entry.APIKeyEntries[i].APIKey)
1650
- if key == "" {
1651
- continue
1652
- }
1653
- existing[key] = struct{}{}
1654
- }
1655
- for _, raw := range keys {
1656
- key := strings.TrimSpace(raw)
1657
- if key == "" {
1658
- continue
1659
- }
1660
- if _, ok := existing[key]; ok {
1661
- continue
1662
- }
1663
- entry.APIKeyEntries = append(entry.APIKeyEntries, OpenAICompatibilityAPIKey{APIKey: key})
1664
- existing[key] = struct{}{}
1665
- changed = true
1666
- }
1667
- return changed
1668
- }
1669
-
1670
- func findOpenAICompatTarget(entries []OpenAICompatibility, legacyName, legacyBase string) *OpenAICompatibility {
1671
- nameKey := strings.ToLower(strings.TrimSpace(legacyName))
1672
- baseKey := strings.ToLower(strings.TrimSpace(legacyBase))
1673
- if nameKey != "" && baseKey != "" {
1674
- for i := range entries {
1675
- if strings.ToLower(strings.TrimSpace(entries[i].Name)) == nameKey &&
1676
- strings.ToLower(strings.TrimSpace(entries[i].BaseURL)) == baseKey {
1677
- return &entries[i]
1678
- }
1679
- }
1680
- }
1681
- if baseKey != "" {
1682
- for i := range entries {
1683
- if strings.ToLower(strings.TrimSpace(entries[i].BaseURL)) == baseKey {
1684
- return &entries[i]
1685
- }
1686
- }
1687
- }
1688
- if nameKey != "" {
1689
- for i := range entries {
1690
- if strings.ToLower(strings.TrimSpace(entries[i].Name)) == nameKey {
1691
- return &entries[i]
1692
- }
1693
- }
1694
- }
1695
- return nil
1696
- }
1697
-
1698
- func (cfg *Config) migrateLegacyAmpConfig(legacy *legacyConfigData) bool {
1699
- if cfg == nil || legacy == nil {
1700
- return false
1701
- }
1702
- changed := false
1703
- if cfg.AmpCode.UpstreamURL == "" {
1704
- if val := strings.TrimSpace(legacy.AmpUpstreamURL); val != "" {
1705
- cfg.AmpCode.UpstreamURL = val
1706
- changed = true
1707
- }
1708
- }
1709
- if cfg.AmpCode.UpstreamAPIKey == "" {
1710
- if val := strings.TrimSpace(legacy.AmpUpstreamAPIKey); val != "" {
1711
- cfg.AmpCode.UpstreamAPIKey = val
1712
- changed = true
1713
- }
1714
- }
1715
- if legacy.AmpRestrictManagement != nil {
1716
- cfg.AmpCode.RestrictManagementToLocalhost = *legacy.AmpRestrictManagement
1717
- changed = true
1718
- }
1719
- if len(cfg.AmpCode.ModelMappings) == 0 && len(legacy.AmpModelMappings) > 0 {
1720
- cfg.AmpCode.ModelMappings = append([]AmpModelMapping(nil), legacy.AmpModelMappings...)
1721
- changed = true
1722
- }
1723
- return changed
1724
- }
1725
-
1726
- func removeLegacyOpenAICompatAPIKeys(root *yaml.Node) {
1727
- if root == nil || root.Kind != yaml.MappingNode {
1728
- return
1729
- }
1730
- idx := findMapKeyIndex(root, "openai-compatibility")
1731
- if idx < 0 || idx+1 >= len(root.Content) {
1732
- return
1733
- }
1734
- seq := root.Content[idx+1]
1735
- if seq == nil || seq.Kind != yaml.SequenceNode {
1736
- return
1737
- }
1738
- for i := range seq.Content {
1739
- if seq.Content[i] != nil && seq.Content[i].Kind == yaml.MappingNode {
1740
- removeMapKey(seq.Content[i], "api-keys")
1741
- }
1742
- }
1743
- }
1744
-
1745
- func removeLegacyAmpKeys(root *yaml.Node) {
1746
- if root == nil || root.Kind != yaml.MappingNode {
1747
- return
1748
- }
1749
- removeMapKey(root, "amp-upstream-url")
1750
- removeMapKey(root, "amp-upstream-api-key")
1751
- removeMapKey(root, "amp-restrict-management-to-localhost")
1752
- removeMapKey(root, "amp-model-mappings")
1753
- }
1754
-
1755
- func removeLegacyGenerativeLanguageKeys(root *yaml.Node) {
1756
- if root == nil || root.Kind != yaml.MappingNode {
1757
- return
1758
- }
1759
- removeMapKey(root, "generative-language-api-key")
1760
- }
1761
-
1762
- func removeLegacyAuthBlock(root *yaml.Node) {
1763
- if root == nil || root.Kind != yaml.MappingNode {
1764
- return
1765
- }
1766
- removeMapKey(root, "auth")
1767
- }
 
5
  package config
6
 
7
  import (
 
 
8
  "errors"
9
  "fmt"
10
  "os"
11
  "strings"
12
  "syscall"
13
 
 
 
14
  "gopkg.in/yaml.v3"
15
  )
16
 
 
660
  // Return the populated configuration struct.
661
  return &cfg, nil
662
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
internal/config/migration.go ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "strings"
5
+
6
+ "gopkg.in/yaml.v3"
7
+ )
8
+
9
+ // Legacy migration helpers (move deprecated config keys into structured fields).
10
+ type legacyConfigData struct {
11
+ LegacyGeminiKeys []string `yaml:"generative-language-api-key"`
12
+ OpenAICompat []legacyOpenAICompatibility `yaml:"openai-compatibility"`
13
+ AmpUpstreamURL string `yaml:"amp-upstream-url"`
14
+ AmpUpstreamAPIKey string `yaml:"amp-upstream-api-key"`
15
+ AmpRestrictManagement *bool `yaml:"amp-restrict-management-to-localhost"`
16
+ AmpModelMappings []AmpModelMapping `yaml:"amp-model-mappings"`
17
+ }
18
+
19
+ type legacyOpenAICompatibility struct {
20
+ Name string `yaml:"name"`
21
+ BaseURL string `yaml:"base-url"`
22
+ APIKeys []string `yaml:"api-keys"`
23
+ }
24
+
25
+ func (cfg *Config) migrateLegacyGeminiKeys(legacy []string) bool {
26
+ if cfg == nil || len(legacy) == 0 {
27
+ return false
28
+ }
29
+ changed := false
30
+ seen := make(map[string]struct{}, len(cfg.GeminiKey))
31
+ for i := range cfg.GeminiKey {
32
+ key := strings.TrimSpace(cfg.GeminiKey[i].APIKey)
33
+ if key == "" {
34
+ continue
35
+ }
36
+ seen[key] = struct{}{}
37
+ }
38
+ for _, raw := range legacy {
39
+ key := strings.TrimSpace(raw)
40
+ if key == "" {
41
+ continue
42
+ }
43
+ if _, exists := seen[key]; exists {
44
+ continue
45
+ }
46
+ cfg.GeminiKey = append(cfg.GeminiKey, GeminiKey{APIKey: key})
47
+ seen[key] = struct{}{}
48
+ changed = true
49
+ }
50
+ return changed
51
+ }
52
+
53
+ func (cfg *Config) migrateLegacyOpenAICompatibilityKeys(legacy []legacyOpenAICompatibility) bool {
54
+ if cfg == nil || len(cfg.OpenAICompatibility) == 0 || len(legacy) == 0 {
55
+ return false
56
+ }
57
+ changed := false
58
+ for _, legacyEntry := range legacy {
59
+ if len(legacyEntry.APIKeys) == 0 {
60
+ continue
61
+ }
62
+ target := findOpenAICompatTarget(cfg.OpenAICompatibility, legacyEntry.Name, legacyEntry.BaseURL)
63
+ if target == nil {
64
+ continue
65
+ }
66
+ if mergeLegacyOpenAICompatAPIKeys(target, legacyEntry.APIKeys) {
67
+ changed = true
68
+ }
69
+ }
70
+ return changed
71
+ }
72
+
73
+ func mergeLegacyOpenAICompatAPIKeys(entry *OpenAICompatibility, keys []string) bool {
74
+ if entry == nil || len(keys) == 0 {
75
+ return false
76
+ }
77
+ changed := false
78
+ existing := make(map[string]struct{}, len(entry.APIKeyEntries))
79
+ for i := range entry.APIKeyEntries {
80
+ key := strings.TrimSpace(entry.APIKeyEntries[i].APIKey)
81
+ if key == "" {
82
+ continue
83
+ }
84
+ existing[key] = struct{}{}
85
+ }
86
+ for _, raw := range keys {
87
+ key := strings.TrimSpace(raw)
88
+ if key == "" {
89
+ continue
90
+ }
91
+ if _, ok := existing[key]; ok {
92
+ continue
93
+ }
94
+ entry.APIKeyEntries = append(entry.APIKeyEntries, OpenAICompatibilityAPIKey{APIKey: key})
95
+ existing[key] = struct{}{}
96
+ changed = true
97
+ }
98
+ return changed
99
+ }
100
+
101
+ func findOpenAICompatTarget(entries []OpenAICompatibility, legacyName, legacyBase string) *OpenAICompatibility {
102
+ nameKey := strings.ToLower(strings.TrimSpace(legacyName))
103
+ baseKey := strings.ToLower(strings.TrimSpace(legacyBase))
104
+ if nameKey != "" && baseKey != "" {
105
+ for i := range entries {
106
+ if strings.ToLower(strings.TrimSpace(entries[i].Name)) == nameKey &&
107
+ strings.ToLower(strings.TrimSpace(entries[i].BaseURL)) == baseKey {
108
+ return &entries[i]
109
+ }
110
+ }
111
+ }
112
+ if baseKey != "" {
113
+ for i := range entries {
114
+ if strings.ToLower(strings.TrimSpace(entries[i].BaseURL)) == baseKey {
115
+ return &entries[i]
116
+ }
117
+ }
118
+ }
119
+ if nameKey != "" {
120
+ for i := range entries {
121
+ if strings.ToLower(strings.TrimSpace(entries[i].Name)) == nameKey {
122
+ return &entries[i]
123
+ }
124
+ }
125
+ }
126
+ return nil
127
+ }
128
+
129
+ func (cfg *Config) migrateLegacyAmpConfig(legacy *legacyConfigData) bool {
130
+ if cfg == nil || legacy == nil {
131
+ return false
132
+ }
133
+ changed := false
134
+ if cfg.AmpCode.UpstreamURL == "" {
135
+ if val := strings.TrimSpace(legacy.AmpUpstreamURL); val != "" {
136
+ cfg.AmpCode.UpstreamURL = val
137
+ changed = true
138
+ }
139
+ }
140
+ if cfg.AmpCode.UpstreamAPIKey == "" {
141
+ if val := strings.TrimSpace(legacy.AmpUpstreamAPIKey); val != "" {
142
+ cfg.AmpCode.UpstreamAPIKey = val
143
+ changed = true
144
+ }
145
+ }
146
+ if legacy.AmpRestrictManagement != nil {
147
+ cfg.AmpCode.RestrictManagementToLocalhost = *legacy.AmpRestrictManagement
148
+ changed = true
149
+ }
150
+ if len(cfg.AmpCode.ModelMappings) == 0 && len(legacy.AmpModelMappings) > 0 {
151
+ cfg.AmpCode.ModelMappings = append([]AmpModelMapping(nil), legacy.AmpModelMappings...)
152
+ changed = true
153
+ }
154
+ return changed
155
+ }
156
+
157
+ func removeLegacyOpenAICompatAPIKeys(root *yaml.Node) {
158
+ if root == nil || root.Kind != yaml.MappingNode {
159
+ return
160
+ }
161
+ idx := findMapKeyIndex(root, "openai-compatibility")
162
+ if idx < 0 || idx+1 >= len(root.Content) {
163
+ return
164
+ }
165
+ seq := root.Content[idx+1]
166
+ if seq == nil || seq.Kind != yaml.SequenceNode {
167
+ return
168
+ }
169
+ for i := range seq.Content {
170
+ if seq.Content[i] != nil && seq.Content[i].Kind == yaml.MappingNode {
171
+ removeMapKey(seq.Content[i], "api-keys")
172
+ }
173
+ }
174
+ }
175
+
176
+ func removeLegacyAmpKeys(root *yaml.Node) {
177
+ if root == nil || root.Kind != yaml.MappingNode {
178
+ return
179
+ }
180
+ removeMapKey(root, "amp-upstream-url")
181
+ removeMapKey(root, "amp-upstream-api-key")
182
+ removeMapKey(root, "amp-restrict-management-to-localhost")
183
+ removeMapKey(root, "amp-model-mappings")
184
+ }
185
+
186
+ func removeLegacyGenerativeLanguageKeys(root *yaml.Node) {
187
+ if root == nil || root.Kind != yaml.MappingNode {
188
+ return
189
+ }
190
+ removeMapKey(root, "generative-language-api-key")
191
+ }
192
+
193
+ func removeLegacyAuthBlock(root *yaml.Node) {
194
+ if root == nil || root.Kind != yaml.MappingNode {
195
+ return
196
+ }
197
+ removeMapKey(root, "auth")
198
+ }
internal/config/sanitizer.go ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "strings"
7
+
8
+ log "github.com/sirupsen/logrus"
9
+ "golang.org/x/crypto/bcrypt"
10
+ )
11
+
12
+ // SanitizePayloadRules validates raw JSON payload rule params and drops invalid rules.
13
+ func (cfg *Config) SanitizePayloadRules() {
14
+ if cfg == nil {
15
+ return
16
+ }
17
+ cfg.Payload.DefaultRaw = sanitizePayloadRawRules(cfg.Payload.DefaultRaw, "default-raw")
18
+ cfg.Payload.OverrideRaw = sanitizePayloadRawRules(cfg.Payload.OverrideRaw, "override-raw")
19
+ }
20
+
21
+ func sanitizePayloadRawRules(rules []PayloadRule, section string) []PayloadRule {
22
+ if len(rules) == 0 {
23
+ return rules
24
+ }
25
+ out := make([]PayloadRule, 0, len(rules))
26
+ for i := range rules {
27
+ rule := rules[i]
28
+ if len(rule.Params) == 0 {
29
+ continue
30
+ }
31
+ invalid := false
32
+ for path, value := range rule.Params {
33
+ raw, ok := payloadRawString(value)
34
+ if !ok {
35
+ continue
36
+ }
37
+ trimmed := bytes.TrimSpace(raw)
38
+ if len(trimmed) == 0 || !json.Valid(trimmed) {
39
+ log.WithFields(log.Fields{
40
+ "section": section,
41
+ "rule_index": i + 1,
42
+ "param": path,
43
+ }).Warn("payload rule dropped: invalid raw JSON")
44
+ invalid = true
45
+ break
46
+ }
47
+ }
48
+ if invalid {
49
+ continue
50
+ }
51
+ out = append(out, rule)
52
+ }
53
+ return out
54
+ }
55
+
56
+ func payloadRawString(value any) ([]byte, bool) {
57
+ switch typed := value.(type) {
58
+ case string:
59
+ return []byte(typed), true
60
+ case []byte:
61
+ return typed, true
62
+ default:
63
+ return nil, false
64
+ }
65
+ }
66
+
67
+ // SanitizeOAuthModelAlias normalizes and deduplicates global OAuth model name aliases.
68
+ // It trims whitespace, normalizes channel keys to lower-case, drops empty entries,
69
+ // allows multiple aliases per upstream name, and ensures aliases are unique within each channel.
70
+ func (cfg *Config) SanitizeOAuthModelAlias() {
71
+ if cfg == nil || len(cfg.OAuthModelAlias) == 0 {
72
+ return
73
+ }
74
+ out := make(map[string][]OAuthModelAlias, len(cfg.OAuthModelAlias))
75
+ for rawChannel, aliases := range cfg.OAuthModelAlias {
76
+ channel := strings.ToLower(strings.TrimSpace(rawChannel))
77
+ if channel == "" || len(aliases) == 0 {
78
+ continue
79
+ }
80
+ seenAlias := make(map[string]struct{}, len(aliases))
81
+ clean := make([]OAuthModelAlias, 0, len(aliases))
82
+ for _, entry := range aliases {
83
+ name := strings.TrimSpace(entry.Name)
84
+ alias := strings.TrimSpace(entry.Alias)
85
+ if name == "" || alias == "" {
86
+ continue
87
+ }
88
+ if strings.EqualFold(name, alias) {
89
+ continue
90
+ }
91
+ aliasKey := strings.ToLower(alias)
92
+ if _, ok := seenAlias[aliasKey]; ok {
93
+ continue
94
+ }
95
+ seenAlias[aliasKey] = struct{}{}
96
+ clean = append(clean, OAuthModelAlias{Name: name, Alias: alias, Fork: entry.Fork})
97
+ }
98
+ if len(clean) > 0 {
99
+ out[channel] = clean
100
+ }
101
+ }
102
+ cfg.OAuthModelAlias = out
103
+ }
104
+
105
+ // SanitizeOpenAICompatibility removes OpenAI-compatibility provider entries that are
106
+ // not actionable, specifically those missing a BaseURL. It trims whitespace before
107
+ // evaluation and preserves the relative order of remaining entries.
108
+ func (cfg *Config) SanitizeOpenAICompatibility() {
109
+ if cfg == nil || len(cfg.OpenAICompatibility) == 0 {
110
+ return
111
+ }
112
+ out := make([]OpenAICompatibility, 0, len(cfg.OpenAICompatibility))
113
+ for i := range cfg.OpenAICompatibility {
114
+ e := cfg.OpenAICompatibility[i]
115
+ e.Name = strings.TrimSpace(e.Name)
116
+ e.Prefix = normalizeModelPrefix(e.Prefix)
117
+ e.BaseURL = strings.TrimSpace(e.BaseURL)
118
+ e.Headers = NormalizeHeaders(e.Headers)
119
+ if e.BaseURL == "" {
120
+ // Skip providers with no base-url; treated as removed
121
+ continue
122
+ }
123
+ out = append(out, e)
124
+ }
125
+ cfg.OpenAICompatibility = out
126
+ }
127
+
128
+ // SanitizeCodexKeys removes Codex API key entries missing a BaseURL.
129
+ // It trims whitespace and preserves order for remaining entries.
130
+ func (cfg *Config) SanitizeCodexKeys() {
131
+ if cfg == nil || len(cfg.CodexKey) == 0 {
132
+ return
133
+ }
134
+ out := make([]CodexKey, 0, len(cfg.CodexKey))
135
+ for i := range cfg.CodexKey {
136
+ e := cfg.CodexKey[i]
137
+ e.Prefix = normalizeModelPrefix(e.Prefix)
138
+ e.BaseURL = strings.TrimSpace(e.BaseURL)
139
+ e.Headers = NormalizeHeaders(e.Headers)
140
+ e.ExcludedModels = NormalizeExcludedModels(e.ExcludedModels)
141
+ if e.BaseURL == "" {
142
+ continue
143
+ }
144
+ out = append(out, e)
145
+ }
146
+ cfg.CodexKey = out
147
+ }
148
+
149
+
150
+ // SanitizeClaudeKeys normalizes headers and API key entries for Claude credentials.
151
+ func (cfg *Config) SanitizeClaudeKeys() {
152
+ if cfg == nil || len(cfg.ClaudeKey) == 0 {
153
+ return
154
+ }
155
+ for i := range cfg.ClaudeKey {
156
+ entry := &cfg.ClaudeKey[i]
157
+ entry.Prefix = normalizeModelPrefix(entry.Prefix)
158
+ entry.Headers = NormalizeHeaders(entry.Headers)
159
+ entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
160
+ entry.BaseURL = strings.TrimSpace(entry.BaseURL)
161
+ entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
162
+ entry.APIKey = strings.TrimSpace(entry.APIKey)
163
+ // Sanitize APIKeyEntries
164
+ for j := range entry.APIKeyEntries {
165
+ apiKeyEntry := &entry.APIKeyEntries[j]
166
+ apiKeyEntry.APIKey = strings.TrimSpace(apiKeyEntry.APIKey)
167
+ apiKeyEntry.ProxyURL = strings.TrimSpace(apiKeyEntry.ProxyURL)
168
+ }
169
+ }
170
+ }
171
+
172
+ // SanitizeGeminiKeys deduplicates and normalizes Gemini credentials.
173
+ func (cfg *Config) SanitizeGeminiKeys() {
174
+ if cfg == nil {
175
+ return
176
+ }
177
+
178
+ seen := make(map[string]struct{}, len(cfg.GeminiKey))
179
+ out := cfg.GeminiKey[:0]
180
+ for i := range cfg.GeminiKey {
181
+ entry := cfg.GeminiKey[i]
182
+ entry.APIKey = strings.TrimSpace(entry.APIKey)
183
+ if entry.APIKey == "" {
184
+ continue
185
+ }
186
+ entry.Prefix = normalizeModelPrefix(entry.Prefix)
187
+ entry.BaseURL = strings.TrimSpace(entry.BaseURL)
188
+ entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
189
+ entry.Headers = NormalizeHeaders(entry.Headers)
190
+ entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
191
+ if _, exists := seen[entry.APIKey]; exists {
192
+ continue
193
+ }
194
+ seen[entry.APIKey] = struct{}{}
195
+ out = append(out, entry)
196
+ }
197
+ cfg.GeminiKey = out
198
+ }
199
+
200
+ func normalizeModelPrefix(prefix string) string {
201
+ trimmed := strings.TrimSpace(prefix)
202
+ trimmed = strings.Trim(trimmed, "/")
203
+ if trimmed == "" {
204
+ return ""
205
+ }
206
+ if strings.Contains(trimmed, "/") {
207
+ return ""
208
+ }
209
+ return trimmed
210
+ }
211
+
212
+ func syncInlineAccessProvider(cfg *Config) {
213
+ if cfg == nil {
214
+ return
215
+ }
216
+ if len(cfg.APIKeys) == 0 {
217
+ if provider := cfg.ConfigAPIKeyProvider(); provider != nil && len(provider.APIKeys) > 0 {
218
+ cfg.APIKeys = append([]string(nil), provider.APIKeys...)
219
+ }
220
+ }
221
+ cfg.Access.Providers = nil
222
+ }
223
+
224
+ // looksLikeBcrypt returns true if the provided string appears to be a bcrypt hash.
225
+ func looksLikeBcrypt(s string) bool {
226
+ return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$")
227
+ }
228
+
229
+ // NormalizeHeaders trims header keys and values and removes empty pairs.
230
+ func NormalizeHeaders(headers map[string]string) map[string]string {
231
+ if len(headers) == 0 {
232
+ return nil
233
+ }
234
+ clean := make(map[string]string, len(headers))
235
+ for k, v := range headers {
236
+ key := strings.TrimSpace(k)
237
+ val := strings.TrimSpace(v)
238
+ if key == "" || val == "" {
239
+ continue
240
+ }
241
+ clean[key] = val
242
+ }
243
+ if len(clean) == 0 {
244
+ return nil
245
+ }
246
+ return clean
247
+ }
248
+
249
+ // NormalizeExcludedModels trims, lowercases, and deduplicates model exclusion patterns.
250
+ // It preserves the order of first occurrences and drops empty entries.
251
+ func NormalizeExcludedModels(models []string) []string {
252
+ if len(models) == 0 {
253
+ return nil
254
+ }
255
+ seen := make(map[string]struct{}, len(models))
256
+ out := make([]string, 0, len(models))
257
+ for _, raw := range models {
258
+ trimmed := strings.ToLower(strings.TrimSpace(raw))
259
+ if trimmed == "" {
260
+ continue
261
+ }
262
+ if _, exists := seen[trimmed]; exists {
263
+ continue
264
+ }
265
+ seen[trimmed] = struct{}{}
266
+ out = append(out, trimmed)
267
+ }
268
+ if len(out) == 0 {
269
+ return nil
270
+ }
271
+ return out
272
+ }
273
+
274
+ // NormalizeOAuthExcludedModels cleans provider -> excluded models mappings by normalizing provider keys
275
+ // and applying model exclusion normalization to each entry.
276
+ func NormalizeOAuthExcludedModels(entries map[string][]string) map[string][]string {
277
+ if len(entries) == 0 {
278
+ return nil
279
+ }
280
+ out := make(map[string][]string, len(entries))
281
+ for provider, models := range entries {
282
+ key := strings.ToLower(strings.TrimSpace(provider))
283
+ if key == "" {
284
+ continue
285
+ }
286
+ normalized := NormalizeExcludedModels(models)
287
+ if len(normalized) == 0 {
288
+ continue
289
+ }
290
+ out[key] = normalized
291
+ }
292
+ if len(out) == 0 {
293
+ return nil
294
+ }
295
+ return out
296
+ }
297
+
298
+ // hashSecret hashes the given secret using bcrypt.
299
+ func hashSecret(secret string) (string, error) {
300
+ // Use default cost for simplicity.
301
+ hashedBytes, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost)
302
+ if err != nil {
303
+ return "", err
304
+ }
305
+ return string(hashedBytes), nil
306
+ }
307
+
308
+ func sanitizeConfigForPersist(cfg *Config) *Config {
309
+ if cfg == nil {
310
+ return nil
311
+ }
312
+ clone := *cfg
313
+ clone.SDKConfig = cfg.SDKConfig
314
+ clone.SDKConfig.Access = AccessConfig{}
315
+ return &clone
316
+ }
internal/config/yaml_utils.go ADDED
@@ -0,0 +1,615 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import (
4
+ "bytes"
5
+ "fmt"
6
+ "os"
7
+ "strings"
8
+
9
+ "gopkg.in/yaml.v3"
10
+ )
11
+
12
+ // SaveConfigPreserveComments writes the config back to YAML while preserving existing comments
13
+ // and key ordering by loading the original file into a yaml.Node tree and updating values in-place.
14
+ func SaveConfigPreserveComments(configFile string, cfg *Config) error {
15
+ persistCfg := sanitizeConfigForPersist(cfg)
16
+ // Load original YAML as a node tree to preserve comments and ordering.
17
+ data, err := os.ReadFile(configFile)
18
+ if err != nil {
19
+ return err
20
+ }
21
+
22
+ var original yaml.Node
23
+ if err = yaml.Unmarshal(data, &original); err != nil {
24
+ return err
25
+ }
26
+ if original.Kind != yaml.DocumentNode || len(original.Content) == 0 {
27
+ return fmt.Errorf("invalid yaml document structure")
28
+ }
29
+ if original.Content[0] == nil || original.Content[0].Kind != yaml.MappingNode {
30
+ return fmt.Errorf("expected root mapping node")
31
+ }
32
+
33
+ // Marshal the current cfg to YAML, then unmarshal to a yaml.Node we can merge from.
34
+ rendered, err := yaml.Marshal(persistCfg)
35
+ if err != nil {
36
+ return err
37
+ }
38
+ var generated yaml.Node
39
+ if err = yaml.Unmarshal(rendered, &generated); err != nil {
40
+ return err
41
+ }
42
+ if generated.Kind != yaml.DocumentNode || len(generated.Content) == 0 || generated.Content[0] == nil {
43
+ return fmt.Errorf("invalid generated yaml structure")
44
+ }
45
+ if generated.Content[0].Kind != yaml.MappingNode {
46
+ return fmt.Errorf("expected generated root mapping node")
47
+ }
48
+
49
+ // Remove deprecated sections before merging back the sanitized config.
50
+ removeLegacyAuthBlock(original.Content[0])
51
+ removeLegacyOpenAICompatAPIKeys(original.Content[0])
52
+ removeLegacyAmpKeys(original.Content[0])
53
+ removeLegacyGenerativeLanguageKeys(original.Content[0])
54
+
55
+ pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-excluded-models")
56
+
57
+ // Merge generated into original in-place, preserving comments/order of existing nodes.
58
+ mergeMappingPreserve(original.Content[0], generated.Content[0])
59
+ normalizeCollectionNodeStyles(original.Content[0])
60
+
61
+ // Write back.
62
+ f, err := os.Create(configFile)
63
+ if err != nil {
64
+ return err
65
+ }
66
+ defer func() { _ = f.Close() }()
67
+ var buf bytes.Buffer
68
+ enc := yaml.NewEncoder(&buf)
69
+ enc.SetIndent(2)
70
+ if err = enc.Encode(&original); err != nil {
71
+ _ = enc.Close()
72
+ return err
73
+ }
74
+ if err = enc.Close(); err != nil {
75
+ return err
76
+ }
77
+ data = NormalizeCommentIndentation(buf.Bytes())
78
+ _, err = f.Write(data)
79
+ return err
80
+ }
81
+
82
+ // SaveConfigPreserveCommentsUpdateNestedScalar updates a nested scalar key path like ["a","b"]
83
+ // while preserving comments and positions.
84
+ func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error {
85
+ data, err := os.ReadFile(configFile)
86
+ if err != nil {
87
+ return err
88
+ }
89
+ var root yaml.Node
90
+ if err = yaml.Unmarshal(data, &root); err != nil {
91
+ return err
92
+ }
93
+ if root.Kind != yaml.DocumentNode || len(root.Content) == 0 {
94
+ return fmt.Errorf("invalid yaml document structure")
95
+ }
96
+ node := root.Content[0]
97
+ // descend mapping nodes following path
98
+ for i, key := range path {
99
+ if i == len(path)-1 {
100
+ // set final scalar
101
+ v := getOrCreateMapValue(node, key)
102
+ v.Kind = yaml.ScalarNode
103
+ v.Tag = "!!str"
104
+ v.Value = value
105
+ } else {
106
+ next := getOrCreateMapValue(node, key)
107
+ if next.Kind != yaml.MappingNode {
108
+ next.Kind = yaml.MappingNode
109
+ next.Tag = "!!map"
110
+ }
111
+ node = next
112
+ }
113
+ }
114
+ f, err := os.Create(configFile)
115
+ if err != nil {
116
+ return err
117
+ }
118
+ defer func() { _ = f.Close() }()
119
+ var buf bytes.Buffer
120
+ enc := yaml.NewEncoder(&buf)
121
+ enc.SetIndent(2)
122
+ if err = enc.Encode(&root); err != nil {
123
+ _ = enc.Close()
124
+ return err
125
+ }
126
+ if err = enc.Close(); err != nil {
127
+ return err
128
+ }
129
+ data = NormalizeCommentIndentation(buf.Bytes())
130
+ _, err = f.Write(data)
131
+ return err
132
+ }
133
+
134
+ // NormalizeCommentIndentation removes indentation from standalone YAML comment lines to keep them left aligned.
135
+ func NormalizeCommentIndentation(data []byte) []byte {
136
+ lines := bytes.Split(data, []byte("\n"))
137
+ changed := false
138
+ for i, line := range lines {
139
+ trimmed := bytes.TrimLeft(line, " \t")
140
+ if len(trimmed) == 0 || trimmed[0] != '#' {
141
+ continue
142
+ }
143
+ if len(trimmed) == len(line) {
144
+ continue
145
+ }
146
+ lines[i] = append([]byte(nil), trimmed...)
147
+ changed = true
148
+ }
149
+ if !changed {
150
+ return data
151
+ }
152
+ return bytes.Join(lines, []byte("\n"))
153
+ }
154
+
155
+ // getOrCreateMapValue finds the value node for a given key in a mapping node.
156
+ // If not found, it appends a new key/value pair and returns the new value node.
157
+ func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node {
158
+ if mapNode.Kind != yaml.MappingNode {
159
+ mapNode.Kind = yaml.MappingNode
160
+ mapNode.Tag = "!!map"
161
+ mapNode.Content = nil
162
+ }
163
+ for i := 0; i+1 < len(mapNode.Content); i += 2 {
164
+ k := mapNode.Content[i]
165
+ if k.Value == key {
166
+ return mapNode.Content[i+1]
167
+ }
168
+ }
169
+ // append new key/value
170
+ mapNode.Content = append(mapNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key})
171
+ val := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ""}
172
+ mapNode.Content = append(mapNode.Content, val)
173
+ return val
174
+ }
175
+
176
+ // mergeMappingPreserve merges keys from src into dst mapping node while preserving
177
+ // key order and comments of existing keys in dst. New keys are only added if their
178
+ // value is non-zero to avoid polluting the config with defaults.
179
+ func mergeMappingPreserve(dst, src *yaml.Node) {
180
+ if dst == nil || src == nil {
181
+ return
182
+ }
183
+ if dst.Kind != yaml.MappingNode || src.Kind != yaml.MappingNode {
184
+ // If kinds do not match, prefer replacing dst with src semantics in-place
185
+ // but keep dst node object to preserve any attached comments at the parent level.
186
+ copyNodeShallow(dst, src)
187
+ return
188
+ }
189
+ for i := 0; i+1 < len(src.Content); i += 2 {
190
+ sk := src.Content[i]
191
+ sv := src.Content[i+1]
192
+ idx := findMapKeyIndex(dst, sk.Value)
193
+ if idx >= 0 {
194
+ // Merge into existing value node (always update, even to zero values)
195
+ dv := dst.Content[idx+1]
196
+ mergeNodePreserve(dv, sv)
197
+ } else {
198
+ // New key: only add if value is non-zero to avoid polluting config with defaults
199
+ if isZeroValueNode(sv) {
200
+ continue
201
+ }
202
+ dst.Content = append(dst.Content, deepCopyNode(sk), deepCopyNode(sv))
203
+ }
204
+ }
205
+ }
206
+
207
+ // mergeNodePreserve merges src into dst for scalars, mappings and sequences while
208
+ // reusing destination nodes to keep comments and anchors. For sequences, it updates
209
+ // in-place by index.
210
+ func mergeNodePreserve(dst, src *yaml.Node) {
211
+ if dst == nil || src == nil {
212
+ return
213
+ }
214
+ switch src.Kind {
215
+ case yaml.MappingNode:
216
+ if dst.Kind != yaml.MappingNode {
217
+ copyNodeShallow(dst, src)
218
+ }
219
+ mergeMappingPreserve(dst, src)
220
+ case yaml.SequenceNode:
221
+ // Preserve explicit null style if dst was null and src is empty sequence
222
+ if dst.Kind == yaml.ScalarNode && dst.Tag == "!!null" && len(src.Content) == 0 {
223
+ // Keep as null to preserve original style
224
+ return
225
+ }
226
+ if dst.Kind != yaml.SequenceNode {
227
+ dst.Kind = yaml.SequenceNode
228
+ dst.Tag = "!!seq"
229
+ dst.Content = nil
230
+ }
231
+ reorderSequenceForMerge(dst, src)
232
+ // Update elements in place
233
+ minContent := len(dst.Content)
234
+ if len(src.Content) < minContent {
235
+ minContent = len(src.Content)
236
+ }
237
+ for i := 0; i < minContent; i++ {
238
+ if dst.Content[i] == nil {
239
+ dst.Content[i] = deepCopyNode(src.Content[i])
240
+ continue
241
+ }
242
+ mergeNodePreserve(dst.Content[i], src.Content[i])
243
+ if dst.Content[i] != nil && src.Content[i] != nil &&
244
+ dst.Content[i].Kind == yaml.MappingNode && src.Content[i].Kind == yaml.MappingNode {
245
+ pruneMissingMapKeys(dst.Content[i], src.Content[i])
246
+ }
247
+ }
248
+ // Append any extra items from src
249
+ for i := len(dst.Content); i < len(src.Content); i++ {
250
+ dst.Content = append(dst.Content, deepCopyNode(src.Content[i]))
251
+ }
252
+ // Truncate if dst has extra items not in src
253
+ if len(src.Content) < len(dst.Content) {
254
+ dst.Content = dst.Content[:len(src.Content)]
255
+ }
256
+ case yaml.ScalarNode, yaml.AliasNode:
257
+ // For scalars, update Tag and Value but keep Style from dst to preserve quoting
258
+ dst.Kind = src.Kind
259
+ dst.Tag = src.Tag
260
+ dst.Value = src.Value
261
+ // Keep dst.Style as-is intentionally
262
+ case 0:
263
+ // Unknown/empty kind; do nothing
264
+ default:
265
+ // Fallback: replace shallowly
266
+ copyNodeShallow(dst, src)
267
+ }
268
+ }
269
+
270
+ // findMapKeyIndex returns the index of key node in dst mapping (index of key, not value).
271
+ // Returns -1 when not found.
272
+ func findMapKeyIndex(mapNode *yaml.Node, key string) int {
273
+ if mapNode == nil || mapNode.Kind != yaml.MappingNode {
274
+ return -1
275
+ }
276
+ for i := 0; i+1 < len(mapNode.Content); i += 2 {
277
+ if mapNode.Content[i] != nil && mapNode.Content[i].Value == key {
278
+ return i
279
+ }
280
+ }
281
+ return -1
282
+ }
283
+
284
+ // isZeroValueNode returns true if the YAML node represents a zero/default value
285
+ // that should not be written as a new key to preserve config cleanliness.
286
+ // For mappings and sequences, recursively checks if all children are zero values.
287
+ func isZeroValueNode(node *yaml.Node) bool {
288
+ if node == nil {
289
+ return true
290
+ }
291
+ switch node.Kind {
292
+ case yaml.ScalarNode:
293
+ switch node.Tag {
294
+ case "!!bool":
295
+ return node.Value == "false"
296
+ case "!!int", "!!float":
297
+ return node.Value == "0" || node.Value == "0.0"
298
+ case "!!str":
299
+ return node.Value == ""
300
+ case "!!null":
301
+ return true
302
+ }
303
+ case yaml.SequenceNode:
304
+ if len(node.Content) == 0 {
305
+ return true
306
+ }
307
+ // Check if all elements are zero values
308
+ for _, child := range node.Content {
309
+ if !isZeroValueNode(child) {
310
+ return false
311
+ }
312
+ }
313
+ return true
314
+ case yaml.MappingNode:
315
+ if len(node.Content) == 0 {
316
+ return true
317
+ }
318
+ // Check if all values are zero values (values are at odd indices)
319
+ for i := 1; i < len(node.Content); i += 2 {
320
+ if !isZeroValueNode(node.Content[i]) {
321
+ return false
322
+ }
323
+ }
324
+ return true
325
+ }
326
+ return false
327
+ }
328
+
329
+ // deepCopyNode creates a deep copy of a yaml.Node graph.
330
+ func deepCopyNode(n *yaml.Node) *yaml.Node {
331
+ if n == nil {
332
+ return nil
333
+ }
334
+ cp := *n
335
+ if len(n.Content) > 0 {
336
+ cp.Content = make([]*yaml.Node, len(n.Content))
337
+ for i := range n.Content {
338
+ cp.Content[i] = deepCopyNode(n.Content[i])
339
+ }
340
+ }
341
+ return &cp
342
+ }
343
+
344
+ // copyNodeShallow copies type/tag/value and resets content to match src, but
345
+ // keeps the same destination node pointer to preserve parent relations/comments.
346
+ func copyNodeShallow(dst, src *yaml.Node) {
347
+ if dst == nil || src == nil {
348
+ return
349
+ }
350
+ dst.Kind = src.Kind
351
+ dst.Tag = src.Tag
352
+ dst.Value = src.Value
353
+ // Replace content with deep copy from src
354
+ if len(src.Content) > 0 {
355
+ dst.Content = make([]*yaml.Node, len(src.Content))
356
+ for i := range src.Content {
357
+ dst.Content[i] = deepCopyNode(src.Content[i])
358
+ }
359
+ } else {
360
+ dst.Content = nil
361
+ }
362
+ }
363
+
364
+ func reorderSequenceForMerge(dst, src *yaml.Node) {
365
+ if dst == nil || src == nil {
366
+ return
367
+ }
368
+ if len(dst.Content) == 0 {
369
+ return
370
+ }
371
+ if len(src.Content) == 0 {
372
+ return
373
+ }
374
+ original := append([]*yaml.Node(nil), dst.Content...)
375
+ used := make([]bool, len(original))
376
+ ordered := make([]*yaml.Node, len(src.Content))
377
+ for i := range src.Content {
378
+ if idx := matchSequenceElement(original, used, src.Content[i]); idx >= 0 {
379
+ ordered[i] = original[idx]
380
+ used[idx] = true
381
+ }
382
+ }
383
+ dst.Content = ordered
384
+ }
385
+
386
+ func matchSequenceElement(original []*yaml.Node, used []bool, target *yaml.Node) int {
387
+ if target == nil {
388
+ return -1
389
+ }
390
+ switch target.Kind {
391
+ case yaml.MappingNode:
392
+ id := sequenceElementIdentity(target)
393
+ if id != "" {
394
+ for i := range original {
395
+ if used[i] || original[i] == nil || original[i].Kind != yaml.MappingNode {
396
+ continue
397
+ }
398
+ if sequenceElementIdentity(original[i]) == id {
399
+ return i
400
+ }
401
+ }
402
+ }
403
+ case yaml.ScalarNode:
404
+ val := strings.TrimSpace(target.Value)
405
+ if val != "" {
406
+ for i := range original {
407
+ if used[i] || original[i] == nil || original[i].Kind != yaml.ScalarNode {
408
+ continue
409
+ }
410
+ if strings.TrimSpace(original[i].Value) == val {
411
+ return i
412
+ }
413
+ }
414
+ }
415
+ default:
416
+ }
417
+ // Fallback to structural equality to preserve nodes lacking explicit identifiers.
418
+ for i := range original {
419
+ if used[i] || original[i] == nil {
420
+ continue
421
+ }
422
+ if nodesStructurallyEqual(original[i], target) {
423
+ return i
424
+ }
425
+ }
426
+ return -1
427
+ }
428
+
429
+ func sequenceElementIdentity(node *yaml.Node) string {
430
+ if node == nil || node.Kind != yaml.MappingNode {
431
+ return ""
432
+ }
433
+ identityKeys := []string{"id", "name", "alias", "api-key", "api_key", "apikey", "key", "provider", "model"}
434
+ for _, k := range identityKeys {
435
+ if v := mappingScalarValue(node, k); v != "" {
436
+ return k + "=" + v
437
+ }
438
+ }
439
+ for i := 0; i+1 < len(node.Content); i += 2 {
440
+ keyNode := node.Content[i]
441
+ valNode := node.Content[i+1]
442
+ if keyNode == nil || valNode == nil || valNode.Kind != yaml.ScalarNode {
443
+ continue
444
+ }
445
+ val := strings.TrimSpace(valNode.Value)
446
+ if val != "" {
447
+ return strings.ToLower(strings.TrimSpace(keyNode.Value)) + "=" + val
448
+ }
449
+ }
450
+ return ""
451
+ }
452
+
453
+ func mappingScalarValue(node *yaml.Node, key string) string {
454
+ if node == nil || node.Kind != yaml.MappingNode {
455
+ return ""
456
+ }
457
+ lowerKey := strings.ToLower(key)
458
+ for i := 0; i+1 < len(node.Content); i += 2 {
459
+ keyNode := node.Content[i]
460
+ valNode := node.Content[i+1]
461
+ if keyNode == nil || valNode == nil || valNode.Kind != yaml.ScalarNode {
462
+ continue
463
+ }
464
+ if strings.ToLower(strings.TrimSpace(keyNode.Value)) == lowerKey {
465
+ return strings.TrimSpace(valNode.Value)
466
+ }
467
+ }
468
+ return ""
469
+ }
470
+
471
+ func nodesStructurallyEqual(a, b *yaml.Node) bool {
472
+ if a == nil || b == nil {
473
+ return a == b
474
+ }
475
+ if a.Kind != b.Kind {
476
+ return false
477
+ }
478
+ switch a.Kind {
479
+ case yaml.MappingNode:
480
+ if len(a.Content) != len(b.Content) {
481
+ return false
482
+ }
483
+ for i := 0; i+1 < len(a.Content); i += 2 {
484
+ if !nodesStructurallyEqual(a.Content[i], b.Content[i]) {
485
+ return false
486
+ }
487
+ if !nodesStructurallyEqual(a.Content[i+1], b.Content[i+1]) {
488
+ return false
489
+ }
490
+ }
491
+ return true
492
+ case yaml.SequenceNode:
493
+ if len(a.Content) != len(b.Content) {
494
+ return false
495
+ }
496
+ for i := range a.Content {
497
+ if !nodesStructurallyEqual(a.Content[i], b.Content[i]) {
498
+ return false
499
+ }
500
+ }
501
+ return true
502
+ case yaml.ScalarNode:
503
+ return strings.TrimSpace(a.Value) == strings.TrimSpace(b.Value)
504
+ case yaml.AliasNode:
505
+ return nodesStructurallyEqual(a.Alias, b.Alias)
506
+ default:
507
+ return strings.TrimSpace(a.Value) == strings.TrimSpace(b.Value)
508
+ }
509
+ }
510
+
511
+ func removeMapKey(mapNode *yaml.Node, key string) {
512
+ if mapNode == nil || mapNode.Kind != yaml.MappingNode || key == "" {
513
+ return
514
+ }
515
+ for i := 0; i+1 < len(mapNode.Content); i += 2 {
516
+ if mapNode.Content[i] != nil && mapNode.Content[i].Value == key {
517
+ mapNode.Content = append(mapNode.Content[:i], mapNode.Content[i+2:]...)
518
+ return
519
+ }
520
+ }
521
+ }
522
+
523
+ func pruneMappingToGeneratedKeys(dstRoot, srcRoot *yaml.Node, key string) {
524
+ if key == "" || dstRoot == nil || srcRoot == nil {
525
+ return
526
+ }
527
+ if dstRoot.Kind != yaml.MappingNode || srcRoot.Kind != yaml.MappingNode {
528
+ return
529
+ }
530
+ dstIdx := findMapKeyIndex(dstRoot, key)
531
+ if dstIdx < 0 || dstIdx+1 >= len(dstRoot.Content) {
532
+ return
533
+ }
534
+ srcIdx := findMapKeyIndex(srcRoot, key)
535
+ if srcIdx < 0 {
536
+ removeMapKey(dstRoot, key)
537
+ return
538
+ }
539
+ if srcIdx+1 >= len(srcRoot.Content) {
540
+ return
541
+ }
542
+ srcVal := srcRoot.Content[srcIdx+1]
543
+ dstVal := dstRoot.Content[dstIdx+1]
544
+ if srcVal == nil {
545
+ dstRoot.Content[dstIdx+1] = nil
546
+ return
547
+ }
548
+ if srcVal.Kind != yaml.MappingNode {
549
+ dstRoot.Content[dstIdx+1] = deepCopyNode(srcVal)
550
+ return
551
+ }
552
+ if dstVal == nil || dstVal.Kind != yaml.MappingNode {
553
+ dstRoot.Content[dstIdx+1] = deepCopyNode(srcVal)
554
+ return
555
+ }
556
+ pruneMissingMapKeys(dstVal, srcVal)
557
+ }
558
+
559
+ func pruneMissingMapKeys(dstMap, srcMap *yaml.Node) {
560
+ if dstMap == nil || srcMap == nil || dstMap.Kind != yaml.MappingNode || srcMap.Kind != yaml.MappingNode {
561
+ return
562
+ }
563
+ keep := make(map[string]struct{}, len(srcMap.Content)/2)
564
+ for i := 0; i+1 < len(srcMap.Content); i += 2 {
565
+ keyNode := srcMap.Content[i]
566
+ if keyNode == nil {
567
+ continue
568
+ }
569
+ key := strings.TrimSpace(keyNode.Value)
570
+ if key == "" {
571
+ continue
572
+ }
573
+ keep[key] = struct{}{}
574
+ }
575
+ for i := 0; i+1 < len(dstMap.Content); {
576
+ keyNode := dstMap.Content[i]
577
+ if keyNode == nil {
578
+ i += 2
579
+ continue
580
+ }
581
+ key := strings.TrimSpace(keyNode.Value)
582
+ if _, ok := keep[key]; !ok {
583
+ dstMap.Content = append(dstMap.Content[:i], dstMap.Content[i+2:]...)
584
+ continue
585
+ }
586
+ i += 2
587
+ }
588
+ }
589
+
590
+ // normalizeCollectionNodeStyles forces YAML collections to use block notation, keeping
591
+ // lists and maps readable. Empty sequences retain flow style ([]) so empty list markers
592
+ // remain compact.
593
+ func normalizeCollectionNodeStyles(node *yaml.Node) {
594
+ if node == nil {
595
+ return
596
+ }
597
+ switch node.Kind {
598
+ case yaml.MappingNode:
599
+ node.Style = 0
600
+ for i := range node.Content {
601
+ normalizeCollectionNodeStyles(node.Content[i])
602
+ }
603
+ case yaml.SequenceNode:
604
+ if len(node.Content) == 0 {
605
+ node.Style = yaml.FlowStyle
606
+ } else {
607
+ node.Style = 0
608
+ }
609
+ for i := range node.Content {
610
+ normalizeCollectionNodeStyles(node.Content[i])
611
+ }
612
+ default:
613
+ // Scalars keep their existing style to preserve quoting
614
+ }
615
+ }
internal/runtime/executor/gemini_vertex_executor.go CHANGED
@@ -30,144 +30,10 @@ import (
30
  const (
31
  // vertexAPIVersion aligns with current public Vertex Generative AI API.
32
  vertexAPIVersion = "v1"
33
- )
34
-
35
- // isImagenModel checks if the model name is an Imagen image generation model.
36
- // Imagen models use the :predict action instead of :generateContent.
37
- func isImagenModel(model string) bool {
38
- lowerModel := strings.ToLower(model)
39
- return strings.Contains(lowerModel, "imagen")
40
- }
41
-
42
- // getVertexAction returns the appropriate action for the given model.
43
- // Imagen models use "predict", while Gemini models use "generateContent".
44
- func getVertexAction(model string, isStream bool) string {
45
- if isImagenModel(model) {
46
- return "predict"
47
- }
48
- if isStream {
49
- return "streamGenerateContent"
50
- }
51
- return "generateContent"
52
- }
53
-
54
- // convertImagenToGeminiResponse converts Imagen API response to Gemini format
55
- // so it can be processed by the standard translation pipeline.
56
- // This ensures Imagen models return responses in the same format as gemini-3-pro-image-preview.
57
- func convertImagenToGeminiResponse(data []byte, model string) []byte {
58
- predictions := gjson.GetBytes(data, "predictions")
59
- if !predictions.Exists() || !predictions.IsArray() {
60
- return data
61
- }
62
-
63
- // Build Gemini-compatible response with inlineData
64
- parts := make([]map[string]any, 0)
65
- for _, pred := range predictions.Array() {
66
- imageData := pred.Get("bytesBase64Encoded").String()
67
- mimeType := pred.Get("mimeType").String()
68
- if mimeType == "" {
69
- mimeType = "image/png"
70
- }
71
- if imageData != "" {
72
- parts = append(parts, map[string]any{
73
- "inlineData": map[string]any{
74
- "mimeType": mimeType,
75
- "data": imageData,
76
- },
77
- })
78
- }
79
- }
80
-
81
- // Generate unique response ID using timestamp
82
- responseId := fmt.Sprintf("imagen-%d", time.Now().UnixNano())
83
-
84
- response := map[string]any{
85
- "candidates": []map[string]any{{
86
- "content": map[string]any{
87
- "parts": parts,
88
- "role": "model",
89
- },
90
- "finishReason": "STOP",
91
- }},
92
- "responseId": responseId,
93
- "modelVersion": model,
94
- // Imagen API doesn't return token counts, set to 0 for tracking purposes
95
- "usageMetadata": map[string]any{
96
- "promptTokenCount": 0,
97
- "candidatesTokenCount": 0,
98
- "totalTokenCount": 0,
99
- },
100
- }
101
-
102
- result, err := json.Marshal(response)
103
- if err != nil {
104
- return data
105
- }
106
- return result
107
- }
108
-
109
- // convertToImagenRequest converts a Gemini-style request to Imagen API format.
110
- // Imagen API uses a different structure: instances[].prompt instead of contents[].
111
- func convertToImagenRequest(payload []byte) ([]byte, error) {
112
- // Extract prompt from Gemini-style contents
113
- prompt := ""
114
-
115
- // Try to get prompt from contents[0].parts[0].text
116
- contentsText := gjson.GetBytes(payload, "contents.0.parts.0.text")
117
- if contentsText.Exists() {
118
- prompt = contentsText.String()
119
- }
120
-
121
- // If no contents, try messages format (OpenAI-compatible)
122
- if prompt == "" {
123
- messagesText := gjson.GetBytes(payload, "messages.#.content")
124
- if messagesText.Exists() && messagesText.IsArray() {
125
- for _, msg := range messagesText.Array() {
126
- if msg.String() != "" {
127
- prompt = msg.String()
128
- break
129
- }
130
- }
131
- }
132
- }
133
-
134
- // If still no prompt, try direct prompt field
135
- if prompt == "" {
136
- directPrompt := gjson.GetBytes(payload, "prompt")
137
- if directPrompt.Exists() {
138
- prompt = directPrompt.String()
139
- }
140
- }
141
-
142
- if prompt == "" {
143
- return nil, fmt.Errorf("imagen: no prompt found in request")
144
- }
145
 
146
- // Build Imagen API request
147
- imagenReq := map[string]any{
148
- "instances": []map[string]any{
149
- {
150
- "prompt": prompt,
151
- },
152
- },
153
- "parameters": map[string]any{
154
- "sampleCount": 1,
155
- },
156
- }
157
-
158
- // Extract optional parameters
159
- if aspectRatio := gjson.GetBytes(payload, "aspectRatio"); aspectRatio.Exists() {
160
- imagenReq["parameters"].(map[string]any)["aspectRatio"] = aspectRatio.String()
161
- }
162
- if sampleCount := gjson.GetBytes(payload, "sampleCount"); sampleCount.Exists() {
163
- imagenReq["parameters"].(map[string]any)["sampleCount"] = int(sampleCount.Int())
164
- }
165
- if negativePrompt := gjson.GetBytes(payload, "negativePrompt"); negativePrompt.Exists() {
166
- imagenReq["instances"].([]map[string]any)[0]["negativePrompt"] = negativePrompt.String()
167
- }
168
-
169
- return json.Marshal(imagenReq)
170
- }
171
 
172
  // GeminiVertexExecutor sends requests to Vertex AI Gemini endpoints using service account credentials.
173
  type GeminiVertexExecutor struct {
@@ -175,12 +41,6 @@ type GeminiVertexExecutor struct {
175
  }
176
 
177
  // NewGeminiVertexExecutor creates a new Vertex AI Gemini executor instance.
178
- //
179
- // Parameters:
180
- // - cfg: The application configuration
181
- //
182
- // Returns:
183
- // - *GeminiVertexExecutor: A new Vertex AI Gemini executor instance
184
  func NewGeminiVertexExecutor(cfg *config.Config) *GeminiVertexExecutor {
185
  return &GeminiVertexExecutor{cfg: cfg}
186
  }
@@ -188,31 +48,28 @@ func NewGeminiVertexExecutor(cfg *config.Config) *GeminiVertexExecutor {
188
  // Identifier returns the executor identifier.
189
  func (e *GeminiVertexExecutor) Identifier() string { return "vertex" }
190
 
 
 
 
 
 
 
 
 
 
 
 
191
  // PrepareRequest injects Vertex credentials into the outgoing HTTP request.
192
  func (e *GeminiVertexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
193
  if req == nil {
194
  return nil
195
  }
196
- apiKey, _ := vertexAPICreds(auth)
197
- if strings.TrimSpace(apiKey) != "" {
198
- req.Header.Set("x-goog-api-key", apiKey)
199
- req.Header.Del("Authorization")
200
- return nil
201
- }
202
- _, _, saJSON, errCreds := vertexCreds(auth)
203
- if errCreds != nil {
204
- return errCreds
205
- }
206
- token, errToken := vertexAccessToken(req.Context(), e.cfg, auth, saJSON)
207
- if errToken != nil {
208
- return errToken
209
- }
210
- if strings.TrimSpace(token) == "" {
211
- return statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
212
  }
213
- req.Header.Set("Authorization", "Bearer "+token)
214
- req.Header.Del("x-goog-api-key")
215
- return nil
216
  }
217
 
218
  // HttpRequest injects Vertex credentials into the request and executes it.
@@ -233,101 +90,18 @@ func (e *GeminiVertexExecutor) HttpRequest(ctx context.Context, auth *cliproxyau
233
 
234
  // Execute performs a non-streaming request to the Vertex AI API.
235
  func (e *GeminiVertexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
236
- // Try API key authentication first
237
- apiKey, baseURL := vertexAPICreds(auth)
238
-
239
- // If no API key found, fall back to service account authentication
240
- if apiKey == "" {
241
- projectID, location, saJSON, errCreds := vertexCreds(auth)
242
- if errCreds != nil {
243
- return resp, errCreds
244
- }
245
- return e.executeWithServiceAccount(ctx, auth, req, opts, projectID, location, saJSON)
246
- }
247
-
248
- // Use API key authentication
249
- return e.executeWithAPIKey(ctx, auth, req, opts, apiKey, baseURL)
250
- }
251
-
252
- // ExecuteStream performs a streaming request to the Vertex AI API.
253
- func (e *GeminiVertexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
254
- // Try API key authentication first
255
- apiKey, baseURL := vertexAPICreds(auth)
256
-
257
- // If no API key found, fall back to service account authentication
258
- if apiKey == "" {
259
- projectID, location, saJSON, errCreds := vertexCreds(auth)
260
- if errCreds != nil {
261
- return nil, errCreds
262
- }
263
- return e.executeStreamWithServiceAccount(ctx, auth, req, opts, projectID, location, saJSON)
264
- }
265
-
266
- // Use API key authentication
267
- return e.executeStreamWithAPIKey(ctx, auth, req, opts, apiKey, baseURL)
268
- }
269
-
270
- // CountTokens counts tokens for the given request using the Vertex AI API.
271
- func (e *GeminiVertexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
272
- // Try API key authentication first
273
- apiKey, baseURL := vertexAPICreds(auth)
274
-
275
- // If no API key found, fall back to service account authentication
276
- if apiKey == "" {
277
- projectID, location, saJSON, errCreds := vertexCreds(auth)
278
- if errCreds != nil {
279
- return cliproxyexecutor.Response{}, errCreds
280
- }
281
- return e.countTokensWithServiceAccount(ctx, auth, req, opts, projectID, location, saJSON)
282
  }
283
 
284
- // Use API key authentication
285
- return e.countTokensWithAPIKey(ctx, auth, req, opts, apiKey, baseURL)
286
- }
287
-
288
- // Refresh refreshes the authentication credentials (no-op for Vertex).
289
- func (e *GeminiVertexExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
290
- return auth, nil
291
- }
292
-
293
- // executeWithServiceAccount handles authentication using service account credentials.
294
- // This method contains the original service account authentication logic.
295
- func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, projectID, location string, saJSON []byte) (resp cliproxyexecutor.Response, err error) {
296
  baseModel := thinking.ParseSuffix(req.Model).ModelName
297
-
298
  reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
299
  defer reporter.trackFailure(ctx, &err)
300
 
301
- var body []byte
302
-
303
- // Handle Imagen models with special request format
304
- if isImagenModel(baseModel) {
305
- imagenBody, errImagen := convertToImagenRequest(req.Payload)
306
- if errImagen != nil {
307
- return resp, errImagen
308
- }
309
- body = imagenBody
310
- } else {
311
- // Standard Gemini translation flow
312
- from := opts.SourceFormat
313
- to := sdktranslator.FromString("gemini")
314
-
315
- originalPayload := bytes.Clone(req.Payload)
316
- if len(opts.OriginalRequest) > 0 {
317
- originalPayload = bytes.Clone(opts.OriginalRequest)
318
- }
319
- originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
320
- body = sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
321
-
322
- body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
323
- if err != nil {
324
- return resp, err
325
- }
326
-
327
- body = fixGeminiImageAspectRatio(baseModel, body)
328
- requestedModel := payloadRequestedModel(opts, req.Model)
329
- body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
330
- body, _ = sjson.SetBytes(body, "model", baseModel)
331
  }
332
 
333
  action := getVertexAction(baseModel, false)
@@ -336,43 +110,19 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
336
  action = "countTokens"
337
  }
338
  }
339
- baseURL := vertexBaseURL(location)
340
- url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, baseModel, action)
341
- if opts.Alt != "" && action != "countTokens" {
342
- url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
343
- }
344
  body, _ = sjson.DeleteBytes(body, "session_id")
345
 
346
  httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
347
  if errNewReq != nil {
348
  return resp, errNewReq
349
  }
350
- httpReq.Header.Set("Content-Type", "application/json")
351
- if token, errTok := vertexAccessToken(ctx, e.cfg, auth, saJSON); errTok == nil && token != "" {
352
- httpReq.Header.Set("Authorization", "Bearer "+token)
353
- } else if errTok != nil {
354
- log.Errorf("vertex executor: access token error: %v", errTok)
355
- return resp, statusErr{code: 500, msg: "internal server error"}
356
- }
357
- applyGeminiHeaders(httpReq, auth)
358
 
359
- var authID, authLabel, authType, authValue string
360
- if auth != nil {
361
- authID = auth.ID
362
- authLabel = auth.Label
363
- authType, authValue = auth.AccountInfo()
364
  }
365
- recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
366
- URL: url,
367
- Method: http.MethodPost,
368
- Headers: httpReq.Header.Clone(),
369
- Body: body,
370
- Provider: e.Identifier(),
371
- AuthID: authID,
372
- AuthLabel: authLabel,
373
- AuthType: authType,
374
- AuthValue: authValue,
375
- })
376
 
377
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
378
  httpResp, errDo := httpClient.Do(httpReq)
@@ -385,14 +135,12 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
385
  log.Errorf("vertex executor: close response body error: %v", errClose)
386
  }
387
  }()
 
388
  recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
389
  if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
390
- b, _ := io.ReadAll(httpResp.Body)
391
- appendAPIResponseChunk(ctx, e.cfg, b)
392
- logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
393
- err = statusErr{code: httpResp.StatusCode, msg: string(b)}
394
- return resp, err
395
  }
 
396
  data, errRead := io.ReadAll(httpResp.Body)
397
  if errRead != nil {
398
  recordAPIResponseError(ctx, e.cfg, errRead)
@@ -401,13 +149,11 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
401
  appendAPIResponseChunk(ctx, e.cfg, data)
402
  reporter.publish(ctx, parseGeminiUsage(data))
403
 
404
- // For Imagen models, convert response to Gemini format before translation
405
- // This ensures Imagen responses use the same format as gemini-3-pro-image-preview
406
  if isImagenModel(baseModel) {
407
  data = convertImagenToGeminiResponse(data, baseModel)
408
  }
409
 
410
- // Standard Gemini translation (works for both Gemini and converted Imagen responses)
411
  from := opts.SourceFormat
412
  to := sdktranslator.FromString("gemini")
413
  var param any
@@ -416,273 +162,30 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
416
  return resp, nil
417
  }
418
 
419
- // executeWithAPIKey handles authentication using API key credentials.
420
- func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, apiKey, baseURL string) (resp cliproxyexecutor.Response, err error) {
421
- baseModel := thinking.ParseSuffix(req.Model).ModelName
422
-
423
- reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
424
- defer reporter.trackFailure(ctx, &err)
425
-
426
- from := opts.SourceFormat
427
- to := sdktranslator.FromString("gemini")
428
-
429
- originalPayload := bytes.Clone(req.Payload)
430
- if len(opts.OriginalRequest) > 0 {
431
- originalPayload = bytes.Clone(opts.OriginalRequest)
432
- }
433
- originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
434
- body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
435
-
436
- body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
437
- if err != nil {
438
- return resp, err
439
- }
440
-
441
- body = fixGeminiImageAspectRatio(baseModel, body)
442
- requestedModel := payloadRequestedModel(opts, req.Model)
443
- body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
444
- body, _ = sjson.SetBytes(body, "model", baseModel)
445
-
446
- action := getVertexAction(baseModel, false)
447
- if req.Metadata != nil {
448
- if a, _ := req.Metadata["action"].(string); a == "countTokens" {
449
- action = "countTokens"
450
- }
451
- }
452
-
453
- // For API key auth, use simpler URL format without project/location
454
- if baseURL == "" {
455
- baseURL = "https://generativelanguage.googleapis.com"
456
- }
457
- url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, action)
458
- if opts.Alt != "" && action != "countTokens" {
459
- url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
460
- }
461
- body, _ = sjson.DeleteBytes(body, "session_id")
462
-
463
- httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
464
- if errNewReq != nil {
465
- return resp, errNewReq
466
- }
467
- httpReq.Header.Set("Content-Type", "application/json")
468
- if apiKey != "" {
469
- httpReq.Header.Set("x-goog-api-key", apiKey)
470
- }
471
- applyGeminiHeaders(httpReq, auth)
472
-
473
- var authID, authLabel, authType, authValue string
474
- if auth != nil {
475
- authID = auth.ID
476
- authLabel = auth.Label
477
- authType, authValue = auth.AccountInfo()
478
- }
479
- recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
480
- URL: url,
481
- Method: http.MethodPost,
482
- Headers: httpReq.Header.Clone(),
483
- Body: body,
484
- Provider: e.Identifier(),
485
- AuthID: authID,
486
- AuthLabel: authLabel,
487
- AuthType: authType,
488
- AuthValue: authValue,
489
- })
490
-
491
- httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
492
- httpResp, errDo := httpClient.Do(httpReq)
493
- if errDo != nil {
494
- recordAPIResponseError(ctx, e.cfg, errDo)
495
- return resp, errDo
496
- }
497
- defer func() {
498
- if errClose := httpResp.Body.Close(); errClose != nil {
499
- log.Errorf("vertex executor: close response body error: %v", errClose)
500
- }
501
- }()
502
- recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
503
- if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
504
- b, _ := io.ReadAll(httpResp.Body)
505
- appendAPIResponseChunk(ctx, e.cfg, b)
506
- logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
507
- err = statusErr{code: httpResp.StatusCode, msg: string(b)}
508
- return resp, err
509
- }
510
- data, errRead := io.ReadAll(httpResp.Body)
511
- if errRead != nil {
512
- recordAPIResponseError(ctx, e.cfg, errRead)
513
- return resp, errRead
514
- }
515
- appendAPIResponseChunk(ctx, e.cfg, data)
516
- reporter.publish(ctx, parseGeminiUsage(data))
517
- var param any
518
- out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
519
- resp = cliproxyexecutor.Response{Payload: []byte(out)}
520
- return resp, nil
521
- }
522
-
523
- // executeStreamWithServiceAccount handles streaming authentication using service account credentials.
524
- func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, projectID, location string, saJSON []byte) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
525
- baseModel := thinking.ParseSuffix(req.Model).ModelName
526
-
527
- reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
528
- defer reporter.trackFailure(ctx, &err)
529
-
530
- from := opts.SourceFormat
531
- to := sdktranslator.FromString("gemini")
532
-
533
- originalPayload := bytes.Clone(req.Payload)
534
- if len(opts.OriginalRequest) > 0 {
535
- originalPayload = bytes.Clone(opts.OriginalRequest)
536
- }
537
- originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
538
- body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
539
-
540
- body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
541
- if err != nil {
542
- return nil, err
543
- }
544
-
545
- body = fixGeminiImageAspectRatio(baseModel, body)
546
- requestedModel := payloadRequestedModel(opts, req.Model)
547
- body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
548
- body, _ = sjson.SetBytes(body, "model", baseModel)
549
-
550
- action := getVertexAction(baseModel, true)
551
- baseURL := vertexBaseURL(location)
552
- url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, baseModel, action)
553
- // Imagen models don't support streaming, skip SSE params
554
- if !isImagenModel(baseModel) {
555
- if opts.Alt == "" {
556
- url = url + "?alt=sse"
557
- } else {
558
- url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
559
- }
560
- }
561
- body, _ = sjson.DeleteBytes(body, "session_id")
562
-
563
- httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
564
- if errNewReq != nil {
565
- return nil, errNewReq
566
- }
567
- httpReq.Header.Set("Content-Type", "application/json")
568
- if token, errTok := vertexAccessToken(ctx, e.cfg, auth, saJSON); errTok == nil && token != "" {
569
- httpReq.Header.Set("Authorization", "Bearer "+token)
570
- } else if errTok != nil {
571
- log.Errorf("vertex executor: access token error: %v", errTok)
572
- return nil, statusErr{code: 500, msg: "internal server error"}
573
- }
574
- applyGeminiHeaders(httpReq, auth)
575
-
576
- var authID, authLabel, authType, authValue string
577
- if auth != nil {
578
- authID = auth.ID
579
- authLabel = auth.Label
580
- authType, authValue = auth.AccountInfo()
581
- }
582
- recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
583
- URL: url,
584
- Method: http.MethodPost,
585
- Headers: httpReq.Header.Clone(),
586
- Body: body,
587
- Provider: e.Identifier(),
588
- AuthID: authID,
589
- AuthLabel: authLabel,
590
- AuthType: authType,
591
- AuthValue: authValue,
592
- })
593
-
594
- httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
595
- httpResp, errDo := httpClient.Do(httpReq)
596
- if errDo != nil {
597
- recordAPIResponseError(ctx, e.cfg, errDo)
598
- return nil, errDo
599
- }
600
- recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
601
- if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
602
- b, _ := io.ReadAll(httpResp.Body)
603
- appendAPIResponseChunk(ctx, e.cfg, b)
604
- logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
605
- if errClose := httpResp.Body.Close(); errClose != nil {
606
- log.Errorf("vertex executor: close response body error: %v", errClose)
607
- }
608
- return nil, statusErr{code: httpResp.StatusCode, msg: string(b)}
609
- }
610
-
611
- out := make(chan cliproxyexecutor.StreamChunk)
612
- stream = out
613
- go func() {
614
- defer close(out)
615
- defer func() {
616
- if errClose := httpResp.Body.Close(); errClose != nil {
617
- log.Errorf("vertex executor: close response body error: %v", errClose)
618
- }
619
- }()
620
- scanner := bufio.NewScanner(httpResp.Body)
621
- scanner.Buffer(nil, streamScannerBuffer)
622
- var param any
623
- for scanner.Scan() {
624
- line := scanner.Bytes()
625
- appendAPIResponseChunk(ctx, e.cfg, line)
626
- if detail, ok := parseGeminiStreamUsage(line); ok {
627
- reporter.publish(ctx, detail)
628
- }
629
- lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
630
- for i := range lines {
631
- out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
632
- }
633
- }
634
- lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, []byte("[DONE]"), &param)
635
- for i := range lines {
636
- out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
637
- }
638
- if errScan := scanner.Err(); errScan != nil {
639
- recordAPIResponseError(ctx, e.cfg, errScan)
640
- reporter.publishFailure(ctx)
641
- out <- cliproxyexecutor.StreamChunk{Err: errScan}
642
- }
643
- }()
644
- return stream, nil
645
- }
646
-
647
- // executeStreamWithAPIKey handles streaming authentication using API key credentials.
648
- func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, apiKey, baseURL string) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
649
- baseModel := thinking.ParseSuffix(req.Model).ModelName
650
-
651
- reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
652
- defer reporter.trackFailure(ctx, &err)
653
-
654
- from := opts.SourceFormat
655
- to := sdktranslator.FromString("gemini")
656
-
657
- originalPayload := bytes.Clone(req.Payload)
658
- if len(opts.OriginalRequest) > 0 {
659
- originalPayload = bytes.Clone(opts.OriginalRequest)
660
  }
661
- originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
662
- body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
663
 
664
- body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
 
 
 
 
665
  if err != nil {
666
  return nil, err
667
  }
668
 
669
- body = fixGeminiImageAspectRatio(baseModel, body)
670
- requestedModel := payloadRequestedModel(opts, req.Model)
671
- body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
672
- body, _ = sjson.SetBytes(body, "model", baseModel)
673
-
674
  action := getVertexAction(baseModel, true)
675
- // For API key auth, use simpler URL format without project/location
676
- if baseURL == "" {
677
- baseURL = "https://generativelanguage.googleapis.com"
678
- }
679
- url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, action)
680
- // Imagen models don't support streaming, skip SSE params
681
- if !isImagenModel(baseModel) {
682
- if opts.Alt == "" {
683
- url = url + "?alt=sse"
684
  } else {
685
- url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
686
  }
687
  }
688
  body, _ = sjson.DeleteBytes(body, "session_id")
@@ -691,29 +194,11 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
691
  if errNewReq != nil {
692
  return nil, errNewReq
693
  }
694
- httpReq.Header.Set("Content-Type", "application/json")
695
- if apiKey != "" {
696
- httpReq.Header.Set("x-goog-api-key", apiKey)
697
- }
698
- applyGeminiHeaders(httpReq, auth)
699
 
700
- var authID, authLabel, authType, authValue string
701
- if auth != nil {
702
- authID = auth.ID
703
- authLabel = auth.Label
704
- authType, authValue = auth.AccountInfo()
705
  }
706
- recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
707
- URL: url,
708
- Method: http.MethodPost,
709
- Headers: httpReq.Header.Clone(),
710
- Body: body,
711
- Provider: e.Identifier(),
712
- AuthID: authID,
713
- AuthLabel: authLabel,
714
- AuthType: authType,
715
- AuthValue: authValue,
716
- })
717
 
718
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
719
  httpResp, errDo := httpClient.Do(httpReq)
@@ -723,13 +208,7 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
723
  }
724
  recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
725
  if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
726
- b, _ := io.ReadAll(httpResp.Body)
727
- appendAPIResponseChunk(ctx, e.cfg, b)
728
- logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
729
- if errClose := httpResp.Body.Close(); errClose != nil {
730
- log.Errorf("vertex executor: close response body error: %v", errClose)
731
- }
732
- return nil, statusErr{code: httpResp.StatusCode, msg: string(b)}
733
  }
734
 
735
  out := make(chan cliproxyexecutor.StreamChunk)
@@ -743,6 +222,8 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
743
  }()
744
  scanner := bufio.NewScanner(httpResp.Body)
745
  scanner.Buffer(nil, streamScannerBuffer)
 
 
746
  var param any
747
  for scanner.Scan() {
748
  line := scanner.Bytes()
@@ -768,60 +249,36 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
768
  return stream, nil
769
  }
770
 
771
- // countTokensWithServiceAccount counts tokens using service account credentials.
772
- func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, projectID, location string, saJSON []byte) (cliproxyexecutor.Response, error) {
773
- baseModel := thinking.ParseSuffix(req.Model).ModelName
774
-
775
- from := opts.SourceFormat
776
- to := sdktranslator.FromString("gemini")
777
-
778
- translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
779
 
780
- translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String(), e.Identifier())
 
781
  if err != nil {
782
  return cliproxyexecutor.Response{}, err
783
  }
784
 
785
- translatedReq = fixGeminiImageAspectRatio(baseModel, translatedReq)
786
- translatedReq, _ = sjson.SetBytes(translatedReq, "model", baseModel)
787
- respCtx := context.WithValue(ctx, "alt", opts.Alt)
788
- translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools")
789
- translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig")
790
- translatedReq, _ = sjson.DeleteBytes(translatedReq, "safetySettings")
791
 
792
- baseURL := vertexBaseURL(location)
793
- url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, baseModel, "countTokens")
794
 
795
- httpReq, errNewReq := http.NewRequestWithContext(respCtx, http.MethodPost, url, bytes.NewReader(translatedReq))
796
  if errNewReq != nil {
797
  return cliproxyexecutor.Response{}, errNewReq
798
  }
799
- httpReq.Header.Set("Content-Type", "application/json")
800
- if token, errTok := vertexAccessToken(ctx, e.cfg, auth, saJSON); errTok == nil && token != "" {
801
- httpReq.Header.Set("Authorization", "Bearer "+token)
802
- } else if errTok != nil {
803
- log.Errorf("vertex executor: access token error: %v", errTok)
804
- return cliproxyexecutor.Response{}, statusErr{code: 500, msg: "internal server error"}
805
- }
806
- applyGeminiHeaders(httpReq, auth)
807
 
808
- var authID, authLabel, authType, authValue string
809
- if auth != nil {
810
- authID = auth.ID
811
- authLabel = auth.Label
812
- authType, authValue = auth.AccountInfo()
813
  }
814
- recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
815
- URL: url,
816
- Method: http.MethodPost,
817
- Headers: httpReq.Header.Clone(),
818
- Body: translatedReq,
819
- Provider: e.Identifier(),
820
- AuthID: authID,
821
- AuthLabel: authLabel,
822
- AuthType: authType,
823
- AuthValue: authValue,
824
- })
825
 
826
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
827
  httpResp, errDo := httpClient.Do(httpReq)
@@ -836,59 +293,153 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context
836
  }()
837
  recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
838
  if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
839
- b, _ := io.ReadAll(httpResp.Body)
840
- appendAPIResponseChunk(ctx, e.cfg, b)
841
- logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
842
- return cliproxyexecutor.Response{}, statusErr{code: httpResp.StatusCode, msg: string(b)}
843
  }
 
844
  data, errRead := io.ReadAll(httpResp.Body)
845
  if errRead != nil {
846
  recordAPIResponseError(ctx, e.cfg, errRead)
847
  return cliproxyexecutor.Response{}, errRead
848
  }
849
  appendAPIResponseChunk(ctx, e.cfg, data)
 
 
 
850
  count := gjson.GetBytes(data, "totalTokens").Int()
851
  out := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)
852
  return cliproxyexecutor.Response{Payload: []byte(out)}, nil
853
  }
854
 
855
- // countTokensWithAPIKey handles token counting using API key credentials.
856
- func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, apiKey, baseURL string) (cliproxyexecutor.Response, error) {
857
- baseModel := thinking.ParseSuffix(req.Model).ModelName
 
858
 
859
- from := opts.SourceFormat
860
- to := sdktranslator.FromString("gemini")
 
 
 
861
 
862
- translatedReq := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
863
 
864
- translatedReq, err := thinking.ApplyThinking(translatedReq, req.Model, from.String(), to.String(), e.Identifier())
 
865
  if err != nil {
866
- return cliproxyexecutor.Response{}, err
867
  }
 
 
 
 
 
 
 
 
868
 
869
- translatedReq = fixGeminiImageAspectRatio(baseModel, translatedReq)
870
- translatedReq, _ = sjson.SetBytes(translatedReq, "model", baseModel)
871
- respCtx := context.WithValue(ctx, "alt", opts.Alt)
872
- translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools")
873
- translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig")
874
- translatedReq, _ = sjson.DeleteBytes(translatedReq, "safetySettings")
 
 
 
 
 
 
 
875
 
876
- // For API key auth, use simpler URL format without project/location
877
- if baseURL == "" {
878
- baseURL = "https://generativelanguage.googleapis.com"
 
 
 
879
  }
880
- url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, "countTokens")
 
881
 
882
- httpReq, errNewReq := http.NewRequestWithContext(respCtx, http.MethodPost, url, bytes.NewReader(translatedReq))
883
- if errNewReq != nil {
884
- return cliproxyexecutor.Response{}, errNewReq
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
885
  }
886
- httpReq.Header.Set("Content-Type", "application/json")
887
- if apiKey != "" {
888
- httpReq.Header.Set("x-goog-api-key", apiKey)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
889
  }
890
- applyGeminiHeaders(httpReq, auth)
891
 
 
 
 
 
 
 
 
 
 
892
  var authID, authLabel, authType, authValue string
893
  if auth != nil {
894
  authID = auth.ID
@@ -896,44 +447,145 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth *
896
  authType, authValue = auth.AccountInfo()
897
  }
898
  recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
899
- URL: url,
900
- Method: http.MethodPost,
901
- Headers: httpReq.Header.Clone(),
902
- Body: translatedReq,
903
  Provider: e.Identifier(),
904
  AuthID: authID,
905
  AuthLabel: authLabel,
906
  AuthType: authType,
907
  AuthValue: authValue,
908
  })
 
909
 
910
- httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
911
- httpResp, errDo := httpClient.Do(httpReq)
912
- if errDo != nil {
913
- recordAPIResponseError(ctx, e.cfg, errDo)
914
- return cliproxyexecutor.Response{}, errDo
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
915
  }
916
- defer func() {
917
- if errClose := httpResp.Body.Close(); errClose != nil {
918
- log.Errorf("vertex executor: close response body error: %v", errClose)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
919
  }
920
- }()
921
- recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
922
- if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
923
- b, _ := io.ReadAll(httpResp.Body)
924
- appendAPIResponseChunk(ctx, e.cfg, b)
925
- logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
926
- return cliproxyexecutor.Response{}, statusErr{code: httpResp.StatusCode, msg: string(b)}
927
  }
928
- data, errRead := io.ReadAll(httpResp.Body)
929
- if errRead != nil {
930
- recordAPIResponseError(ctx, e.cfg, errRead)
931
- return cliproxyexecutor.Response{}, errRead
 
 
 
 
 
 
 
 
 
 
 
 
 
932
  }
933
- appendAPIResponseChunk(ctx, e.cfg, data)
934
- count := gjson.GetBytes(data, "totalTokens").Int()
935
- out := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)
936
- return cliproxyexecutor.Response{Payload: []byte(out)}, nil
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
937
  }
938
 
939
  // vertexCreds extracts project, location and raw service account JSON from auth metadata.
@@ -945,7 +597,6 @@ func vertexCreds(a *cliproxyauth.Auth) (projectID, location string, serviceAccou
945
  projectID = strings.TrimSpace(v)
946
  }
947
  if projectID == "" {
948
- // Some service accounts may use "project"; still prefer standard field
949
  if v, ok := a.Metadata["project"].(string); ok {
950
  projectID = strings.TrimSpace(v)
951
  }
@@ -956,7 +607,7 @@ func vertexCreds(a *cliproxyauth.Auth) (projectID, location string, serviceAccou
956
  if v, ok := a.Metadata["location"].(string); ok && strings.TrimSpace(v) != "" {
957
  location = strings.TrimSpace(v)
958
  } else {
959
- location = "us-central1"
960
  }
961
  var sa map[string]any
962
  if raw, ok := a.Metadata["service_account"].(map[string]any); ok {
@@ -976,7 +627,7 @@ func vertexCreds(a *cliproxyauth.Auth) (projectID, location string, serviceAccou
976
  return projectID, location, saJSON, nil
977
  }
978
 
979
- // vertexAPICreds extracts API key and base URL from auth attributes following the claudeCreds pattern.
980
  func vertexAPICreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
981
  if a == nil {
982
  return "", ""
@@ -996,7 +647,7 @@ func vertexAPICreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
996
  func vertexBaseURL(location string) string {
997
  loc := strings.TrimSpace(location)
998
  if loc == "" {
999
- loc = "us-central1"
1000
  }
1001
  return fmt.Sprintf("https://%s-aiplatform.googleapis.com", loc)
1002
  }
@@ -1005,7 +656,6 @@ func vertexAccessToken(ctx context.Context, cfg *config.Config, auth *cliproxyau
1005
  if httpClient := newProxyAwareHTTPClient(ctx, cfg, auth, 0); httpClient != nil {
1006
  ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
1007
  }
1008
- // Use cloud-platform scope for Vertex AI.
1009
  creds, errCreds := google.CredentialsFromJSON(ctx, saJSON, "https://www.googleapis.com/auth/cloud-platform")
1010
  if errCreds != nil {
1011
  return "", fmt.Errorf("vertex executor: parse service account json failed: %w", errCreds)
 
30
  const (
31
  // vertexAPIVersion aligns with current public Vertex Generative AI API.
32
  vertexAPIVersion = "v1"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
+ // defaultVertexLocation is the default location for Vertex AI resources.
35
+ defaultVertexLocation = "us-central1"
36
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
  // GeminiVertexExecutor sends requests to Vertex AI Gemini endpoints using service account credentials.
39
  type GeminiVertexExecutor struct {
 
41
  }
42
 
43
  // NewGeminiVertexExecutor creates a new Vertex AI Gemini executor instance.
 
 
 
 
 
 
44
  func NewGeminiVertexExecutor(cfg *config.Config) *GeminiVertexExecutor {
45
  return &GeminiVertexExecutor{cfg: cfg}
46
  }
 
48
  // Identifier returns the executor identifier.
49
  func (e *GeminiVertexExecutor) Identifier() string { return "vertex" }
50
 
51
+ // vertexContext holds resolved configuration and credentials for a request.
52
+ type vertexContext struct {
53
+ IsAPIKey bool
54
+ APIKey string
55
+ ProjectID string
56
+ Location string
57
+ ServiceAccountJSON []byte
58
+ BaseURL string
59
+ CompatConfig *config.VertexCompatKey
60
+ }
61
+
62
  // PrepareRequest injects Vertex credentials into the outgoing HTTP request.
63
  func (e *GeminiVertexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
64
  if req == nil {
65
  return nil
66
  }
67
+ ctx := req.Context()
68
+ vCtx, err := e.resolveVertexContext(auth)
69
+ if err != nil {
70
+ return err
 
 
 
 
 
 
 
 
 
 
 
 
71
  }
72
+ return e.setVertexHeaders(ctx, req, vCtx, auth)
 
 
73
  }
74
 
75
  // HttpRequest injects Vertex credentials into the request and executes it.
 
90
 
91
  // Execute performs a non-streaming request to the Vertex AI API.
92
  func (e *GeminiVertexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
93
+ vCtx, err := e.resolveVertexContext(auth)
94
+ if err != nil {
95
+ return resp, err
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  }
97
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  baseModel := thinking.ParseSuffix(req.Model).ModelName
 
99
  reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
100
  defer reporter.trackFailure(ctx, &err)
101
 
102
+ body, _, err := e.preparePayload(ctx, req, opts, baseModel, false)
103
+ if err != nil {
104
+ return resp, err
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  }
106
 
107
  action := getVertexAction(baseModel, false)
 
110
  action = "countTokens"
111
  }
112
  }
113
+
114
+ url := e.buildVertexURL(vCtx, baseModel, action, opts.Alt)
 
 
 
115
  body, _ = sjson.DeleteBytes(body, "session_id")
116
 
117
  httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
118
  if errNewReq != nil {
119
  return resp, errNewReq
120
  }
 
 
 
 
 
 
 
 
121
 
122
+ if err := e.setVertexHeaders(ctx, httpReq, vCtx, auth); err != nil {
123
+ return resp, err
 
 
 
124
  }
125
+ e.recordRequest(ctx, httpReq, body, auth)
 
 
 
 
 
 
 
 
 
 
126
 
127
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
128
  httpResp, errDo := httpClient.Do(httpReq)
 
135
  log.Errorf("vertex executor: close response body error: %v", errClose)
136
  }
137
  }()
138
+
139
  recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
140
  if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
141
+ return resp, e.handleErrorResponse(ctx, httpResp)
 
 
 
 
142
  }
143
+
144
  data, errRead := io.ReadAll(httpResp.Body)
145
  if errRead != nil {
146
  recordAPIResponseError(ctx, e.cfg, errRead)
 
149
  appendAPIResponseChunk(ctx, e.cfg, data)
150
  reporter.publish(ctx, parseGeminiUsage(data))
151
 
152
+ // Handle Imagen models conversion
 
153
  if isImagenModel(baseModel) {
154
  data = convertImagenToGeminiResponse(data, baseModel)
155
  }
156
 
 
157
  from := opts.SourceFormat
158
  to := sdktranslator.FromString("gemini")
159
  var param any
 
162
  return resp, nil
163
  }
164
 
165
+ // ExecuteStream performs a streaming request to the Vertex AI API.
166
+ func (e *GeminiVertexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
167
+ vCtx, err := e.resolveVertexContext(auth)
168
+ if err != nil {
169
+ return nil, err
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  }
 
 
171
 
172
+ baseModel := thinking.ParseSuffix(req.Model).ModelName
173
+ reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth)
174
+ defer reporter.trackFailure(ctx, &err)
175
+
176
+ body, _, err := e.preparePayload(ctx, req, opts, baseModel, true)
177
  if err != nil {
178
  return nil, err
179
  }
180
 
 
 
 
 
 
181
  action := getVertexAction(baseModel, true)
182
+ url := e.buildVertexURL(vCtx, baseModel, action, opts.Alt)
183
+ // Add alt=sse if not overridden and not imagen (imagen doesn't stream)
184
+ if !isImagenModel(baseModel) && !strings.Contains(url, "alt=") {
185
+ if strings.Contains(url, "?") {
186
+ url += "&alt=sse"
 
 
 
 
187
  } else {
188
+ url += "?alt=sse"
189
  }
190
  }
191
  body, _ = sjson.DeleteBytes(body, "session_id")
 
194
  if errNewReq != nil {
195
  return nil, errNewReq
196
  }
 
 
 
 
 
197
 
198
+ if err := e.setVertexHeaders(ctx, httpReq, vCtx, auth); err != nil {
199
+ return nil, err
 
 
 
200
  }
201
+ e.recordRequest(ctx, httpReq, body, auth)
 
 
 
 
 
 
 
 
 
 
202
 
203
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
204
  httpResp, errDo := httpClient.Do(httpReq)
 
208
  }
209
  recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
210
  if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
211
+ return nil, e.handleErrorResponse(ctx, httpResp)
 
 
 
 
 
 
212
  }
213
 
214
  out := make(chan cliproxyexecutor.StreamChunk)
 
222
  }()
223
  scanner := bufio.NewScanner(httpResp.Body)
224
  scanner.Buffer(nil, streamScannerBuffer)
225
+ from := opts.SourceFormat
226
+ to := sdktranslator.FromString("gemini")
227
  var param any
228
  for scanner.Scan() {
229
  line := scanner.Bytes()
 
249
  return stream, nil
250
  }
251
 
252
+ // CountTokens counts tokens for the given request using the Vertex AI API.
253
+ func (e *GeminiVertexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
254
+ vCtx, err := e.resolveVertexContext(auth)
255
+ if err != nil {
256
+ return cliproxyexecutor.Response{}, err
257
+ }
 
 
258
 
259
+ baseModel := thinking.ParseSuffix(req.Model).ModelName
260
+ body, _, err := e.preparePayload(ctx, req, opts, baseModel, false)
261
  if err != nil {
262
  return cliproxyexecutor.Response{}, err
263
  }
264
 
265
+ // CountTokens specific cleanup
266
+ body, _ = sjson.DeleteBytes(body, "tools")
267
+ body, _ = sjson.DeleteBytes(body, "generationConfig")
268
+ body, _ = sjson.DeleteBytes(body, "safetySettings")
 
 
269
 
270
+ url := e.buildVertexURL(vCtx, baseModel, "countTokens", opts.Alt)
271
+ respCtx := context.WithValue(ctx, "alt", opts.Alt)
272
 
273
+ httpReq, errNewReq := http.NewRequestWithContext(respCtx, http.MethodPost, url, bytes.NewReader(body))
274
  if errNewReq != nil {
275
  return cliproxyexecutor.Response{}, errNewReq
276
  }
 
 
 
 
 
 
 
 
277
 
278
+ if err := e.setVertexHeaders(ctx, httpReq, vCtx, auth); err != nil {
279
+ return cliproxyexecutor.Response{}, err
 
 
 
280
  }
281
+ e.recordRequest(ctx, httpReq, body, auth)
 
 
 
 
 
 
 
 
 
 
282
 
283
  httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
284
  httpResp, errDo := httpClient.Do(httpReq)
 
293
  }()
294
  recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
295
  if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
296
+ return cliproxyexecutor.Response{}, e.handleErrorResponse(ctx, httpResp)
 
 
 
297
  }
298
+
299
  data, errRead := io.ReadAll(httpResp.Body)
300
  if errRead != nil {
301
  recordAPIResponseError(ctx, e.cfg, errRead)
302
  return cliproxyexecutor.Response{}, errRead
303
  }
304
  appendAPIResponseChunk(ctx, e.cfg, data)
305
+
306
+ from := opts.SourceFormat
307
+ to := sdktranslator.FromString("gemini")
308
  count := gjson.GetBytes(data, "totalTokens").Int()
309
  out := sdktranslator.TranslateTokenCount(ctx, to, from, count, data)
310
  return cliproxyexecutor.Response{Payload: []byte(out)}, nil
311
  }
312
 
313
+ // Refresh refreshes the authentication credentials (no-op for Vertex).
314
+ func (e *GeminiVertexExecutor) Refresh(_ context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
315
+ return auth, nil
316
+ }
317
 
318
+ // resolveVertexContext resolves the execution context (API Key vs Service Account) and configuration.
319
+ func (e *GeminiVertexExecutor) resolveVertexContext(auth *cliproxyauth.Auth) (*vertexContext, error) {
320
+ if auth == nil {
321
+ return nil, fmt.Errorf("vertex executor: auth is nil")
322
+ }
323
 
324
+ // 1. Try API Key
325
+ apiKey, baseURL := vertexAPICreds(auth)
326
+ if apiKey != "" {
327
+ compat := e.resolveVertexConfig(auth)
328
+ if compat != nil && compat.BaseURL != "" {
329
+ baseURL = compat.BaseURL
330
+ }
331
+ if baseURL == "" {
332
+ baseURL = "https://generativelanguage.googleapis.com"
333
+ }
334
+ return &vertexContext{
335
+ IsAPIKey: true,
336
+ APIKey: apiKey,
337
+ BaseURL: baseURL,
338
+ CompatConfig: compat,
339
+ }, nil
340
+ }
341
 
342
+ // 2. Fallback to Service Account
343
+ projectID, location, saJSON, err := vertexCreds(auth)
344
  if err != nil {
345
+ return nil, err
346
  }
347
+ return &vertexContext{
348
+ IsAPIKey: false,
349
+ ProjectID: projectID,
350
+ Location: location,
351
+ ServiceAccountJSON: saJSON,
352
+ BaseURL: vertexBaseURL(location),
353
+ }, nil
354
+ }
355
 
356
+ // buildVertexURL constructs the API URL based on the context and action.
357
+ func (e *GeminiVertexExecutor) buildVertexURL(vCtx *vertexContext, model, action, alt string) string {
358
+ var url string
359
+ if vCtx.IsAPIKey {
360
+ // API Key: base/v1/publishers/google/models/{model}:{action}
361
+ base := strings.TrimRight(vCtx.BaseURL, "/")
362
+ url = fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", base, vertexAPIVersion, model, action)
363
+ } else {
364
+ // Service Account: base/v1/projects/{id}/locations/{loc}/publishers/google/models/{model}:{action}
365
+ base := strings.TrimRight(vCtx.BaseURL, "/")
366
+ url = fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s",
367
+ base, vertexAPIVersion, vCtx.ProjectID, vCtx.Location, model, action)
368
+ }
369
 
370
+ if alt != "" && action != "countTokens" {
371
+ if strings.Contains(url, "?") {
372
+ url += fmt.Sprintf("&alt=%s", alt)
373
+ } else {
374
+ url += fmt.Sprintf("?$alt=%s", alt)
375
+ }
376
  }
377
+ return url
378
+ }
379
 
380
+ // setVertexHeaders applies the necessary headers for authentication and configuration.
381
+ func (e *GeminiVertexExecutor) setVertexHeaders(ctx context.Context, req *http.Request, vCtx *vertexContext, auth *cliproxyauth.Auth) error {
382
+ req.Header.Set("Content-Type", "application/json")
383
+
384
+ if vCtx.IsAPIKey {
385
+ req.Header.Set("x-goog-api-key", vCtx.APIKey)
386
+ req.Header.Del("Authorization")
387
+ // Apply compat headers if available
388
+ if vCtx.CompatConfig != nil && len(vCtx.CompatConfig.Headers) > 0 {
389
+ for k, v := range vCtx.CompatConfig.Headers {
390
+ req.Header.Set(k, v)
391
+ }
392
+ }
393
+ } else {
394
+ // Service Account needs Access Token
395
+ token, err := vertexAccessToken(ctx, e.cfg, auth, vCtx.ServiceAccountJSON)
396
+ if err != nil {
397
+ return err
398
+ }
399
+ if strings.TrimSpace(token) == "" {
400
+ return statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
401
+ }
402
+ req.Header.Set("Authorization", "Bearer "+token)
403
+ req.Header.Del("x-goog-api-key")
404
  }
405
+
406
+ // Apply headers from auth attributes (overrides compat headers if conflict)
407
+ applyGeminiHeaders(req, auth)
408
+ return nil
409
+ }
410
+
411
+ // preparePayload handles common payload preparation logic.
412
+ func (e *GeminiVertexExecutor) preparePayload(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, baseModel string, isStream bool) (body []byte, originalTranslated []byte, err error) {
413
+ // Handle Imagen models separately
414
+ if isImagenModel(baseModel) {
415
+ body, err = convertToImagenRequest(req.Payload)
416
+ return body, nil, err
417
+ }
418
+
419
+ from := opts.SourceFormat
420
+ to := sdktranslator.FromString("gemini")
421
+
422
+ originalPayload := bytes.Clone(req.Payload)
423
+ if len(opts.OriginalRequest) > 0 {
424
+ originalPayload = bytes.Clone(opts.OriginalRequest)
425
+ }
426
+ originalTranslated = sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, isStream)
427
+ body = sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), isStream)
428
+
429
+ body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
430
+ if err != nil {
431
+ return nil, nil, err
432
  }
 
433
 
434
+ body = fixGeminiImageAspectRatio(baseModel, body)
435
+ requestedModel := payloadRequestedModel(opts, req.Model)
436
+ body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
437
+ body, _ = sjson.SetBytes(body, "model", baseModel)
438
+ return body, originalTranslated, nil
439
+ }
440
+
441
+ // recordRequest helper to log the upstream request.
442
+ func (e *GeminiVertexExecutor) recordRequest(ctx context.Context, req *http.Request, body []byte, auth *cliproxyauth.Auth) {
443
  var authID, authLabel, authType, authValue string
444
  if auth != nil {
445
  authID = auth.ID
 
447
  authType, authValue = auth.AccountInfo()
448
  }
449
  recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
450
+ URL: req.URL.String(),
451
+ Method: req.Method,
452
+ Headers: req.Header.Clone(),
453
+ Body: body,
454
  Provider: e.Identifier(),
455
  AuthID: authID,
456
  AuthLabel: authLabel,
457
  AuthType: authType,
458
  AuthValue: authValue,
459
  })
460
+ }
461
 
462
+ // handleErrorResponse handles non-2xx responses.
463
+ func (e *GeminiVertexExecutor) handleErrorResponse(ctx context.Context, resp *http.Response) error {
464
+ b, _ := io.ReadAll(resp.Body)
465
+ appendAPIResponseChunk(ctx, e.cfg, b)
466
+ logWithRequestID(ctx).Debugf("request error, error status: %d, error message: %s", resp.StatusCode, summarizeErrorBody(resp.Header.Get("Content-Type"), b))
467
+ return statusErr{code: resp.StatusCode, msg: string(b)}
468
+ }
469
+
470
+ // --- Helper Functions ---
471
+
472
+ // isImagenModel checks if the model name is an Imagen image generation model.
473
+ func isImagenModel(model string) bool {
474
+ lowerModel := strings.ToLower(model)
475
+ return strings.Contains(lowerModel, "imagen")
476
+ }
477
+
478
+ // getVertexAction returns the appropriate action for the given model.
479
+ func getVertexAction(model string, isStream bool) string {
480
+ if isImagenModel(model) {
481
+ return "predict"
482
  }
483
+ if isStream {
484
+ return "streamGenerateContent"
485
+ }
486
+ return "generateContent"
487
+ }
488
+
489
+ // convertImagenToGeminiResponse converts Imagen API response to Gemini format.
490
+ func convertImagenToGeminiResponse(data []byte, model string) []byte {
491
+ predictions := gjson.GetBytes(data, "predictions")
492
+ if !predictions.Exists() || !predictions.IsArray() {
493
+ return data
494
+ }
495
+
496
+ parts := make([]map[string]any, 0)
497
+ for _, pred := range predictions.Array() {
498
+ imageData := pred.Get("bytesBase64Encoded").String()
499
+ mimeType := pred.Get("mimeType").String()
500
+ if mimeType == "" {
501
+ mimeType = "image/png"
502
+ }
503
+ if imageData != "" {
504
+ parts = append(parts, map[string]any{
505
+ "inlineData": map[string]any{
506
+ "mimeType": mimeType,
507
+ "data": imageData,
508
+ },
509
+ })
510
  }
 
 
 
 
 
 
 
511
  }
512
+
513
+ responseId := fmt.Sprintf("imagen-%d", time.Now().UnixNano())
514
+ response := map[string]any{
515
+ "candidates": []map[string]any{{
516
+ "content": map[string]any{
517
+ "parts": parts,
518
+ "role": "model",
519
+ },
520
+ "finishReason": "STOP",
521
+ }},
522
+ "responseId": responseId,
523
+ "modelVersion": model,
524
+ "usageMetadata": map[string]any{
525
+ "promptTokenCount": 0,
526
+ "candidatesTokenCount": 0,
527
+ "totalTokenCount": 0,
528
+ },
529
  }
530
+
531
+ result, err := json.Marshal(response)
532
+ if err != nil {
533
+ return data
534
+ }
535
+ return result
536
+ }
537
+
538
+ // convertToImagenRequest converts a Gemini-style request to Imagen API format.
539
+ func convertToImagenRequest(payload []byte) ([]byte, error) {
540
+ prompt := ""
541
+ contentsText := gjson.GetBytes(payload, "contents.0.parts.0.text")
542
+ if contentsText.Exists() {
543
+ prompt = contentsText.String()
544
+ }
545
+
546
+ if prompt == "" {
547
+ messagesText := gjson.GetBytes(payload, "messages.#.content")
548
+ if messagesText.Exists() && messagesText.IsArray() {
549
+ for _, msg := range messagesText.Array() {
550
+ if msg.String() != "" {
551
+ prompt = msg.String()
552
+ break
553
+ }
554
+ }
555
+ }
556
+ }
557
+
558
+ if prompt == "" {
559
+ directPrompt := gjson.GetBytes(payload, "prompt")
560
+ if directPrompt.Exists() {
561
+ prompt = directPrompt.String()
562
+ }
563
+ }
564
+
565
+ if prompt == "" {
566
+ return nil, fmt.Errorf("imagen: no prompt found in request")
567
+ }
568
+
569
+ imagenReq := map[string]any{
570
+ "instances": []map[string]any{
571
+ {"prompt": prompt},
572
+ },
573
+ "parameters": map[string]any{
574
+ "sampleCount": 1,
575
+ },
576
+ }
577
+
578
+ if aspectRatio := gjson.GetBytes(payload, "aspectRatio"); aspectRatio.Exists() {
579
+ imagenReq["parameters"].(map[string]any)["aspectRatio"] = aspectRatio.String()
580
+ }
581
+ if sampleCount := gjson.GetBytes(payload, "sampleCount"); sampleCount.Exists() {
582
+ imagenReq["parameters"].(map[string]any)["sampleCount"] = int(sampleCount.Int())
583
+ }
584
+ if negativePrompt := gjson.GetBytes(payload, "negativePrompt"); negativePrompt.Exists() {
585
+ imagenReq["instances"].([]map[string]any)[0]["negativePrompt"] = negativePrompt.String()
586
+ }
587
+
588
+ return json.Marshal(imagenReq)
589
  }
590
 
591
  // vertexCreds extracts project, location and raw service account JSON from auth metadata.
 
597
  projectID = strings.TrimSpace(v)
598
  }
599
  if projectID == "" {
 
600
  if v, ok := a.Metadata["project"].(string); ok {
601
  projectID = strings.TrimSpace(v)
602
  }
 
607
  if v, ok := a.Metadata["location"].(string); ok && strings.TrimSpace(v) != "" {
608
  location = strings.TrimSpace(v)
609
  } else {
610
+ location = defaultVertexLocation
611
  }
612
  var sa map[string]any
613
  if raw, ok := a.Metadata["service_account"].(map[string]any); ok {
 
627
  return projectID, location, saJSON, nil
628
  }
629
 
630
+ // vertexAPICreds extracts API key and base URL from auth attributes.
631
  func vertexAPICreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
632
  if a == nil {
633
  return "", ""
 
647
  func vertexBaseURL(location string) string {
648
  loc := strings.TrimSpace(location)
649
  if loc == "" {
650
+ loc = defaultVertexLocation
651
  }
652
  return fmt.Sprintf("https://%s-aiplatform.googleapis.com", loc)
653
  }
 
656
  if httpClient := newProxyAwareHTTPClient(ctx, cfg, auth, 0); httpClient != nil {
657
  ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
658
  }
 
659
  creds, errCreds := google.CredentialsFromJSON(ctx, saJSON, "https://www.googleapis.com/auth/cloud-platform")
660
  if errCreds != nil {
661
  return "", fmt.Errorf("vertex executor: parse service account json failed: %w", errCreds)