Skip to content
Merged
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
5 changes: 0 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,6 @@ logs
web/default/dist
web/node_modules
.env
one-api
max-api
new-api
sub2api
CLIProxyAPI
/__debug_bin*
.DS_Store
tiktoken_cache
Expand Down
4 changes: 2 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ MAX API is more than a code repository. It is a long-term open collaboration aro
| [MAX API Issues](https://github.com/MAX-API-Next/MAX-API/issues) | Report reproducible problems, propose improvements, and share compatibility changes |
| [MAX API Releases](https://github.com/MAX-API-Next/MAX-API/releases) | Follow stable release updates |
| [Ask DeepWiki](https://deepwiki.com/MAX-API-Next/MAX-API) | Search and understand the codebase quickly |
| Technical and ecosystem cooperation | Contact `maxapi@max-api.ai` |
| Technical and ecosystem cooperation | Contact `cscitech@cscitech.top` |

### Contributors we are looking for

Expand Down Expand Up @@ -345,7 +345,7 @@ Multi-node deployments must share `SESSION_SECRET` while using a different `NODE

This project is licensed under the [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).

If you modify this project and provide it to users over a network, please understand and comply with AGPLv3 source-availability obligations. For institutional cooperation or other licensing questions, contact maxapi@max-api.ai.
If you modify this project and provide it to users over a network, please understand and comply with AGPLv3 source-availability obligations. For institutional cooperation or other licensing questions, contact cscitech@cscitech.top.

---

Expand Down
4 changes: 2 additions & 2 deletions README.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ MAX API est plus qu'un dépôt de code. C'est une collaboration ouverte de long
| [Issues MAX API](https://github.com/MAX-API-Next/MAX-API/issues) | Signaler un problème reproductible, proposer une amélioration ou partager un changement de compatibilité |
| [Versions MAX API](https://github.com/MAX-API-Next/MAX-API/releases) | Suivre les mises à jour des versions stables |
| [Ask DeepWiki](https://deepwiki.com/MAX-API-Next/MAX-API) | Rechercher et comprendre rapidement le code |
| Coopération technique et écosystème | Contacter `maxapi@max-api.ai` |
| Coopération technique et écosystème | Contacter `cscitech@cscitech.top` |

### Profils de contributeurs recherchés

Expand Down Expand Up @@ -345,7 +345,7 @@ Les déploiements multi-nœuds doivent partager `SESSION_SECRET`, tout en utilis

Ce projet est distribué sous la [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).

Si vous modifiez ce projet et le fournissez à des utilisateurs via un réseau, veuillez comprendre et respecter les obligations de mise à disposition du code source prévues par l'AGPLv3. Pour une coopération institutionnelle ou toute question de licence, contactez maxapi@max-api.ai.
Si vous modifiez ce projet et le fournissez à des utilisateurs via un réseau, veuillez comprendre et respecter les obligations de mise à disposition du code source prévues par l'AGPLv3. Pour une coopération institutionnelle ou toute question de licence, contactez cscitech@cscitech.top.

---

Expand Down
4 changes: 2 additions & 2 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ MAX API は単なるコードリポジトリではありません。AI Models、
| [MAX API Issues](https://github.com/MAX-API-Next/MAX-API/issues) | 再現可能な問題、改善提案、互換性の変化を共有 |
| [MAX API Releases](https://github.com/MAX-API-Next/MAX-API/releases) | 正式版の更新を確認 |
| [Ask DeepWiki](https://deepwiki.com/MAX-API-Next/MAX-API) | コードベースをすばやく検索・理解 |
| 技術・エコシステム連携 | `maxapi@max-api.ai` まで連絡 |
| 技術・エコシステム連携 | `cscitech@cscitech.top` まで連絡 |

### 求めている貢献者

Expand Down Expand Up @@ -345,7 +345,7 @@ docker compose up -d

本プロジェクトは [GNU Affero General Public License v3.0(AGPLv3)](./LICENSE) の下で提供されます。

本プロジェクトを変更し、ネットワーク経由でユーザーへ提供する場合は、AGPLv3 のソースコード提供義務を理解し、遵守してください。機関との協力、その他のライセンスに関する問い合わせは maxapi@max-api.ai までご連絡ください。
本プロジェクトを変更し、ネットワーク経由でユーザーへ提供する場合は、AGPLv3 のソースコード提供義務を理解し、遵守してください。機関との協力、その他のライセンスに関する問い合わせは cscitech@cscitech.top までご連絡ください。

---

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ MAX API 不只是一个代码仓库,也是一项面向 AI Models、Agents、Ag
| [MAX API Issues](https://github.com/MAX-API-Next/MAX-API/issues) | 提交可复现问题、需求建议和兼容性变化 |
| [MAX API Releases](https://github.com/MAX-API-Next/MAX-API/releases) | 获取正式版本发布动态 |
| [Ask DeepWiki](https://deepwiki.com/MAX-API-Next/MAX-API) | 快速检索和理解项目代码 |
| 技术与生态合作 | 联系 `maxapi@max-api.ai` |
| 技术与生态合作 | 联系 `cscitech@cscitech.top` |

### 我们正在寻找这些共建者

Expand Down Expand Up @@ -347,7 +347,7 @@ docker compose up -d

本项目采用 [GNU Affero 通用公共许可证 v3.0(AGPLv3)](./LICENSE) 授权。

如果你修改并通过网络向用户提供本项目服务,请理解并遵守 AGPLv3 对应的源码提供等义务。机构合作或其他许可问题,请加入社群或联系:maxapi@max-api.ai。
如果你修改并通过网络向用户提供本项目服务,请理解并遵守 AGPLv3 对应的源码提供等义务。机构合作或其他许可问题,请加入社群或联系:cscitech@cscitech.top。

---

Expand Down
4 changes: 2 additions & 2 deletions README.zh_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ MAX API 不只是一个代码仓库,也是一项面向 AI Models、Agents、Ag
| [MAX API Issues](https://github.com/MAX-API-Next/MAX-API/issues) | 提交可复现问题、需求建议和兼容性变化 |
| [MAX API Releases](https://github.com/MAX-API-Next/MAX-API/releases) | 获取正式版本发布动态 |
| [Ask DeepWiki](https://deepwiki.com/MAX-API-Next/MAX-API) | 快速检索和理解项目代码 |
| 技术与生态合作 | 联系 `maxapi@max-api.ai` |
| 技术与生态合作 | 联系 `cscitech@cscitech.top` |

### 我们正在寻找这些共建者

Expand Down Expand Up @@ -347,7 +347,7 @@ docker compose up -d

本项目采用 [GNU Affero 通用公共许可证 v3.0(AGPLv3)](./LICENSE) 授权。

如果你修改并通过网络向用户提供本项目服务,请理解并遵守 AGPLv3 对应的源码提供等义务。机构合作或其他许可问题,请联系:maxapi@max-api.ai。
如果你修改并通过网络向用户提供本项目服务,请理解并遵守 AGPLv3 对应的源码提供等义务。机构合作或其他许可问题,请联系:cscitech@cscitech.top。

---

Expand Down
4 changes: 2 additions & 2 deletions README.zh_TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ MAX API 不只是一個程式碼倉庫,也是一項面向 AI Models、Agents
| [MAX API Issues](https://github.com/MAX-API-Next/MAX-API/issues) | 提交可重現問題、需求建議與相容性變化 |
| [MAX API Releases](https://github.com/MAX-API-Next/MAX-API/releases) | 取得正式版本發布動態 |
| [Ask DeepWiki](https://deepwiki.com/MAX-API-Next/MAX-API) | 快速檢索與理解專案程式碼 |
| 技術與生態合作 | 聯絡 `maxapi@max-api.ai` |
| 技術與生態合作 | 聯絡 `cscitech@cscitech.top` |

### 我們正在尋找這些共建者

Expand Down Expand Up @@ -347,7 +347,7 @@ docker compose up -d

本專案採用 [GNU Affero 通用公共許可證 v3.0(AGPLv3)](./LICENSE) 授權。

如果你修改並透過網路向使用者提供本專案服務,請理解並遵守 AGPLv3 對應的原始碼提供等義務。機構合作或其他許可問題,請聯絡:maxapi@max-api.ai。
如果你修改並透過網路向使用者提供本專案服務,請理解並遵守 AGPLv3 對應的原始碼提供等義務。機構合作或其他許可問題,請聯絡:cscitech@cscitech.top。

---

Expand Down
155 changes: 155 additions & 0 deletions common/channel_health_observation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package common

import (
"context"
"errors"
"fmt"
"strconv"
"sync"
"time"

"github.com/go-redis/redis/v8"
)

const (
channelHealthRuntimeKeyPrefix = "maxapi:channel-timeout:v1:observation:"
channelHealthRuntimeTTL = 24 * time.Hour
channelHealthObservationTimeout = 100 * time.Millisecond
channelHealthObservationQueueSize = 256
)

var channelHealthObservationQueue = make(chan ChannelHealthObservation, channelHealthObservationQueueSize)

var channelHealthObservationWorkerOnce sync.Once

func startChannelHealthObservationWorker() {
channelHealthObservationWorkerOnce.Do(func() {
go func() {
for observation := range channelHealthObservationQueue {
// The worker is deliberately best-effort. A slow or unavailable
// Redis instance must not remain on a relay response path.
_ = RecordChannelHealthObservation(observation)
}
}()
})
}

// EnqueueChannelHealthObservation schedules a best-effort diagnostic write.
// It never waits for Redis and drops the observation when the bounded queue is
// full, preserving the upstream response path as fail-open.
func EnqueueChannelHealthObservation(observation ChannelHealthObservation) {
if !observation.PolicyEnabled || observation.ChannelID <= 0 || observation.Event == "" || !RedisEnabled || RDB == nil {
return
}
startChannelHealthObservationWorker()
select {
case channelHealthObservationQueue <- observation:
default:
// A full diagnostics queue is equivalent to an unavailable optional
// projection. The request path remains unaffected.
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ChannelHealthObservation is the minimal, non-sensitive runtime fact emitted
// by a selected upstream channel attempt. It deliberately carries no request
// or response body and no provider credential.
type ChannelHealthObservation struct {
ChannelID int
Event string
PolicyEnabled bool
ObservedAt time.Time
AttemptLatencyMS int64
Stream bool
RetryIndex int
}

// ChannelHealthRuntimeTransition is a small process-local bridge from the
// relay/runtime package to Smart Operations. The runtime state itself remains
// in Redis; this hook only lets the existing notification projector react
// immediately without introducing a package cycle.
type ChannelHealthRuntimeTransition struct {
ChannelID int
Transition string
Penalty int64
PenaltyUntil time.Time
RuntimeDisabled bool
DisabledUntil time.Time
ObservedAt time.Time
}

var channelHealthRuntimeTransitionObserver struct {
sync.RWMutex
fn func(ChannelHealthRuntimeTransition)
}

func SetChannelHealthRuntimeTransitionObserver(observer func(ChannelHealthRuntimeTransition)) {
channelHealthRuntimeTransitionObserver.Lock()
channelHealthRuntimeTransitionObserver.fn = observer
channelHealthRuntimeTransitionObserver.Unlock()
}

func PublishChannelHealthRuntimeTransition(transition ChannelHealthRuntimeTransition) {
channelHealthRuntimeTransitionObserver.RLock()
observer := channelHealthRuntimeTransitionObserver.fn
channelHealthRuntimeTransitionObserver.RUnlock()
if observer != nil {
observer(transition)
}
}

func isChannelHealthTimeoutEvent(event string) bool {
switch event {
case "transport_timeout", "stream_idle_timeout", "request_timeout",
"streaming_first_result_timeout", "non_streaming_response_timeout":
return true
default:
return false
}
}

func channelHealthRuntimeKey(channelID int) string {
return fmt.Sprintf("%s%d", channelHealthRuntimeKeyPrefix, channelID)
}

// RecordChannelHealthObservation writes a bounded runtime projection. Redis
// failure is returned to the caller, which must treat it as diagnostic-only
// and never let it change relay behavior.
func RecordChannelHealthObservation(observation ChannelHealthObservation) error {
if observation.ChannelID <= 0 || observation.Event == "" {
return errors.New("channel health observation requires channel id and event")
}
if !RedisEnabled || RDB == nil {
return nil
}
if observation.ObservedAt.IsZero() {
observation.ObservedAt = time.Now()
}
if observation.AttemptLatencyMS < 0 {
observation.AttemptLatencyMS = 0
}

ctx, cancel := context.WithTimeout(context.Background(), channelHealthObservationTimeout)
defer cancel()
key := channelHealthRuntimeKey(observation.ChannelID)
fields := map[string]interface{}{
"channel_id": strconv.Itoa(observation.ChannelID),
"last_event": observation.Event,
"last_observed_at": observation.ObservedAt.UTC().Format(time.RFC3339Nano),
"last_attempt_latency": strconv.FormatInt(observation.AttemptLatencyMS, 10),
"last_stream": strconv.FormatBool(observation.Stream),
"last_retry_index": strconv.Itoa(observation.RetryIndex),
}

pipe := RDB.TxPipeline()
pipe.HIncrBy(ctx, key, "observations", 1)
if isChannelHealthTimeoutEvent(observation.Event) {
pipe.HIncrBy(ctx, key, "timeouts", 1)
}
pipe.HSet(ctx, key, fields)
pipe.Expire(ctx, key, channelHealthRuntimeTTL)
_, err := pipe.Exec(ctx)
if err != nil && !errors.Is(err, redis.Nil) {
return fmt.Errorf("record channel health observation: %w", err)
}
return nil
}
105 changes: 105 additions & 0 deletions common/channel_health_observation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package common

import (
"context"
"testing"
"time"

"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/require"
)

func TestRecordChannelHealthObservationStoresBoundedRuntimeProjection(t *testing.T) {
server := miniredis.RunT(t)
oldRDB, oldEnabled := RDB, RedisEnabled
RDB = redis.NewClient(&redis.Options{Addr: server.Addr()})
RedisEnabled = true
t.Cleanup(func() {
_ = RDB.Close()
RDB, RedisEnabled = oldRDB, oldEnabled
})

now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)
require.NoError(t, RecordChannelHealthObservation(ChannelHealthObservation{
ChannelID: 936,
Event: "first_response",
ObservedAt: now,
AttemptLatencyMS: 1200,
Stream: true,
RetryIndex: 1,
}))
require.NoError(t, RecordChannelHealthObservation(ChannelHealthObservation{
ChannelID: 936,
Event: "stream_idle_timeout",
ObservedAt: now.Add(time.Second),
AttemptLatencyMS: 2200,
Stream: true,
RetryIndex: 1,
}))

fields, err := RDB.HGetAll(context.Background(), channelHealthRuntimeKey(936)).Result()
require.NoError(t, err)
require.Equal(t, "2", fields["observations"])
require.Equal(t, "1", fields["timeouts"])
require.Equal(t, "stream_idle_timeout", fields["last_event"])
require.Equal(t, "2200", fields["last_attempt_latency"])
require.Equal(t, "true", fields["last_stream"])
require.Equal(t, "1", fields["last_retry_index"])
require.Equal(t, "936", fields["channel_id"])
}

func TestRecordChannelHealthObservationCountsCanonicalTimeoutKinds(t *testing.T) {
server := miniredis.RunT(t)
oldRDB, oldEnabled := RDB, RedisEnabled
RDB = redis.NewClient(&redis.Options{Addr: server.Addr()})
RedisEnabled = true
t.Cleanup(func() {
_ = RDB.Close()
RDB, RedisEnabled = oldRDB, oldEnabled
})

for _, event := range []string{"streaming_first_result_timeout", "non_streaming_response_timeout"} {
require.NoError(t, RecordChannelHealthObservation(ChannelHealthObservation{
ChannelID: 936,
Event: event,
}))
}
fields, err := RDB.HGetAll(context.Background(), channelHealthRuntimeKey(936)).Result()
require.NoError(t, err)
require.Equal(t, "2", fields["timeouts"])
}

func TestRecordChannelHealthObservationSkipsRedisWhenDisabled(t *testing.T) {
oldRDB, oldEnabled := RDB, RedisEnabled
RDB = nil
RedisEnabled = false
t.Cleanup(func() {
RDB, RedisEnabled = oldRDB, oldEnabled
})

require.NoError(t, RecordChannelHealthObservation(ChannelHealthObservation{
ChannelID: 1,
Event: "first_response",
}))
}

func TestEnqueueChannelHealthObservationSkipsDisabledPolicy(t *testing.T) {
server := miniredis.RunT(t)
oldRDB, oldEnabled := RDB, RedisEnabled
RDB = redis.NewClient(&redis.Options{Addr: server.Addr()})
RedisEnabled = true
t.Cleanup(func() {
_ = RDB.Close()
RDB, RedisEnabled = oldRDB, oldEnabled
})

EnqueueChannelHealthObservation(ChannelHealthObservation{
ChannelID: 937,
Event: "first_response",
})
time.Sleep(50 * time.Millisecond)
count, err := RDB.Exists(context.Background(), channelHealthRuntimeKey(937)).Result()
require.NoError(t, err)
require.Zero(t, count)
}
Loading
Loading