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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,9 @@ Environment proxies apply only when no proxy is specified on the credential, gro
- 2.0 is designed for a **single application instance**. Instances do not share state, so horizontal scaling is not supported.
- Usage and cost are **estimates** derived from upstream responses. They support operational analysis and capacity planning, and do not equal a provider invoice or a financial reconciliation.
- Subscription channels depend on upstream OAuth and compatibility protocols and may change as upstreams change. Only connect accounts you are entitled to use, and follow each provider's terms.
- In OpenAI Responses, stateful requests relying on `previous_response_id`, `conversation`, or an existing resource ID are only reliable with a single credential, or with an upstream that shares resources across credentials.
- Responses continuation with `previous_response_id` automatically uses native Responses routes that declare upstream-managed storage: currently `openai`, `gpt_load`, `xai`, `newapi`, `cliproxyapi`, and `sub2api`. Ownership is isolated by AccessKey and pins the original credential when current routing permits, independently of soft affinity; actual state availability depends on the upstream. Stateless and converted responses are not registered, and Codex subscription WebSocket continuation is not yet integrated. Unknown IDs, including IDs created before upgrading or outside this gateway, are rejected. Group parameter overrides cannot change this field.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- Response bindings stay in memory for up to 30 days, with limits of 100,000 entries and 16 MiB of ID text; older entries are evicted when capacity is reached. A successful checkpoint during normal shutdown allows restoration from the same data directory. Crash recovery and continued upstream state availability are not guaranteed.
- `conversation` and other existing resource IDs are outside this ownership routing scope and still depend on a single credential or upstream resource sharing across credentials.

## Moving from 1.x

Expand Down
4 changes: 3 additions & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,9 @@ Windows 普通用户可改为下载 `gpt-load-windows-setup.exe`。双击并确
- 2.0 按**单应用实例**设计,多个实例之间不共享状态,不支持直接横向扩容。
- 用量与成本是基于上游返回数据的**估算**,用于运行分析和资源评估,不等同于服务商账单或财务对账结果。
- 订阅渠道依赖上游 OAuth 与兼容协议,可能随上游变化调整。请只接入自己有权使用的账号,并遵守对应服务商条款。
- OpenAI Responses 中依赖 `previous_response_id`、`conversation` 或既有资源 ID 的有状态请求,只有在单凭据或上游跨凭据共享资源时才可靠。
- Responses 的 `previous_response_id` 续接按协议及现有存储能力自动接入:原生 Responses 且声明由上游管理状态的渠道目前包括 `openai`、`gpt_load`、`xai`、`newapi`、`cliproxyapi`、`sub2api`。按 AccessKey 隔离归属,在当前路由允许时固定原凭据,不受软亲和开关影响;实际状态可用性由上游决定。无状态及转换响应不登记,Codex 订阅的 WebSocket 续接尚未接入。未知 ID(包括升级前或网关外创建的 ID)直接拒绝;Group 参数覆盖不能改写该字段。
- 响应归属保存在内存中,默认保留 30 天,最多 100,000 条,ID 文本合计最多 16 MiB,达到容量时淘汰旧记录。正常停机成功保存 checkpoint 后可在同一数据目录恢复;不保证崩溃恢复或上游历史仍有效。
- `conversation` 与其他既有资源 ID 不在上述归属路由范围内,仍依赖单凭据或上游跨凭据共享资源。

## 从 1.x 切换

