Compare commits

..

8 Commits

Author SHA1 Message Date
Luis Pater 172f282e9e feat: add graceful shutdown for streaming response handling
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
- Introduced `streamDone` channel to signal the completion of streaming goroutines.
- Updated `processStreamingChunks` to incorporate proper cleanup and defer close operations.
- Ensured `streamWriter` and associated resources are released when streaming completes.
2025-09-17 19:46:57 +08:00
Luis Pater 7f0c9b1942 Merge pull request #43 from kyle-the-dev/fix/gemini-schema-sanitization
fix: comprehensive JSON Schema sanitization for Claude to Gemini
2025-09-16 20:26:32 +08:00
Luis Pater 8c2063aeea Fix log key name from gpt-5-codex to force-gpt-5-codex for consistency 2025-09-16 20:03:48 +08:00
Luis Pater ed6e7750e2 Update config key gpt-5-codex to force-gpt-5-codex for clarity in example configuration file
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
2025-09-16 20:00:21 +08:00
Kyle Ryan a0c389a854 fix: comprehensive JSON Schema sanitization for Claude to Gemini
- Add SanitizeSchemaForGemini utility handling union types, allOf, exclusiveMinimum
- Fix both gemini-cli and gemini API translators
- Resolve "Proto field is not repeating, cannot start list" errors
- Maintain backward compatibility with fallback logic

This fixes Claude Code CLI compatibility issues when using tools with either
Gemini CLI credentials or direct Gemini API keys by properly sanitizing
JSON Schema fields that are incompatible with Gemini's Protocol Buffer validation.
2025-09-16 18:24:49 +08:00
hkfires e9037fceb0 fix(windows): Improve path handling and file read reliability 2025-09-16 10:01:58 +08:00
Luis Pater 2406cc775e Add GPT-5 Codex model support and configuration options in documentation 2025-09-16 04:53:25 +08:00
Luis Pater b84cbee77a Add support for forcing GPT-5 Codex model configuration
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
- Introduced a new `ForceGPT5Codex` configuration option in settings.
- Added relevant API endpoints for managing `ForceGPT5Codex`.
- Enhanced Codex client to handle GPT-5 Codex-specific logic and mapping.
- Updated example configuration file to include the new option.

