Compare commits

..

8 Commits

Author SHA1 Message Date
Luis Pater
fb760718e2 ci(homebrew): add workflow to auto-bump Homebrew formula on release 2025-10-07 22:55:23 +08:00
Luis Pater
d6721e4e75 Merge pull request #95 from router-for-me/gemini-web
feat(cliproxy): Rebind auth executors on config change
2025-10-07 21:30:31 +08:00
hkfires
514f5a8ad4 feat(cliproxy): Rebind auth executors on config change 2025-10-07 21:23:21 +08:00
Luis Pater
a68e0dd8aa Merge pull request #94 from router-for-me/gemini-web
Add Gem Mode for Gemini Web
2025-10-07 21:01:05 +08:00
hkfires
75d7763c5c refactor(gemini-web): Rename flash image preview model ID 2025-10-07 20:35:53 +08:00
hkfires
9bb7df7af7 feat(gemini-web): Enable config hot-reload and fix Gem selection 2025-10-07 20:23:33 +08:00
hkfires
43665cb649 feat(gemini-web): Replace code-mode with flexible gem-mode 2025-10-07 19:36:22 +08:00
Luis Pater
39337627b9 feat(auth): include email attribute in auth files response
- Added logic to parse and include the "email" attribute from auth files.
- Updated file data extraction to support additional metadata.
2025-10-07 15:45:27 +08:00
12 changed files with 97 additions and 29 deletions

16
.github/workflows/homebrew.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
name: Bump Homebrew formula
on:
release:
types: [ released ]
jobs:
homebrew:
name: Bump Homebrew formula
runs-on: ubuntu-latest
steps:
- name: Update Homebrew Formula
uses: dawidd6/action-homebrew-bump-formula@v5
with:
token: ${{ secrets.HOMEBREW_TOKEN }}
formula: cliproxyapi

View File

@@ -328,7 +328,7 @@ The server uses a YAML configuration file (`config.yaml`) located in the project
| `openai-compatibility.*.models.*.alias` | string | "" | The alias used in the API. |
| `gemini-web` | object | {} | Configuration specific to the Gemini Web client. |
| `gemini-web.context` | boolean | true | Enables conversation context reuse for continuous dialogue. |
| `gemini-web.code-mode` | boolean | false | Enables code mode for optimized responses in coding-related tasks. |
| `gemini-web.gem-mode` | string | "" | Selects a predefined Gem to attach for Gemini Web requests; allowed values: `coding-partner`, `writing-editor`. When empty, no Gem is attached. |
| `gemini-web.max-chars-per-request` | integer | 1,000,000 | The maximum number of characters to send to Gemini Web in a single request. |
| `gemini-web.disable-continuation-hint` | boolean | false | Disables the continuation hint for split prompts. |
@@ -378,7 +378,7 @@ quota-exceeded:
# Gemini Web client configuration
gemini-web:
context: true # Enable conversation context reuse
code-mode: false # Enable code mode
gem-mode: "" # Select Gem: "coding-partner" or "writing-editor"; empty means no Gem
max-chars-per-request: 1000000 # Max characters per request
# API keys for official Generative Language API

View File

@@ -340,7 +340,7 @@ console.log(await claudeResponse.json());
| `openai-compatibility.*.models.*.alias` | string | "" | 在API中使用的别名。 |
| `gemini-web` | object | {} | Gemini Web 客户端的特定配置。 |
| `gemini-web.context` | boolean | true | 是否启用会话上下文重用,以实现连续对话。 |
| `gemini-web.code-mode` | boolean | false | 是否启用代码模式,优化代码相关任务的响应。 |
| `gemini-web.gem-mode` | string | "" | 选择要附加的预设 Gem`coding-partner` 或 `writing-editor`);为空表示不附加。 |
| `gemini-web.max-chars-per-request` | integer | 1,000,000 | 单次请求发送给 Gemini Web 的最大字符数。 |
| `gemini-web.disable-continuation-hint` | boolean | false | 当提示被拆分时,是否禁用连续提示的暗示。 |
@@ -390,7 +390,7 @@ quota-exceeded:
# Gemini Web 客户端配置
gemini-web:
context: true # 启用会话上下文重用
code-mode: false # 启用代码模式
gem-mode: "" # 选择 Gem"coding-partner" 或 "writing-editor";为空表示不附加
max-chars-per-request: 1000000 # 单次请求最大字符数
# AIStduio Gemini API 的 API 密钥

View File