Expand Down
4 changes: 3 additions & 1 deletion README_JP.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,9 @@ Windows の一般ユーザーは代わりに `gpt-load-windows-setup.exe` を利
- 2.0 は**単一アプリケーションインスタンス**を前提に設計されています。インスタンス間で状態を共有しないため、そのままの水平スケールには対応していません。
- 使用量とコストはアップストリームの応答に基づく**概算**です。運用分析やリソース評価には使えますが、プロバイダーの請求書や会計上の照合結果とは一致しません。
- サブスクリプションチャネルはアップストリームの OAuth と互換プロトコルに依存し、アップストリームの変更に伴って調整が必要になる場合があります。利用権限のあるアカウントのみを接続し、各プロバイダーの規約に従ってください。
- OpenAI Responses で `previous_response_id`、`conversation`、既存のリソース ID に依存するステートフルなリクエストは、単一の認証情報を使う場合、またはアップストリームが認証情報間でリソースを共有している場合にのみ確実に動作します。
- Responses の `previous_response_id` による継続は、ネイティブ Responses と上流での状態管理を宣言した経路に自動で適用されます。現在は `openai`、`gpt_load`、`xai`、`newapi`、`cliproxyapi`、`sub2api` が該当します。帰属を AccessKey ごとに分離し、現在のルーティングで許可される元の認証情報へ固定します。ソフトアフィニティ設定には依存せず、状態が実際に利用できるかは上流に依存します。ステートレス応答と変換された応答は登録せず、Codex サブスクリプションの WebSocket 継続はまだ接続していません。アップグレード前やゲートウェイ外で作成されたものを含め、不明な ID は拒否されます。Group のパラメータ上書きでこのフィールドを変更することはできません。
- 応答の帰属はメモリに最大 30 日間保持され、上限は 100,000 件および ID テキスト合計 16 MiB です。容量に達すると古い記録を削除します。通常終了時に checkpoint の保存が成功すれば、同じデータディレクトリから復元できます。クラッシュからの復元や、アップストリームの履歴が引き続き有効であることは保証しません。
- `conversation` とその他の既存リソース ID はこの帰属ルーティングの対象外であり、単一の認証情報またはアップストリームでの認証情報間のリソース共有が引き続き必要です。

## 1.x からの移行

Expand Down
28 changes: 20 additions & 8 deletions internal/app/runtime_checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,28 +25,32 @@ type runtimeStateCheckpointDocument struct {
Credentials []state.CredentialRuntimeCheckpoint `json:"credentials,omitempty"`
Stats []health.StatsRuntimeCheckpoint `json:"stats,omitempty"`
Scheduling *state.SchedulingCheckpoint `json:"scheduling,omitempty"`
Responses []state.ResponseBinding `json:"responses,omitempty"`
}

// FileRuntimeStateCheckpoint stores the small, disposable runtime checkpoint
// in DATA_DIR. The startup path consumes the file before parsing it so a
// malformed or partially written file cannot be retried on every restart.
type FileRuntimeStateCheckpoint struct {
path string
registry *state.CredentialRegistry
stats *health.StatsStore
removeFile func(string) error
path string
registry *state.CredentialRegistry
stats *health.StatsStore
responseBindings *state.ResponseBindings
removeFile func(string) error
}

func NewFileRuntimeStateCheckpoint(
dataDir string,
registry *state.CredentialRegistry,
stats *health.StatsStore,
responseBindings *state.ResponseBindings,
) *FileRuntimeStateCheckpoint {
return &FileRuntimeStateCheckpoint{
path: filepath.Join(dataDir, runtimeStateCheckpointFileName),
registry: registry,
stats: stats,
removeFile: os.Remove,
path: filepath.Join(dataDir, runtimeStateCheckpointFileName),
registry: registry,
stats: stats,
responseBindings: responseBindings,
removeFile: os.Remove,
}
}

Expand Down Expand Up @@ -80,6 +84,11 @@ func (checkpoint *FileRuntimeStateCheckpoint) Restore(ctx context.Context) error
if checkpoint.stats != nil {
checkpoint.stats.RestoreRuntimeCheckpoint(document.Stats)
}
if checkpoint.responseBindings != nil {
if err := checkpoint.responseBindings.RestoreCheckpoint(document.Responses); err != nil {
return err
}
}
return nil
}