Add GPT-5 Codex model support and configuration options in documentation
2025-09-16 04:40:19 +08:00
15 changed files with 365 additions and 37 deletions
+2
View File
@@ -1,4 +1,6 @@
config.yaml
*.exe
bin/*
docs/*
logs/*
conv/*
+15 -2
View File
@@ -229,6 +229,7 @@ console.log(await claudeResponse.json());
- gemini-2.5-flash
- gemini-2.5-flash-lite
- gpt-5
- gpt-5-codex
- claude-opus-4-1-20250805
- claude-opus-4-20250514
- claude-sonnet-4-20250514
@@ -262,6 +263,7 @@ The server uses a YAML configuration file (`config.yaml`) located in the project
| `debug` | boolean | false | Enable debug mode for verbose logging. |
| `api-keys` | string[] | [] | List of API keys that can be used to authenticate requests. |
| `generative-language-api-key` | string[] | [] | List of Generative Language API keys. |
| `force-gpt-5-codex` | bool | false | Force the conversion of GPT-5 calls to GPT-5 Codex. |
| `codex-api-key` | object | {} | List of Codex API keys. |
| `codex-api-key.api-key` | string | "" | Codex API key. |
| `codex-api-key.base-url` | string | "" | Custom Codex API endpoint, if you use a third-party API endpoint. |
@@ -322,6 +324,9 @@ generative-language-api-key:
- "AIzaSy...03"
- "AIzaSy...04"
# Force the conversion of GPT-5 calls to GPT-5 Codex.
force-gpt-5-codex: true
# Codex API keys
codex-api-key:
- api-key: "sk-atSM..."
@@ -423,7 +428,7 @@ export ANTHROPIC_MODEL=gemini-2.5-pro
export ANTHROPIC_SMALL_FAST_MODEL=gemini-2.5-flash
```
Using OpenAI models:
Using OpenAI GPT 5 models:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
export ANTHROPIC_AUTH_TOKEN=sk-dummy
@@ -431,6 +436,14 @@ export ANTHROPIC_MODEL=gpt-5
export ANTHROPIC_SMALL_FAST_MODEL=gpt-5-minimal
```
Using OpenAI GPT 5 Codex models:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
export ANTHROPIC_AUTH_TOKEN=sk-dummy
export ANTHROPIC_MODEL=gpt-5-codex
export ANTHROPIC_SMALL_FAST_MODEL=gpt-5-codex-low
```
Using Claude models:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
@@ -454,7 +467,7 @@ Start CLI Proxy API server, and then edit the `~/.codex/config.toml` and `~/.cod
config.toml:
```toml
model_provider = "cliproxyapi"
model = "gpt-5" # You can use any of the models that we support.
model = "gpt-5-codex" # Or gpt-5, you can also use any of the models that we support.
model_reasoning_effort = "high"
[model_providers.cliproxyapi]
+16 -2
View File
@@ -241,6 +241,7 @@ console.log(await claudeResponse.json());
- gemini-2.5-flash
- gemini-2.5-flash-lite
- gpt-5
- gpt-5-codex
- claude-opus-4-1-20250805
- claude-opus-4-20250514
- claude-sonnet-4-20250514
@@ -274,6 +275,7 @@ console.log(await claudeResponse.json());
| `debug` | boolean | false | 启用调试模式以获取详细日志。 |
| `api-keys` | string[] | [] | 可用于验证请求的API密钥列表。 |
| `generative-language-api-key` | string[] | [] | 生成式语言API密钥列表。 |
| `force-gpt-5-codex` | bool | false | 强制将 GPT-5 调用转换成 GPT-5 Codex。 |
| `codex-api-key` | object | {} | Codex API密钥列表。 |
| `codex-api-key.api-key` | string | "" | Codex API密钥。 |
| `codex-api-key.base-url` | string | "" | 自定义的Codex API端点 |
@@ -334,6 +336,9 @@ generative-language-api-key:
- "AIzaSy...03"
- "AIzaSy...04"
# 强制将 GPT-5 调用转换成 GPT-5 Codex.
force-gpt-5-codex: true
# Codex API 密钥
codex-api-key:
- api-key: "sk-atSM..."
@@ -430,7 +435,7 @@ export ANTHROPIC_MODEL=gemini-2.5-pro
export ANTHROPIC_SMALL_FAST_MODEL=gemini-2.5-flash
```
使用 OpenAI 模型:
使用 OpenAI GPT 5 模型:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
export ANTHROPIC_AUTH_TOKEN=sk-dummy
@@ -438,6 +443,15 @@ export ANTHROPIC_MODEL=gpt-5
export ANTHROPIC_SMALL_FAST_MODEL=gpt-5-minimal
```
使用 OpenAI GPT 5 Codex 模型:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
export ANTHROPIC_AUTH_TOKEN=sk-dummy
export ANTHROPIC_MODEL=gpt-5-codex
export ANTHROPIC_SMALL_FAST_MODEL=gpt-5-codex-low
```
使用 Claude 模型:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
@@ -461,7 +475,7 @@ export ANTHROPIC_SMALL_FAST_MODEL=qwen3-coder-flash
config.toml:
```toml
model_provider = "cliproxyapi"
model = "gpt-5" # 你可以使用任何我们支持的模型
model = "gpt-5-codex" # 或者是gpt-5,你也可以使用任何我们支持的模型
model_reasoning_effort = "high"
[model_providers.cliproxyapi]
+7 -6
View File
@@ -122,13 +122,14 @@ func main() {
log.Fatalf("failed to get home directory: %v", errUserHomeDir)
}
// Reconstruct the path by replacing the tilde with the user's home directory.
parts := strings.Split(cfg.AuthDir, string(os.PathSeparator))
if len(parts) > 1 {
parts[0] = home
cfg.AuthDir = filepath.Join(parts...)
} else {
// If the path is just "~", set it to the home directory.
remainder := strings.TrimPrefix(cfg.AuthDir, "~")
remainder = strings.TrimLeft(remainder, "/\\")
if remainder == "" {
cfg.AuthDir = home
} else {
// Normalize any slash style in the remainder so Windows paths keep nested directories.
normalized := strings.ReplaceAll(remainder, "\\", "/")
cfg.AuthDir = filepath.Join(home, filepath.FromSlash(normalized))
}
}
+3
View File
@@ -41,6 +41,9 @@ generative-language-api-key:
- "AIzaSy...03"
- "AIzaSy...04"
# forces the use of GPT-5 Codex model.
force-gpt-5-codex: true
# Codex API keys
codex-api-key:
- api-key: "sk-atSM..."
@@ -12,6 +12,14 @@ func (h *Handler) GetConfig(c *gin.Context) {
func (h *Handler) GetDebug(c *gin.Context) { c.JSON(200, gin.H{"debug": h.cfg.Debug}) }
func (h *Handler) PutDebug(c *gin.Context) { h.updateBoolField(c, func(v bool) { h.cfg.Debug = v }) }
// ForceGPT5Codex
func (h *Handler) GetForceGPT5Codex(c *gin.Context) {
c.JSON(200, gin.H{"gpt-5-codex": h.cfg.ForceGPT5Codex})
}
func (h *Handler) PutForceGPT5Codex(c *gin.Context) {
h.updateBoolField(c, func(v bool) { h.cfg.ForceGPT5Codex = v })
}
// Request log
func (h *Handler) GetRequestLog(c *gin.Context) { c.JSON(200, gin.H{"request-log": h.cfg.RequestLog}) }
func (h *Handler) PutRequestLog(c *gin.Context) {
+19 -3
View File
@@ -28,6 +28,7 @@ type ResponseWriterWrapper struct {
isStreaming bool // isStreaming indicates whether the response is a streaming type (e.g., text/event-stream).
streamWriter logging.StreamingLogWriter // streamWriter is a writer for handling streaming log entries.
chunkChannel chan []byte // chunkChannel is a channel for asynchronously passing response chunks to the logger.
streamDone chan struct{} // streamDone signals when the streaming goroutine completes.
logger logging.RequestLogger // logger is the instance of the request logger service.
requestInfo *RequestInfo // requestInfo holds the details of the original request.
statusCode int // statusCode stores the HTTP status code of the response.
@@ -108,9 +109,11 @@ func (w *ResponseWriterWrapper) WriteHeader(statusCode int) {
if err == nil {
w.streamWriter = streamWriter
w.chunkChannel = make(chan []byte, 100) // Buffered channel for async writes
doneChan := make(chan struct{})
w.streamDone = doneChan
// Start async chunk processor
go w.processStreamingChunks()
go w.processStreamingChunks(doneChan)
// Write status immediately
_ = streamWriter.WriteStatus(statusCode, w.headers)
@@ -168,7 +171,13 @@ func (w *ResponseWriterWrapper) detectStreaming(contentType string) bool {
// processStreamingChunks runs in a separate goroutine to process response chunks from the chunkChannel.
// It asynchronously writes each chunk to the streaming log writer.
func (w *ResponseWriterWrapper) processStreamingChunks() {
func (w *ResponseWriterWrapper) processStreamingChunks(done chan struct{}) {
if done == nil {
return
}
defer close(done)
if w.streamWriter == nil || w.chunkChannel == nil {
return
}
@@ -194,8 +203,15 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
w.chunkChannel = nil
}
if w.streamDone != nil {
<-w.streamDone
w.streamDone = nil
}
if w.streamWriter != nil {
return w.streamWriter.Close()
err := w.streamWriter.Close()
w.streamWriter = nil
return err
}
} else {
// Capture final status code and headers if not already captured
+4
View File
@@ -200,6 +200,10 @@ func (s *Server) setupRoutes() {
mgmt.PUT("/debug", s.mgmt.PutDebug)
mgmt.PATCH("/debug", s.mgmt.PutDebug)
mgmt.GET("/force-gpt-5-codex", s.mgmt.GetForceGPT5Codex)
mgmt.PUT("/force-gpt-5-codex", s.mgmt.PutForceGPT5Codex)
mgmt.PATCH("/force-gpt-5-codex", s.mgmt.PutForceGPT5Codex)
mgmt.GET("/proxy-url", s.mgmt.GetProxyURL)
mgmt.PUT("/proxy-url", s.mgmt.PutProxyURL)
mgmt.PATCH("/proxy-url", s.mgmt.PutProxyURL)
+23
View File
@@ -136,6 +136,10 @@ func (c *CodexClient) CanProvideModel(modelName string) bool {
"gpt-5-low",
"gpt-5-medium",
"gpt-5-high",
"gpt-5-codex",
"gpt-5-codex-low",
"gpt-5-codex-medium",
"gpt-5-codex-high",
"codex-mini-latest",
}
return util.InArray(models, modelName)
@@ -415,6 +419,25 @@ func (c *CodexClient) APIRequest(ctx context.Context, modelName, endpoint string
case "gpt-5-high":
jsonBody, _ = sjson.SetBytes(jsonBody, "reasoning.effort", "high")
}
} else if util.InArray([]string{"gpt-5-codex", "gpt-5-codex-low", "gpt-5-codex-medium", "gpt-5-codex-high"}, modelName) {
jsonBody, _ = sjson.SetBytes(jsonBody, "model", "gpt-5-codex")
switch modelName {
case "gpt-5-codex":
jsonBody, _ = sjson.SetBytes(jsonBody, "reasoning.effort", "medium")
case "gpt-5-codex-low":
jsonBody, _ = sjson.SetBytes(jsonBody, "reasoning.effort", "low")
case "gpt-5-codex-medium":
jsonBody, _ = sjson.SetBytes(jsonBody, "reasoning.effort", "medium")
case "gpt-5-codex-high":
jsonBody, _ = sjson.SetBytes(jsonBody, "reasoning.effort", "high")
}
} else if c.cfg.ForceGPT5Codex {
if gjson.GetBytes(jsonBody, "model").String() == "gpt-5" {
if gjson.GetBytes(jsonBody, "reasoning.effort").String() == "minimal" {
jsonBody, _ = sjson.SetBytes(jsonBody, "reasoning.effort", "low")
}
jsonBody, _ = sjson.SetBytes(jsonBody, "model", "gpt-5-codex")
}
}
url := fmt.Sprintf("%s%s", chatGPTEndpoint, endpoint)
+3
View File
@@ -44,6 +44,9 @@ type Config struct {
// ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file.
ClaudeKey []ClaudeKey `yaml:"claude-api-key" json:"claude-api-key"`
// ForceGPT5Codex forces the use of GPT-5 Codex model.
ForceGPT5Codex bool `yaml:"force-gpt-5-codex" json:"force-gpt-5-codex"`
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
+52
View File
@@ -215,6 +215,58 @@ func GetOpenAIModels() []*ModelInfo {
MaxCompletionTokens: 128000,
SupportedParameters: []string{"tools"},
},
{
ID: "gpt-5-codex",
Object: "model",
Created: time.Now().Unix(),
OwnedBy: "openai",
Type: "openai",
Version: "gpt-5-2025-09-15",
DisplayName: "GPT 5 Codex",
Description: "Stable version of GPT 5 Codex, The best model for coding and agentic tasks across domains.",
ContextLength: 400000,
MaxCompletionTokens: 128000,
SupportedParameters: []string{"tools"},
},
{
ID: "gpt-5-codex-low",
Object: "model",
Created: time.Now().Unix(),
OwnedBy: "openai",
Type: "openai",
Version: "gpt-5-2025-09-15",
DisplayName: "GPT 5 Codex Low",
Description: "Stable version of GPT 5 Codex, The best model for coding and agentic tasks across domains.",
ContextLength: 400000,
MaxCompletionTokens: 128000,
SupportedParameters: []string{"tools"},
},
{
ID: "gpt-5-codex-medium",
Object: "model",
Created: time.Now().Unix(),
OwnedBy: "openai",
Type: "openai",
Version: "gpt-5-2025-09-15",
DisplayName: "GPT 5 Codex Medium",
Description: "Stable version of GPT 5 Codex, The best model for coding and agentic tasks across domains.",
ContextLength: 400000,
MaxCompletionTokens: 128000,
SupportedParameters: []string{"tools"},
},
{
ID: "gpt-5-codex-high",
Object: "model",
Created: time.Now().Unix(),
OwnedBy: "openai",
Type: "openai",
Version: "gpt-5-2025-09-15",
DisplayName: "GPT 5 Codex High",
Description: "Stable version of GPT 5 Codex, The best model for coding and agentic tasks across domains.",
ContextLength: 400000,
MaxCompletionTokens: 128000,
SupportedParameters: []string{"tools"},
},
{
ID: "codex-mini-latest",
Object: "model",
@@ -136,8 +136,14 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
inputSchemaResult := toolResult.Get("input_schema")
if inputSchemaResult.Exists() && inputSchemaResult.IsObject() {
inputSchema := inputSchemaResult.Raw
inputSchema, _ = sjson.Delete(inputSchema, "additionalProperties")
inputSchema, _ = sjson.Delete(inputSchema, "$schema")
// Use comprehensive schema sanitization for Gemini API compatibility
if sanitizedSchema, sanitizeErr := util.SanitizeSchemaForGemini(inputSchema); sanitizeErr == nil {
inputSchema = sanitizedSchema
} else {
// Fallback to basic cleanup if sanitization fails
inputSchema, _ = sjson.Delete(inputSchema, "additionalProperties")
inputSchema, _ = sjson.Delete(inputSchema, "$schema")
}
tool, _ := sjson.Delete(toolResult.Raw, "input_schema")
tool, _ = sjson.SetRaw(tool, "parameters", inputSchema)
var toolDeclaration any
@@ -129,8 +129,14 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
inputSchemaResult := toolResult.Get("input_schema")
if inputSchemaResult.Exists() && inputSchemaResult.IsObject() {
inputSchema := inputSchemaResult.Raw
inputSchema, _ = sjson.Delete(inputSchema, "additionalProperties")
inputSchema, _ = sjson.Delete(inputSchema, "$schema")
// Use comprehensive schema sanitization for Gemini API compatibility
if sanitizedSchema, sanitizeErr := util.SanitizeSchemaForGemini(inputSchema); sanitizeErr == nil {
inputSchema = sanitizedSchema
} else {
// Fallback to basic cleanup if sanitization fails
inputSchema, _ = sjson.Delete(inputSchema, "additionalProperties")
inputSchema, _ = sjson.Delete(inputSchema, "$schema")
}
tool, _ := sjson.Delete(toolResult.Raw, "input_schema")
tool, _ = sjson.SetRaw(tool, "parameters", inputSchema)
var toolDeclaration any
+157
View File
@@ -212,3 +212,160 @@ func FixJSON(input string) string {
return out.String()
}
// SanitizeSchemaForGemini removes JSON Schema fields that are incompatible with Gemini API
// to prevent "Proto field is not repeating, cannot start list" errors.
//
// Parameters:
// - schemaJSON: The JSON schema string to sanitize
//
// Returns:
// - string: The sanitized schema string
// - error: An error if the operation fails
//
// This function removes the following incompatible fields:
// - additionalProperties: Not supported in Gemini function declarations
// - $schema: JSON Schema meta-schema identifier, not needed for API
// - allOf/anyOf/oneOf: Union type constructs not supported
// - exclusiveMinimum/exclusiveMaximum: Advanced validation constraints
// - patternProperties: Advanced property pattern matching
// - dependencies: Property dependencies not supported
// - type arrays: Converts ["string", "null"] to just "string"
func SanitizeSchemaForGemini(schemaJSON string) (string, error) {
// Remove top-level incompatible fields
fieldsToRemove := []string{
"additionalProperties",
"$schema",
"allOf",
"anyOf",
"oneOf",
"exclusiveMinimum",
"exclusiveMaximum",
"patternProperties",
"dependencies",
}
result := schemaJSON
var err error
for _, field := range fieldsToRemove {
result, err = sjson.Delete(result, field)
if err != nil {
continue // Continue even if deletion fails
}
}
// Handle type arrays by converting them to single types
result = sanitizeTypeFields(result)
// Recursively clean nested objects
result = cleanNestedSchemas(result)
return result, nil
}
// sanitizeTypeFields converts type arrays to single types for Gemini compatibility
func sanitizeTypeFields(jsonStr string) string {
// Parse the JSON to find all "type" fields
parsed := gjson.Parse(jsonStr)
result := jsonStr
// Walk through all paths to find type fields
var typeFields []string
walkForTypeFields(parsed, "", &typeFields)
// Process each type field
for _, path := range typeFields {
typeValue := gjson.Get(result, path)
if typeValue.IsArray() {
// Convert array to single type (prioritize string, then others)
arr := typeValue.Array()
if len(arr) > 0 {
var preferredType string
for _, t := range arr {
typeStr := t.String()
if typeStr == "string" {
preferredType = "string"
break
} else if typeStr == "number" || typeStr == "integer" {
preferredType = typeStr
if preferredType == "" {
preferredType = typeStr
}
} else if preferredType == "" {
preferredType = typeStr
}
}
if preferredType != "" {
result, _ = sjson.Set(result, path, preferredType)
}
}
}
}
return result
}
// walkForTypeFields recursively finds all "type" field paths in the JSON
func walkForTypeFields(value gjson.Result, path string, paths *[]string) {
switch value.Type {
case gjson.JSON:
value.ForEach(func(key, val gjson.Result) bool {
var childPath string
if path == "" {
childPath = key.String()
} else {
childPath = path + "." + key.String()
}
if key.String() == "type" {
*paths = append(*paths, childPath)
}
walkForTypeFields(val, childPath, paths)
return true
})
}
}
// cleanNestedSchemas recursively removes incompatible fields from nested schema objects
func cleanNestedSchemas(jsonStr string) string {
fieldsToRemove := []string{"allOf", "anyOf", "oneOf", "exclusiveMinimum", "exclusiveMaximum"}
// Find all nested paths that might contain these fields
var pathsToClean []string
parsed := gjson.Parse(jsonStr)
findNestedSchemaPaths(parsed, "", fieldsToRemove, &pathsToClean)
result := jsonStr
// Remove fields from all found paths
for _, path := range pathsToClean {
result, _ = sjson.Delete(result, path)
}
return result
}
// findNestedSchemaPaths recursively finds paths containing incompatible schema fields
func findNestedSchemaPaths(value gjson.Result, path string, fieldsToFind []string, paths *[]string) {
switch value.Type {
case gjson.JSON:
value.ForEach(func(key, val gjson.Result) bool {
var childPath string
if path == "" {
childPath = key.String()
} else {
childPath = path + "." + key.String()
}
// Check if this key is one we want to remove
for _, field := range fieldsToFind {
if key.String() == field {
*paths = append(*paths, childPath)
break
}
}
findNestedSchemaPaths(val, childPath, fieldsToFind, paths)
return true
})
}
}
+40 -20
View File
@@ -44,6 +44,11 @@ type Watcher struct {
lastConfigHash string
}
const (
authFileReadMaxAttempts = 5
authFileReadRetryDelay = 100 * time.Millisecond
)
// NewWatcher creates a new file watcher instance
func NewWatcher(configPath, authDir string, reloadCallback func(map[string]interfaces.Client, *config.Config)) (*Watcher, error) {
watcher, errNewWatcher := fsnotify.NewWatcher()
@@ -240,6 +245,9 @@ func (w *Watcher) reloadConfig() bool {
if oldConfig.RemoteManagement.AllowRemote != newConfig.RemoteManagement.AllowRemote {
log.Debugf(" remote-management.allow-remote: %t -> %t", oldConfig.RemoteManagement.AllowRemote, newConfig.RemoteManagement.AllowRemote)
}
if oldConfig.ForceGPT5Codex != newConfig.ForceGPT5Codex {
log.Debugf(" force-gpt-5-codex: %t -> %t", oldConfig.ForceGPT5Codex, newConfig.ForceGPT5Codex)
}
}
log.Infof("config successfully reloaded, triggering client reload")
@@ -295,7 +303,7 @@ func (w *Watcher) reloadClients() {
// Rebuild auth file hash cache for current clients
w.lastAuthHashes = make(map[string]string, len(newFileClients))
for path := range newFileClients {
if data, err := os.ReadFile(path); err == nil && len(data) > 0 {
if data, err := readAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay); err == nil && len(data) > 0 {
sum := sha256.Sum256(data)
w.lastAuthHashes[path] = hex.EncodeToString(sum[:])
}
@@ -324,7 +332,7 @@ func (w *Watcher) reloadClients() {
// createClientFromFile creates a single client instance from a given token file path.
func (w *Watcher) createClientFromFile(path string, cfg *config.Config) (interfaces.Client, error) {
data, errReadFile := os.ReadFile(path)
data, errReadFile := readAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay)
if errReadFile != nil {
return nil, errReadFile
}
@@ -382,8 +390,38 @@ func (w *Watcher) clientsToSlice(clientMap map[string]interfaces.Client) []inter
return s
}
// readAuthFileWithRetry attempts to read the auth file multiple times to work around
// short-lived locks on Windows while token files are being written.
func readAuthFileWithRetry(path string, attempts int, delay time.Duration) ([]byte, error) {
var lastErr error
for i := 0; i < attempts; i++ {
data, err := os.ReadFile(path)
if err == nil {
return data, nil
}
lastErr = err
if i < attempts-1 {
time.Sleep(delay)
}
}
return nil, lastErr
}
// addOrUpdateClient handles the addition or update of a single client.
func (w *Watcher) addOrUpdateClient(path string) {
data, errRead := readAuthFileWithRetry(path, authFileReadMaxAttempts, authFileReadRetryDelay)
if errRead != nil {
log.Errorf("failed to read auth file %s: %v", filepath.Base(path), errRead)
return
}
if len(data) == 0 {
log.Debugf("ignoring empty auth file: %s", filepath.Base(path))
return
}
sum := sha256.Sum256(data)
curHash := hex.EncodeToString(sum[:])
w.clientsMutex.Lock()
cfg := w.config
@@ -392,24 +430,6 @@ func (w *Watcher) addOrUpdateClient(path string) {
w.clientsMutex.Unlock()
return
}
// Read file to check for emptiness and calculate hash
data, errRead := os.ReadFile(path)
if errRead != nil {
log.Errorf("failed to read auth file %s: %v", filepath.Base(path), errRead)
w.clientsMutex.Unlock()
return
}
if len(data) == 0 {
// Empty file: ignore (wait for a subsequent WRITE)
log.Debugf("ignoring empty auth file: %s", filepath.Base(path))
w.clientsMutex.Unlock()
return
}
// Calculate a hash of the current content and compare with the cache
sum := sha256.Sum256(data)
curHash := hex.EncodeToString(sum[:])
if prev, ok := w.lastAuthHashes[path]; ok && prev == curHash {
log.Debugf("auth file unchanged (hash match), skipping reload: %s", filepath.Base(path))
w.clientsMutex.Unlock()