mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 15:43:13 +08:00
JSON Schema 规定 required 必须是属性名数组,但部分客户端会发成 null,
严格校验的上游直接拒绝整个请求:
xAI 400 /required: null is not of type "array"
Moonshot 400 At path 'required': required must be an array
同样的模型发纯文本请求一切正常,只有带 tools 时才失败,因此表现得像模型
或渠道故障。
三处改动:
1. 清洗 required:null 时整键删除,而不是替换成 []。JSON Schema 里缺省
required 等价于无必填项,删除保留了客户端原意,置 [] 反而凭空造出一条
客户端从未声明的约束。
2. 把 input_schema 也识别为 schema 根。此前只认 OpenAI Responses 的
parameters 拼法,Anthropic Messages 的工具定义因此绕过整套清洗。
3. 在 ForwardAsAnthropic 入口调用清洗。/v1/messages 此前不做任何工具
Schema 处理;该函数会分流到原生 Anthropic 直通、Chat Completions 与
Responses 三条路径,每条都原样透传 tools,所以必须在分流之前处理。
另外把 contentSchema 补进子 schema 关键字:它承载嵌套 schema,其中的
null required 此前会漏网。
配套测试同时锁定反向保证:default / examples / const / enum 里装的是实例
数据,字面量 {"required": null} 属于客户端 payload,不能被改写。现有的
关键字白名单下降本就如此,测试用于防止它退化成无差别遍历。
721 lines
22 KiB
Go
721 lines
22 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const (
|
|
// Keep this aligned with the gateway's default maximum request-body size. A
|
|
// caller with a larger custom limit must not make this compatibility pass an
|
|
// unbounded parser or patch accumulator.
|
|
openAIResponsesToolSchemaMaxBodySize = 256 << 20
|
|
openAIResponsesToolSchemaMaxDepth = 128
|
|
openAIResponsesToolSchemaMaxEdits = 1 << 20
|
|
openAIResponsesObjectUnionMaxSize = 1 << 20
|
|
openAIResponsesObjectUnionMaxDepth = 32
|
|
|
|
openAIResponsesToolSchemaFallbackType = `"object"`
|
|
)
|
|
|
|
var errOpenAIResponsesToolSchemaLimit = errors.New("OpenAI Responses tool schema safety limit exceeded")
|
|
|
|
// shouldRepairOpenAIResponsesNullToolSchemaType reports whether the upstream
|
|
// path requires a concrete object type at a function tool's parameter root.
|
|
// This defect is shared by the OpenAI, Anthropic, Grok, and CN-compatible paths.
|
|
func shouldRepairOpenAIResponsesNullToolSchemaType(platform string) bool {
|
|
return platform == PlatformOpenAI || platform == PlatformAnthropic || platform == PlatformGrok || IsCNProvider(platform)
|
|
}
|
|
|
|
// shouldSanitizeOpenAIResponsesToolSchemaPatterns is intentionally narrower:
|
|
// regex lookaround rejection is an OpenAI-specific schema constraint.
|
|
func shouldSanitizeOpenAIResponsesToolSchemaPatterns(platform string) bool {
|
|
return platform == PlatformOpenAI
|
|
}
|
|
|
|
func sanitizeOpenAIResponsesToolSchemasForPlatform(body []byte, platform string) ([]byte, bool, error) {
|
|
normalized := body
|
|
changed := false
|
|
if shouldRepairOpenAIResponsesNullToolSchemaType(platform) {
|
|
next, repaired, err := sanitizeOpenAIResponsesToolParameterTypes(normalized)
|
|
if err != nil {
|
|
return body, false, fmt.Errorf("sanitize OpenAI Responses tool parameters: %w", err)
|
|
}
|
|
if repaired {
|
|
normalized = next
|
|
changed = true
|
|
}
|
|
}
|
|
if shouldSanitizeOpenAIResponsesToolSchemaPatterns(platform) {
|
|
next, sanitized, err := sanitizeOpenAIResponsesToolSchemaPatterns(normalized)
|
|
if err != nil {
|
|
return body, false, fmt.Errorf("sanitize OpenAI Responses tool schema patterns: %w", err)
|
|
}
|
|
if sanitized {
|
|
normalized = next
|
|
changed = true
|
|
}
|
|
}
|
|
return normalized, changed, nil
|
|
}
|
|
|
|
// sanitizeOpenAIResponsesToolSchemaPatterns removes only schema constraints
|
|
// containing regex lookaround, which OpenAI rejects. It deliberately does not
|
|
// descend into instance-valued keywords such as default, examples, const, or
|
|
// enum.
|
|
func sanitizeOpenAIResponsesToolSchemaPatterns(body []byte) ([]byte, bool, error) {
|
|
if len(body) == 0 || !openAIResponsesBodyMayContainRegexLookaround(body) {
|
|
return body, false, nil
|
|
}
|
|
return sanitizeOpenAIResponsesToolSchemas(body, openAIResponsesToolSchemaOptions{removeLookaroundPatterns: true})
|
|
}
|
|
|
|
// sanitizeOpenAIResponsesToolParameterTypes replaces an explicitly null type
|
|
// on a tool's parameter root with object. It also repairs the known Codex
|
|
// missing root type when it is unambiguously implied by an object-only
|
|
// oneOf/anyOf. Other missing types and nested property schemas are left
|
|
// unchanged.
|
|
func sanitizeOpenAIResponsesToolParameterTypes(body []byte) ([]byte, bool, error) {
|
|
if len(body) == 0 {
|
|
return body, false, nil
|
|
}
|
|
return sanitizeOpenAIResponsesToolSchemas(body, openAIResponsesToolSchemaOptions{
|
|
replaceNullParameterTypes: true,
|
|
injectObjectUnionRootObjectType: true,
|
|
dropNullRequired: true,
|
|
})
|
|
}
|
|
|
|
func openAIResponsesBodyMayContainRegexLookaround(body []byte) bool {
|
|
// An opening parenthesis may be literal or encoded as JSON's canonical
|
|
// Unicode escape. Other lookaround characters may themselves be escaped, so
|
|
// the full decoded pattern is checked only after scoped parsing.
|
|
return bytes.Contains(body, []byte("(")) || bytes.Contains(body, []byte(`\u0028`))
|
|
}
|
|
|
|
func hasRegexLookaround(pattern string) bool {
|
|
return strings.Contains(pattern, "(?=") || strings.Contains(pattern, "(?!") ||
|
|
strings.Contains(pattern, "(?<=") || strings.Contains(pattern, "(?<!")
|
|
}
|
|
|
|
type openAIResponsesToolSchemaOptions struct {
|
|
removeLookaroundPatterns bool
|
|
replaceNullParameterTypes bool
|
|
injectObjectUnionRootObjectType bool
|
|
dropNullRequired bool
|
|
}
|
|
|
|
type openAIResponsesToolSchemaContext uint8
|
|
|
|
const (
|
|
openAIResponsesToolSchemaSkip openAIResponsesToolSchemaContext = iota
|
|
openAIResponsesToolSchemaDocument
|
|
openAIResponsesToolSchemaTools
|
|
openAIResponsesToolSchemaTool
|
|
openAIResponsesToolSchemaInput
|
|
openAIResponsesToolSchemaInputItem
|
|
openAIResponsesToolSchemaToolCarrier
|
|
openAIResponsesToolSchemaTypeProbe
|
|
openAIResponsesToolSchemaFunction
|
|
openAIResponsesToolSchema
|
|
openAIResponsesToolSchemaArray
|
|
openAIResponsesToolSchemaMap
|
|
openAIResponsesToolSchemaOrArray
|
|
)
|
|
|
|
type openAIResponsesToolSchemaEdit struct {
|
|
start int
|
|
end int
|
|
replacement string
|
|
}
|
|
|
|
type openAIResponsesToolSchemaParser struct {
|
|
body []byte
|
|
pos int
|
|
options openAIResponsesToolSchemaOptions
|
|
edits []openAIResponsesToolSchemaEdit
|
|
probeType string
|
|
}
|
|
|
|
func sanitizeOpenAIResponsesToolSchemas(
|
|
body []byte, options openAIResponsesToolSchemaOptions,
|
|
) ([]byte, bool, error) {
|
|
if len(body) > openAIResponsesToolSchemaMaxBodySize {
|
|
return body, false, nil
|
|
}
|
|
if !utf8.Valid(body) {
|
|
return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: invalid UTF-8")
|
|
}
|
|
p := openAIResponsesToolSchemaParser{body: body, options: options}
|
|
if err := p.parseValue(openAIResponsesToolSchemaDocument, false, 0); err != nil {
|
|
if errors.Is(err, errOpenAIResponsesToolSchemaLimit) {
|
|
return body, false, nil
|
|
}
|
|
return nil, false, err
|
|
}
|
|
p.skipWhitespace()
|
|
if p.pos != len(body) {
|
|
return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: trailing data at byte %d", p.pos)
|
|
}
|
|
if !json.Valid(body) {
|
|
return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: invalid JSON")
|
|
}
|
|
if len(p.edits) == 0 {
|
|
return body, false, nil
|
|
}
|
|
return applyOpenAIResponsesToolSchemaEdits(body, p.edits)
|
|
}
|
|
|
|
func (p *openAIResponsesToolSchemaParser) parseValue(
|
|
context openAIResponsesToolSchemaContext, schemaRoot bool, depth int,
|
|
) error {
|
|
if depth > openAIResponsesToolSchemaMaxDepth {
|
|
return errOpenAIResponsesToolSchemaLimit
|
|
}
|
|
p.skipWhitespace()
|
|
if p.pos >= len(p.body) {
|
|
return p.syntaxError("expected value")
|
|
}
|
|
if p.body[p.pos] == '{' {
|
|
switch context {
|
|
case openAIResponsesToolSchemaInput:
|
|
context = openAIResponsesToolSchemaInputItem
|
|
case openAIResponsesToolSchemaOrArray, openAIResponsesToolSchemaArray:
|
|
context = openAIResponsesToolSchema
|
|
}
|
|
}
|
|
if context == openAIResponsesToolSchemaInputItem && p.body[p.pos] == '{' {
|
|
probe := openAIResponsesToolSchemaParser{body: p.body, pos: p.pos}
|
|
if err := probe.parseValue(openAIResponsesToolSchemaTypeProbe, false, depth); err != nil {
|
|
return err
|
|
}
|
|
switch probe.probeType {
|
|
case "function", "custom", "tool_search", "namespace":
|
|
context = openAIResponsesToolSchemaTool
|
|
case "additional_tools":
|
|
context = openAIResponsesToolSchemaToolCarrier
|
|
default:
|
|
context = openAIResponsesToolSchemaSkip
|
|
}
|
|
}
|
|
switch p.body[p.pos] {
|
|
case '{':
|
|
return p.parseObject(context, schemaRoot, depth+1)
|
|
case '[':
|
|
return p.parseArray(context, depth+1)
|
|
case '"':
|
|
_, _, err := p.parseString()
|
|
return err
|
|
case 't':
|
|
return p.parseKeyword("true")
|
|
case 'f':
|
|
return p.parseKeyword("false")
|
|
case 'n':
|
|
return p.parseKeyword("null")
|
|
default:
|
|
return p.parseNumber()
|
|
}
|
|
}
|
|
|
|
func (p *openAIResponsesToolSchemaParser) parseObject(
|
|
context openAIResponsesToolSchemaContext, schemaRoot bool, depth int,
|
|
) error {
|
|
p.pos++
|
|
p.skipWhitespace()
|
|
if p.consume('}') {
|
|
return nil
|
|
}
|
|
previousComma := -1
|
|
deleteRunStart := -1
|
|
deleteRunPreviousComma := -1
|
|
rootTypeCount := 0
|
|
nullRootTypeStart := -1
|
|
nullRootTypeEnd := -1
|
|
parameterCount := 0
|
|
parameterValueStart := -1
|
|
parameterValueEnd := -1
|
|
for {
|
|
p.skipWhitespace()
|
|
memberStart := p.pos
|
|
keyStart, keyEnd, err := p.parseString()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key := p.body[keyStart:keyEnd]
|
|
p.skipWhitespace()
|
|
if !p.consume(':') {
|
|
return p.syntaxError("expected colon")
|
|
}
|
|
p.skipWhitespace()
|
|
valueStart := p.pos
|
|
|
|
childContext, childSchemaRoot := openAIResponsesToolSchemaChildContext(context, key)
|
|
if err := p.parseValue(childContext, childSchemaRoot, depth); err != nil {
|
|
return err
|
|
}
|
|
valueEnd := p.pos
|
|
p.skipWhitespace()
|
|
|
|
deleteMember := false
|
|
if context == openAIResponsesToolSchemaTypeProbe && openAIResponsesJSONStringEquals(key, "type") {
|
|
if value, ok := decodeOpenAIResponsesJSONStringValue(p.body[valueStart:valueEnd]); ok {
|
|
p.probeType = strings.TrimSpace(value)
|
|
}
|
|
}
|
|
if (context == openAIResponsesToolSchemaTool || context == openAIResponsesToolSchemaFunction) &&
|
|
openAIResponsesJSONStringEquals(key, "parameters") {
|
|
parameterCount++
|
|
parameterValueStart = valueStart
|
|
parameterValueEnd = valueEnd
|
|
}
|
|
if context == openAIResponsesToolSchema && openAIResponsesJSONStringEquals(key, "pattern") && p.options.removeLookaroundPatterns {
|
|
if pattern, ok := decodeOpenAIResponsesJSONStringValue(p.body[valueStart:valueEnd]); ok && hasRegexLookaround(pattern) {
|
|
deleteMember = true
|
|
}
|
|
}
|
|
// JSON Schema requires "required" to be an array. Clients occasionally emit
|
|
// null, which xAI and Moonshot reject outright with a 400. An absent
|
|
// "required" is the semantic equivalent of an empty one, so dropping the
|
|
// member preserves the client's intent while satisfying strict validators.
|
|
if context == openAIResponsesToolSchema && p.options.dropNullRequired &&
|
|
openAIResponsesJSONStringEquals(key, "required") &&
|
|
bytes.Equal(p.body[valueStart:valueEnd], []byte("null")) {
|
|
deleteMember = true
|
|
}
|
|
if context == openAIResponsesToolSchema && schemaRoot && openAIResponsesJSONStringEquals(key, "type") {
|
|
// Duplicate JSON keys have parser-dependent effective values. Repair a
|
|
// root type only when it is unique rather than choosing a winner and
|
|
// potentially changing the client's effective schema.
|
|
rootTypeCount++
|
|
if bytes.Equal(p.body[valueStart:valueEnd], []byte("null")) {
|
|
nullRootTypeStart = valueStart
|
|
nullRootTypeEnd = valueEnd
|
|
}
|
|
}
|
|
|
|
if p.pos >= len(p.body) {
|
|
return p.syntaxError("unterminated object")
|
|
}
|
|
if deleteMember && deleteRunStart < 0 {
|
|
deleteRunStart = memberStart
|
|
deleteRunPreviousComma = previousComma
|
|
}
|
|
if !deleteMember && deleteRunStart >= 0 {
|
|
// Remove through the delimiter after the deleted run, but retain the
|
|
// whitespace that originally preceded the next member.
|
|
if err := p.addEdit(deleteRunStart, previousComma+1, ""); err != nil {
|
|
return err
|
|
}
|
|
deleteRunStart = -1
|
|
deleteRunPreviousComma = -1
|
|
}
|
|
if p.body[p.pos] == ',' {
|
|
comma := p.pos
|
|
p.pos++
|
|
previousComma = comma
|
|
continue
|
|
}
|
|
if p.body[p.pos] != '}' {
|
|
return p.syntaxError("expected comma or closing brace")
|
|
}
|
|
if deleteRunStart >= 0 {
|
|
start := deleteRunStart
|
|
if deleteRunPreviousComma >= 0 {
|
|
start = deleteRunPreviousComma
|
|
}
|
|
if err := p.addEdit(start, p.pos, ""); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if p.options.replaceNullParameterTypes && rootTypeCount == 1 && nullRootTypeStart >= 0 {
|
|
if err := p.addEdit(nullRootTypeStart, nullRootTypeEnd, openAIResponsesToolSchemaFallbackType); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if p.options.injectObjectUnionRootObjectType &&
|
|
(context == openAIResponsesToolSchemaTool || context == openAIResponsesToolSchemaFunction) &&
|
|
parameterCount == 1 && parameterValueStart >= 0 {
|
|
if edit, ok := openAIMissingRootObjectUnionTypeEdit(
|
|
p.body[parameterValueStart:parameterValueEnd], parameterValueStart,
|
|
); ok {
|
|
if err := p.addEdit(edit.start, edit.end, edit.replacement); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
p.pos++
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var (
|
|
openAIResponsesJSONOneOfKey = []byte(`"oneOf"`)
|
|
openAIResponsesJSONAnyOfKey = []byte(`"anyOf"`)
|
|
openAIResponsesJSONEscapeNeedle = []byte{'\\'}
|
|
)
|
|
|
|
func openAIMissingRootObjectUnionTypeEdit(raw []byte, absoluteStart int) (openAIResponsesToolSchemaEdit, bool) {
|
|
if len(raw) > openAIResponsesObjectUnionMaxSize {
|
|
return openAIResponsesToolSchemaEdit{}, false
|
|
}
|
|
if !bytes.Contains(raw, openAIResponsesJSONOneOfKey) && !bytes.Contains(raw, openAIResponsesJSONAnyOfKey) &&
|
|
!bytes.Contains(raw, openAIResponsesJSONEscapeNeedle) {
|
|
return openAIResponsesToolSchemaEdit{}, false
|
|
}
|
|
var schema map[string]json.RawMessage
|
|
if len(raw) < 2 || raw[0] != '{' || json.Unmarshal(raw, &schema) != nil {
|
|
return openAIResponsesToolSchemaEdit{}, false
|
|
}
|
|
if _, hasType := schema["type"]; hasType || !openAIResponsesSchemaHasObjectOnlyUnion(schema, 0) {
|
|
return openAIResponsesToolSchemaEdit{}, false
|
|
}
|
|
return openAIResponsesToolSchemaEdit{
|
|
start: absoluteStart + 1,
|
|
end: absoluteStart + 1,
|
|
replacement: `"type":"object",`,
|
|
}, true
|
|
}
|
|
|
|
func openAIResponsesSchemaHasObjectOnlyUnion(schema map[string]json.RawMessage, depth int) bool {
|
|
if depth > openAIResponsesObjectUnionMaxDepth {
|
|
return false
|
|
}
|
|
found := false
|
|
for _, keyword := range []string{"oneOf", "anyOf"} {
|
|
raw, ok := schema[keyword]
|
|
if !ok {
|
|
continue
|
|
}
|
|
found = true
|
|
var branches []json.RawMessage
|
|
if json.Unmarshal(raw, &branches) != nil || len(branches) == 0 {
|
|
return false
|
|
}
|
|
for _, branch := range branches {
|
|
if !openAIResponsesSchemaIsObjectOnly(branch, depth+1) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
func openAIResponsesSchemaIsObjectOnly(raw json.RawMessage, depth int) bool {
|
|
if depth > openAIResponsesObjectUnionMaxDepth {
|
|
return false
|
|
}
|
|
var schema map[string]json.RawMessage
|
|
if json.Unmarshal(raw, &schema) != nil {
|
|
return false
|
|
}
|
|
if rawType, ok := schema["type"]; ok {
|
|
var schemaType string
|
|
return json.Unmarshal(rawType, &schemaType) == nil && schemaType == "object"
|
|
}
|
|
return openAIResponsesSchemaHasObjectOnlyUnion(schema, depth+1)
|
|
}
|
|
|
|
func (p *openAIResponsesToolSchemaParser) parseArray(
|
|
context openAIResponsesToolSchemaContext, depth int,
|
|
) error {
|
|
p.pos++
|
|
p.skipWhitespace()
|
|
if p.consume(']') {
|
|
return nil
|
|
}
|
|
childContext := openAIResponsesToolSchemaSkip
|
|
switch context {
|
|
case openAIResponsesToolSchemaTools:
|
|
childContext = openAIResponsesToolSchemaTool
|
|
case openAIResponsesToolSchemaInput:
|
|
childContext = openAIResponsesToolSchemaInputItem
|
|
case openAIResponsesToolSchemaArray, openAIResponsesToolSchemaOrArray:
|
|
childContext = openAIResponsesToolSchema
|
|
}
|
|
for {
|
|
if err := p.parseValue(childContext, false, depth); err != nil {
|
|
return err
|
|
}
|
|
p.skipWhitespace()
|
|
if p.consume(',') {
|
|
continue
|
|
}
|
|
if !p.consume(']') {
|
|
return p.syntaxError("expected comma or closing bracket")
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func openAIResponsesToolSchemaChildContext(
|
|
context openAIResponsesToolSchemaContext, key []byte,
|
|
) (openAIResponsesToolSchemaContext, bool) {
|
|
switch context {
|
|
case openAIResponsesToolSchemaDocument:
|
|
switch {
|
|
case openAIResponsesJSONStringEquals(key, "tools"):
|
|
return openAIResponsesToolSchemaTools, false
|
|
case openAIResponsesJSONStringEquals(key, "input"):
|
|
return openAIResponsesToolSchemaInput, false
|
|
}
|
|
case openAIResponsesToolSchemaTool:
|
|
switch {
|
|
// Anthropic Messages carries the same JSON Schema under input_schema, so
|
|
// /v1/messages tools must reach the sanitizer too.
|
|
case openAIResponsesJSONStringMatchesAny(key, "parameters", "input_schema"):
|
|
return openAIResponsesToolSchema, true
|
|
case openAIResponsesJSONStringEquals(key, "function"):
|
|
return openAIResponsesToolSchemaFunction, false
|
|
case openAIResponsesJSONStringEquals(key, "tools"):
|
|
return openAIResponsesToolSchemaTools, false
|
|
}
|
|
case openAIResponsesToolSchemaToolCarrier:
|
|
if openAIResponsesJSONStringEquals(key, "tools") {
|
|
return openAIResponsesToolSchemaTools, false
|
|
}
|
|
case openAIResponsesToolSchemaFunction:
|
|
switch {
|
|
case openAIResponsesJSONStringEquals(key, "parameters"):
|
|
return openAIResponsesToolSchema, true
|
|
case openAIResponsesJSONStringEquals(key, "tools"):
|
|
return openAIResponsesToolSchemaTools, false
|
|
}
|
|
case openAIResponsesToolSchema:
|
|
switch {
|
|
case openAIResponsesJSONStringMatchesAny(key,
|
|
"additionalProperties", "additionalItems", "contains", "not", "if", "then", "else",
|
|
"propertyNames", "unevaluatedProperties", "unevaluatedItems", "contentSchema"):
|
|
return openAIResponsesToolSchema, false
|
|
case openAIResponsesJSONStringEquals(key, "items"):
|
|
return openAIResponsesToolSchemaOrArray, false
|
|
case openAIResponsesJSONStringMatchesAny(key, "anyOf", "oneOf", "allOf", "prefixItems"):
|
|
return openAIResponsesToolSchemaArray, false
|
|
case openAIResponsesJSONStringMatchesAny(key,
|
|
"properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies"):
|
|
return openAIResponsesToolSchemaMap, false
|
|
}
|
|
case openAIResponsesToolSchemaMap:
|
|
return openAIResponsesToolSchema, false
|
|
}
|
|
return openAIResponsesToolSchemaSkip, false
|
|
}
|
|
|
|
func (p *openAIResponsesToolSchemaParser) parseString() (int, int, error) {
|
|
if p.pos >= len(p.body) || p.body[p.pos] != '"' {
|
|
return 0, 0, p.syntaxError("expected string")
|
|
}
|
|
start := p.pos
|
|
p.pos++
|
|
for p.pos < len(p.body) {
|
|
switch p.body[p.pos] {
|
|
case '"':
|
|
p.pos++
|
|
return start, p.pos, nil
|
|
case '\\':
|
|
p.pos++
|
|
if p.pos >= len(p.body) {
|
|
return 0, 0, p.syntaxError("unterminated string escape")
|
|
}
|
|
switch p.body[p.pos] {
|
|
case 'u':
|
|
if p.pos+4 >= len(p.body) {
|
|
return 0, 0, p.syntaxError("short unicode escape")
|
|
}
|
|
for _, digit := range p.body[p.pos+1 : p.pos+5] {
|
|
if !isOpenAIResponsesJSONHexDigit(digit) {
|
|
return 0, 0, p.syntaxError("invalid unicode escape")
|
|
}
|
|
}
|
|
p.pos += 5
|
|
continue
|
|
case '"', '\\', '/', 'b', 'f', 'n', 'r', 't':
|
|
p.pos++
|
|
default:
|
|
return 0, 0, p.syntaxError("invalid string escape")
|
|
}
|
|
default:
|
|
if p.body[p.pos] < 0x20 {
|
|
return 0, 0, p.syntaxError("control character in string")
|
|
}
|
|
p.pos++
|
|
}
|
|
}
|
|
return 0, 0, p.syntaxError("unterminated string")
|
|
}
|
|
|
|
func isOpenAIResponsesJSONHexDigit(value byte) bool {
|
|
return value >= '0' && value <= '9' || value >= 'a' && value <= 'f' || value >= 'A' && value <= 'F'
|
|
}
|
|
|
|
func (p *openAIResponsesToolSchemaParser) parseKeyword(keyword string) error {
|
|
if len(p.body)-p.pos < len(keyword) || string(p.body[p.pos:p.pos+len(keyword)]) != keyword {
|
|
return p.syntaxError("invalid literal")
|
|
}
|
|
p.pos += len(keyword)
|
|
return nil
|
|
}
|
|
|
|
func (p *openAIResponsesToolSchemaParser) parseNumber() error {
|
|
start := p.pos
|
|
if p.consume('-') && p.pos >= len(p.body) {
|
|
return p.syntaxError("invalid number")
|
|
}
|
|
if p.consume('0') {
|
|
if p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' {
|
|
return p.syntaxError("invalid leading zero")
|
|
}
|
|
} else {
|
|
if p.pos >= len(p.body) || p.body[p.pos] < '1' || p.body[p.pos] > '9' {
|
|
return p.syntaxError("invalid number")
|
|
}
|
|
for p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' {
|
|
p.pos++
|
|
}
|
|
}
|
|
if p.consume('.') {
|
|
fractionStart := p.pos
|
|
for p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' {
|
|
p.pos++
|
|
}
|
|
if p.pos == fractionStart {
|
|
return p.syntaxError("invalid fraction")
|
|
}
|
|
}
|
|
if p.pos < len(p.body) && (p.body[p.pos] == 'e' || p.body[p.pos] == 'E') {
|
|
p.pos++
|
|
if p.pos < len(p.body) && (p.body[p.pos] == '+' || p.body[p.pos] == '-') {
|
|
p.pos++
|
|
}
|
|
exponentStart := p.pos
|
|
for p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' {
|
|
p.pos++
|
|
}
|
|
if p.pos == exponentStart {
|
|
return p.syntaxError("invalid exponent")
|
|
}
|
|
}
|
|
if p.pos == start {
|
|
return p.syntaxError("invalid value")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *openAIResponsesToolSchemaParser) addEdit(start, end int, replacement string) error {
|
|
if start < 0 || end < start || end > len(p.body) {
|
|
return p.syntaxError("invalid patch span")
|
|
}
|
|
if len(p.edits) >= openAIResponsesToolSchemaMaxEdits {
|
|
return errOpenAIResponsesToolSchemaLimit
|
|
}
|
|
p.edits = append(p.edits, openAIResponsesToolSchemaEdit{start: start, end: end, replacement: replacement})
|
|
return nil
|
|
}
|
|
|
|
func (p *openAIResponsesToolSchemaParser) skipWhitespace() {
|
|
for p.pos < len(p.body) {
|
|
switch p.body[p.pos] {
|
|
case ' ', '\t', '\n', '\r':
|
|
p.pos++
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *openAIResponsesToolSchemaParser) consume(want byte) bool {
|
|
if p.pos < len(p.body) && p.body[p.pos] == want {
|
|
p.pos++
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (p *openAIResponsesToolSchemaParser) syntaxError(message string) error {
|
|
return fmt.Errorf("sanitize OpenAI Responses tool schemas: %s at byte %d", message, p.pos)
|
|
}
|
|
|
|
func decodeOpenAIResponsesJSONString(raw []byte) (string, error) {
|
|
decoded, err := strconv.Unquote(string(raw))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return decoded, nil
|
|
}
|
|
|
|
func openAIResponsesJSONStringEquals(raw []byte, want string) bool {
|
|
if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' {
|
|
return false
|
|
}
|
|
if !bytes.Contains(raw, []byte{'\\'}) {
|
|
return bytes.Equal(raw[1:len(raw)-1], []byte(want))
|
|
}
|
|
decoded, err := decodeOpenAIResponsesJSONString(raw)
|
|
return err == nil && decoded == want
|
|
}
|
|
|
|
func openAIResponsesJSONStringMatchesAny(raw []byte, candidates ...string) bool {
|
|
for _, candidate := range candidates {
|
|
if openAIResponsesJSONStringEquals(raw, candidate) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func decodeOpenAIResponsesJSONStringValue(raw []byte) (string, bool) {
|
|
if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' {
|
|
return "", false
|
|
}
|
|
decoded, err := decodeOpenAIResponsesJSONString(raw)
|
|
return decoded, err == nil
|
|
}
|
|
|
|
func applyOpenAIResponsesToolSchemaEdits(
|
|
body []byte, edits []openAIResponsesToolSchemaEdit,
|
|
) ([]byte, bool, error) {
|
|
sort.Slice(edits, func(i, j int) bool {
|
|
if edits[i].start != edits[j].start {
|
|
return edits[i].start < edits[j].start
|
|
}
|
|
return edits[i].end < edits[j].end
|
|
})
|
|
|
|
merged := edits[:0]
|
|
for _, edit := range edits {
|
|
if len(merged) == 0 || edit.start >= merged[len(merged)-1].end {
|
|
merged = append(merged, edit)
|
|
continue
|
|
}
|
|
previous := &merged[len(merged)-1]
|
|
if previous.replacement != "" || edit.replacement != "" {
|
|
return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: overlapping patches")
|
|
}
|
|
if edit.end > previous.end {
|
|
previous.end = edit.end
|
|
}
|
|
}
|
|
|
|
delta := 0
|
|
for _, edit := range merged {
|
|
delta += len(edit.replacement) - (edit.end - edit.start)
|
|
}
|
|
sanitized := make([]byte, 0, len(body)+delta)
|
|
cursor := 0
|
|
for _, edit := range merged {
|
|
sanitized = append(sanitized, body[cursor:edit.start]...)
|
|
sanitized = append(sanitized, edit.replacement...)
|
|
cursor = edit.end
|
|
}
|
|
sanitized = append(sanitized, body[cursor:]...)
|
|
if !json.Valid(sanitized) {
|
|
return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: patch produced invalid JSON")
|
|
}
|
|
return sanitized, true, nil
|
|
}
|