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') }}
+
+