Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ UPSTREAM_CHANNELS=playground,build
# 非流式请求先使用 Build 原生单次调用,不可用时回退流式并记录原因
BUILD_NATIVE_NONSTREAM=true

# 仅在 Build 未启用时执行有损旧版回退:缺省类型补 string,联合根类型取首个明确分支
# 保留联合分支约束;不改写 response schema 或禁止约束;修改后重启生成服务
PLAYGROUND_SCHEMA_FALLBACK=false

# WAA 后端:camoufox 使用 Camoufox 页面,go 在服务进程内运行
WAA_BACKEND=camoufox

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 未启用时,对工具缺省类型及明确的联合类型执行有损旧版回退;保留分支约束,不改写结构化输出或禁止约束,详见[兼容边界](docs/protocol.md#playground-schema-fallback) |
| `WAA_BACKEND` | `camoufox` | `camoufox` 在 Camoufox 页面运行 WAA;`go` 在服务进程内运行 WAA,不下载也不启动 Camoufox |
| `TEMPORARY_CHAT` | `false` | WAA 预热页是否使用临时对话 |

Expand Down
1 change: 1 addition & 0 deletions README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 defaults for missing tool types and typed anyOf/oneOf roots, only when Build is disabled. Keeps branch constraints; never rewrites response schemas or prohibitions. Restart the generation service after changing. |
| `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 |

Expand Down
17 changes: 17 additions & 0 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,23 @@ Schema 归一化规则:
| array 缺少 `items` | `prefixItems` 中带类型的项组成 `anyOf`;其余使用开放元素节点 |
| 其他 Schema 字段 | 返回 `400 invalid_request` / `INVALID_ARGUMENT` |

<a id="playground-schema-fallback"></a>

**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。兼容处理使用独立请求副本,不修改调用者持有的工具声明、聊天正文或工具返回数据。

- `anyOf` / `oneOf` 的首个分支能推导出具体类型时,可为联合节点补该根类型(数组同时保留首分支的 items)。所有原有分支及其 required、enum、数值和排他条件仍保留,不丢弃分支。此举收窄可用类型,不是无损转换。例如提问工具的 `options.items = {"anyOf":[{"type":"string"},{"type":"object","properties":{"label":{"type":"string"}},"required":["label"]}]}` 会额外得到根 `type:string`,模型可生成纯文本选项。
- 不改写结构化输出 `response schema`;不近似缺少明确首分支类型、叠加其他组合/否定约束的联合根节点。参数中的原生布尔 Schema(如 `items:true/false`、`properties.x:true/false`)不做此回退,零参数根 `{}`/`true` 仍沿用已有 object 归一化;`{"type":"boolean"}` 的普通布尔参数不受影响。
- 首个联合分支为 array 时,必须有 Playground 可编码的明确 items;不通过丢弃未声明元素类型的分支来放行。
- 开启 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 |
Expand Down
172 changes: 172 additions & 0 deletions internal/aistudio/schema_fallback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package aistudio

import (
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
)

// prepareSchemaRequest never changes Build routing or response schemas. The opt-in
// fallback narrows missing tool types and typed unions 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 legacy root types only at positive tool nodes.
// Typed disjunctions retain every branch/constraint and gain a root type. This
// narrows alternatives; it is not lossless native JSON Schema support.
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
}
if err := fallbackUnionRootType(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
}

// fallbackUnionRootType narrows a single anyOf/oneOf to its first concrete type,
// as legacy Playground encoding did, without deleting any alternative. Keeping
// oneOf intact also preserves exclusivity rather than flattening overlapping cases.
func fallbackUnionRootType(schema map[string]json.RawMessage) error {
if _, typed := schema["type"]; typed {
return nil
}
name := "anyOf"
if _, exists := schema[name]; !exists {
name = "oneOf"
}
raw, exists := schema[name]
if !exists {
return nil
}
for key := range schema {
if key == name {
continue
}
switch key {
case "description", "nullable", "example", "default", "$schema":
default:
return fmt.Errorf("含 %s 的组合节点需要显式类型或 Build 通道", key)
}
}
var variants []json.RawMessage
if json.Unmarshal(raw, &variants) != nil || len(variants) == 0 {
return fmt.Errorf("schema.%s 必须是非空 Schema 数组", name)
}
var first map[string]json.RawMessage
if json.Unmarshal(variants[0], &first) != nil || first == nil {
return fmt.Errorf("schema.%s 首个分支需要明确类型", name)
}
if err := normalizeConstAndMetadata(first); err != nil {
return err
}
if err := normalizeImplicitType(first); err != nil {
return err
}
var typeName string
if json.Unmarshal(first["type"], &typeName) != nil || schemaTypeCodes[strings.ToLower(typeName)] == 0 {
return fmt.Errorf("schema.%s 首个分支需要明确类型", name)
}
schema["type"] = first["type"]
if strings.EqualFold(typeName, "array") {
items, err := encodeJSONSchema(first["items"])
if err != nil || schemaWireNeedsBuild(items) {
return fmt.Errorf("schema.%s 首个数组分支需要可由 Playground 表达的明确 items", name)
}
schema["items"] = first["items"]
}
return nil
}
151 changes: 151 additions & 0 deletions internal/aistudio/schema_fallback_test.go
Original file line number Diff line number Diff line change
@@ -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"]}`,
`{"allOf":[{"type":"string"},{"type":"integer"}]}`,
`{"anyOf":[{"type":"string"},{"type":"integer"}],"not":{"type":"string"}}`,
`{"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)
}
})
}
}
Loading