Expand All @@ -99,6 +108,9 @@ func (checkpoint *FileRuntimeStateCheckpoint) Save(ctx context.Context) error {
if checkpoint.stats != nil {
document.Stats = checkpoint.stats.CaptureRuntimeCheckpoint()
}
if checkpoint.responseBindings != nil {
document.Responses = checkpoint.responseBindings.CaptureCheckpoint()
}
payload, err := json.Marshal(document)
if err != nil {
return err
Expand Down
34 changes: 29 additions & 5 deletions internal/app/runtime_checkpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func TestAppLogsCheckpointRestoreFailureOnce(t *testing.T) {
DB: db,
StartupBootstrap: startupBootstrapFunc(noopStartupBootstrap),
RuntimeState: runtimeStateLoaderFunc(func(context.Context) error { return nil }),
RuntimeCheckpoint: NewFileRuntimeStateCheckpoint(dataDir, nil, nil),
RuntimeCheckpoint: NewFileRuntimeStateCheckpoint(dataDir, nil, nil, nil),
ControlRuntime: newControlRuntimeFake(nil, false),
RequestLogs: newRequestLogRuntimeFake(nil, nil),
})
Expand Down Expand Up @@ -199,7 +199,7 @@ func TestFileRuntimeStateCheckpointRestoresAndConsumesFile(t *testing.T) {
stats := health.NewStatsStore()
stats.RecordFailure(1, health.FailureCategoryUpstreamHostError, 503, time.Date(2026, 8, 7, 11, 59, 0, 0, time.UTC))

checkpoint := NewFileRuntimeStateCheckpoint(dataDir, registry, stats)
checkpoint := NewFileRuntimeStateCheckpoint(dataDir, registry, stats, nil)
if err := checkpoint.Save(context.Background()); err != nil {
t.Fatalf("Save() error = %v", err)
}
Expand All @@ -218,7 +218,7 @@ func TestFileRuntimeStateCheckpointRestoresAndConsumesFile(t *testing.T) {
t.Fatalf("replace loaded registry: %v", err)
}
loadedStats := health.NewStatsStore()
loader := NewFileRuntimeStateCheckpoint(dataDir, loadedRegistry, loadedStats)
loader := NewFileRuntimeStateCheckpoint(dataDir, loadedRegistry, loadedStats, nil)
if err := loader.Restore(context.Background()); err != nil {
t.Fatalf("Restore() error = %v", err)
}
Expand All @@ -237,6 +237,30 @@ func TestFileRuntimeStateCheckpointRestoresAndConsumesFile(t *testing.T) {
}
}

func TestFileRuntimeStateCheckpointRestoresResponseOwnership(t *testing.T) {
dir := t.TempDir()
original := state.NewResponseBindings()
if !original.Record(7, "stored-response", state.CredentialRef{ID: 2, GroupID: 3, IdentityGeneration: 4}) {
t.Fatal("record failed")
}
want, _ := original.Lookup(7, "stored-response")
checkpoint := NewFileRuntimeStateCheckpoint(dir, nil, nil, original)
if err := checkpoint.Save(context.Background()); err != nil {
t.Fatal(err)
}
restored := state.NewResponseBindings()
loader := NewFileRuntimeStateCheckpoint(dir, nil, nil, restored)
if err := loader.Restore(context.Background()); err != nil {
t.Fatal(err)
}
got, ok := restored.Lookup(7, "stored-response")
if !ok || got.AccessKeyID != want.AccessKeyID || got.CredentialID != want.CredentialID ||
got.GroupID != want.GroupID || got.IdentityGeneration != want.IdentityGeneration ||
!got.ExpiresAt.Equal(want.ExpiresAt) {
t.Fatalf("restored binding = %#v, %t; want %#v", got, ok, want)
}
}

func TestFileRuntimeStateCheckpointReturnsErrorWhenDeleteFails(t *testing.T) {
dataDir := t.TempDir()
path := filepath.Join(dataDir, runtimeStateCheckpointFileName)
Expand All @@ -257,7 +281,7 @@ func TestFileRuntimeStateCheckpointReturnsErrorWhenDeleteFails(t *testing.T) {
}}); err != nil {
t.Fatalf("replace registry: %v", err)
}
checkpoint := NewFileRuntimeStateCheckpoint(dataDir, registry, health.NewStatsStore())
checkpoint := NewFileRuntimeStateCheckpoint(dataDir, registry, health.NewStatsStore(), nil)
// The normal file implementation removes the file successfully. This test
// documents that a failed removal must prevent applying stale data through
// the injectable filesystem hook used by the implementation.
Expand All @@ -283,7 +307,7 @@ func TestFileRuntimeStateCheckpointConsumesMalformedFileAndReturnsError(t *testi
}}); err != nil {
t.Fatalf("replace registry: %v", err)
}
checkpoint := NewFileRuntimeStateCheckpoint(dataDir, registry, health.NewStatsStore())
checkpoint := NewFileRuntimeStateCheckpoint(dataDir, registry, health.NewStatsStore(), nil)
if err := checkpoint.Restore(context.Background()); err == nil {
t.Fatal("Restore() error = nil, want malformed checkpoint error")
}
Expand Down
4 changes: 2 additions & 2 deletions internal/app/scheduling_checkpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ func TestRuntimeCheckpointRestoresOnlyMatchingSchedulingIdentities(t *testing.T)
d.Members[2].Progress = d.Watermark
d.Members[2].LastSelected = 8
})
if err := NewFileRuntimeStateCheckpoint(dir, original, nil).Save(context.Background()); err != nil {
if err := NewFileRuntimeStateCheckpoint(dir, original, nil, nil).Save(context.Background()); err != nil {
t.Fatal(err)
}
loaded := makeRegistry(map[uint]uint64{1: 1, 2: 2, 3: 1})
if err := NewFileRuntimeStateCheckpoint(dir, loaded, nil).Restore(context.Background()); err != nil {
if err := NewFileRuntimeStateCheckpoint(dir, loaded, nil, nil).Restore(context.Background()); err != nil {
t.Fatal(err)
}
loaded.SchedulingState().WithLock(func(d *state.SchedulingLedger) {
Expand Down
4 changes: 3 additions & 1 deletion internal/container/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ func BuildContainer() (*dig.Container, error) {
app.NewEngineWithLifecycle,
webui.NewServer,
state.NewCredentialRegistry,
state.NewResponseBindings,
accessquota.NewRuntime,
channel.CompileRegistry,
control.NewPriceRuntime,
Expand Down Expand Up @@ -118,8 +119,9 @@ func BuildContainer() (*dig.Container, error) {
cfg *config.Config,
registry *state.CredentialRegistry,
stats *health.StatsStore,
responseBindings *state.ResponseBindings,
) app.RuntimeStateCheckpoint {
return app.NewFileRuntimeStateCheckpoint(cfg.DataDir, registry, stats)
return app.NewFileRuntimeStateCheckpoint(cfg.DataDir, registry, stats, responseBindings)
},
control.NewRuntime,
func(runtime *control.Runtime) app.ControlRuntime { return runtime },
Expand Down
7 changes: 7 additions & 0 deletions internal/control/group_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"gpt-load/internal/channel"
"gpt-load/internal/outboundproxy"
"gpt-load/internal/parameteroverride"
"gpt-load/internal/platform/config"
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/platform/utils"
Expand Down Expand Up @@ -307,6 +308,12 @@ func normalizeGroupSettings(settings config.Settings) (config.Settings, models.J
if settings == nil {
settings = make(config.Settings)
}
if value, exists := settings[state.SettingParameterOverrides]; exists {
rules, err := parameteroverride.Compile(value)
if err != nil || rules.ValidateResponsesContinuation() != nil {
return nil, nil, app_errors.ErrValidation
}
}
encoded, err := json.Marshal(settings)
if err != nil {
return nil, nil, app_errors.ErrValidation
Expand Down
32 changes: 32 additions & 0 deletions internal/control/group_settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,38 @@ func TestUpdateGroupSettingsPublishesParameterOverrides(t *testing.T) {
}
}

func TestGroupSettingsRejectNewContinuationOverridesButKeepLegacyReadable(t *testing.T) {
fixture := newServiceFixture(t)
group := validControlGroup("legacy-continuation")
group.Overrides = models.JSON(`{"parameter_overrides":[{"set":{"previous_response_id":"old-response"}}]}`)
if err := fixture.db.Create(group).Error; err != nil {
t.Fatal(err)
}
if _, err := fixture.manager.Publish(mustBuildCompileInput(t, fixture.db)); err != nil {
t.Fatalf("legacy rules blocked snapshot loading: %v", err)
}
if _, err := fixture.service.GetGroupSettings(t.Context(), group.ID); err != nil {
t.Fatalf("legacy rules prevented management access: %v", err)
}
before := fixture.manager.Current()
_, err := fixture.service.UpdateGroupSettings(t.Context(), group.ID, GroupSettingsUpdateRequest{
Overrides: optionalField[config.Settings]{Set: true, Value: config.Settings{
state.SettingParameterOverrides: []any{map[string]any{
"set": map[string]any{"previous_response_id": "new-response"},
}},
}},
})
if !errors.Is(err, app_errors.ErrValidation) || fixture.manager.Current() != before {
t.Fatalf("new continuation override error = %v", err)
}
_, err = fixture.service.UpdateGroupSettings(t.Context(), group.ID, GroupSettingsUpdateRequest{
Overrides: optionalField[config.Settings]{Set: true, Value: config.Settings{}},
})
if err != nil {
t.Fatalf("legacy rule could not be corrected: %v", err)
}
}

func TestUpdateGroupSettingsPublishesOnceAndReturnsNewSettings(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
Expand Down
2 changes: 1 addition & 1 deletion internal/dialect/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func (d *Anthropic) InspectRequest(req *ParsedRequest) (RequestMetadata, error)
return RequestMetadata{}, fmt.Errorf("parsed request is required")
}

metadata, err := inspectJSONRequestFields(req.Body, true)
metadata, err := inspectJSONRequestFields(req.Body, true, false)
if err != nil {
return RequestMetadata{}, fmt.Errorf("decode %s request: %w", d.Protocol(), err)
}
Expand Down
1 change: 1 addition & 0 deletions internal/dialect/dialect.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type RequestMetadata struct {
Operation execution.Operation
RouteRequirement execution.RouteRequirement
ResponsesStorePreference execution.ResponsesStorePreference
PreviousResponseID string
ObserveUsage bool
PricingMode pricing.Mode
UsageDiagnostics usage.Diagnostics
Expand Down
2 changes: 1 addition & 1 deletion internal/dialect/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func (d *OpenAI) InspectRequest(req *ParsedRequest) (RequestMetadata, error) {
return RequestMetadata{}, fmt.Errorf("parsed request is required")
}

metadata, err := inspectJSONRequestFields(req.Body, true)
metadata, err := inspectJSONRequestFields(req.Body, true, false)
if err != nil {
return RequestMetadata{}, fmt.Errorf("decode %s request: %w", d.Protocol(), err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/dialect/openai_embeddings.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func (d *OpenAIEmbeddings) InspectRequest(request *ParsedRequest) (RequestMetada
return RequestMetadata{}, fmt.Errorf("unsupported %s Content-Type %q", d.Protocol(), contentType)
}
}
metadata, err := inspectJSONRequestFields(request.Body, true)
metadata, err := inspectJSONRequestFields(request.Body, true, false)
if err != nil {
return RequestMetadata{}, fmt.Errorf("decode %s request: %w", d.Protocol(), err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/dialect/openai_images.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func (d *OpenAIImages) InspectRequest(request *ParsedRequest) (RequestMetadata,
if mediaType != "" && mediaType != "application/json" {
return RequestMetadata{}, fmt.Errorf("unsupported %s Content-Type %q", d.Protocol(), contentType)
}
metadata, err := inspectJSONRequestFields(request.Body, true)
metadata, err := inspectJSONRequestFields(request.Body, true, false)
if err != nil {
return RequestMetadata{}, fmt.Errorf("decode %s request: %w", d.Protocol(), err)
}
Expand Down
6 changes: 4 additions & 2 deletions internal/dialect/openai_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func (d *OpenAIResponses) InspectRequest(req *ParsedRequest) (RequestMetadata, e

metadata := RequestMetadata{}
if len(req.Body) > 0 {
parsed, err := inspectJSONRequestFields(req.Body, false)
parsed, err := inspectJSONRequestFields(req.Body, false, req.Method == http.MethodPost && req.Path == openAIResponsesPath)
if err != nil {
return RequestMetadata{}, fmt.Errorf("decode %s request: %w", d.Protocol(), err)
}
Expand All @@ -56,7 +56,9 @@ func (d *OpenAIResponses) InspectRequest(req *ParsedRequest) (RequestMetadata, e
metadata.ObserveUsage = req.Method == http.MethodPost &&
(req.Path == openAIResponsesPath || req.Path == openAIResponsesCompactPath)
if len(req.Body) > 0 {
metadata.AffinityPrefix = inspectPromptAffinityPrefix(d.Protocol(), req.Body)
if metadata.PreviousResponseID == "" {
metadata.AffinityPrefix = inspectPromptAffinityPrefix(d.Protocol(), req.Body)
}
pricingMode, diagnostics, err := openAIRequestPricing(req.Body)
if err != nil {
return RequestMetadata{}, fmt.Errorf("inspect %s request pricing: %w", d.Protocol(), err)
Expand Down
Loading