@@ -90,10 +90,8 @@ quota-exceeded:
# # Disable the short continuation hint appended to intermediate chunks
# # when splitting long prompts. Default is false (hint enabled by default).
# disable-continuation-hint: false
# # Code mode:
# # - true: enable XML wrapping hint and attach the coding-partner Gem.
# # Thought merging (<think> into visible content) applies to STREAMING only;
# # non-stream responses keep reasoning/thought parts separate for clients
# # that expect explicit reasoning fields.
# # - false: disable XML hint and keep <think> separate
# code-mode: false
# # Gem selection (Gem Mode):
# # - "coding-partner": attach the predefined Coding partner Gem
# # - "writing-editor": attach the predefined Writing editor Gem
# # - empty: do not attach any Gem
# gem-mode: ""

View File

@@ -255,7 +255,9 @@ func (h *Handler) ListAuthFiles(c *gin.Context) {
full := filepath.Join(h.cfg.AuthDir, name)
if data, errRead := os.ReadFile(full); errRead == nil {
typeValue := gjson.GetBytes(data, "type").String()
emailValue := gjson.GetBytes(data, "email").String()
fileData["type"] = typeValue
fileData["email"] = emailValue
}
files = append(files, fileData)

View File

@@ -62,10 +62,19 @@ type GeminiWebConfig struct {
// Defaults to true if not set in YAML (see LoadConfig).
Context bool `yaml:"context" json:"context"`
// CodeMode, when true, enables coding mode behaviors for Gemini Web:
// - Attach the predefined "Coding partner" Gem
// - Enable XML wrapping hint for tool markup
// - Merge <think> content into visible content for tool-friendly output
// GemMode selects a predefined Gem to attach for Gemini Web requests.
// Allowed values:
// - "coding-partner"
// - "writing-editor"
// When empty, no Gem is attached by configuration.
// This is independent from CodeMode below, which is kept for backwards compatibility.
GemMode string `yaml:"gem-mode" json:"gem-mode"`
// CodeMode enables legacy coding-mode behaviors for Gemini Web.
// Backwards compatibility: when true, the service behaves as before by
// attaching the predefined "Coding partner" Gem and enabling extra
// conveniences (e.g., XML wrapping hints). Prefer GemMode for selecting
// a Gem going forward.
CodeMode bool `yaml:"code-mode" json:"code-mode"`
// MaxCharsPerRequest caps the number of characters (runes) sent to

View File

@@ -225,7 +225,7 @@ func MaskToken28(s string) string {
}
var NanoBananaModel = map[string]struct{}{
"gemini-2.5-flash-image-preview": {},
"gemini-2.5-flash-image-web": {},
}
// NewGeminiClient creates a client. Pass empty strings to auto-detect via browser cookies (not implemented in Go port).
@@ -380,6 +380,15 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model,
}
inner := []any{item0, nil, item2}
// Attach Gem first to keep index alignment with reference implementation
// so the Gemini Web UI can recognize the selected Gem.
if gem != nil {
// pad with 16 nils then gem ID
for i := 0; i < 16; i++ {
inner = append(inner, nil)
}
inner = append(inner, gem.ID)
}
requestedModel := strings.ToLower(model.Name)
if chat != nil && chat.RequestedModel() != "" {
requestedModel = chat.RequestedModel()
@@ -388,13 +397,6 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model,
inner = ensureAnyLen(inner, 49)
inner[49] = 14
}
if gem != nil {
// pad with 16 nils then gem ID
for i := 0; i < 16; i++ {
inner = append(inner, nil)
}
inner = append(inner, gem.ID)
}
innerJSON, _ := json.Marshal(inner)
outer := []any{nil, string(innerJSON)}
outerJSON, _ := json.Marshal(outer)

View File

@@ -21,7 +21,7 @@ func EnsureGeminiWebAliasMap() {
continue
}
if m.ID == "gemini-2.5-flash" {
aliasMap["gemini-2.5-flash-image-preview"] = "gemini-2.5-flash"
aliasMap["gemini-2.5-flash-image-web"] = "gemini-2.5-flash"
}
alias := AliasFromModelID(m.ID)
aliasMap[strings.ToLower(alias)] = strings.ToLower(m.ID)
@@ -65,8 +65,8 @@ func GetGeminiWebAliasedModels() []*registry.ModelInfo {
continue
} else if m.ID == "gemini-2.5-flash" {
cpy := *m
cpy.ID = "gemini-2.5-flash-image-preview"
cpy.Name = "gemini-2.5-flash-image-preview"
cpy.ID = "gemini-2.5-flash-image-web"
cpy.Name = "gemini-2.5-flash-image-web"
cpy.DisplayName = "Nano Banana"
cpy.Description = "Gemini 2.5 Flash Preview Image"
aliased = append(aliased, &cpy)

View File

@@ -460,10 +460,10 @@ func (s *GeminiWebState) Send(ctx context.Context, modelName string, reqPayload
return nil, s.wrapSendError(err), nil
}
// Hook: For gemini-2.5-flash-image-preview, if the API returns only images without any text,
// Hook: For gemini-2.5-flash-image-web, if the API returns only images without any text,
// inject a small textual summary so that conversation persistence has non-empty assistant text.
// This helps conversation recovery (conv store) to match sessions reliably.
if strings.EqualFold(modelName, "gemini-2.5-flash-image-preview") {
if strings.EqualFold(modelName, "gemini-2.5-flash-image-web") {
if len(output.Candidates) > 0 {
c := output.Candidates[output.Chosen]
hasNoText := strings.TrimSpace(c.Text) == ""
@@ -696,7 +696,22 @@ func (s *GeminiWebState) findReusableSession(modelName string, msgs []RoleText)
}
func (s *GeminiWebState) getConfiguredGem() *Gem {
if s.cfg != nil && s.cfg.GeminiWeb.CodeMode {
if s.cfg == nil {
return nil
}
// New behavior: attach Gem based on explicit GemMode selection.
// Only attaches the Gem; does not toggle any other behavior.
if gm := strings.ToLower(strings.TrimSpace(s.cfg.GeminiWeb.GemMode)); gm != "" {
switch gm {
case "coding-partner":
return &Gem{ID: "coding-partner", Name: "Coding partner", Predefined: true}
case "writing-editor":
return &Gem{ID: "writing-editor", Name: "Writing editor", Predefined: true}
}
}
// Backwards compatibility: legacy CodeMode still attaches Coding partner
// and may enable extra behaviors elsewhere.
if s.cfg.GeminiWeb.CodeMode {
return &Gem{ID: "coding-partner", Name: "Coding partner", Predefined: true}
}
return nil
@@ -1015,3 +1030,10 @@ func FindReusableSessionIn(items map[string]ConversationRecord, index map[string
}
return ConversationRecord{}, nil, 0, false
}
// SetConfig updates the configuration reference used by the state.
// This allows hot-reload of configuration to take effect for existing
// runtime states that were cached on auth during previous requests.
func (s *GeminiWebState) SetConfig(cfg *config.Config) {
s.cfg = cfg
}

View File

@@ -168,6 +168,8 @@ func (e *GeminiWebExecutor) stateFor(auth *cliproxyauth.Auth) (*geminiwebapi.Gem
return nil, fmt.Errorf("gemini-web executor: auth is nil")
}
if runtime, ok := auth.Runtime.(*geminiWebRuntime); ok && runtime != nil && runtime.state != nil {
// Hot-reload: ensure cached state sees the latest config
runtime.state.SetConfig(e.cfg)
return runtime.state, nil
}
@@ -175,6 +177,8 @@ func (e *GeminiWebExecutor) stateFor(auth *cliproxyauth.Auth) (*geminiwebapi.Gem
defer e.mu.Unlock()
if runtime, ok := auth.Runtime.(*geminiWebRuntime); ok && runtime != nil && runtime.state != nil {
// Hot-reload: ensure cached state sees the latest config
runtime.state.SetConfig(e.cfg)
return runtime.state, nil
}

View File

@@ -521,6 +521,9 @@ func (w *Watcher) reloadConfig() bool {
if oldConfig.GeminiWeb.DisableContinuationHint != newConfig.GeminiWeb.DisableContinuationHint {
log.Debugf(" gemini-web.disable-continuation-hint: %t -> %t", oldConfig.GeminiWeb.DisableContinuationHint, newConfig.GeminiWeb.DisableContinuationHint)
}
if oldConfig.GeminiWeb.GemMode != newConfig.GeminiWeb.GemMode {
log.Debugf(" gemini-web.gem-mode: %s -> %s", oldConfig.GeminiWeb.GemMode, newConfig.GeminiWeb.GemMode)
}
if oldConfig.GeminiWeb.CodeMode != newConfig.GeminiWeb.CodeMode {
log.Debugf(" gemini-web.code-mode: %t -> %t", oldConfig.GeminiWeb.CodeMode, newConfig.GeminiWeb.CodeMode)
}

View File

@@ -291,6 +291,17 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) {
}
}
// rebindExecutors refreshes provider executors so they observe the latest configuration.
func (s *Service) rebindExecutors() {
if s == nil || s.coreManager == nil {
return
}
auths := s.coreManager.List()
for _, auth := range auths {
s.ensureExecutorsForAuth(auth)
}
}
// Run starts the service and blocks until the context is cancelled or the server stops.
// It initializes all components including authentication, file watching, HTTP server,
// and starts processing requests. The method blocks until the context is cancelled.
@@ -389,6 +400,7 @@ func (s *Service) Run(ctx context.Context) error {
s.cfgMu.Lock()
s.cfg = newCfg
s.cfgMu.Unlock()
s.rebindExecutors()
}