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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Your application only needs one base URL and one AccessKey. Providers, accounts,
- **One mechanism for API keys and subscriptions** — Codex, Claude, Antigravity, Grok, and API-key channels share credential management, scheduling, and health handling.
- **Scheduling and failure isolation built in** — Multi-credential scheduling, configurable weights, retries, cooldown, blacklisting, and session affinity reduce the impact of overloaded or failing credentials.
- **Observable, self-hosted, and simple to deploy** — Inspect health, routes, logs, usage, and cost estimates in an embedded UI backed by SQLite, MySQL, or PostgreSQL with local credential encryption.
- **Upstream/downstream compatibility** — OpenAI-compatible channels emit both reasoning field spellings (`reasoning` and `reasoning_content`) in responses so any client can read thinking output, and rename the spelling in outbound requests to match what the upstream expects, so thinking content is never silently lost on either side.

## Quick start

Expand Down
1 change: 1 addition & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
- **统一管理 API Key 与订阅账号** — Codex、Claude、Antigravity、Grok 等订阅渠道与 API Key 渠道共享凭据管理、调度和健康体系。
- **内置调度与故障隔离** — 多凭据调度、可配置权重、重试、冷却、黑名单与会话亲和,降低单个凭据过载或失效的影响。
- **可观测、易部署、数据自持** — 提供健康、路由、日志、用量与成本估算;单个 Go 二进制内嵌管理界面,支持 SQLite、MySQL、PostgreSQL 和本地凭据加密。
- **上下游兼容适配** — OpenAI 兼容渠道在响应中同时输出 `reasoning` 与 `reasoning_content` 两种推理字段拼写,让任意客户端都能读到思考内容;出站请求则按上游所需拼写改名,消除上下游各认一种拼写导致的思考内容丢失与上下文退化。

## 快速开始

Expand Down
1 change: 1 addition & 0 deletions README_JP.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ API キー、サブスクリプションアカウント、トラフィック制
- **API キーとサブスクリプションを統一管理** — Codex、Claude、Antigravity、Grok と API キーチャネルで、認証情報管理・スケジューリング・健全性管理を共通化します。
- **スケジューリングと障害分離を内蔵** — 複数認証情報のスケジューリング、設定可能なウェイト、リトライ、クールダウン、ブラックリスト、セッションアフィニティにより、過負荷や失効の影響を抑えます。
- **可観測で導入しやすく、データを自己管理** — 健全性、ルート、ログ、使用量、コスト概算を確認でき、SQLite、MySQL、PostgreSQL とローカル認証情報暗号化を単一バイナリで利用できます。
- **上流・下流の互換性アダプテーション** — OpenAI 互換チャネルはレスポンスで推論フィールド `reasoning` と `reasoning_content` の両表記を出力して任意のクライアントでの表示を保証し、送信リクエストでは上流が必要とする表記にリネームでき、表記の違いによる思考出力の欠落や文脈の劣化を解消します。

## クイックスタート

Expand Down
4 changes: 3 additions & 1 deletion internal/channel/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,15 @@ const (
InputText = spec.InputText
InputURL = spec.InputURL
InputSecret = spec.InputSecret
InputSelect = spec.InputSelect
)

