From 15ac3c667797b5cb548fc190d750f701bbfc275f Mon Sep 17 00:00:00 2001 From: anon0v0 <221615165+anon0v0@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:23:17 +0800 Subject: [PATCH 1/2] feat: add opt-in Playground tool schema fallback --- .env.example | 4 + README.md | 1 + README_en.md | 1 + docs/protocol.md | 15 +++ internal/aistudio/schema_fallback.go | 115 ++++++++++++++++ internal/aistudio/schema_fallback_test.go | 151 ++++++++++++++++++++++ internal/aistudio/service.go | 12 +- internal/api/admin.go | 1 + internal/app/admin.go | 2 + internal/app/admin_config_test.go | 31 +++++ internal/app/lifecycle.go | 3 +- internal/app/runtime.go | 1 + internal/config/config.go | 49 ++++--- internal/config/schema_fallback_test.go | 53 ++++++++ web/src/components/SettingsPanel.vue | 16 +++ web/src/i18n.ts | 6 + web/src/types.ts | 1 + 17 files changed, 440 insertions(+), 22 deletions(-) create mode 100644 internal/aistudio/schema_fallback.go create mode 100644 internal/aistudio/schema_fallback_test.go create mode 100644 internal/config/schema_fallback_test.go diff --git a/.env.example b/.env.example index d6b0e27..c37e380 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,10 @@ UPSTREAM_CHANNELS=playground,build # 非流式请求先使用 Build 原生单次调用,不可用时回退流式并记录原因 BUILD_NATIVE_NONSTREAM=true +# 仅在未启用 Build 时,有损地将工具参数缺省类型收窄为 string;默认保持严格校验 +# 不改写 response schema、原生布尔 Schema、混合类型与禁止约束;修改后重启生成服务 +PLAYGROUND_SCHEMA_FALLBACK=false + # WAA 后端:camoufox 使用 Camoufox 页面,go 在服务进程内运行 WAA_BACKEND=camoufox diff --git a/README.md b/README.md index 6f69378..d3627bf 100644 --- a/README.md +++ b/README.md @@ -440,6 +440,7 @@ cp .env.example .env | `ROUTING_STRATEGY` | `round-robin` | `round-robin` 轮询;`fill-first` 账号粘性优先 | | `UPSTREAM_CHANNELS` | `playground,build` | 生成请求使用的上游通道,可只保留其一 | | `BUILD_NATIVE_NONSTREAM` | `true` | 非流式请求优先使用 Build 原生单次调用;不可用时回退流式并记录原因 | +| `PLAYGROUND_SCHEMA_FALLBACK` | `false` | Build 未启用时,对工具参数缺省类型执行有损的旧版 string 回退;不改写结构化输出或混合/禁止约束,详见[兼容边界](docs/protocol.md#playground-schema-fallback) | | `WAA_BACKEND` | `camoufox` | `camoufox` 在 Camoufox 页面运行 WAA;`go` 在服务进程内运行 WAA,不下载也不启动 Camoufox | | `TEMPORARY_CHAT` | `false` | WAA 预热页是否使用临时对话 | diff --git a/README_en.md b/README_en.md index b1d74cc..a230934 100644 --- a/README_en.md +++ b/README_en.md @@ -440,6 +440,7 @@ cp .env.example .env | `ROUTING_STRATEGY` | `round-robin` | `round-robin` rotates accounts; `fill-first` reuses the first available account | | `UPSTREAM_CHANNELS` | `playground,build` | Upstream channels for generation requests; either one can be used alone | | `BUILD_NATIVE_NONSTREAM` | `true` | Prefer native Build unary calls for non-streaming requests; log any fallback to stream collection | +| `PLAYGROUND_SCHEMA_FALLBACK` | `false` | Opt-in, lossy legacy string defaults for missing tool types, only when Build is disabled. Never rewrites response schemas or mixed/negative constraints; restart the generation service after changing. Prefer explicit tool types. | | `WAA_BACKEND` | `camoufox` | `camoufox` runs WAA in a Camoufox page; `go` runs WAA inside the service process and neither downloads nor starts Camoufox | | `TEMPORARY_CHAT` | `false` | Use Temporary Chat for the WAA prewarm page | diff --git a/docs/protocol.md b/docs/protocol.md index 38bf660..866e525 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -834,6 +834,21 @@ Schema 归一化规则: | array 缺少 `items` | `prefixItems` 中带类型的项组成 `anyOf`;其余使用开放元素节点 | | 其他 Schema 字段 | 返回 `400 invalid_request` / `INVALID_ARGUMENT` | + + +**Playground-only 工具兼容回退(显式启用、有损)** + +v0.2.4 默认保留开放 Schema 的真实语义,因此 `UPSTREAM_CHANNELS=playground` 下,带 `{"type":"array"}` 等工具参数的请求可能返回“此 JSON Schema 需要 Build 通道”。优先在客户端显式声明真实类型(例如确实为字符串数组时添加 `"items":{"type":"string"}`),或使用有资格的 Build 通道。 + +无法立即修改旧客户端时,可设置 `PLAYGROUND_SCHEMA_FALLBACK=true` 并重启生成服务(也可在设置页保存后停止、启动生成服务)。该开关默认关闭,仅在 Build **未启用** 时,将工具参数的无约束缺省节点和未提供 `items` 的数组元素按旧版 string 默认值编码。这会收窄合法参数范围,并非等价 Schema 转换;每次发生回退都会写入 WARN。兼容处理使用独立请求副本,不修改调用者持有的工具声明、聊天正文或工具返回数据。 + +- 不改写结构化输出 `response schema`;不近似混合类型、数值约束或否定约束。参数中的原生布尔 Schema(如 `items:true/false`、`properties.x:true/false`)不做此回退,零参数根 `{}`/`true` 仍沿用已有 object 归一化;`{"type":"boolean"}` 的普通布尔参数不受影响。 +- 开启 Build 后保持原生 Schema 路由,即使 Build 忙碌、冷却或没有可用账户也不因此降级;不会自动打开 Build 或跨通道消耗额度。`tool_choice:none` 忽略的声明不会触发回退。 +- 一项声明无法回退时,拒绝整个请求,不部分修改、不吞掉 `false`、`not:true` 等禁止约束,也不删除 required 属性。 +- 恢复严格模式:将 `PLAYGROUND_SCHEMA_FALLBACK=false` 保存并重启生成服务,无需迁移账号或聊天数据。此设置不能保证所有旧客户端 Schema 都可用于 Playground;超出边界时应修正工具定义或使用 Build。 + +例如 `{"type":"object","properties":{"tags":{"type":"array"}}}` 在此模式下会变为 `{"type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}}}}`。只有接受字符串元素限制的工具才适合开启。 + AI Studio 网页协议使用自动函数调用:auto 请求只携带根 field 7 的函数声明,由模型决定是否调用;none 省略 tools。客户端工具选择映射如下: | 公开协议 | 接受 | 返回 400 | diff --git a/internal/aistudio/schema_fallback.go b/internal/aistudio/schema_fallback.go new file mode 100644 index 0000000..4805399 --- /dev/null +++ b/internal/aistudio/schema_fallback.go @@ -0,0 +1,115 @@ +package aistudio + +import ( + "encoding/json" + "fmt" + "slices" + "sort" + "strings" +) + +// prepareSchemaRequest never changes Build routing or response schemas. The opt-in +// fallback narrows only missing tool parameter types, on a private request copy. +func (s *PooledService) prepareSchemaRequest(request GenerateRequest) (GenerateRequest, bool, error) { + if s.pool.BuildEnabled() || !requestNeedsBuildSchema(request) { + return request, false, nil + } + if !s.SchemaFallback { + return request, false, fmt.Errorf("%w: 此 JSON Schema 需要 Build 通道;请显式声明工具参数类型,或评估 PLAYGROUND_SCHEMA_FALLBACK 的有损兼容模式", ErrInvalidArgument) + } + original := request + request.Tools.Functions = slices.Clone(request.Tools.Functions) + if request.Tools.ToolConfig.Mode != "none" { + for index, declaration := range request.Tools.Functions { + raw, err := normalizeFunctionParameters(declaration.Parameters) + if err != nil { + return original, false, fmt.Errorf("%w: function %d parameters: %w", ErrInvalidArgument, index, err) + } + wire, err := encodeJSONSchema(raw) + if err != nil { + return original, false, fmt.Errorf("%w: function %d parameters: %w", ErrInvalidArgument, index, err) + } + if !schemaHasBooleanNode(raw) && !schemaWireNeedsBuild(wire) { + continue + } + parameters, err := fallbackToolSchema(raw) + if err != nil { + return original, false, fmt.Errorf("%w: function %d parameters 无法回退 Playground: %w", ErrInvalidArgument, index, err) + } + request.Tools.Functions[index].Parameters = parameters + } + } + if requestNeedsBuildSchema(request) { + return original, false, fmt.Errorf("%w: 此 JSON Schema 需要 Build 通道;兼容回退不改写 response schema、组合类型或否定约束", ErrInvalidArgument) + } + return request, true, nil +} + +// fallbackToolSchema restores the legacy string default only at positive, +// unconstrained tool nodes. Boolean schemas and numeric/compound/negative +// constraints are not approximated. Ordinary boolean-typed parameters are valid. +func fallbackToolSchema(raw json.RawMessage) (json.RawMessage, error) { + var schema map[string]json.RawMessage + if json.Unmarshal(raw, &schema) != nil || schema == nil { + return nil, fmt.Errorf("原生布尔 Schema 需要 Build 通道") + } + if err := normalizeConstAndMetadata(schema); err != nil { + return nil, err + } + if err := normalizeNullableVariants(schema); err != nil { + return nil, err + } + if err := normalizeNotSchema(schema); err != nil { + return nil, err + } + _, hadItems := schema["items"] + if err := normalizeImplicitType(schema); err != nil { + return nil, err + } + if _, typed := schema["type"]; !typed { + for key := range schema { + switch key { + case "description", "nullable", "example", "default", "$schema": + default: + return nil, fmt.Errorf("含 %s 的无类型节点需要显式类型或 Build 通道", key) + } + } + schema["type"] = json.RawMessage(`"string"`) + } + var typeName string + _ = json.Unmarshal(schema["type"], &typeName) + if strings.EqualFold(typeName, "array") && !hadItems && schema["prefixItems"] == nil { + schema["items"] = json.RawMessage(`{"type":"string"}`) + } + if items, ok := schema["items"]; ok { + value, err := fallbackToolSchema(items) + if err != nil { + return nil, fmt.Errorf("items: %w", err) + } + schema["items"] = value + } + if rawProperties, ok := schema["properties"]; ok { + var properties map[string]json.RawMessage + if json.Unmarshal(rawProperties, &properties) != nil || properties == nil { + return nil, fmt.Errorf("properties 必须是 JSON object") + } + names := make([]string, 0, len(properties)) + for name := range properties { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + value, err := fallbackToolSchema(properties[name]) + if err != nil { + return nil, fmt.Errorf("properties.%s: %w", name, err) + } + properties[name] = value + } + schema["properties"], _ = json.Marshal(properties) + } + result, err := json.Marshal(schema) + if err == nil { + _, err = encodeJSONSchema(result) + } + return result, err +} diff --git a/internal/aistudio/schema_fallback_test.go b/internal/aistudio/schema_fallback_test.go new file mode 100644 index 0000000..f70b18b --- /dev/null +++ b/internal/aistudio/schema_fallback_test.go @@ -0,0 +1,151 @@ +package aistudio + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" +) + +func fallbackTestRequest(raw string) GenerateRequest { + return GenerateRequest{Model: "test-model", Tools: Tools{Functions: []FunctionDeclaration{{ + Name: "get_weather", Parameters: json.RawMessage(raw), + }}}} +} + +// TestPlaygroundSchemaStrictBaseline reproduces v0.2.4 without an upstream call. +func TestPlaygroundSchemaStrictBaseline(t *testing.T) { + request := fallbackTestRequest(`{"type":"object","properties":{"location":{"type":"string"},"forecast_days":{"type":"array"}},"required":["location"]}`) + if !requestNeedsBuildSchema(request) { + t.Fatal("missing array items must reproduce the native-schema routing condition") + } + service := &PooledService{pool: NewAccountPool(nil, 1)} + _, err := service.Generate(context.Background(), request) + if !errors.Is(err, ErrInvalidArgument) || !strings.Contains(err.Error(), "此 JSON Schema 需要 Build 通道") { + t.Fatalf("expected the pre-network schema error, got %v", err) + } +} + +func TestPlaygroundSchemaFallback(t *testing.T) { + service := &PooledService{pool: NewAccountPool(nil, 1), SchemaFallback: true} + for _, test := range []struct{ name, input, want string }{ + {"missing items", `{"type":"array"}`, `{"type":"array","items":{"type":"string"}}`}, + {"uppercase array", `{"type":"ARRAY"}`, `{"type":"ARRAY","items":{"type":"string"}}`}, + {"nested open property", `{"type":"object","properties":{"value":{}}}`, `{"type":"object","properties":{"value":{"type":"string"}}}`}, + {"typed boolean and nested array", `{"type":"object","properties":{"enabled":{"type":"boolean"},"rows":{"type":"array","items":{"type":"array"}}},"required":["enabled"],"example":{"enabled":false}}`, `{"type":"object","properties":{"enabled":{"type":"boolean"},"rows":{"type":"array","items":{"type":"array","items":{"type":"string"}}}},"required":["enabled"],"example":{"enabled":false}}`}, + {"optional nulls and zero", `{"type":"array","items":null,"minItems":0,"nullable":false,"example":null}`, `{"type":"array","items":{"type":"string"},"minItems":0,"nullable":false,"example":null}`}, + } { + t.Run(test.name, func(t *testing.T) { + request := fallbackTestRequest(test.input) + request.Contents = []Content{{Role: RoleUser, Parts: []Part{{Text: "unchanged"}}}} + before, _ := json.Marshal(request) + got, fallback, err := service.prepareSchemaRequest(request) + if err != nil || !fallback || requestNeedsBuildSchema(got) { + t.Fatalf("fallback=%v err=%v request=%+v", fallback, err, got) + } + var actual, expected any + _ = json.Unmarshal(got.Tools.Functions[0].Parameters, &actual) + _ = json.Unmarshal([]byte(test.want), &expected) + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("got %s, want %s", got.Tools.Functions[0].Parameters, test.want) + } + wire, err := encodeFunctionDeclaration(got.Tools.Functions[0]) + if err != nil || len(wire) == 0 { + t.Fatalf("Playground declaration encoding failed: %v", err) + } + after, _ := json.Marshal(request) + if string(before) != string(after) || !reflect.DeepEqual(request.Contents, got.Contents) { + t.Fatal("caller-owned request changed") + } + again, fallback, err := service.prepareSchemaRequest(got) + if err != nil || fallback || !reflect.DeepEqual(got, again) { + t.Fatal("fallback must be idempotent") + } + }) + } + // With no accounts, successful schema preparation must reach normal scheduling. + _, err := service.Generate(context.Background(), fallbackTestRequest(`{"type":"array"}`)) + if !errors.Is(err, ErrNoEligibleAccount) { + t.Fatalf("Generate did not reach scheduling: %v", err) + } +} + +func TestPlaygroundSchemaFallbackRejectsUnsupported(t *testing.T) { + service := &PooledService{pool: NewAccountPool(nil, 1), SchemaFallback: true} + for _, raw := range []string{ + `{"type":"array","items":true}`, + `{"type":"array","items":false}`, + `{"type":"object","properties":{"deny":false},"required":["deny"]}`, + `{"type":["string","integer"]}`, + `{"anyOf":[{"type":"string"},{"type":"integer"}]}`, + `{"type":"object","properties":{"v":{"not":{"description":"negative"}}}}`, + `{"type":"object","properties":{"value":{"minimum":0}}}`, + `{"type":"object","properties":{"open":{},"deny":{"not":true}}}`, + `{"type":"object","properties":{"open":{},"deny":{"not":{}}}}`, + } { + t.Run(raw, func(t *testing.T) { + request := fallbackTestRequest(raw) + // A convertible sibling must not be committed if any declaration fails. + request.Tools.Functions = append(fallbackTestRequest(`{"type":"array"}`).Tools.Functions, request.Tools.Functions...) + got, fallback, err := service.prepareSchemaRequest(request) + if !errors.Is(err, ErrInvalidArgument) || fallback || !reflect.DeepEqual(got, request) || string(request.Tools.Functions[0].Parameters) != `{"type":"array"}` { + t.Fatalf("unsupported schema accepted or caller mutated: fallback=%v err=%v", fallback, err) + } + }) + } +} + +func TestPlaygroundSchemaFallbackBoundaries(t *testing.T) { + service := &PooledService{pool: NewAccountPool(nil, 1), SchemaFallback: true} + t.Run("Build enabled is never downgraded even without eligible accounts", func(t *testing.T) { + service.pool.SetUpstreamChannels([]Channel{ChannelPlayground, ChannelBuild}) + defer service.pool.SetUpstreamChannels([]Channel{ChannelPlayground}) + request := fallbackTestRequest(`{"type":"array"}`) + got, fallback, err := service.prepareSchemaRequest(request) + if err != nil || fallback || !reflect.DeepEqual(got, request) { + t.Fatalf("Build schema changed: %v", err) + } + }) + t.Run("response schema is never narrowed", func(t *testing.T) { + request := fallbackTestRequest(`{"type":"array"}`) + request.Config.ResponseSchema = json.RawMessage(`{"type":"array"}`) + got, fallback, err := service.prepareSchemaRequest(request) + if !errors.Is(err, ErrInvalidArgument) || fallback || !reflect.DeepEqual(got, request) { + t.Fatalf("response schema changed: %v", err) + } + }) + t.Run("disabled tools ignored", func(t *testing.T) { + request := fallbackTestRequest(`{"type":"array"}`) + request.Tools.ToolConfig.Mode = "none" + got, fallback, err := service.prepareSchemaRequest(request) + if err != nil || fallback || !reflect.DeepEqual(got, request) { + t.Fatalf("disabled tools changed: %v", err) + } + }) + for _, raw := range []string{`{}`, `true`, `null`, `{"type":"object","properties":{"enabled":{"type":"boolean"}}}`, `{"type":"array","items":{"type":"string"}}`} { + request := fallbackTestRequest(raw) + got, fallback, err := service.prepareSchemaRequest(request) + if err != nil || fallback || !reflect.DeepEqual(got, request) { + t.Fatalf("ordinary/zero-argument tool changed: %s err=%v", raw, err) + } + } +} + +func TestPlaygroundSchemaFallbackConcurrent(t *testing.T) { + request := fallbackTestRequest(`{"type":"object","properties":{"tags":{"type":"array"}}}`) + service := &PooledService{pool: NewAccountPool(nil, 1), SchemaFallback: true} + strict := &PooledService{pool: service.pool} + for range 12 { + t.Run("shared request", func(t *testing.T) { + t.Parallel() + if got, fallback, err := service.prepareSchemaRequest(request); err != nil || !fallback || requestNeedsBuildSchema(got) { + t.Errorf("fallback failed: %v", err) + } + if _, _, err := strict.prepareSchemaRequest(request); !errors.Is(err, ErrInvalidArgument) { + t.Errorf("strict request was affected by another caller: %v", err) + } + }) + } +} diff --git a/internal/aistudio/service.go b/internal/aistudio/service.go index 6501762..9767b52 100644 --- a/internal/aistudio/service.go +++ b/internal/aistudio/service.go @@ -21,6 +21,8 @@ type PooledService struct { client *Client // BuildNativeNonstream 让非流式请求优先使用具备资格的 Build 通道 BuildNativeNonstream bool + // SchemaFallback opts into lossy legacy tool schemas when Build is disabled. + SchemaFallback bool } // PoolRequestContextProvider 从租约账户读取协议上下文 @@ -554,15 +556,19 @@ func (s *PooledService) Generate(ctx context.Context, request GenerateRequest) ( if modelID == "" { return nil, fmt.Errorf("%w: GenerateContent model 不能为空", ErrInvalidArgument) } + request, fallback, err := s.prepareSchemaRequest(request) + if err != nil { + return nil, err + } + if fallback { + slog.Warn("Playground 工具 Schema 兼容回退:缺省类型按 string 收窄;原请求未修改") + } resourceID, err := s.pool.ResourceIDForContents(ctx, request.Contents) if err != nil { return nil, err } var channel Channel nativeSchema := requestNeedsBuildSchema(request) - if nativeSchema && !s.pool.BuildEnabled() { - return nil, fmt.Errorf("%w: 此 JSON Schema 需要 Build 通道", ErrInvalidArgument) - } if nativeSchema || request.Unary && s.BuildNativeNonstream { channel = ChannelBuild } diff --git a/internal/api/admin.go b/internal/api/admin.go index 1d133ee..10da6c0 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -181,6 +181,7 @@ type RuntimeConfig struct { AdminPasswordSet bool `json:"admin_password_set"` SavedAdminPassword string `json:"-"` BuildNativeNonstream bool `json:"build_native_nonstream"` + SchemaFallback bool `json:"playground_schema_fallback"` AuthStates string `json:"auth_states"` ListenAddr string `json:"listen_addr"` APIKey string `json:"proxy_api_key"` diff --git a/internal/app/admin.go b/internal/app/admin.go index d89bb38..89d52a8 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -726,6 +726,7 @@ func (admin *runtimeAdmin) UpdateRuntimeConfig(_ context.Context, value api.Runt cfg := config.Config{ AdminAuthEnabled: value.AdminAuthEnabled, AdminUsername: strings.TrimSpace(value.AdminUsername), AdminPassword: password, BuildNativeNonstream: value.BuildNativeNonstream, + SchemaFallback: value.SchemaFallback, AuthStates: value.AuthStates, ListenAddr: value.ListenAddr, ProxyAPIKey: value.APIKey, Proxy: value.Proxy, InitTimeout: initTimeout, RequestTimeout: requestTimeout, WarmWorkerLimit: value.WarmWorkerLimit, MaxActiveWorkers: value.MaxActiveWorkers, @@ -1314,6 +1315,7 @@ func runtimeConfigDTO(cfg config.Config) api.RuntimeConfig { AdminAuthEnabled: cfg.AdminAuthEnabled, AdminUsername: cfg.AdminUsername, AdminPasswordSet: cfg.AdminPassword != "", SavedAdminPassword: cfg.AdminPassword, BuildNativeNonstream: cfg.BuildNativeNonstream, + SchemaFallback: cfg.SchemaFallback, AuthStates: cfg.AuthStates, ListenAddr: cfg.ListenAddr, APIKey: cfg.ProxyAPIKey, ActiveListenAddr: cfg.ListenAddr, ActiveAPIKey: cfg.ProxyAPIKey, Proxy: cfg.Proxy, InitTimeout: cfg.InitTimeout.String(), RequestTimeout: cfg.RequestTimeout.String(), diff --git a/internal/app/admin_config_test.go b/internal/app/admin_config_test.go index f191e68..3f07e60 100644 --- a/internal/app/admin_config_test.go +++ b/internal/app/admin_config_test.go @@ -62,3 +62,34 @@ func TestAdminConfig(t *testing.T) { t.Fatal(err) } } + +func TestSchemaFallbackRuntimeConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), ".env") + active := config.Default() + if err := active.Save(path); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + admin := &runtimeAdmin{configPath: path, requests: newRequestRegistry(ctx)} + value := runtimeConfigDTO(active) + if !sameDataConfig(value, active, dataConfigOverrides{}) { + t.Fatal("unchanged config requires restart") + } + value.SchemaFallback = true + saved, err := admin.UpdateRuntimeConfig(ctx, value) + if err != nil || !saved.SchemaFallback { + t.Fatalf("fallback not saved: %v", err) + } + if sameDataConfig(saved, active, dataConfigOverrides{}) { + t.Fatal("fallback change must require a data-plane restart") + } + loaded, err := admin.RuntimeConfig(ctx) + if err != nil || !loaded.SchemaFallback { + t.Fatalf("fallback not read back: %v", err) + } + loaded.TemporaryChat = !loaded.TemporaryChat + if saved, err = admin.UpdateRuntimeConfig(ctx, loaded); err != nil || !saved.SchemaFallback { + t.Fatalf("unrelated settings save lost fallback: %v", err) + } +} diff --git a/internal/app/lifecycle.go b/internal/app/lifecycle.go index c903775..e72a402 100644 --- a/internal/app/lifecycle.go +++ b/internal/app/lifecycle.go @@ -462,6 +462,7 @@ func sameDataConfig(value api.RuntimeConfig, active config.Config, overrides dat RoutingStrategy: value.RoutingStrategy, UpstreamChannels: value.UpstreamChannels, WAABackend: value.WAABackend, BuildNativeNonstream: value.BuildNativeNonstream, + SchemaFallback: value.SchemaFallback, } overrides.Apply(&saved) return saved.AuthStates == active.AuthStates && saved.Proxy == active.Proxy && @@ -471,7 +472,7 @@ func sameDataConfig(value api.RuntimeConfig, active config.Config, overrides dat saved.PerAccountConcurrency == active.PerAccountConcurrency && saved.TemporaryChat == active.TemporaryChat && saved.RoutingStrategy == active.RoutingStrategy && slices.Equal(saved.UpstreamChannels, active.UpstreamChannels) && saved.WAABackend == active.WAABackend && - saved.BuildNativeNonstream == active.BuildNativeNonstream + saved.BuildNativeNonstream == active.BuildNativeNonstream && saved.SchemaFallback == active.SchemaFallback } var _ aistudio.Service = (*runtimeManager)(nil) diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 15ee5c9..25f8f2e 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -96,6 +96,7 @@ func newRuntime( return nil, nil, nil, errors.Join(err, workers.Close()) } pooled.BuildNativeNonstream = cfg.BuildNativeNonstream + pooled.SchemaFallback = cfg.SchemaFallback service := newTrackedService(lifecycle, pooled, pool, requests, workers, cfg.RequestTimeout) service.buildNativeNonstream = cfg.BuildNativeNonstream admin := newRuntimeAdmin(lifecycle, pool, store, service, requests, login, workers, headers, cfg) diff --git a/internal/config/config.go b/internal/config/config.go index 84d8826..4798700 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,7 @@ var configKeys = [...]string{ "ADMIN_USERNAME", "ADMIN_PASSWORD", "BUILD_NATIVE_NONSTREAM", + "PLAYGROUND_SCHEMA_FALLBACK", } // upstreamChannels 表示生成请求可启用的上游通道 @@ -61,6 +62,7 @@ type Config struct { AdminUsername string `json:"admin_username"` AdminPassword string `json:"-"` BuildNativeNonstream bool `json:"build_native_nonstream"` + SchemaFallback bool `json:"playground_schema_fallback"` AuthStates string `json:"auth_states"` ListenAddr string `json:"listen_addr"` ProxyAPIKey string `json:"proxy_api_key"` @@ -127,6 +129,12 @@ func Load(path string) (Config, error) { return Config{}, fmt.Errorf("BUILD_NATIVE_NONSTREAM 必须是 true 或 false") } } + if value, ok := values["PLAYGROUND_SCHEMA_FALLBACK"]; ok { + cfg.SchemaFallback, err = strconv.ParseBool(strings.TrimSpace(value)) + if err != nil { + return Config{}, fmt.Errorf("PLAYGROUND_SCHEMA_FALLBACK 必须是 true 或 false") + } + } if value, ok := values["AISTUDIO_AUTH_STATES"]; ok { cfg.AuthStates = strings.TrimSpace(value) } @@ -202,24 +210,25 @@ func (c Config) Save(path string) error { return err } values := map[string]string{ - "ADMIN_AUTH_ENABLED": strconv.FormatBool(c.AdminAuthEnabled), - "ADMIN_USERNAME": c.AdminUsername, - "ADMIN_PASSWORD": c.AdminPassword, - "BUILD_NATIVE_NONSTREAM": strconv.FormatBool(c.BuildNativeNonstream), - "AISTUDIO_AUTH_STATES": c.AuthStates, - "LISTEN_ADDR": c.ListenAddr, - "PROXY_API_KEY": c.ProxyAPIKey, - "PROXY": c.Proxy, - "INIT_TIMEOUT": c.InitTimeout.String(), - "REQUEST_TIMEOUT": c.RequestTimeout.String(), - "WARM_WORKER_LIMIT": strconv.Itoa(c.WarmWorkerLimit), - "MAX_ACTIVE_WORKERS": strconv.Itoa(c.MaxActiveWorkers), - "WARM_STARTUP_CONCURRENCY": strconv.Itoa(c.WarmStartupConcurrency), - "PER_ACCOUNT_CONCURRENCY": strconv.Itoa(c.PerAccountConcurrency), - "ROUTING_STRATEGY": c.RoutingStrategy, - "UPSTREAM_CHANNELS": strings.Join(c.UpstreamChannels, ","), - "TEMPORARY_CHAT": strconv.FormatBool(c.TemporaryChat), - "WAA_BACKEND": c.WAABackend, + "ADMIN_AUTH_ENABLED": strconv.FormatBool(c.AdminAuthEnabled), + "ADMIN_USERNAME": c.AdminUsername, + "ADMIN_PASSWORD": c.AdminPassword, + "BUILD_NATIVE_NONSTREAM": strconv.FormatBool(c.BuildNativeNonstream), + "PLAYGROUND_SCHEMA_FALLBACK": strconv.FormatBool(c.SchemaFallback), + "AISTUDIO_AUTH_STATES": c.AuthStates, + "LISTEN_ADDR": c.ListenAddr, + "PROXY_API_KEY": c.ProxyAPIKey, + "PROXY": c.Proxy, + "INIT_TIMEOUT": c.InitTimeout.String(), + "REQUEST_TIMEOUT": c.RequestTimeout.String(), + "WARM_WORKER_LIMIT": strconv.Itoa(c.WarmWorkerLimit), + "MAX_ACTIVE_WORKERS": strconv.Itoa(c.MaxActiveWorkers), + "WARM_STARTUP_CONCURRENCY": strconv.Itoa(c.WarmStartupConcurrency), + "PER_ACCOUNT_CONCURRENCY": strconv.Itoa(c.PerAccountConcurrency), + "ROUTING_STRATEGY": c.RoutingStrategy, + "UPSTREAM_CHANNELS": strings.Join(c.UpstreamChannels, ","), + "TEMPORARY_CHAT": strconv.FormatBool(c.TemporaryChat), + "WAA_BACKEND": c.WAABackend, } var output strings.Builder @@ -315,6 +324,7 @@ func (c Config) MarshalJSON() ([]byte, error) { AdminAuthEnabled bool `json:"admin_auth_enabled"` AdminUsername string `json:"admin_username"` BuildNativeNonstream bool `json:"build_native_nonstream"` + SchemaFallback bool `json:"playground_schema_fallback"` AuthStates string `json:"auth_states"` ListenAddr string `json:"listen_addr"` ProxyAPIKey string `json:"proxy_api_key"` @@ -334,6 +344,7 @@ func (c Config) MarshalJSON() ([]byte, error) { AdminAuthEnabled: c.AdminAuthEnabled, AdminUsername: c.AdminUsername, BuildNativeNonstream: c.BuildNativeNonstream, + SchemaFallback: c.SchemaFallback, AuthStates: c.AuthStates, ListenAddr: c.ListenAddr, ProxyAPIKey: c.ProxyAPIKey, @@ -358,6 +369,7 @@ func (c *Config) UnmarshalJSON(data []byte) error { AdminUsername string `json:"admin_username"` AdminPassword string `json:"admin_password"` BuildNativeNonstream bool `json:"build_native_nonstream"` + SchemaFallback bool `json:"playground_schema_fallback"` AuthStates string `json:"auth_states"` ListenAddr string `json:"listen_addr"` ProxyAPIKey string `json:"proxy_api_key"` @@ -390,6 +402,7 @@ func (c *Config) UnmarshalJSON(data []byte) error { AdminUsername: strings.TrimSpace(value.AdminUsername), AdminPassword: value.AdminPassword, BuildNativeNonstream: value.BuildNativeNonstream, + SchemaFallback: value.SchemaFallback, AuthStates: strings.TrimSpace(value.AuthStates), ListenAddr: strings.TrimSpace(value.ListenAddr), ProxyAPIKey: strings.TrimSpace(value.ProxyAPIKey), diff --git a/internal/config/schema_fallback_test.go b/internal/config/schema_fallback_test.go new file mode 100644 index 0000000..c5c439b --- /dev/null +++ b/internal/config/schema_fallback_test.go @@ -0,0 +1,53 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestSchemaFallbackConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), ".env") + cfg := Default() + if cfg.SchemaFallback { + t.Fatal("lossy fallback must be opt-in") + } + for _, enabled := range []bool{true, false} { + cfg.SchemaFallback = enabled + if err := cfg.Save(path); err != nil { + t.Fatal(err) + } + loaded, err := Load(path) + if err != nil || loaded.SchemaFallback != enabled { + t.Fatalf("env round trip: %v", err) + } + raw, err := json.Marshal(loaded) + if err != nil { + t.Fatal(err) + } + var restored Config + if err := json.Unmarshal(raw, &restored); err != nil || restored.SchemaFallback != enabled { + t.Fatalf("JSON round trip: %v", err) + } + } + t.Setenv("PLAYGROUND_SCHEMA_FALLBACK", "true") + loaded, err := Load(path) + if err != nil || !loaded.SchemaFallback { + t.Fatalf("environment override: %v", err) + } + t.Setenv("PLAYGROUND_SCHEMA_FALLBACK", "invalid") + if _, err := Load(path); err == nil { + t.Fatal("invalid boolean accepted") + } + if err := os.Unsetenv("PLAYGROUND_SCHEMA_FALLBACK"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("UPSTREAM_CHANNELS=playground\n"), 0o600); err != nil { + t.Fatal(err) + } + loaded, err = Load(path) + if err != nil || loaded.SchemaFallback { + t.Fatalf("old env must remain strict: %v", err) + } +} diff --git a/web/src/components/SettingsPanel.vue b/web/src/components/SettingsPanel.vue index 9baf361..c1b20d9 100644 --- a/web/src/components/SettingsPanel.vue +++ b/web/src/components/SettingsPanel.vue @@ -26,6 +26,7 @@ const form = reactive({ admin_username: 'admin', admin_password_set: false, build_native_nonstream: true, + playground_schema_fallback: false, auth_states: 'auth', listen_addr: '127.0.0.1:2048', proxy_api_key: '', @@ -371,6 +372,21 @@ async function saveConfig(): Promise { {{ t('settings.buildNative') }} + +