Skip to content
11 changes: 11 additions & 0 deletions changelog/unreleased/qoder-routing-proxy-and-liveness.md
Original file line number Diff line number Diff line change
@@ -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 就绪状态到路由池,并增加开机启动等待以防过早探测导致节点未就绪。
27 changes: 24 additions & 3 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,31 @@ func New(cfg config.Config) *App {
providerReg.Register(meta.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 {
Expand All @@ -143,7 +161,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)
Expand Down
6 changes: 4 additions & 2 deletions internal/executor/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
62 changes: 56 additions & 6 deletions internal/providers/qoder/direct.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,20 @@ 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
// the CLI bundle. When an in-process Bridge is available (PAT-backed account),
// 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions internal/runtime/manager_probe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading