From 7780b81807baca6e8e2082485f03491485dde5ef Mon Sep 17 00:00:00 2001 From: Keton Loo <258433097+longlixiao-ong@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:13:45 +0000 Subject: [PATCH] fix(qoder): honor proxy for direct chat, sync liveness readiness, and retain canonical model IDs --- .../qoder-routing-proxy-and-liveness.md | 11 ++++ internal/app/app.go | 27 +++++++- internal/executor/chat.go | 6 +- internal/providers/qoder/direct.go | 62 +++++++++++++++++-- internal/runtime/manager_probe.go | 11 ++++ 5 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 changelog/unreleased/qoder-routing-proxy-and-liveness.md diff --git a/changelog/unreleased/qoder-routing-proxy-and-liveness.md b/changelog/unreleased/qoder-routing-proxy-and-liveness.md new file mode 100644 index 00000000..27e073e5 --- /dev/null +++ b/changelog/unreleased/qoder-routing-proxy-and-liveness.md @@ -0,0 +1,11 @@ +### English + +- Keep Qoder requests on their canonical model IDs instead of internal CLI enum keys so upstream chat endpoints do not stall. +- Wire proxy configuration into in-process Qoder chat so account and system proxy URLs are honored. +- Sync worker readiness into the pool when maintenance watchdog probes succeed, and wait briefly at boot for worker daemons to listen. + +### 中文 + +- Qoder 对话请求保持公共模型名直传,不再重写为内部 CLI 枚举 key,避免上游网关超时挂起。 +- 为 Qoder 进程内直连注入代理配置,支持读取账号级与系统级代理设置。 +- 保活巡检探测成功时自动同步 worker 就绪状态到路由池,并增加开机启动等待以防过早探测导致节点未就绪。 diff --git a/internal/app/app.go b/internal/app/app.go index 911b48c1..290e3b60 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -122,13 +122,31 @@ func New(cfg config.Config) *App { providerReg.Register(qwenwork.NewClient(store).Adapter()) qoderClient := qoder.NewClient() qoderClient.Bind(manager.AccountURL, manager.ProxyAPIKey) - qoderClient.SetDirect(qoder.NewDirect(store, func(ctx context.Context, accountID string) (string, error) { + qoderDirect := qoder.NewDirect(store, func(ctx context.Context, accountID string) (string, error) { account, err := store.Get(ctx, accountID) if err != nil { return "", err } return account.ProviderRegion, nil - })) + }) + qoderDirect.SetProxy(func(ctx context.Context, accountID string) (string, error) { + account, err := store.Get(ctx, accountID) + if err != nil { + return "", err + } + if value := strings.TrimSpace(account.ProxyURL); value != "" { + return value, nil + } + value, found, err := store.GetSecret(ctx, "proxy_url") + if err != nil { + return "", err + } + if found { + return strings.TrimSpace(value), nil + } + return "", nil + }) + qoderClient.SetDirect(qoderDirect) qoderClient.SetPATStore(store, func(ctx context.Context, accountID string) (string, error) { account, err := store.Get(ctx, accountID) if err != nil { @@ -139,7 +157,10 @@ func New(cfg config.Config) *App { providerReg.Register(qoderClient.Adapter()) manager.SetProviders(providerReg) manager.SetWorkBuddy(workbuddyClient) - go manager.RefreshAll(context.Background(), false) + go func() { + time.Sleep(3 * time.Second) + _ = manager.RefreshAll(context.Background(), false) + }() recorder := applogs.NewRequestRecorder(store) stopLogs := make(chan struct{}) go recorder.PurgeLoop(stopLogs, time.Hour) diff --git a/internal/executor/chat.go b/internal/executor/chat.go index fe0b2e07..3f4cbdeb 100644 --- a/internal/executor/chat.go +++ b/internal/executor/chat.go @@ -1030,8 +1030,10 @@ func (e ChatExecutor) ChatNonStream(ctx context.Context, req translate.ChatReque // the adapter inserts an empty placeholder, this helper only drops caller text. func sanitizeForItem(item Item, req translate.ChatRequest) translate.ChatRequest { req.Model = AccountModelID(item, req.Model) - if native := NativeModelID(item, req.Model); native != "" { - req.Model = native + if itemProvider(item) != "qoder" { + if native := NativeModelID(item, req.Model); native != "" { + req.Model = native + } } if item.DropSystemPrompt && itemProvider(item) != "qoder" { return translate.DropSystemMessages(req) diff --git a/internal/providers/qoder/direct.go b/internal/providers/qoder/direct.go index 669e87ac..a3fa2373 100644 --- a/internal/providers/qoder/direct.go +++ b/internal/providers/qoder/direct.go @@ -19,6 +19,7 @@ import ( "github.com/caigee-cmd/cli2api/internal/providers" "github.com/caigee-cmd/cli2api/internal/providers/qoder/qauth" "github.com/caigee-cmd/cli2api/internal/translate" + proxyutil "github.com/caigee-cmd/cli2api/internal/proxy" ) // Direct talks to the Qoder chat gateway from this process. It does not load @@ -26,10 +27,12 @@ import ( // it resolves credentials and fresh session tokens from the Bridge; legacy // accounts without a PAT fall back to the stored CLI user_blob. type Direct struct { - store CredentialStore - region func(ctx context.Context, accountID string) (string, error) - bridgeOf func(ctx context.Context, accountID string) (*Bridge, error) - http *http.Client + store CredentialStore + region func(ctx context.Context, accountID string) (string, error) + proxy func(ctx context.Context, accountID string) (string, error) + transports proxyutil.TransportCache + bridgeOf func(ctx context.Context, accountID string) (*Bridge, error) + http *http.Client now func() time.Time // ttfb bounds how long ChatStream waits for the upstream response head // and, after a 200, for the first meaningful SSE data line @@ -64,6 +67,45 @@ func (d *Direct) SetHTTP(client *http.Client) { d.http = client } +func (d *Direct) SetProxy(fn func(ctx context.Context, accountID string) (string, error)) { + if d == nil { + return + } + d.proxy = fn +} + +func (d *Direct) httpClient(ctx context.Context, accountID string) (*http.Client, error) { + if d == nil { + return nil, fmt.Errorf("qoder direct client is nil") + } + base := d.http + if base == nil { + base = &http.Client{} + } + if d.proxy == nil { + return base, nil + } + rawProxy, err := d.proxy(ctx, accountID) + if err != nil { + return nil, err + } + if strings.TrimSpace(rawProxy) == "" { + return base, nil + } + + transport, err := d.transports.Get(rawProxy) + if err != nil { + return nil, fmt.Errorf("configure proxy transport: %w", err) + } + if transport == nil { + return base, nil + } + + client := *base + client.Transport = transport + return &client, nil +} + type directPrepared struct { request *http.Request model string @@ -192,7 +234,11 @@ func (d *Direct) ChatNonStream(ctx context.Context, accountID string, req transl return providers.ChatOutcome{}, err } defer release() - resp, err := d.http.Do(prepared.request) + client, err := d.httpClient(ctx, accountID) + if err != nil { + return providers.ChatOutcome{}, err + } + resp, err := client.Do(prepared.request) if err != nil { return providers.ChatOutcome{}, err } @@ -221,7 +267,11 @@ func (d *Direct) ChatStream(ctx context.Context, accountID string, req translate if err != nil { return nil, providers.ResolvedChat{}, err } - resp, err := doWithFirstByteTimeout(d.http, prepared.request, d.ttfb) + client, err := d.httpClient(ctx, accountID) + if err != nil { + return nil, providers.ResolvedChat{}, err + } + resp, err := doWithFirstByteTimeout(client, prepared.request, d.ttfb) if err != nil { release() return nil, providers.ResolvedChat{}, err diff --git a/internal/runtime/manager_probe.go b/internal/runtime/manager_probe.go index 3a365598..7e01d240 100644 --- a/internal/runtime/manager_probe.go +++ b/internal/runtime/manager_probe.go @@ -185,5 +185,16 @@ func (m *Manager) probeHealthOnly(ctx context.Context, item Item) (bool, error) if statusCode >= 300 || !health.OK { return false, nil } + if health.Ready || health.Hot { + if m.pool != nil && (item.Ready == nil || !*item.Ready) { + m.pool.MergeHealth(item.ID, true, health.Hot, health.InFlight, item.Restarts, health.LastError) + if m.store != nil { + _ = m.store.Observe(ctx, item.ID, health.UID, "ready", health.LastError, "") + } + } + if m.pool != nil && len(item.Models) == 0 { + go m.fetchAccountModels(context.Background(), item) + } + } return true, nil }