Compare commits

...

3 Commits

Author SHA1 Message Date
Luis Pater
dc804e96fb fix(management): improve error handling and normalize YAML comment indentation
Enhance error management for file operations and clean up temporary files. Add `NormalizeCommentIndentation` function to ensure YAML comments maintain consistent formatting.
2025-11-11 08:37:57 +08:00
Luis Pater
ab76cb3662 feat(management): add Vertex service account import and WebSocket auth management
Introduce an endpoint for importing Vertex service account JSON keys and storing them as authentication records. Add handlers for managing WebSocket authentication configuration.
2025-11-10 20:48:31 +08:00
Luis Pater
2965bdadc1 fix(translator): remove debug print statement from OpenAI Gemini request processing 2025-11-10 18:37:05 +08:00
5 changed files with 226 additions and 21 deletions

View File

@@ -28,7 +28,7 @@ func (h *Handler) GetConfigYAML(c *gin.Context) {
return
}
var node yaml.Node
if err := yaml.Unmarshal(data, &node); err != nil {
if err = yaml.Unmarshal(data, &node); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "parse_failed", "message": err.Error()})
return
}
@@ -41,17 +41,18 @@ func (h *Handler) GetConfigYAML(c *gin.Context) {
}
func WriteConfig(path string, data []byte) error {
data = config.NormalizeCommentIndentation(data)
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return err
}
if _, err := f.Write(data); err != nil {
f.Close()
return err
if _, errWrite := f.Write(data); errWrite != nil {
_ = f.Close()
return errWrite
}
if err := f.Sync(); err != nil {
f.Close()
return err
if errSync := f.Sync(); errSync != nil {
_ = f.Close()
return errSync
}
return f.Close()
}
@@ -63,7 +64,7 @@ func (h *Handler) PutConfigYAML(c *gin.Context) {
return
}
var cfg config.Config
if err := yaml.Unmarshal(body, &cfg); err != nil {
if err = yaml.Unmarshal(body, &cfg); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_yaml", "message": err.Error()})
return
}
@@ -75,18 +76,20 @@ func (h *Handler) PutConfigYAML(c *gin.Context) {
return
}
tempFile := tmpFile.Name()
if _, err := tmpFile.Write(body); err != nil {
tmpFile.Close()
os.Remove(tempFile)
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": err.Error()})
if _, errWrite := tmpFile.Write(body); errWrite != nil {
_ = tmpFile.Close()
_ = os.Remove(tempFile)
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": errWrite.Error()})
return
}
if err := tmpFile.Close(); err != nil {
os.Remove(tempFile)
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": err.Error()})
if errClose := tmpFile.Close(); errClose != nil {
_ = os.Remove(tempFile)
c.JSON(http.StatusInternalServerError, gin.H{"error": "write_failed", "message": errClose.Error()})
return
}
defer os.Remove(tempFile)
defer func() {
_ = os.Remove(tempFile)
}()
_, err = config.LoadConfigOptional(tempFile, false)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid_config", "message": err.Error()})
@@ -153,6 +156,14 @@ func (h *Handler) PutRequestLog(c *gin.Context) {
h.updateBoolField(c, func(v bool) { h.cfg.RequestLog = v })
}
// Websocket auth
func (h *Handler) GetWebsocketAuth(c *gin.Context) {
c.JSON(200, gin.H{"ws-auth": h.cfg.WebsocketAuth})
}
func (h *Handler) PutWebsocketAuth(c *gin.Context) {
h.updateBoolField(c, func(v bool) { h.cfg.WebsocketAuth = v })
}
// Request retry
func (h *Handler) GetRequestRetry(c *gin.Context) {
c.JSON(200, gin.H{"request-retry": h.cfg.RequestRetry})

View File

@@ -0,0 +1,156 @@
package management
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/vertex"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
)
// ImportVertexCredential handles uploading a Vertex service account JSON and saving it as an auth record.
func (h *Handler) ImportVertexCredential(c *gin.Context) {
if h == nil || h.cfg == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "config unavailable"})
return
}
if h.cfg.AuthDir == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "auth directory not configured"})
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
return
}
file, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
return
}
var serviceAccount map[string]any
if err := json.Unmarshal(data, &serviceAccount); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json", "message": err.Error()})
return
}
normalizedSA, err := vertex.NormalizeServiceAccountMap(serviceAccount)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid service account", "message": err.Error()})
return
}
serviceAccount = normalizedSA
projectID := strings.TrimSpace(valueAsString(serviceAccount["project_id"]))
if projectID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project_id missing"})
return
}
email := strings.TrimSpace(valueAsString(serviceAccount["client_email"]))
location := strings.TrimSpace(c.PostForm("location"))
if location == "" {
location = strings.TrimSpace(c.Query("location"))
}
if location == "" {
location = "us-central1"
}
fileName := fmt.Sprintf("vertex-%s.json", sanitizeVertexFilePart(projectID))
label := labelForVertex(projectID, email)
storage := &vertex.VertexCredentialStorage{
ServiceAccount: serviceAccount,
ProjectID: projectID,
Email: email,
Location: location,
Type: "vertex",
}
metadata := map[string]any{
"service_account": serviceAccount,
"project_id": projectID,
"email": email,
"location": location,
"type": "vertex",
"label": label,
}
record := &coreauth.Auth{
ID: fileName,
Provider: "vertex",
FileName: fileName,
Storage: storage,
Label: label,
Metadata: metadata,
}
ctx := context.Background()
if reqCtx := c.Request.Context(); reqCtx != nil {
ctx = reqCtx
}
savedPath, err := h.saveTokenRecord(ctx, record)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "save_failed", "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"auth-file": savedPath,
"project_id": projectID,
"email": email,
"location": location,
})
}
func valueAsString(v any) string {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
default:
return fmt.Sprint(t)
}
}
func sanitizeVertexFilePart(s string) string {
out := strings.TrimSpace(s)
replacers := []string{"/", "_", "\\", "_", ":", "_", " ", "-"}
for i := 0; i < len(replacers); i += 2 {
out = strings.ReplaceAll(out, replacers[i], replacers[i+1])
}
if out == "" {
return "vertex"
}
return out
}
func labelForVertex(projectID, email string) string {
p := strings.TrimSpace(projectID)
e := strings.TrimSpace(email)
if p != "" && e != "" {
return fmt.Sprintf("%s (%s)", p, e)
}
if p != "" {
return p
}
if e != "" {
return e
}
return "vertex"
}

