openai_responses_tool_schema.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707
  1. package service
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "sort"
  8. "strconv"
  9. "strings"
  10. "unicode/utf8"
  11. )
  12. const (
  13. // Keep this aligned with the gateway's default maximum request-body size. A
  14. // caller with a larger custom limit must not make this compatibility pass an
  15. // unbounded parser or patch accumulator.
  16. openAIResponsesToolSchemaMaxBodySize = 256 << 20
  17. openAIResponsesToolSchemaMaxDepth = 128
  18. openAIResponsesToolSchemaMaxEdits = 1 << 20
  19. openAIResponsesObjectUnionMaxSize = 1 << 20
  20. openAIResponsesObjectUnionMaxDepth = 32
  21. openAIResponsesToolSchemaFallbackType = `"object"`
  22. )
  23. var errOpenAIResponsesToolSchemaLimit = errors.New("OpenAI Responses tool schema safety limit exceeded")
  24. // shouldRepairOpenAIResponsesNullToolSchemaType reports whether the upstream
  25. // path requires a concrete object type at a function tool's parameter root.
  26. // This defect is shared by the OpenAI, Anthropic, Grok, and CN-compatible paths.
  27. func shouldRepairOpenAIResponsesNullToolSchemaType(platform string) bool {
  28. return platform == PlatformOpenAI || platform == PlatformAnthropic || platform == PlatformGrok || IsCNProvider(platform)
  29. }
  30. // shouldSanitizeOpenAIResponsesToolSchemaPatterns is intentionally narrower:
  31. // regex lookaround rejection is an OpenAI-specific schema constraint.
  32. func shouldSanitizeOpenAIResponsesToolSchemaPatterns(platform string) bool {
  33. return platform == PlatformOpenAI
  34. }
  35. func sanitizeOpenAIResponsesToolSchemasForPlatform(body []byte, platform string) ([]byte, bool, error) {
  36. normalized := body
  37. changed := false
  38. if shouldRepairOpenAIResponsesNullToolSchemaType(platform) {
  39. next, repaired, err := sanitizeOpenAIResponsesToolParameterTypes(normalized)
  40. if err != nil {
  41. return body, false, fmt.Errorf("sanitize OpenAI Responses tool parameters: %w", err)
  42. }
  43. if repaired {
  44. normalized = next
  45. changed = true
  46. }
  47. }
  48. if shouldSanitizeOpenAIResponsesToolSchemaPatterns(platform) {
  49. next, sanitized, err := sanitizeOpenAIResponsesToolSchemaPatterns(normalized)
  50. if err != nil {
  51. return body, false, fmt.Errorf("sanitize OpenAI Responses tool schema patterns: %w", err)
  52. }
  53. if sanitized {
  54. normalized = next
  55. changed = true
  56. }
  57. }
  58. return normalized, changed, nil
  59. }
  60. // sanitizeOpenAIResponsesToolSchemaPatterns removes only schema constraints
  61. // containing regex lookaround, which OpenAI rejects. It deliberately does not
  62. // descend into instance-valued keywords such as default, examples, const, or
  63. // enum.
  64. func sanitizeOpenAIResponsesToolSchemaPatterns(body []byte) ([]byte, bool, error) {
  65. if len(body) == 0 || !openAIResponsesBodyMayContainRegexLookaround(body) {
  66. return body, false, nil
  67. }
  68. return sanitizeOpenAIResponsesToolSchemas(body, openAIResponsesToolSchemaOptions{removeLookaroundPatterns: true})
  69. }
  70. // sanitizeOpenAIResponsesToolParameterTypes replaces an explicitly null type
  71. // on a tool's parameter root with object. It also repairs the known Codex
  72. // missing root type when it is unambiguously implied by an object-only
  73. // oneOf/anyOf. Other missing types and nested property schemas are left
  74. // unchanged.
  75. func sanitizeOpenAIResponsesToolParameterTypes(body []byte) ([]byte, bool, error) {
  76. if len(body) == 0 {
  77. return body, false, nil
  78. }
  79. return sanitizeOpenAIResponsesToolSchemas(body, openAIResponsesToolSchemaOptions{
  80. replaceNullParameterTypes: true,
  81. injectObjectUnionRootObjectType: true,
  82. })
  83. }
  84. func openAIResponsesBodyMayContainRegexLookaround(body []byte) bool {
  85. // An opening parenthesis may be literal or encoded as JSON's canonical
  86. // Unicode escape. Other lookaround characters may themselves be escaped, so
  87. // the full decoded pattern is checked only after scoped parsing.
  88. return bytes.Contains(body, []byte("(")) || bytes.Contains(body, []byte(`\u0028`))
  89. }
  90. func hasRegexLookaround(pattern string) bool {
  91. return strings.Contains(pattern, "(?=") || strings.Contains(pattern, "(?!") ||
  92. strings.Contains(pattern, "(?<=") || strings.Contains(pattern, "(?<!")
  93. }
  94. type openAIResponsesToolSchemaOptions struct {
  95. removeLookaroundPatterns bool
  96. replaceNullParameterTypes bool
  97. injectObjectUnionRootObjectType bool
  98. }
  99. type openAIResponsesToolSchemaContext uint8
  100. const (
  101. openAIResponsesToolSchemaSkip openAIResponsesToolSchemaContext = iota
  102. openAIResponsesToolSchemaDocument
  103. openAIResponsesToolSchemaTools
  104. openAIResponsesToolSchemaTool
  105. openAIResponsesToolSchemaInput
  106. openAIResponsesToolSchemaInputItem
  107. openAIResponsesToolSchemaToolCarrier
  108. openAIResponsesToolSchemaTypeProbe
  109. openAIResponsesToolSchemaFunction
  110. openAIResponsesToolSchema
  111. openAIResponsesToolSchemaArray
  112. openAIResponsesToolSchemaMap
  113. openAIResponsesToolSchemaOrArray
  114. )
  115. type openAIResponsesToolSchemaEdit struct {
  116. start int
  117. end int
  118. replacement string
  119. }
  120. type openAIResponsesToolSchemaParser struct {
  121. body []byte
  122. pos int
  123. options openAIResponsesToolSchemaOptions
  124. edits []openAIResponsesToolSchemaEdit
  125. probeType string
  126. }
  127. func sanitizeOpenAIResponsesToolSchemas(
  128. body []byte, options openAIResponsesToolSchemaOptions,
  129. ) ([]byte, bool, error) {
  130. if len(body) > openAIResponsesToolSchemaMaxBodySize {
  131. return body, false, nil
  132. }
  133. if !utf8.Valid(body) {
  134. return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: invalid UTF-8")
  135. }
  136. p := openAIResponsesToolSchemaParser{body: body, options: options}
  137. if err := p.parseValue(openAIResponsesToolSchemaDocument, false, 0); err != nil {
  138. if errors.Is(err, errOpenAIResponsesToolSchemaLimit) {
  139. return body, false, nil
  140. }
  141. return nil, false, err
  142. }
  143. p.skipWhitespace()
  144. if p.pos != len(body) {
  145. return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: trailing data at byte %d", p.pos)
  146. }
  147. if !json.Valid(body) {
  148. return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: invalid JSON")
  149. }
  150. if len(p.edits) == 0 {
  151. return body, false, nil
  152. }
  153. return applyOpenAIResponsesToolSchemaEdits(body, p.edits)
  154. }
  155. func (p *openAIResponsesToolSchemaParser) parseValue(
  156. context openAIResponsesToolSchemaContext, schemaRoot bool, depth int,
  157. ) error {
  158. if depth > openAIResponsesToolSchemaMaxDepth {
  159. return errOpenAIResponsesToolSchemaLimit
  160. }
  161. p.skipWhitespace()
  162. if p.pos >= len(p.body) {
  163. return p.syntaxError("expected value")
  164. }
  165. if p.body[p.pos] == '{' {
  166. switch context {
  167. case openAIResponsesToolSchemaInput:
  168. context = openAIResponsesToolSchemaInputItem
  169. case openAIResponsesToolSchemaOrArray, openAIResponsesToolSchemaArray:
  170. context = openAIResponsesToolSchema
  171. }
  172. }
  173. if context == openAIResponsesToolSchemaInputItem && p.body[p.pos] == '{' {
  174. probe := openAIResponsesToolSchemaParser{body: p.body, pos: p.pos}
  175. if err := probe.parseValue(openAIResponsesToolSchemaTypeProbe, false, depth); err != nil {
  176. return err
  177. }
  178. switch probe.probeType {
  179. case "function", "custom", "tool_search", "namespace":
  180. context = openAIResponsesToolSchemaTool
  181. case "additional_tools":
  182. context = openAIResponsesToolSchemaToolCarrier
  183. default:
  184. context = openAIResponsesToolSchemaSkip
  185. }
  186. }
  187. switch p.body[p.pos] {
  188. case '{':
  189. return p.parseObject(context, schemaRoot, depth+1)
  190. case '[':
  191. return p.parseArray(context, depth+1)
  192. case '"':
  193. _, _, err := p.parseString()
  194. return err
  195. case 't':
  196. return p.parseKeyword("true")
  197. case 'f':
  198. return p.parseKeyword("false")
  199. case 'n':
  200. return p.parseKeyword("null")
  201. default:
  202. return p.parseNumber()
  203. }
  204. }
  205. func (p *openAIResponsesToolSchemaParser) parseObject(
  206. context openAIResponsesToolSchemaContext, schemaRoot bool, depth int,
  207. ) error {
  208. p.pos++
  209. p.skipWhitespace()
  210. if p.consume('}') {
  211. return nil
  212. }
  213. previousComma := -1
  214. deleteRunStart := -1
  215. deleteRunPreviousComma := -1
  216. rootTypeCount := 0
  217. nullRootTypeStart := -1
  218. nullRootTypeEnd := -1
  219. parameterCount := 0
  220. parameterValueStart := -1
  221. parameterValueEnd := -1
  222. for {
  223. p.skipWhitespace()
  224. memberStart := p.pos
  225. keyStart, keyEnd, err := p.parseString()
  226. if err != nil {
  227. return err
  228. }
  229. key := p.body[keyStart:keyEnd]
  230. p.skipWhitespace()
  231. if !p.consume(':') {
  232. return p.syntaxError("expected colon")
  233. }
  234. p.skipWhitespace()
  235. valueStart := p.pos
  236. childContext, childSchemaRoot := openAIResponsesToolSchemaChildContext(context, key)
  237. if err := p.parseValue(childContext, childSchemaRoot, depth); err != nil {
  238. return err
  239. }
  240. valueEnd := p.pos
  241. p.skipWhitespace()
  242. deleteMember := false
  243. if context == openAIResponsesToolSchemaTypeProbe && openAIResponsesJSONStringEquals(key, "type") {
  244. if value, ok := decodeOpenAIResponsesJSONStringValue(p.body[valueStart:valueEnd]); ok {
  245. p.probeType = strings.TrimSpace(value)
  246. }
  247. }
  248. if (context == openAIResponsesToolSchemaTool || context == openAIResponsesToolSchemaFunction) &&
  249. openAIResponsesJSONStringEquals(key, "parameters") {
  250. parameterCount++
  251. parameterValueStart = valueStart
  252. parameterValueEnd = valueEnd
  253. }
  254. if context == openAIResponsesToolSchema && openAIResponsesJSONStringEquals(key, "pattern") && p.options.removeLookaroundPatterns {
  255. if pattern, ok := decodeOpenAIResponsesJSONStringValue(p.body[valueStart:valueEnd]); ok && hasRegexLookaround(pattern) {
  256. deleteMember = true
  257. }
  258. }
  259. if context == openAIResponsesToolSchema && schemaRoot && openAIResponsesJSONStringEquals(key, "type") {
  260. // Duplicate JSON keys have parser-dependent effective values. Repair a
  261. // root type only when it is unique rather than choosing a winner and
  262. // potentially changing the client's effective schema.
  263. rootTypeCount++
  264. if bytes.Equal(p.body[valueStart:valueEnd], []byte("null")) {
  265. nullRootTypeStart = valueStart
  266. nullRootTypeEnd = valueEnd
  267. }
  268. }
  269. if p.pos >= len(p.body) {
  270. return p.syntaxError("unterminated object")
  271. }
  272. if deleteMember && deleteRunStart < 0 {
  273. deleteRunStart = memberStart
  274. deleteRunPreviousComma = previousComma
  275. }
  276. if !deleteMember && deleteRunStart >= 0 {
  277. // Remove through the delimiter after the deleted run, but retain the
  278. // whitespace that originally preceded the next member.
  279. if err := p.addEdit(deleteRunStart, previousComma+1, ""); err != nil {
  280. return err
  281. }
  282. deleteRunStart = -1
  283. deleteRunPreviousComma = -1
  284. }
  285. if p.body[p.pos] == ',' {
  286. comma := p.pos
  287. p.pos++
  288. previousComma = comma
  289. continue
  290. }
  291. if p.body[p.pos] != '}' {
  292. return p.syntaxError("expected comma or closing brace")
  293. }
  294. if deleteRunStart >= 0 {
  295. start := deleteRunStart
  296. if deleteRunPreviousComma >= 0 {
  297. start = deleteRunPreviousComma
  298. }
  299. if err := p.addEdit(start, p.pos, ""); err != nil {
  300. return err
  301. }
  302. }
  303. if p.options.replaceNullParameterTypes && rootTypeCount == 1 && nullRootTypeStart >= 0 {
  304. if err := p.addEdit(nullRootTypeStart, nullRootTypeEnd, openAIResponsesToolSchemaFallbackType); err != nil {
  305. return err
  306. }
  307. }
  308. if p.options.injectObjectUnionRootObjectType &&
  309. (context == openAIResponsesToolSchemaTool || context == openAIResponsesToolSchemaFunction) &&
  310. parameterCount == 1 && parameterValueStart >= 0 {
  311. if edit, ok := openAIMissingRootObjectUnionTypeEdit(
  312. p.body[parameterValueStart:parameterValueEnd], parameterValueStart,
  313. ); ok {
  314. if err := p.addEdit(edit.start, edit.end, edit.replacement); err != nil {
  315. return err
  316. }
  317. }
  318. }
  319. p.pos++
  320. return nil
  321. }
  322. }
  323. var (
  324. openAIResponsesJSONOneOfKey = []byte(`"oneOf"`)
  325. openAIResponsesJSONAnyOfKey = []byte(`"anyOf"`)
  326. openAIResponsesJSONEscapeNeedle = []byte{'\\'}
  327. )
  328. func openAIMissingRootObjectUnionTypeEdit(raw []byte, absoluteStart int) (openAIResponsesToolSchemaEdit, bool) {
  329. if len(raw) > openAIResponsesObjectUnionMaxSize {
  330. return openAIResponsesToolSchemaEdit{}, false
  331. }
  332. if !bytes.Contains(raw, openAIResponsesJSONOneOfKey) && !bytes.Contains(raw, openAIResponsesJSONAnyOfKey) &&
  333. !bytes.Contains(raw, openAIResponsesJSONEscapeNeedle) {
  334. return openAIResponsesToolSchemaEdit{}, false
  335. }
  336. var schema map[string]json.RawMessage
  337. if len(raw) < 2 || raw[0] != '{' || json.Unmarshal(raw, &schema) != nil {
  338. return openAIResponsesToolSchemaEdit{}, false
  339. }
  340. if _, hasType := schema["type"]; hasType || !openAIResponsesSchemaHasObjectOnlyUnion(schema, 0) {
  341. return openAIResponsesToolSchemaEdit{}, false
  342. }
  343. return openAIResponsesToolSchemaEdit{
  344. start: absoluteStart + 1,
  345. end: absoluteStart + 1,
  346. replacement: `"type":"object",`,
  347. }, true
  348. }
  349. func openAIResponsesSchemaHasObjectOnlyUnion(schema map[string]json.RawMessage, depth int) bool {
  350. if depth > openAIResponsesObjectUnionMaxDepth {
  351. return false
  352. }
  353. found := false
  354. for _, keyword := range []string{"oneOf", "anyOf"} {
  355. raw, ok := schema[keyword]
  356. if !ok {
  357. continue
  358. }
  359. found = true
  360. var branches []json.RawMessage
  361. if json.Unmarshal(raw, &branches) != nil || len(branches) == 0 {
  362. return false
  363. }
  364. for _, branch := range branches {
  365. if !openAIResponsesSchemaIsObjectOnly(branch, depth+1) {
  366. return false
  367. }
  368. }
  369. }
  370. return found
  371. }
  372. func openAIResponsesSchemaIsObjectOnly(raw json.RawMessage, depth int) bool {
  373. if depth > openAIResponsesObjectUnionMaxDepth {
  374. return false
  375. }
  376. var schema map[string]json.RawMessage
  377. if json.Unmarshal(raw, &schema) != nil {
  378. return false
  379. }
  380. if rawType, ok := schema["type"]; ok {
  381. var schemaType string
  382. return json.Unmarshal(rawType, &schemaType) == nil && schemaType == "object"
  383. }
  384. return openAIResponsesSchemaHasObjectOnlyUnion(schema, depth+1)
  385. }
  386. func (p *openAIResponsesToolSchemaParser) parseArray(
  387. context openAIResponsesToolSchemaContext, depth int,
  388. ) error {
  389. p.pos++
  390. p.skipWhitespace()
  391. if p.consume(']') {
  392. return nil
  393. }
  394. childContext := openAIResponsesToolSchemaSkip
  395. switch context {
  396. case openAIResponsesToolSchemaTools:
  397. childContext = openAIResponsesToolSchemaTool
  398. case openAIResponsesToolSchemaInput:
  399. childContext = openAIResponsesToolSchemaInputItem
  400. case openAIResponsesToolSchemaArray, openAIResponsesToolSchemaOrArray:
  401. childContext = openAIResponsesToolSchema
  402. }
  403. for {
  404. if err := p.parseValue(childContext, false, depth); err != nil {
  405. return err
  406. }
  407. p.skipWhitespace()
  408. if p.consume(',') {
  409. continue
  410. }
  411. if !p.consume(']') {
  412. return p.syntaxError("expected comma or closing bracket")
  413. }
  414. return nil
  415. }
  416. }
  417. func openAIResponsesToolSchemaChildContext(
  418. context openAIResponsesToolSchemaContext, key []byte,
  419. ) (openAIResponsesToolSchemaContext, bool) {
  420. switch context {
  421. case openAIResponsesToolSchemaDocument:
  422. switch {
  423. case openAIResponsesJSONStringEquals(key, "tools"):
  424. return openAIResponsesToolSchemaTools, false
  425. case openAIResponsesJSONStringEquals(key, "input"):
  426. return openAIResponsesToolSchemaInput, false
  427. }
  428. case openAIResponsesToolSchemaTool:
  429. switch {
  430. case openAIResponsesJSONStringEquals(key, "parameters"):
  431. return openAIResponsesToolSchema, true
  432. case openAIResponsesJSONStringEquals(key, "function"):
  433. return openAIResponsesToolSchemaFunction, false
  434. case openAIResponsesJSONStringEquals(key, "tools"):
  435. return openAIResponsesToolSchemaTools, false
  436. }
  437. case openAIResponsesToolSchemaToolCarrier:
  438. if openAIResponsesJSONStringEquals(key, "tools") {
  439. return openAIResponsesToolSchemaTools, false
  440. }
  441. case openAIResponsesToolSchemaFunction:
  442. switch {
  443. case openAIResponsesJSONStringEquals(key, "parameters"):
  444. return openAIResponsesToolSchema, true
  445. case openAIResponsesJSONStringEquals(key, "tools"):
  446. return openAIResponsesToolSchemaTools, false
  447. }
  448. case openAIResponsesToolSchema:
  449. switch {
  450. case openAIResponsesJSONStringMatchesAny(key,
  451. "additionalProperties", "additionalItems", "contains", "not", "if", "then", "else",
  452. "propertyNames", "unevaluatedProperties", "unevaluatedItems"):
  453. return openAIResponsesToolSchema, false
  454. case openAIResponsesJSONStringEquals(key, "items"):
  455. return openAIResponsesToolSchemaOrArray, false
  456. case openAIResponsesJSONStringMatchesAny(key, "anyOf", "oneOf", "allOf", "prefixItems"):
  457. return openAIResponsesToolSchemaArray, false
  458. case openAIResponsesJSONStringMatchesAny(key,
  459. "properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies"):
  460. return openAIResponsesToolSchemaMap, false
  461. }
  462. case openAIResponsesToolSchemaMap:
  463. return openAIResponsesToolSchema, false
  464. }
  465. return openAIResponsesToolSchemaSkip, false
  466. }
  467. func (p *openAIResponsesToolSchemaParser) parseString() (int, int, error) {
  468. if p.pos >= len(p.body) || p.body[p.pos] != '"' {
  469. return 0, 0, p.syntaxError("expected string")
  470. }
  471. start := p.pos
  472. p.pos++
  473. for p.pos < len(p.body) {
  474. switch p.body[p.pos] {
  475. case '"':
  476. p.pos++
  477. return start, p.pos, nil
  478. case '\\':
  479. p.pos++
  480. if p.pos >= len(p.body) {
  481. return 0, 0, p.syntaxError("unterminated string escape")
  482. }
  483. switch p.body[p.pos] {
  484. case 'u':
  485. if p.pos+4 >= len(p.body) {
  486. return 0, 0, p.syntaxError("short unicode escape")
  487. }
  488. for _, digit := range p.body[p.pos+1 : p.pos+5] {
  489. if !isOpenAIResponsesJSONHexDigit(digit) {
  490. return 0, 0, p.syntaxError("invalid unicode escape")
  491. }
  492. }
  493. p.pos += 5
  494. continue
  495. case '"', '\\', '/', 'b', 'f', 'n', 'r', 't':
  496. p.pos++
  497. default:
  498. return 0, 0, p.syntaxError("invalid string escape")
  499. }
  500. default:
  501. if p.body[p.pos] < 0x20 {
  502. return 0, 0, p.syntaxError("control character in string")
  503. }
  504. p.pos++
  505. }
  506. }
  507. return 0, 0, p.syntaxError("unterminated string")
  508. }
  509. func isOpenAIResponsesJSONHexDigit(value byte) bool {
  510. return value >= '0' && value <= '9' || value >= 'a' && value <= 'f' || value >= 'A' && value <= 'F'
  511. }
  512. func (p *openAIResponsesToolSchemaParser) parseKeyword(keyword string) error {
  513. if len(p.body)-p.pos < len(keyword) || string(p.body[p.pos:p.pos+len(keyword)]) != keyword {
  514. return p.syntaxError("invalid literal")
  515. }
  516. p.pos += len(keyword)
  517. return nil
  518. }
  519. func (p *openAIResponsesToolSchemaParser) parseNumber() error {
  520. start := p.pos
  521. if p.consume('-') && p.pos >= len(p.body) {
  522. return p.syntaxError("invalid number")
  523. }
  524. if p.consume('0') {
  525. if p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' {
  526. return p.syntaxError("invalid leading zero")
  527. }
  528. } else {
  529. if p.pos >= len(p.body) || p.body[p.pos] < '1' || p.body[p.pos] > '9' {
  530. return p.syntaxError("invalid number")
  531. }
  532. for p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' {
  533. p.pos++
  534. }
  535. }
  536. if p.consume('.') {
  537. fractionStart := p.pos
  538. for p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' {
  539. p.pos++
  540. }
  541. if p.pos == fractionStart {
  542. return p.syntaxError("invalid fraction")
  543. }
  544. }
  545. if p.pos < len(p.body) && (p.body[p.pos] == 'e' || p.body[p.pos] == 'E') {
  546. p.pos++
  547. if p.pos < len(p.body) && (p.body[p.pos] == '+' || p.body[p.pos] == '-') {
  548. p.pos++
  549. }
  550. exponentStart := p.pos
  551. for p.pos < len(p.body) && p.body[p.pos] >= '0' && p.body[p.pos] <= '9' {
  552. p.pos++
  553. }
  554. if p.pos == exponentStart {
  555. return p.syntaxError("invalid exponent")
  556. }
  557. }
  558. if p.pos == start {
  559. return p.syntaxError("invalid value")
  560. }
  561. return nil
  562. }
  563. func (p *openAIResponsesToolSchemaParser) addEdit(start, end int, replacement string) error {
  564. if start < 0 || end < start || end > len(p.body) {
  565. return p.syntaxError("invalid patch span")
  566. }
  567. if len(p.edits) >= openAIResponsesToolSchemaMaxEdits {
  568. return errOpenAIResponsesToolSchemaLimit
  569. }
  570. p.edits = append(p.edits, openAIResponsesToolSchemaEdit{start: start, end: end, replacement: replacement})
  571. return nil
  572. }
  573. func (p *openAIResponsesToolSchemaParser) skipWhitespace() {
  574. for p.pos < len(p.body) {
  575. switch p.body[p.pos] {
  576. case ' ', '\t', '\n', '\r':
  577. p.pos++
  578. default:
  579. return
  580. }
  581. }
  582. }
  583. func (p *openAIResponsesToolSchemaParser) consume(want byte) bool {
  584. if p.pos < len(p.body) && p.body[p.pos] == want {
  585. p.pos++
  586. return true
  587. }
  588. return false
  589. }
  590. func (p *openAIResponsesToolSchemaParser) syntaxError(message string) error {
  591. return fmt.Errorf("sanitize OpenAI Responses tool schemas: %s at byte %d", message, p.pos)
  592. }
  593. func decodeOpenAIResponsesJSONString(raw []byte) (string, error) {
  594. decoded, err := strconv.Unquote(string(raw))
  595. if err != nil {
  596. return "", err
  597. }
  598. return decoded, nil
  599. }
  600. func openAIResponsesJSONStringEquals(raw []byte, want string) bool {
  601. if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' {
  602. return false
  603. }
  604. if !bytes.Contains(raw, []byte{'\\'}) {
  605. return bytes.Equal(raw[1:len(raw)-1], []byte(want))
  606. }
  607. decoded, err := decodeOpenAIResponsesJSONString(raw)
  608. return err == nil && decoded == want
  609. }
  610. func openAIResponsesJSONStringMatchesAny(raw []byte, candidates ...string) bool {
  611. for _, candidate := range candidates {
  612. if openAIResponsesJSONStringEquals(raw, candidate) {
  613. return true
  614. }
  615. }
  616. return false
  617. }
  618. func decodeOpenAIResponsesJSONStringValue(raw []byte) (string, bool) {
  619. if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' {
  620. return "", false
  621. }
  622. decoded, err := decodeOpenAIResponsesJSONString(raw)
  623. return decoded, err == nil
  624. }
  625. func applyOpenAIResponsesToolSchemaEdits(
  626. body []byte, edits []openAIResponsesToolSchemaEdit,
  627. ) ([]byte, bool, error) {
  628. sort.Slice(edits, func(i, j int) bool {
  629. if edits[i].start != edits[j].start {
  630. return edits[i].start < edits[j].start
  631. }
  632. return edits[i].end < edits[j].end
  633. })
  634. merged := edits[:0]
  635. for _, edit := range edits {
  636. if len(merged) == 0 || edit.start >= merged[len(merged)-1].end {
  637. merged = append(merged, edit)
  638. continue
  639. }
  640. previous := &merged[len(merged)-1]
  641. if previous.replacement != "" || edit.replacement != "" {
  642. return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: overlapping patches")
  643. }
  644. if edit.end > previous.end {
  645. previous.end = edit.end
  646. }
  647. }
  648. delta := 0
  649. for _, edit := range merged {
  650. delta += len(edit.replacement) - (edit.end - edit.start)
  651. }
  652. sanitized := make([]byte, 0, len(body)+delta)
  653. cursor := 0
  654. for _, edit := range merged {
  655. sanitized = append(sanitized, body[cursor:edit.start]...)
  656. sanitized = append(sanitized, edit.replacement...)
  657. cursor = edit.end
  658. }
  659. sanitized = append(sanitized, body[cursor:]...)
  660. if !json.Valid(sanitized) {
  661. return nil, false, fmt.Errorf("sanitize OpenAI Responses tool schemas: patch produced invalid JSON")
  662. }
  663. return sanitized, true, nil
  664. }