// FieldDescriptor is the public, value-free schema for one channel field.
type FieldDescriptor struct {
Key string `json:"key"`
Label string `json:"label"`
InputKind InputKind `json:"input_kind"`
Options []string `json:"options,omitempty"`
Required bool `json:"required"`
Sensitive bool `json:"sensitive"`
DefaultValue *string `json:"default_value"`
Expand Down Expand Up @@ -739,7 +741,7 @@ func validateDefinition(definition definition) error {
seen := make(map[string]struct{}, len(schema))
for _, field := range schema {
key := field.descriptor.Key
if key == "" || field.normalize == nil || (field.descriptor.InputKind != InputText && field.descriptor.InputKind != InputURL && field.descriptor.InputKind != InputSecret) {
if key == "" || field.normalize == nil || !field.descriptor.InputKind.Valid() {
return fmt.Errorf("channel %q has invalid %s field", id, name)
}
if _, duplicate := seen[key]; duplicate {
Expand Down
27 changes: 26 additions & 1 deletion internal/channel/compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package channel
import (
"encoding/json"
"fmt"
"slices"
"strings"

"gpt-load/internal/channel/spec"
Expand Down Expand Up @@ -254,9 +255,27 @@ func compileSchema(channelID string, name string, fields []spec.Field) (objectSc
return nil, fmt.Errorf("channel %q has duplicate %s field %q", channelID, name, field.Key)
}
seen[field.Key] = struct{}{}
if field.InputKind != spec.InputText && field.InputKind != spec.InputURL && field.InputKind != spec.InputSecret {
if !field.InputKind.Valid() {
return nil, fmt.Errorf("channel %q has invalid %s field %q input kind", channelID, name, field.Key)
}
var optionSeen map[string]struct{}
if field.InputKind == spec.InputSelect {
optionSeen = make(map[string]struct{}, len(field.Options))
for _, option := range field.Options {
if option == "" {
return nil, fmt.Errorf("channel %q has an empty select option for %s field %q", channelID, name, field.Key)
}
if _, duplicate := optionSeen[option]; duplicate {
return nil, fmt.Errorf("channel %q has duplicate select option %q for %s field %q", channelID, name, option, field.Key)
}
optionSeen[option] = struct{}{}
}
if len(optionSeen) == 0 {
return nil, fmt.Errorf("channel %q has select %s field %q without options", channelID, name, field.Key)
}
} else if len(field.Options) > 0 {
return nil, fmt.Errorf("channel %q has select options on non-select %s field %q", channelID, name, field.Key)
}
if field.Sensitive != (field.InputKind == spec.InputSecret) {
return nil, fmt.Errorf("channel %q has inconsistent %s field %q sensitivity", channelID, name, field.Key)
}
Expand All @@ -271,12 +290,18 @@ func compileSchema(channelID string, name string, fields []spec.Field) (objectSc
if err != nil {
return nil, fmt.Errorf("channel %q has invalid default for %s field %q: %w", channelID, name, field.Key, err)
}
if field.InputKind == spec.InputSelect {
if _, valid := optionSeen[defaultValue]; !valid {
return nil, fmt.Errorf("channel %q has a default outside the options of %s field %q", channelID, name, field.Key)
}
}
value := defaultValue
publicDefault = &value
}
result = append(result, fieldSpec{
descriptor: FieldDescriptor{
Key: field.Key, Label: field.Label, InputKind: field.InputKind,
Options: slices.Clone(field.Options),
Required: field.Required, Sensitive: field.Sensitive, DefaultValue: publicDefault,
},
defaultValue: defaultValue,
Expand Down
22 changes: 18 additions & 4 deletions internal/channel/modules/openai_compatible.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,24 @@ func OpenAICompatible() spec.Module {
Type: spec.ConnectionAPIKey,
CredentialInput: "batch_text",
},
Params: []spec.Field{{
Key: "base_url", Label: "Base URL", InputKind: spec.InputURL,
Required: true, Normalizer: spec.NormalizeBaseURL,
}},
Params: []spec.Field{
{
Key: "base_url", Label: "Base URL", InputKind: spec.InputURL,
Required: true, Normalizer: spec.NormalizeBaseURL,
},
// Key names are part of the stored group params contract;
// renaming them invalidates persisted rows.
{
Key: "reasoning_content_alias", Label: "Response Reasoning Alias",
InputKind: spec.InputSelect, Options: spec.ReasoningAliasResponseOptions,
Normalizer: spec.NormalizeResponseReasoningAlias,
},
{
Key: "request_reasoning_alias", Label: "Request Reasoning Alias",
InputKind: spec.InputSelect, Options: spec.ReasoningAliasOptions,
Normalizer: spec.NormalizeReasoningAlias,
},
},
Credentials: []spec.Field{{
Key: "api_key", Label: "API Key", InputKind: spec.InputSecret,
Required: true, Sensitive: true, Normalizer: spec.NormalizeNonEmpty,
Expand Down
79 changes: 79 additions & 0 deletions internal/channel/reasoning_alias_params_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package channel

import (
"encoding/json"
"testing"
)

func TestOpenAICompatibleReasoningAliasParamsNormalizeBothDirections(t *testing.T) {
registry := NewRegistry()
cases := []struct {
name string
raw string
key string
want string
wantOK bool
}{
{
name: "request canonical rename direction",
raw: `{"base_url":"https://example.com/v1","request_reasoning_alias":"reasoning_content_to_reasoning"}`,
key: "request_reasoning_alias",
want: "reasoning_content_to_reasoning",
wantOK: true,
},
{
name: "request empty stays omitted",
raw: `{"base_url":"https://example.com/v1","request_reasoning_alias":" "}`,
key: "request_reasoning_alias",
want: "",
wantOK: false,
},
{
name: "response duplicate accepted",
raw: `{"base_url":"https://example.com/v1","reasoning_content_alias":"duplicate"}`,
key: "reasoning_content_alias",
want: "duplicate",
wantOK: true,
},
{
name: "response off kept explicit",
raw: `{"base_url":"https://example.com/v1","reasoning_content_alias":"off"}`,
key: "reasoning_content_alias",
want: "off",
wantOK: true,
},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
params, err := registry.ValidateParams(OpenAICompatible, json.RawMessage(test.raw))
if err != nil {
t.Fatalf("ValidateParams error = %v", err)
}
got, ok := params.Value(test.key)
if ok != test.wantOK || got != test.want {
t.Fatalf("Value(%s) = %q, %t; want %q, %t", test.key, got, ok, test.want, test.wantOK)
}
})
}
}

func TestOpenAICompatibleReasoningAliasParamsRejectJunk(t *testing.T) {
registry := NewRegistry()
for _, raw := range []string{
`{"base_url":"https://example.com/v1","reasoning_content_alias":"maybe"}`,
`{"base_url":"https://example.com/v1","reasoning_content_alias":"true"}`,
`{"base_url":"https://example.com/v1","reasoning_content_alias":"reasoning_to_content"}`,
`{"base_url":"https://example.com/v1","request_reasoning_alias":"content_to_reasoning"}`,
`{"base_url":"https://example.com/v1","request_reasoning_alias":"duplicate"}`,
`{"base_url":"https://example.com/v1","request_reasoning_alias":"true"}`,
`{"base_url":"https://example.com/v1","request_reasoning_alias":"false"}`,
`{"base_url":"https://example.com/v1","request_reasoning_alias":true}`,
`{"base_url":"https://example.com/v1","reasoning_content_alias":"reasoning_content_to_reasoningx"}`,
`{"base_url":"https://example.com/v1","reasoning_content_alias":true}`,
`{"base_url":"https://example.com/v1","request_reasoning_alias":{"mode":"off"}}`,
} {
if _, err := registry.ValidateParams(OpenAICompatible, json.RawMessage(raw)); err == nil {
t.Fatalf("ValidateParams(%s) error = nil", raw)
}
}
}
15 changes: 14 additions & 1 deletion internal/channel/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,25 @@ func TestRegistryPublicDescriptorsContainSchemasButNoInternalOrSecretValues(t *t
t.Fatalf("openai credential field = %#v", credentialField)
}
compatible, ok := registry.Get(OpenAICompatible)
if !ok || len(compatible.ParamFields) != 1 {
if !ok || len(compatible.ParamFields) != 3 {
t.Fatalf("Get(openai_compatible) = %#v, %t", compatible, ok)
}
if field := compatible.ParamFields[0]; field.Key != "base_url" || field.InputKind != InputURL || !field.Required || field.Sensitive {
t.Fatalf("openai compatible param field = %#v", field)
}
for index, key := range []string{"reasoning_content_alias", "request_reasoning_alias"} {
field := compatible.ParamFields[index+1]
if field.Key != key || field.InputKind != InputSelect || field.Required || field.Sensitive {
t.Fatalf("openai compatible alias param %d = %#v", index, field)
}
expected := spec.ReasoningAliasOptions
if key == "reasoning_content_alias" {
expected = spec.ReasoningAliasResponseOptions
}
if strings.Join(field.Options, ",") != strings.Join(expected, ",") {
t.Fatalf("openai compatible alias param %d options = %v", index, field.Options)
}
}
encoded, err := json.Marshal(compatible)
if err != nil {
t.Fatalf("json.Marshal(descriptor) error = %v", err)
Expand Down
47 changes: 47 additions & 0 deletions internal/channel/spec/definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,54 @@ const (
InputText InputKind = "text"
InputURL InputKind = "url"
InputSecret InputKind = "secret"
InputSelect InputKind = "select"
)

// Valid reports whether the input kind is part of the public field contract.
func (kind InputKind) Valid() bool {
switch kind {
case InputText, InputURL, InputSecret, InputSelect:
return true
default:
return false
}
}

// Canonical values shared by the OpenAI-compatible reasoning alias select
// parameters. The strings are persisted in group params and read back by the
// execution layer, so they are part of the on-disk contract. Renaming stays
// on the request direction where the admin knows the one upstream spelling;
// responses only ever see off and duplicate.
const (
// ReasoningAliasOff forwards reasoning fields untouched in one direction.
ReasoningAliasOff = "off"
// ReasoningAliasReasoningToContent renames reasoning to reasoning_content
// in outbound requests.
ReasoningAliasReasoningToContent = "reasoning_to_content"
// ReasoningAliasContentToReasoning renames reasoning_content to reasoning
// in outbound requests.
ReasoningAliasContentToReasoning = "reasoning_content_to_reasoning"
// ReasoningAliasDuplicate copies whichever spelling is present to the
// other one so both survive in responses.
ReasoningAliasDuplicate = "duplicate"
)

// ReasoningAliasOptions lists the accepted canonical values of the request
// reasoning alias select parameter, in presentation order.
var ReasoningAliasOptions = []string{
ReasoningAliasOff,
ReasoningAliasReasoningToContent,
ReasoningAliasContentToReasoning,
}

// ReasoningAliasResponseOptions is the response select's option list. The
// client spelling is unknown per request, so a rename forces a guess;
// duplicate emits both instead.
var ReasoningAliasResponseOptions = []string{
ReasoningAliasOff,
ReasoningAliasDuplicate,
}

// ValueNormalizer canonicalizes one field without retaining its input.
type ValueNormalizer func(string) (string, error)

Expand All @@ -147,6 +193,7 @@ type Field struct {
Key string
Label string
InputKind InputKind
Options []string
Required bool
Sensitive bool
Default string
Expand Down
31 changes: 31 additions & 0 deletions internal/channel/spec/normalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,37 @@ func NormalizeOptionalHTTPSBaseURL(value string) (string, error) {
return NormalizeHTTPSBaseURL(value)
}

// normalizeReasoningAliasOption canonicalizes one reasoning alias select
// parameter to one of the given canonical options. An empty value stays
// empty so the option can be omitted, which the execution layer treats as
// off.
func normalizeReasoningAliasOption(value string, options []string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" {
return "", nil
}
for _, option := range options {
if normalized == option {
return option, nil
}
}
return "", fmt.Errorf("must be one of %s", strings.Join(options, ", "))
Comment on lines +77 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 检查历史 reasoning_content_alias 布尔值是否在参数验证前转换。
rg -n -C 5 \
  'reasoning_content_alias|NormalizeResponseReasoningAlias|ValidateParams|TargetConfig|ReasoningAliasDuplicate' \
  internal/channel/compiler.go internal/channel/channel.go internal/execution/bifrost/model_alias.go

# 检查是否已有旧布尔值配置的迁移或读取回归测试。
rg -n -C 4 \
  '"reasoning_content_alias":(true|false)|reasoning_content_alias.*true|reasoning_content_alias.*false' \
  internal --glob '*_test.go'

Repository: tbphp/gpt-load

Length of output: 14890


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- ValidateParams and parameter decoding ---'
sed -n '470,545p' internal/channel/channel.go
printf '%s\n' '--- parameter schema and normalization symbols ---'
rg -n -C 4 'type Params|func \(.*\) Value|CanonicalJSON|normalizeReasoningAliasOption|reasoning_content_alias|ResolveExecutionTarget|\\.Resolve\\(' internal/channel internal/execution --glob '*.go'
printf '%s\n' '--- persisted group-parameter read paths ---'
rg -n -C 5 'TargetConfig|target_config|group.*param|params.*group|ResolveExecutionTarget|Resolve\\(' internal --glob '*.go' --glob '*_test.go'
printf '%s\n' '--- relevant tests ---'
sed -n '1,130p' internal/channel/reasoning_alias_params_test.go

Repository: tbphp/gpt-load

Length of output: 2918


🤖 get_repo_knowledge executed:

get_repo_knowledge tbphp/gpt-load /tmp/coderabbit-repo-knowledge/tbphp-gpt-load-62eb2efe/learnings

Length of output: 727


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- parameter types and validators ---'
rg -n -C 5 'type Params|func .*Value|CanonicalJSON|normalizeReasoningAliasOption|reasoning_content_alias' internal/channel internal/execution --glob '*.go'

printf '%s\n' '--- target resolution callers and persisted configuration paths ---'
rg -n -C 5 'ResolveExecutionTarget|Resolve\(|TargetConfig|target_config|group_params|GroupParams|group.*params|params.*group' internal --glob '*.go' --glob '*_test.go'

printf '%s\n' '--- channel tests around historical values ---'
sed -n '1,130p' internal/channel/reasoning_alias_params_test.go

Repository: tbphp/gpt-load

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- exact validator definitions ---'
rg -l 'normalizeReasoningAliasOption|type Params|func .*validate' internal/channel
for f in $(rg -l 'normalizeReasoningAliasOption|type Params|func .*validate' internal/channel); do
  printf '\n--- %s ---\n' "$f"
  rg -n -C 8 'normalizeReasoningAliasOption|type Params|func .*validate|reasoning_content_alias|request_reasoning_alias' "$f"
done

printf '%s\n' '--- state loading and snapshot parameter flow ---'
rg -n -C 6 'ChannelRegistry.ValidateParams|group.Params|Params:|params:' internal/state/loader internal/state --glob '*.go' --glob '*_test.go' | head -n 320

printf '%s\n' '--- group model and loader conversion ---'
rg -n -C 8 'type Group|Params .*JSON|GroupConfig|group.*Params|Params.*group' internal/models internal/state/loader internal/state --glob '*.go' --glob '*_test.go' | head -n 360

Repository: tbphp/gpt-load

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- reasoning alias binding ---'
rg -l 'NormalizeResponseReasoningAlias|NormalizeReasoningAlias|reasoning_content_alias' internal/channel --glob '*.go'
rg -n -C 12 'NormalizeResponseReasoningAlias|NormalizeReasoningAlias|reasoning_content_alias|request_reasoning_alias' internal/channel/modules internal/channel --glob '*.go' | head -n 260

printf '%s\n' '--- strict object decoding and field normalization ---'
sed -n '600,690p' internal/channel/channel.go
rg -n -C 8 'func decodeStrictObject|type fieldSpec|Normalizer|normalize' internal/channel --glob '*.go'

printf '%s\n' '--- loader pass-through and compile validation ---'
sed -n '588,670p' internal/state/loader/loader.go
sed -n '248,275p' internal/state/snapshot.go

Repository: tbphp/gpt-load

Length of output: 50371


在加载持久化组参数时迁移历史布尔值。

如果启用的历史组参数包含 reasoning_content_alias: truefalseinternal/state/loader/loader.go 会将 row.Params 原样传入 GroupConfig.Params。随后 internal/state/snapshot.go 调用 Registry.ValidateParams,该验证器要求每个字段值为 JSON 字符串,因此会在执行 normalizeReasoningAliasOption 前返回 must be a string。请在验证前按既定映射转换历史布尔值,并添加加载路径的回归测试。

}

// NormalizeReasoningAlias canonicalizes the request reasoning alias select
// parameter to one of the ReasoningAliasOptions values. Duplicate is rejected
// on this direction: the outbound upstream spelling is known, so emitting
// both adds nothing a rename does not already cover.
func NormalizeReasoningAlias(value string) (string, error) {
return normalizeReasoningAliasOption(value, ReasoningAliasOptions)
}

// NormalizeResponseReasoningAlias canonicalizes the response reasoning alias
// select parameter to off or duplicate.
func NormalizeResponseReasoningAlias(value string) (string, error) {
return normalizeReasoningAliasOption(value, ReasoningAliasResponseOptions)
}

// NormalizeCloudIdentifier rejects whitespace and control characters in a
// provider-owned cloud configuration value.
func NormalizeCloudIdentifier(value string) (string, error) {
Expand Down
8 changes: 8 additions & 0 deletions internal/execution/bifrost/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"gpt-load/internal/dialect"
"gpt-load/internal/execution"
"gpt-load/internal/execution/geminiimage"
"gpt-load/internal/execution/responsealias"
"gpt-load/internal/protocol"
"gpt-load/internal/reasoning"
)
Expand Down Expand Up @@ -622,6 +623,13 @@ func (r *Runtime) prepare(spec execution.AttemptSpec, stream bool) (preparedAtte
}
return preparedAttempt{}, &failure
}
// Rename reasoning spellings in the outbound chat completions body
// before it reaches the passthrough transport. The typed request path
// rebuilds messages through the SDK and has no equivalent hook.
// Converted image requests carry a Gemini payload and never match.
if !convertedImages && needsRequestReasoningAlias(spec) {
body = responsealias.RewriteRequestMessages(body, requestReasoningAliasMode(spec))
}
passthroughPath := ""
if convertedImages {
body, err = geminiimage.ConvertRequest(body)
Expand Down
Loading