View File

@@ -484,6 +484,9 @@ func (s *Server) registerManagementRoutes() {
mgmt.GET("/request-log", s.mgmt.GetRequestLog)
mgmt.PUT("/request-log", s.mgmt.PutRequestLog)
mgmt.PATCH("/request-log", s.mgmt.PutRequestLog)
mgmt.GET("/ws-auth", s.mgmt.GetWebsocketAuth)
mgmt.PUT("/ws-auth", s.mgmt.PutWebsocketAuth)
mgmt.PATCH("/ws-auth", s.mgmt.PutWebsocketAuth)
mgmt.GET("/request-retry", s.mgmt.GetRequestRetry)
mgmt.PUT("/request-retry", s.mgmt.PutRequestRetry)
@@ -508,6 +511,7 @@ func (s *Server) registerManagementRoutes() {
mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)
mgmt.POST("/vertex/import", s.mgmt.ImportVertexCredential)
mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)

View File

@@ -5,6 +5,7 @@
package config
import (
"bytes"
"errors"
"fmt"
"os"
@@ -462,13 +463,19 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error {
return err
}
defer func() { _ = f.Close() }()
enc := yaml.NewEncoder(f)
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err = enc.Encode(&original); err != nil {
_ = enc.Close()
return err
}
return enc.Close()
if err = enc.Close(); err != nil {
return err
}
data = NormalizeCommentIndentation(buf.Bytes())
_, err = f.Write(data)
return err
}
func sanitizeConfigForPersist(cfg *Config) *Config {
@@ -518,13 +525,40 @@ func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []stri
return err
}
defer func() { _ = f.Close() }()
enc := yaml.NewEncoder(f)
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err = enc.Encode(&root); err != nil {
_ = enc.Close()
return err
}
return enc.Close()
if err = enc.Close(); err != nil {
return err
}
data = NormalizeCommentIndentation(buf.Bytes())
_, err = f.Write(data)
return err
}
// NormalizeCommentIndentation removes indentation from standalone YAML comment lines to keep them left aligned.
func NormalizeCommentIndentation(data []byte) []byte {
lines := bytes.Split(data, []byte("\n"))
changed := false
for i, line := range lines {
trimmed := bytes.TrimLeft(line, " \t")
if len(trimmed) == 0 || trimmed[0] != '#' {
continue
}
if len(trimmed) == len(line) {
continue
}
lines[i] = append([]byte(nil), trimmed...)
changed = true
}
if !changed {
return data
}
return bytes.Join(lines, []byte("\n"))
}
// getOrCreateMapValue finds the value node for a given key in a mapping node.
@@ -766,6 +800,7 @@ func matchSequenceElement(original []*yaml.Node, used []bool, target *yaml.Node)
}
}
}
default:
}
// Fallback to structural equality to preserve nodes lacking explicit identifiers.
for i := range original {

View File

@@ -159,7 +159,6 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
}
}
}
fmt.Printf("11111")
for i := 0; i < len(arr); i++ {
m := arr[i]