diff --git a/README.md b/README.md index 91b007e22..9be383ac5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_CN.md b/README_CN.md index b086e219a..f4348c4c7 100644 --- a/README_CN.md +++ b/README_CN.md @@ -58,6 +58,7 @@ - **统一管理 API Key 与订阅账号** — Codex、Claude、Antigravity、Grok 等订阅渠道与 API Key 渠道共享凭据管理、调度和健康体系。 - **内置调度与故障隔离** — 多凭据调度、可配置权重、重试、冷却、黑名单与会话亲和,降低单个凭据过载或失效的影响。 - **可观测、易部署、数据自持** — 提供健康、路由、日志、用量与成本估算;单个 Go 二进制内嵌管理界面,支持 SQLite、MySQL、PostgreSQL 和本地凭据加密。 +- **上下游兼容适配** — OpenAI 兼容渠道在响应中同时输出 `reasoning` 与 `reasoning_content` 两种推理字段拼写,让任意客户端都能读到思考内容;出站请求则按上游所需拼写改名,消除上下游各认一种拼写导致的思考内容丢失与上下文退化。 ## 快速开始 diff --git a/README_JP.md b/README_JP.md index b6092e902..1e9d9cdc5 100644 --- a/README_JP.md +++ b/README_JP.md @@ -58,6 +58,7 @@ API キー、サブスクリプションアカウント、トラフィック制 - **API キーとサブスクリプションを統一管理** — Codex、Claude、Antigravity、Grok と API キーチャネルで、認証情報管理・スケジューリング・健全性管理を共通化します。 - **スケジューリングと障害分離を内蔵** — 複数認証情報のスケジューリング、設定可能なウェイト、リトライ、クールダウン、ブラックリスト、セッションアフィニティにより、過負荷や失効の影響を抑えます。 - **可観測で導入しやすく、データを自己管理** — 健全性、ルート、ログ、使用量、コスト概算を確認でき、SQLite、MySQL、PostgreSQL とローカル認証情報暗号化を単一バイナリで利用できます。 +- **上流・下流の互換性アダプテーション** — OpenAI 互換チャネルはレスポンスで推論フィールド `reasoning` と `reasoning_content` の両表記を出力して任意のクライアントでの表示を保証し、送信リクエストでは上流が必要とする表記にリネームでき、表記の違いによる思考出力の欠落や文脈の劣化を解消します。 ## クイックスタート diff --git a/internal/channel/channel.go b/internal/channel/channel.go index 8f5fdfb3f..bab07b604 100644 --- a/internal/channel/channel.go +++ b/internal/channel/channel.go @@ -54,6 +54,7 @@ const ( InputText = spec.InputText InputURL = spec.InputURL InputSecret = spec.InputSecret + InputSelect = spec.InputSelect ) // FieldDescriptor is the public, value-free schema for one channel field. @@ -61,6 +62,7 @@ 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"` @@ -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 { diff --git a/internal/channel/compiler.go b/internal/channel/compiler.go index 8967fefc3..29f3525e5 100644 --- a/internal/channel/compiler.go +++ b/internal/channel/compiler.go @@ -3,6 +3,7 @@ package channel import ( "encoding/json" "fmt" + "slices" "strings" "gpt-load/internal/channel/spec" @@ -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) } @@ -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, diff --git a/internal/channel/modules/openai_compatible.go b/internal/channel/modules/openai_compatible.go index e741f2474..927cabb6c 100644 --- a/internal/channel/modules/openai_compatible.go +++ b/internal/channel/modules/openai_compatible.go @@ -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, diff --git a/internal/channel/reasoning_alias_params_test.go b/internal/channel/reasoning_alias_params_test.go new file mode 100644 index 000000000..90f25bd83 --- /dev/null +++ b/internal/channel/reasoning_alias_params_test.go @@ -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) + } + } +} diff --git a/internal/channel/registry_test.go b/internal/channel/registry_test.go index da2dacef1..fea5808f0 100644 --- a/internal/channel/registry_test.go +++ b/internal/channel/registry_test.go @@ -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) diff --git a/internal/channel/spec/definition.go b/internal/channel/spec/definition.go index 0f39413c8..d83fbd7fd 100644 --- a/internal/channel/spec/definition.go +++ b/internal/channel/spec/definition.go @@ -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) @@ -147,6 +193,7 @@ type Field struct { Key string Label string InputKind InputKind + Options []string Required bool Sensitive bool Default string diff --git a/internal/channel/spec/normalize.go b/internal/channel/spec/normalize.go index 3d1f92195..e89a8e7d0 100644 --- a/internal/channel/spec/normalize.go +++ b/internal/channel/spec/normalize.go @@ -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, ", ")) +} + +// 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) { diff --git a/internal/execution/bifrost/executor.go b/internal/execution/bifrost/executor.go index c56bb80cd..4aae05fa5 100644 --- a/internal/execution/bifrost/executor.go +++ b/internal/execution/bifrost/executor.go @@ -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" ) @@ -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) diff --git a/internal/execution/bifrost/model_alias.go b/internal/execution/bifrost/model_alias.go index c8b7d5f11..440680d0e 100644 --- a/internal/execution/bifrost/model_alias.go +++ b/internal/execution/bifrost/model_alias.go @@ -2,8 +2,11 @@ package bifrost import ( "bytes" + "encoding/json" "fmt" + "strings" + "gpt-load/internal/channel/spec" "gpt-load/internal/execution" "gpt-load/internal/execution/responsealias" "gpt-load/internal/protocol" @@ -103,24 +106,137 @@ func needsClientModelAlias(spec execution.AttemptSpec) bool { return responsealias.Needs(spec.ClientModel, spec.UpstreamModel) } +// parseRequestReasoningAliasMode resolves the request reasoning alias +// parameter. Unset, empty, invalid and the response-only duplicate value +// select off. +func parseRequestReasoningAliasMode(raw json.RawMessage) responsealias.ReasoningMode { + text, ok := reasoningAliasText(raw) + if !ok { + return responsealias.ReasoningModeOff + } + switch text { + case spec.ReasoningAliasReasoningToContent: + return responsealias.ReasoningModeReasoningToContent + case spec.ReasoningAliasContentToReasoning: + return responsealias.ReasoningModeContentToReasoning + default: + return responsealias.ReasoningModeOff + } +} + +// parseResponseReasoningAliasMode resolves the response reasoning alias +// parameter. Unset, empty and invalid values select off. +func parseResponseReasoningAliasMode(raw json.RawMessage) responsealias.ReasoningMode { + text, ok := reasoningAliasText(raw) + if !ok { + return responsealias.ReasoningModeOff + } + switch text { + case spec.ReasoningAliasDuplicate: + return responsealias.ReasoningModeDuplicate + default: + return responsealias.ReasoningModeOff + } +} + +// reasoningAliasText decodes one stored alias parameter into the trimmed, +// lowercased text the mode parsers compare. Unset and undecodable payloads +// are not ok, which selects off. +func reasoningAliasText(raw json.RawMessage) (string, bool) { + if len(raw) == 0 { + return "", false + } + var text string + if json.Unmarshal(raw, &text) != nil { + return "", false + } + return strings.ToLower(strings.TrimSpace(text)), true +} + +// reasoningAliasModes reads both reasoning alias directions from the resolved +// target configuration. The reasoning_content_alias key name is fixed by +// stored group params and must not be renamed. +func reasoningAliasModes(spec execution.AttemptSpec) (responseMode, requestMode responsealias.ReasoningMode) { + if len(spec.TargetConfig) == 0 { + return responsealias.ReasoningModeOff, responsealias.ReasoningModeOff + } + var config map[string]json.RawMessage + if err := json.Unmarshal(spec.TargetConfig, &config); err != nil { + return responsealias.ReasoningModeOff, responsealias.ReasoningModeOff + } + return parseResponseReasoningAliasMode(config["reasoning_content_alias"]), + parseRequestReasoningAliasMode(config["request_reasoning_alias"]) +} + +func needsResponseReasoningAlias(spec execution.AttemptSpec) bool { + responseMode, _ := reasoningAliasModes(spec) + return spec.ClientProtocol == protocol.OpenAICompletions && + responseMode != responsealias.ReasoningModeOff +} + +func responseReasoningAliasMode(spec execution.AttemptSpec) responsealias.ReasoningMode { + if !needsResponseReasoningAlias(spec) { + return responsealias.ReasoningModeOff + } + responseMode, _ := reasoningAliasModes(spec) + return responseMode +} + +// needsRequestReasoningAlias gates the outbound chat completions body +// rewrite. Only the native OpenAI chat completions route forwards client +// message objects verbatim, so other protocols never see this rewrite. +func needsRequestReasoningAlias(spec execution.AttemptSpec) bool { + _, requestMode := reasoningAliasModes(spec) + return spec.ClientProtocol == protocol.OpenAICompletions && + requestMode != responsealias.ReasoningModeOff +} + +func requestReasoningAliasMode(spec execution.AttemptSpec) responsealias.ReasoningMode { + if !needsRequestReasoningAlias(spec) { + return responsealias.ReasoningModeOff + } + _, requestMode := reasoningAliasModes(spec) + return requestMode +} + func rewriteClientResponseModel(clientProtocol protocol.Protocol, body []byte, clientModel string) ([]byte, error) { return responsealias.RewriteJSON(clientProtocol, body, clientModel) } +// rewriteClientResponseAlias rewrites a native response, optionally with the +// client model name and both reasoning spellings. An empty clientModel skips +// the model rewrite and ReasoningModeOff skips the reasoning rewrite. +func rewriteClientResponseAlias( + clientProtocol protocol.Protocol, + body []byte, + clientModel string, + mode responsealias.ReasoningMode, +) ([]byte, error) { + return responsealias.RewriteJSONReasoning(clientProtocol, body, clientModel, mode) +} + type nativeAliasSSERewriter struct { clientProtocol protocol.Protocol clientModel string + reasoningMode responsealias.ReasoningMode pending []byte maxEventBytes int } func newNativeAliasSSERewriter(spec execution.AttemptSpec) *nativeAliasSSERewriter { - if !needsClientModelAlias(spec) { + needsModelAlias := needsClientModelAlias(spec) + reasoningMode := responseReasoningAliasMode(spec) + if !needsModelAlias && reasoningMode == responsealias.ReasoningModeOff { return nil } + clientModel := "" + if needsModelAlias { + clientModel = spec.ClientModel + } return &nativeAliasSSERewriter{ clientProtocol: spec.ClientProtocol, - clientModel: spec.ClientModel, + clientModel: clientModel, + reasoningMode: reasoningMode, maxEventBytes: execution.SSEEventLimit(spec.ClientProtocol), } } @@ -152,7 +268,7 @@ func (r *nativeAliasSSERewriter) push(chunk []byte) ([]byte, error) { } event := append([]byte(nil), r.pending[:eventEnd]...) r.pending = r.pending[eventEnd:] - rewritten, err := rewriteClientSSEEvent(event, r.clientProtocol, r.clientModel) + rewritten, err := rewriteClientSSEEventAlias(event, r.clientProtocol, r.clientModel, r.reasoningMode) if err != nil { return nil, err } @@ -169,7 +285,7 @@ func (r *nativeAliasSSERewriter) finish() ([]byte, error) { } event := append([]byte(nil), r.pending...) r.pending = nil - return rewriteClientSSEEvent(event, r.clientProtocol, r.clientModel) + return rewriteClientSSEEventAlias(event, r.clientProtocol, r.clientModel, r.reasoningMode) } func firstNativeSSEDelimiter(data []byte) (int, int) { @@ -196,6 +312,17 @@ type nativeSSELine struct { data []byte } +// rewriteClientSSEEventAlias rewrites one native SSE event, optionally with +// the client model name and both reasoning spellings on each data payload. +func rewriteClientSSEEventAlias( + event []byte, + clientProtocol protocol.Protocol, + clientModel string, + mode responsealias.ReasoningMode, +) ([]byte, error) { + return responsealias.RewriteSSEReasoning(clientProtocol, event, clientModel, mode) +} + func rewriteClientSSEEvent(event []byte, clientProtocol protocol.Protocol, clientModel string) ([]byte, error) { return responsealias.RewriteSSE(clientProtocol, event, clientModel) } diff --git a/internal/execution/bifrost/passthrough.go b/internal/execution/bifrost/passthrough.go index f834b1cc9..a49a6a30b 100644 --- a/internal/execution/bifrost/passthrough.go +++ b/internal/execution/bifrost/passthrough.go @@ -16,6 +16,7 @@ import ( "gpt-load/internal/dialect" "gpt-load/internal/execution" "gpt-load/internal/execution/geminiimage" + "gpt-load/internal/execution/responsealias" "gpt-load/internal/platform/httpheader" "gpt-load/internal/protocol" ) @@ -392,9 +393,15 @@ complete: } model = openAIResponseModel(bodyBytes, "") } - if needsClientModelAlias(spec) && headers.Get("Content-Encoding") == "" { + reasoningMode := responseReasoningAliasMode(spec) + if (needsClientModelAlias(spec) || reasoningMode != responsealias.ReasoningModeOff) && + headers.Get("Content-Encoding") == "" { + clientModel := "" + if needsClientModelAlias(spec) { + clientModel = spec.ClientModel + } var err error - bodyBytes, err = rewriteClientResponseModel(spec.ClientProtocol, bodyBytes, spec.ClientModel) + bodyBytes, err = rewriteClientResponseAlias(spec.ClientProtocol, bodyBytes, clientModel, reasoningMode) if err != nil { return startedUnaryFailure(status, headers, execution.ErrorKindInternal, "rewrite native response model") } diff --git a/internal/execution/bifrost/reasoning_alias_modes_test.go b/internal/execution/bifrost/reasoning_alias_modes_test.go new file mode 100644 index 000000000..496fe6721 --- /dev/null +++ b/internal/execution/bifrost/reasoning_alias_modes_test.go @@ -0,0 +1,116 @@ +package bifrost + +import ( + "bytes" + "encoding/json" + "testing" + + "gpt-load/internal/execution" + "gpt-load/internal/execution/responsealias" + "gpt-load/internal/protocol" +) + +func TestParseRequestReasoningAliasMode(t *testing.T) { + cases := []struct { + raw string + want responsealias.ReasoningMode + }{ + {raw: ``, want: responsealias.ReasoningModeOff}, + {raw: `"off"`, want: responsealias.ReasoningModeOff}, + {raw: `"reasoning_to_content"`, want: responsealias.ReasoningModeReasoningToContent}, + {raw: `"reasoning_content_to_reasoning"`, want: responsealias.ReasoningModeContentToReasoning}, + {raw: `"true"`, want: responsealias.ReasoningModeOff}, + {raw: `"false"`, want: responsealias.ReasoningModeOff}, + {raw: `true`, want: responsealias.ReasoningModeOff}, + {raw: `false`, want: responsealias.ReasoningModeOff}, + {raw: `"duplicate"`, want: responsealias.ReasoningModeOff}, + {raw: `"maybe"`, want: responsealias.ReasoningModeOff}, + } + for _, test := range cases { + if got := parseRequestReasoningAliasMode(json.RawMessage(test.raw)); got != test.want { + t.Fatalf("parseRequestReasoningAliasMode(%s) = %q, want %q", test.raw, got, test.want) + } + } +} + +func TestParseResponseReasoningAliasMode(t *testing.T) { + cases := []struct { + raw string + want responsealias.ReasoningMode + }{ + {raw: ``, want: responsealias.ReasoningModeOff}, + {raw: `"off"`, want: responsealias.ReasoningModeOff}, + {raw: `"duplicate"`, want: responsealias.ReasoningModeDuplicate}, + {raw: `"reasoning_to_content"`, want: responsealias.ReasoningModeOff}, + {raw: `"reasoning_content_to_reasoning"`, want: responsealias.ReasoningModeOff}, + {raw: `"true"`, want: responsealias.ReasoningModeOff}, + {raw: `"false"`, want: responsealias.ReasoningModeOff}, + {raw: `true`, want: responsealias.ReasoningModeOff}, + {raw: `false`, want: responsealias.ReasoningModeOff}, + {raw: `"maybe"`, want: responsealias.ReasoningModeOff}, + } + for _, test := range cases { + if got := parseResponseReasoningAliasMode(json.RawMessage(test.raw)); got != test.want { + t.Fatalf("parseResponseReasoningAliasMode(%s) = %q, want %q", test.raw, got, test.want) + } + } +} + +func TestReasoningAliasModesReadBothKeys(t *testing.T) { + spec := execution.AttemptSpec{ + ClientProtocol: protocol.OpenAICompletions, + TargetConfig: json.RawMessage( + `{"base_url":"https://example.com/v1","reasoning_content_alias":"duplicate","request_reasoning_alias":"reasoning_content_to_reasoning"}`, + ), + } + responseMode, requestMode := reasoningAliasModes(spec) + if responseMode != responsealias.ReasoningModeDuplicate { + t.Fatalf("response mode = %q", responseMode) + } + if requestMode != responsealias.ReasoningModeContentToReasoning { + t.Fatalf("request mode = %q", requestMode) + } + if !needsResponseReasoningAlias(spec) { + t.Fatal("needsResponseReasoningAlias = false for duplicate") + } + if !needsRequestReasoningAlias(spec) { + t.Fatal("needsRequestReasoningAlias = false for set direction") + } + + // A stored request duplicate (response-only value) selects off. + stray := execution.AttemptSpec{ + ClientProtocol: protocol.OpenAICompletions, + TargetConfig: json.RawMessage(`{"base_url":"https://example.com/v1","request_reasoning_alias":"duplicate"}`), + } + if got := requestReasoningAliasMode(stray); got != responsealias.ReasoningModeOff { + t.Fatalf("request mode for stored duplicate = %q, want off", got) + } + + // Non chat-completions protocols never take the alias path. + other := spec + other.ClientProtocol = protocol.Anthropic + if needsResponseReasoningAlias(other) || needsRequestReasoningAlias(other) { + t.Fatal("alias gated wrong protocol") + } +} + +func TestRewriteClientResponseAliasEmitsBothSpellings(t *testing.T) { + body := []byte(`{"model":"up","choices":[{"message":{"role":"assistant","content":"x","reasoning_content":"deep think"}}]}`) + out, err := rewriteClientResponseAlias(protocol.OpenAICompletions, body, "", responsealias.ReasoningModeDuplicate) + if err != nil { + t.Fatalf("rewrite error = %v", err) + } + if !bytes.Contains(out, []byte(`"reasoning":"deep think"`)) { + t.Fatalf("reasoning duplicate missing: %s", out) + } + if !bytes.Contains(out, []byte(`"reasoning_content":"deep think"`)) { + t.Fatalf("source spelling dropped: %s", out) + } + out, err = rewriteClientResponseAlias(protocol.OpenAICompletions, body, "", responsealias.ReasoningModeOff) + if err != nil { + t.Fatalf("off rewrite error = %v", err) + } + if !bytes.Equal(out, body) { + t.Fatalf("off mode rewrote body: %s", out) + } +} diff --git a/internal/execution/responsealias/reasoning_alias.go b/internal/execution/responsealias/reasoning_alias.go new file mode 100644 index 000000000..6ffbb3b84 --- /dev/null +++ b/internal/execution/responsealias/reasoning_alias.go @@ -0,0 +1,108 @@ +package responsealias + +import ( + "bytes" + "encoding/json" +) + +const ( + reasoningField = "reasoning" + contentField = "reasoning_content" +) + +// normalizeOpenAIReasoning copies whichever reasoning spelling holds a string +// across every choices[].message and choices[].delta object to the other one +// so clients parsing either spelling keep displaying thinking output. Only an +// absent, null, or empty destination is filled, so an object that already +// carries two different string spellings is left untouched. Payloads that are +// not OpenAI chat completions objects or fail to parse are returned +// byte-identical. +func normalizeOpenAIReasoning(payload []byte) []byte { + if !bytes.Contains(payload, []byte(`"`+reasoningField+`"`)) && + !bytes.Contains(payload, []byte(`"`+contentField+`"`)) { + return payload + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.UseNumber() + var doc map[string]any + if err := decoder.Decode(&doc); err != nil || doc == nil { + return payload + } + choices, _ := doc["choices"].([]any) + if len(choices) == 0 { + return payload + } + changed := false + for _, item := range choices { + choice, _ := item.(map[string]any) + if choice == nil { + continue + } + for _, key := range [...]string{"delta", "message"} { + part, _ := choice[key].(map[string]any) + if part == nil { + continue + } + if duplicateReasoningField(part) { + changed = true + } + } + } + if !changed { + return payload + } + rewritten, err := json.Marshal(doc) + if err != nil { + return payload + } + return rewritten +} + +// moveReasoningField renames source to target on one message-shaped object +// and reports whether it wrote. The rename only happens when source holds a +// string; the source spelling is dropped even when target keeps its own +// content, so only one spelling survives. +func moveReasoningField(object map[string]any, source, target string) bool { + text, ok := object[source].(string) + if !ok { + return false + } + writeReasoningTarget(object, target, text) + delete(object, source) + return true +} + +// writeReasoningTarget writes text to target and reports whether it wrote. +// An absent, null, or empty-string target receives the value; a target +// holding any other value keeps its own content and nothing is written. +func writeReasoningTarget(object map[string]any, target, text string) bool { + switch current := object[target].(type) { + case nil: + case string: + if current != "" { + return false + } + default: + return false + } + object[target] = text + return true +} + +// duplicateReasoningField copies whichever reasoning spelling holds a +// non-empty string to the other one and reports whether it wrote, so both +// spellings survive and readers of either spelling work. Some upstreams emit +// an empty-string or null reasoning as a placeholder while carrying the text +// in reasoning_content, so such spellings count as absent: the empty one is +// filled from the other spelling instead of being selected as the source and +// blocking the copy. +func duplicateReasoningField(object map[string]any) bool { + if text, ok := object[reasoningField].(string); ok && text != "" { + return writeReasoningTarget(object, contentField, text) + } + text, ok := object[contentField].(string) + if !ok || text == "" { + return false + } + return writeReasoningTarget(object, reasoningField, text) +} diff --git a/internal/execution/responsealias/reasoning_alias_test.go b/internal/execution/responsealias/reasoning_alias_test.go new file mode 100644 index 000000000..86b279246 --- /dev/null +++ b/internal/execution/responsealias/reasoning_alias_test.go @@ -0,0 +1,187 @@ +package responsealias + +import ( + "bytes" + "encoding/json" + "testing" + + "gpt-load/internal/protocol" +) + +func decodeChoicePart(t *testing.T, payload []byte, key string) map[string]any { + t.Helper() + var doc map[string]any + if err := json.Unmarshal(payload, &doc); err != nil { + t.Fatalf("payload is not JSON: %v (%s)", err, payload) + } + choices, ok := doc["choices"].([]any) + if !ok || len(choices) == 0 { + t.Fatalf("payload has no choices: %s", payload) + } + choice := choices[0].(map[string]any) + part, ok := choice[key].(map[string]any) + if !ok { + t.Fatalf("payload has no %s: %s", key, payload) + } + return part +} + +func TestNormalizeOpenAIReasoningCopiesEitherSpelling(t *testing.T) { + cases := []struct { + name string + payload string + partKey string + want string + }{ + { + name: "message reasoning copied to content", + payload: `{"choices":[{"message":{"role":"assistant","content":"x","reasoning":"think"}}]}`, + partKey: "message", + want: "think", + }, + { + name: "message content copied to reasoning", + payload: `{"choices":[{"message":{"role":"assistant","reasoning_content":"think"}}]}`, + partKey: "message", + want: "think", + }, + { + name: "delta reasoning copied to content", + payload: `{"choices":[{"index":0,"delta":{"reasoning":"tok"}}]}`, + partKey: "delta", + want: "tok", + }, + { + name: "empty destination is filled", + payload: `{"choices":[{"message":{"reasoning":"think","reasoning_content":""}}]}`, + partKey: "message", + want: "think", + }, + { + name: "empty reasoning is backfilled from content", + payload: `{"choices":[{"message":{"reasoning":"","reasoning_content":"think"}}]}`, + partKey: "message", + want: "think", + }, + { + name: "null reasoning is backfilled from content", + payload: `{"choices":[{"message":{"reasoning":null,"reasoning_content":"think"}}]}`, + partKey: "message", + want: "think", + }, + { + name: "null content is backfilled from reasoning", + payload: `{"choices":[{"message":{"reasoning":"think","reasoning_content":null}}]}`, + partKey: "message", + want: "think", + }, + { + name: "delta empty reasoning is backfilled from content", + payload: `{"choices":[{"index":0,"delta":{"reasoning":"","reasoning_content":"tok"}}]}`, + partKey: "delta", + want: "tok", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + out := normalizeOpenAIReasoning([]byte(test.payload)) + part := decodeChoicePart(t, out, test.partKey) + if part[reasoningField] != test.want || part[contentField] != test.want { + t.Fatalf("spellings not both %q: %s", test.want, out) + } + }) + } +} + +func TestNormalizeOpenAIReasoningLeavesPayloadsUntouched(t *testing.T) { + cases := []struct { + name string + payload string + }{ + { + name: "both spellings non-empty", + payload: `{"choices":[{"message":{"reasoning":"a","reasoning_content":"b"}}]}`, + }, + { + name: "both spellings empty strings", + payload: `{"choices":[{"message":{"reasoning":"","reasoning_content":""}}]}`, + }, + { + name: "non-string spellings ignored", + payload: `{"choices":[{"delta":{"reasoning":42}}]}`, + }, + { + name: "no reasoning fields at all", + payload: `{"choices":[{"delta":{"content":"plain"}}]}`, + }, + { + name: "missing choices", + payload: `{"id":"chatcmpl-1","reasoning_content":"top level"}`, + }, + { + name: "malformed json", + payload: `{"choices":[{"delta":{"reasoning":`, + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + out := normalizeOpenAIReasoning([]byte(test.payload)) + if !bytes.Equal(out, []byte(test.payload)) { + t.Fatalf("payload rewritten: %s", out) + } + }) + } +} + +func TestRewriteJSONReasoningScopesToOpenAICompletions(t *testing.T) { + payload := []byte(`{"choices":[{"message":{"role":"assistant","reasoning":"think"}}]}`) + out, err := RewriteJSONReasoning(protocol.OpenAICompletions, payload, "", ReasoningModeDuplicate) + if err != nil { + t.Fatalf("rewrite error = %v", err) + } + part := decodeChoicePart(t, out, "message") + if part[contentField] != "think" || part[reasoningField] != "think" { + t.Fatalf("duplicate missing: %s", out) + } + out, err = RewriteJSONReasoning(protocol.OpenAICompletions, payload, "", ReasoningModeOff) + if err != nil { + t.Fatalf("off rewrite error = %v", err) + } + if !bytes.Equal(out, payload) { + t.Fatalf("off mode rewrote payload: %s", out) + } + out, err = RewriteJSONReasoning(protocol.Anthropic, payload, "", ReasoningModeDuplicate) + if err != nil { + t.Fatalf("anthropic rewrite error = %v", err) + } + if !bytes.Equal(out, payload) { + t.Fatalf("anthropic payload was rewritten: %s", out) + } +} + +func TestRewriteSSEReasoningCopiesBothSpellingsPerEvent(t *testing.T) { + data := []byte("data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"a\"}}]}\n\n" + + "data: [DONE]\n\n") + out, err := RewriteSSEReasoning(protocol.OpenAICompletions, data, "", ReasoningModeDuplicate) + if err != nil { + t.Fatalf("rewrite sse error = %v", err) + } + events := 0 + for _, line := range bytes.Split(out, []byte("\n\n")) { + if !bytes.HasPrefix(line, []byte("data: {")) { + continue + } + payload := bytes.TrimPrefix(line, []byte("data: ")) + part := decodeChoicePart(t, payload, "delta") + if part[contentField] != "a" || part[reasoningField] != "a" { + t.Fatalf("delta duplicate missing: %s", payload) + } + events++ + } + if events != 1 { + t.Fatalf("rewritten events = %d, want 1: %s", events, out) + } + if !bytes.Contains(out, []byte("[DONE]")) { + t.Fatalf("DONE marker lost: %s", out) + } +} diff --git a/internal/execution/responsealias/request_alias.go b/internal/execution/responsealias/request_alias.go new file mode 100644 index 000000000..e0dbc0f06 --- /dev/null +++ b/internal/execution/responsealias/request_alias.go @@ -0,0 +1,57 @@ +package responsealias + +import ( + "bytes" + "encoding/json" +) + +// RewriteRequestMessages renames the reasoning spelling selected by mode +// across every object in the messages array of one chat completions request +// body, so the upstream that the admin configured can replay the client's +// retained thinking. After a rename only the target spelling remains; a +// non-empty existing target keeps its value and the source spelling is +// dropped. Only the rename modes apply: off and the response-only duplicate +// leave the body untouched. Bodies that are not chat completions objects or +// fail to parse are returned byte-identical. +func RewriteRequestMessages(body []byte, mode ReasoningMode) []byte { + var source, target string + switch mode { + case ReasoningModeReasoningToContent: + source, target = reasoningField, contentField + case ReasoningModeContentToReasoning: + source, target = contentField, reasoningField + default: + return body + } + if !bytes.Contains(body, []byte(`"`+source+`"`)) { + return body + } + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var doc map[string]any + if err := decoder.Decode(&doc); err != nil || doc == nil { + return body + } + messages, _ := doc["messages"].([]any) + if len(messages) == 0 { + return body + } + changed := false + for _, item := range messages { + message, _ := item.(map[string]any) + if message == nil { + continue + } + if moveReasoningField(message, source, target) { + changed = true + } + } + if !changed { + return body + } + rewritten, err := json.Marshal(doc) + if err != nil { + return body + } + return rewritten +} diff --git a/internal/execution/responsealias/request_alias_test.go b/internal/execution/responsealias/request_alias_test.go new file mode 100644 index 000000000..0021cc90f --- /dev/null +++ b/internal/execution/responsealias/request_alias_test.go @@ -0,0 +1,111 @@ +package responsealias + +import ( + "bytes" + "encoding/json" + "testing" +) + +func decodeRequestMessage(t *testing.T, body []byte, index int) map[string]any { + t.Helper() + var doc map[string]any + if err := json.Unmarshal(body, &doc); err != nil { + t.Fatalf("body is not JSON: %v (%s)", err, body) + } + messages, ok := doc["messages"].([]any) + if !ok || index >= len(messages) { + t.Fatalf("body has no message %d: %s", index, body) + } + return messages[index].(map[string]any) +} + +func TestRewriteRequestMessagesRenamesBothDirections(t *testing.T) { + cases := []struct { + name string + mode ReasoningMode + body string + source string + target string + wantVal string + }{ + { + name: "reasoning to content", + mode: ReasoningModeReasoningToContent, + body: `{"model":"m","messages":[{"role":"assistant","content":"x","reasoning":"old think"},{"role":"user","content":"next"}]}`, + source: reasoningField, + target: contentField, + wantVal: "old think", + }, + { + name: "content to reasoning", + mode: ReasoningModeContentToReasoning, + body: `{"model":"m","messages":[{"role":"assistant","content":"x","reasoning_content":"old think"}]}`, + source: contentField, + target: reasoningField, + wantVal: "old think", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + out := RewriteRequestMessages([]byte(test.body), test.mode) + message := decodeRequestMessage(t, out, 0) + if got := message[test.target]; got != test.wantVal { + t.Fatalf("target %s = %v, want %q (%s)", test.target, got, test.wantVal, out) + } + if _, exists := message[test.source]; exists { + t.Fatalf("source %s survived the rename: %s", test.source, out) + } + }) + } +} + +func TestRewriteRequestMessagesNonEmptyTargetWins(t *testing.T) { + out := RewriteRequestMessages( + []byte(`{"messages":[{"role":"assistant","reasoning":"new","reasoning_content":"keep"}]}`), + ReasoningModeReasoningToContent, + ) + message := decodeRequestMessage(t, out, 0) + if got := message[contentField]; got != "keep" { + t.Fatalf("target %s = %v, want %q (%s)", contentField, got, "keep", out) + } + if _, exists := message[reasoningField]; exists { + t.Fatalf("source %s survived the rename: %s", reasoningField, out) + } +} + +func TestRewriteRequestMessagesLeavesBodiesUntouched(t *testing.T) { + cases := []struct { + name string + mode ReasoningMode + body string + }{ + { + name: "mode off", + mode: ReasoningModeOff, + body: `{"messages":[{"role":"assistant","reasoning":"think"}]}`, + }, + { + name: "response-only duplicate selects off", + mode: ReasoningModeDuplicate, + body: `{"messages":[{"role":"assistant","reasoning":"think"}]}`, + }, + { + name: "no messages", + mode: ReasoningModeReasoningToContent, + body: `{"model":"m","reasoning":"top level"}`, + }, + { + name: "malformed json", + mode: ReasoningModeContentToReasoning, + body: `{"messages":[{"reasoning_content":"`, + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + out := RewriteRequestMessages([]byte(test.body), test.mode) + if !bytes.Equal(out, []byte(test.body)) { + t.Fatalf("body rewritten: %s", out) + } + }) + } +} diff --git a/internal/execution/responsealias/response_alias.go b/internal/execution/responsealias/response_alias.go index fd0695a92..045656ad2 100644 --- a/internal/execution/responsealias/response_alias.go +++ b/internal/execution/responsealias/response_alias.go @@ -6,6 +6,7 @@ import ( "bytes" "fmt" + "gpt-load/internal/channel/spec" "gpt-load/internal/dialect" "gpt-load/internal/protocol" ) @@ -16,8 +17,55 @@ func Needs(clientModel, upstreamModel string) bool { return clientModel != "" && upstreamModel != "" && clientModel != upstreamModel } +// ReasoningMode selects the reasoning-field handling for chat completions +// payloads. The zero value disables the rewrite. The rename modes are +// request-direction only; responses treat anything but duplicate as off. +type ReasoningMode string + +const ( + // ReasoningModeOff forwards reasoning fields untouched. + ReasoningModeOff ReasoningMode = spec.ReasoningAliasOff + // ReasoningModeReasoningToContent renames reasoning to reasoning_content + // in outbound requests. + ReasoningModeReasoningToContent ReasoningMode = spec.ReasoningAliasReasoningToContent + // ReasoningModeContentToReasoning renames reasoning_content to reasoning + // in outbound requests. + ReasoningModeContentToReasoning ReasoningMode = spec.ReasoningAliasContentToReasoning + // ReasoningModeDuplicate copies whichever spelling is present to the + // other one so both survive in responses. + ReasoningModeDuplicate ReasoningMode = spec.ReasoningAliasDuplicate +) + // RewriteJSON rewrites model fields in one protocol response object. func RewriteJSON(clientProtocol protocol.Protocol, body []byte, clientModel string) ([]byte, error) { + return rewriteJSON(clientProtocol, body, clientModel, ReasoningModeOff) +} + +// RewriteJSONReasoning rewrites one protocol response object, optionally +// emitting both reasoning spellings. An empty clientModel skips the model +// rewrite; the reasoning rewrite only applies to OpenAI chat completions +// objects. +func RewriteJSONReasoning( + clientProtocol protocol.Protocol, + body []byte, + clientModel string, + mode ReasoningMode, +) ([]byte, error) { + return rewriteJSON(clientProtocol, body, clientModel, mode) +} + +func rewriteJSON( + clientProtocol protocol.Protocol, + body []byte, + clientModel string, + mode ReasoningMode, +) ([]byte, error) { + if mode == ReasoningModeDuplicate && clientProtocol == protocol.OpenAICompletions { + body = normalizeOpenAIReasoning(body) + } + if clientModel == "" { + return body, nil + } rewriter, err := modelRewriter(clientProtocol) if err != nil { return nil, err @@ -34,12 +82,32 @@ func RewriteJSON(clientProtocol protocol.Protocol, body []byte, clientModel stri // blank-line delimiter is also accepted because some upstream bridges emit one // logical event per chunk without retaining the delimiter. func RewriteSSE(clientProtocol protocol.Protocol, data []byte, clientModel string) ([]byte, error) { + return rewriteSSE(clientProtocol, data, clientModel, ReasoningModeOff) +} + +// RewriteSSEReasoning rewrites SSE events like RewriteSSE and optionally +// emits both reasoning spellings in each data payload. +func RewriteSSEReasoning( + clientProtocol protocol.Protocol, + data []byte, + clientModel string, + mode ReasoningMode, +) ([]byte, error) { + return rewriteSSE(clientProtocol, data, clientModel, mode) +} + +func rewriteSSE( + clientProtocol protocol.Protocol, + data []byte, + clientModel string, + mode ReasoningMode, +) ([]byte, error) { var output bytes.Buffer remaining := data for len(remaining) > 0 { index, delimiterLength := firstSSEDelimiter(remaining) if index < 0 { - rewritten, err := rewriteSSEEvent(clientProtocol, remaining, clientModel) + rewritten, err := rewriteSSEEvent(clientProtocol, remaining, clientModel, mode) if err != nil { return nil, err } @@ -47,7 +115,7 @@ func RewriteSSE(clientProtocol protocol.Protocol, data []byte, clientModel strin break } eventEnd := index + delimiterLength - rewritten, err := rewriteSSEEvent(clientProtocol, remaining[:eventEnd], clientModel) + rewritten, err := rewriteSSEEvent(clientProtocol, remaining[:eventEnd], clientModel, mode) if err != nil { return nil, err } @@ -78,7 +146,12 @@ func modelRewriter(clientProtocol protocol.Protocol) (dialect.ModelRewriter, err } } -func rewriteSSEEvent(clientProtocol protocol.Protocol, event []byte, clientModel string) ([]byte, error) { +func rewriteSSEEvent( + clientProtocol protocol.Protocol, + event []byte, + clientModel string, + mode ReasoningMode, +) ([]byte, error) { lines := splitSSELines(event) dataValues := make([][]byte, 0, 1) firstDataLine := -1 @@ -98,7 +171,7 @@ func rewriteSSEEvent(clientProtocol protocol.Protocol, event []byte, clientModel if len(payload) == 0 || bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) { return bytes.Clone(event), nil } - rewritten, err := RewriteJSON(clientProtocol, payload, clientModel) + rewritten, err := rewriteJSON(clientProtocol, payload, clientModel, mode) if err != nil { return nil, err } diff --git a/web/src/app/resources/channels.ts b/web/src/app/resources/channels.ts index b07895279..292a4b8da 100644 --- a/web/src/app/resources/channels.ts +++ b/web/src/app/resources/channels.ts @@ -16,7 +16,7 @@ import { projectString, } from './projector' -export type ChannelFieldInputKind = 'text' | 'url' | 'secret' +export type ChannelFieldInputKind = 'text' | 'url' | 'secret' | 'select' export type ChannelConnectionType = 'api_key' | 'subscription' const authorizationMethods = ['browser_oauth', 'device_oauth', 'oauth_file'] as const export type ChannelAuthorizationMethod = (typeof authorizationMethods)[number] @@ -73,6 +73,7 @@ export interface ChannelFieldDto { key: string label: string input_kind: ChannelFieldInputKind + options: string[] required: boolean sensitive: boolean default_value: string | null @@ -122,12 +123,13 @@ const fieldFields = [ 'key', 'label', 'input_kind', + 'options', 'required', 'sensitive', 'default_value', ] as const const listFields = ['items', 'total'] as const -const inputKinds = ['text', 'url', 'secret'] as const +const inputKinds = ['text', 'url', 'secret', 'select'] as const const connectionTypes = ['api_key', 'subscription'] as const const credentialInputs = ['batch_text', 'authorization'] as const const connectionFields = ['type', 'credential_input', 'authorization_methods'] as const @@ -188,12 +190,15 @@ function projectChannelField(value: unknown): ChannelFieldDto { const inputKind = projectEnum(record.input_kind, inputKinds) const sensitive = projectBoolean(record.sensitive) const defaultValue = record.default_value === null ? null : projectString(record.default_value) + const options = projectArray(record.options ?? [], (option) => projectString(option)) if ( key !== key.trim() || !/^[a-z][a-z0-9_]*$/u.test(key) || label.trim().length === 0 || sensitive !== (inputKind === 'secret') || - (sensitive && defaultValue !== null) + (sensitive && defaultValue !== null) || + (inputKind === 'select' && options.length === 0) || + (inputKind !== 'select' && options.length > 0) ) { invalidResponse() } @@ -201,6 +206,7 @@ function projectChannelField(value: unknown): ChannelFieldDto { key, label, input_kind: inputKind, + options, required: projectBoolean(record.required), sensitive, default_value: defaultValue, diff --git a/web/src/features/groups/group-route.ts b/web/src/features/groups/group-route.ts index ecdbf3ae4..bb8243c3e 100644 --- a/web/src/features/groups/group-route.ts +++ b/web/src/features/groups/group-route.ts @@ -13,7 +13,7 @@ import { export type GroupTab = 'credentials' | 'models' | 'settings' export type GroupSettingsSection = - 'general' | 'routing' | 'runtime' | 'parameters' | 'headers' | 'danger' + 'general' | 'routing' | 'reasoning' | 'runtime' | 'parameters' | 'headers' | 'danger' export type GroupModelDiscoveryFilter = 'unadded' | 'all' export interface CredentialRouteState { @@ -42,6 +42,7 @@ const credentialPageSizes = new Set([2 const settingsSections = new Set([ 'general', 'routing', + 'reasoning', 'runtime', 'parameters', 'headers', diff --git a/web/src/features/groups/settings/GroupSettingsBaseForm.vue b/web/src/features/groups/settings/GroupSettingsBaseForm.vue index a6c0f4308..e0838e0c3 100644 --- a/web/src/features/groups/settings/GroupSettingsBaseForm.vue +++ b/web/src/features/groups/settings/GroupSettingsBaseForm.vue @@ -9,12 +9,14 @@ import type { GroupModelItemDto, } from '@/api/control/types' import type { ChannelFieldDto } from '@/app/resources/channels' +import AppSelect from '@/components/ui/AppSelect.vue' import GroupTestFields from '../GroupTestFields.vue' import AppSwitch from '@/components/ui/AppSwitch.vue' import { isValidPriceMultiplier } from '@/lib/price-multiplier' +import type { GroupSettingsSection } from '../group-route' const props = defineProps<{ - section: 'general' | 'routing' + section: Extract channelId: string connectionType: ConnectionType defaultBaseUrl: string @@ -98,10 +100,41 @@ function parameterHelp(field: ChannelFieldDto): string { if (field.key === 'base_url' && props.channelId === 'sub2api') { return t('group.settings.base.sub2ApiUrlDescription') } + if (field.key === 'reasoning_content_alias') { + return t('group.settings.base.reasoningResponseHelp') + } + if (field.key === 'request_reasoning_alias') { + return t('group.settings.base.reasoningRequestHelp') + } return t('group.settings.base.urlWarning') } +const reasoningOptionLabels: Record = { + off: 'group.settings.base.reasoningOptionOff', + reasoning_to_content: 'group.settings.base.reasoningOptionToContent', + reasoning_content_to_reasoning: 'group.settings.base.reasoningOptionToReasoning', + duplicate: 'group.settings.base.reasoningOptionDuplicate', +} + +function selectOptions(field: ChannelFieldDto): { value: string; label: string }[] { + return field.options.map((option) => ({ + value: option, + label: reasoningOptionLabels[option] ? t(reasoningOptionLabels[option]) : option, + })) +} + +function selectValue(field: ChannelFieldDto): string { + const raw = props.params[field.key] ?? '' + return field.options.includes(raw) ? raw : (field.options[0] ?? '') +} + function parameterLabel(field: ChannelFieldDto): string { + if (field.key === 'reasoning_content_alias') { + return t('group.settings.base.reasoningResponseLabel') + } + if (field.key === 'request_reasoning_alias') { + return t('group.settings.base.reasoningRequestLabel') + } if (field.key !== 'base_url') return field.label return t('common.upstreamUrl.label') } @@ -110,6 +143,16 @@ function parameterPlaceholder(field: ChannelFieldDto): string | undefined { if (field.input_kind !== 'url') return undefined return field.key === 'base_url' ? defaultBaseUrls.value[0] || 'https://' : 'https://' } + +const reasoningFieldKeys = ['reasoning_content_alias', 'request_reasoning_alias'] as const +const reasoningFields = computed(() => + props.paramFields.filter((field) => reasoningFieldKeys.some((key) => field.key === key)), +) +// The reasoning rename selects render in their own settings section; the +// general grid shows everything else. +const generalParamFields = computed(() => + props.paramFields.filter((field) => !reasoningFieldKeys.some((key) => field.key === key)), +)