Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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