8000 feat(sdk)!: the JSON the SDK carries through is RFC 8785 canonical at… · felinics/twilight@cef8f18 · GitHub
[go: up one dir, main page]

Skip to content

Commit cef8f18

Browse files
authored
feat(sdk)!: the JSON the SDK carries through is RFC 8785 canonical at its entry points (#54)
CanonicalJSON re-encodes one document in its JCS form (gowebpki/jcs): members sorted, no insignificant whitespace, numbers in binary64 form, so equal documents are equal bytes in every language that implements the RFC. Not one document, an object with a repeated member, or an escaped lone surrogate is ErrInvalidJSON rather than something the parser resolves. ParseToolArguments, ToolArgumentsJSON, JSONOutput and RawJSONOutput apply it, so ToolArguments and ToolOutput hold canonical bytes; RawJSONOutput now returns an error. CanonicalProviderOptions does the same for Request.ProviderOptions. json.Marshal of a Request built this way is deterministic, so a digest over it needs no normalization layer outside the SDK. Numbers have binary64 semantics: exact identifiers are strings.
1 parent ce17305 commit cef8f18

16 files changed

Lines changed: 322 additions & 49 deletions

‎docs/api-reference.md‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ type Request struct {
244244
ReasoningEffort *string
245245
ReasoningSummary *string
246246
PromptCacheKey *string
247-
ProviderOptions map[string]json.RawMessage // keyed by Provider.Name(); each provider decodes its own entry
247+
ProviderOptions map[string]json.RawMessage // keyed by Provider.Name(); each provider decodes its own entry; CanonicalProviderOptions puts the values in canonical form
248248
}
249249
```
250250

@@ -291,8 +291,8 @@ type ToolArguments struct {
291291
Text string // the model's text when it was not a JSON document
292292
}
293293

294-
func ParseToolArguments(text string) ToolArguments // classifies provider text; "" is the empty object
295-
func ToolArgumentsJSON(v any) (ToolArguments, error) // encodes v
294+
func ParseToolArguments(text string) ToolArguments // classifies provider text; "" is the empty object; a document is kept canonical
295+
func ToolArgumentsJSON(v any) (ToolArguments, error) // encodes v in canonical form
296296
func (a ToolArguments) Valid() bool // a document (the zero value counts as {})
297297
func (a ToolArguments) Unmarshal(v any) error // ErrInvalidToolArguments when not Valid
298298
func (a ToolArguments) String() string // the text as the model produced it
@@ -304,11 +304,18 @@ type ToolOutput struct {
304304
}
305305

306306
func TextOutput(text string) ToolOutput
307-
func JSONOutput(v any) (ToolOutput, error)
308-
func RawJSONOutput(raw json.RawMessage) ToolOutput
307+
func JSONOutput(v any) (ToolOutput, error) // encodes v in canonical form
308+
func RawJSONOutput(raw json.RawMessage) (ToolOutput, error) // re-encodes raw in canonical form; ErrInvalidJSON otherwise
309309
func (o ToolOutput) String() string
310310
func (o ToolOutput) IsJSON() bool
311311

312+
// Canonical form: the JSON the SDK carries through (tool arguments, tool
313+
// outputs, provider options) is re-encoded as RFC 8785 (JCS), so equal
314+
// documents are equal bytes and json.Marshal(Request) is deterministic.
315+
func CanonicalJSON(raw []byte) (json.RawMessage, error) // RFC 8785; numbers in binary64 form; ErrInvalidJSON for anything but one document (a repeated member or an escaped lone surrogate included)
316+
func CanonicalProviderOptions(options map[string]json.RawMessage) (map[string]json.RawMessage, error)
317+
var ErrInvalidJSON error
318+
312319
type ProviderMetadata map[string]map[string]string // namespace → key → opaque token
313320

314321
func NewProviderMetadata(namespace string, values map[string]string) ProviderMetadata

‎docs/tools.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ type ToolOutput struct {
6262

6363
`args.Unmarshal(&v)` decodes the document into your type; it returns `ErrInvalidToolArguments` when the model's text was not a JSON document. `sdk.TextOutput(s)`, `sdk.JSONOutput(v)` and `sdk.RawJSONOutput(raw)` build outputs.
6464

65+
The JSON the SDK keeps is canonical in the sense of RFC 8785 (JCS): `ParseToolArguments`, `ToolArgumentsJSON`, `JSONOutput` and `RawJSONOutput` re-encode the document with object members sorted, no insignificant whitespace and numbers in their IEEE-754 binary64 form (`sdk.CanonicalJSON` is the rule; `sdk.CanonicalProviderOptions` applies it to `Request.ProviderOptions`). Two spellings of the same document give the same bytes in every language that implements RFC 8785, and `json.Marshal` of a `Request` built this way is deterministic, so a digest over it needs no further normalization. Numbers have binary64 semantics: `2.0` becomes `2`, and an identifier that must stay exact belongs in a JSON string, not a number. An object with a repeated member is not accepted: `ParseToolArguments` keeps such text as invalid arguments and `RawJSONOutput` returns `ErrInvalidJSON`.
66+
6567
A model sometimes emits arguments that are not valid JSON (a truncated call, for example). Such a call is still reported, with the text in `Text` and `Valid()` false, so you can answer it with an error result and let the model correct itself instead of running the tool on guessed arguments.
6668

6769
## One Model Call

‎go.mod‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ require (
99
github.com/google/jsonschema-go v0.4.2
1010
github.com/google/uuid v1.6.0
1111
github.com/gorilla/websocket v1.5.3
12+
github.com/gowebpki/jcs v1.0.1
1213
)
1314

1415
require (

‎go.sum‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBU
2626
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
2727
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
2828
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
29+
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
30+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2931
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
3032
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
3133
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
@@ -34,3 +36,13 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
3436
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
3537
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
3638
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
39+
github.com/gowebpki/jcs v1.0.1 h1:Qjzg8EOkrOTuWP7DqQ1FbYtcpEbeTzUoTN9bptp8FOU=
40+
github.com/gowebpki/jcs v1.0.1/go.mod h1:CID1cNZ+sHp1CCpAR8mPf6QRtagFBgPJE0FCUQ6+BrI=
41+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
42+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
43+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
44+
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
45+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
46+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
47+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
48+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

‎provider/anthropic/messages/messages_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ func TestDoGenerate_ToolCallMultiTurn(t *testing.T) {
514514
Content: []sdk.MessagePart{sdk.ToolResultPart{
515515
ToolCallID: "toolu_abc",
516516
ToolName: "get_weather",
517-
Result: sdk.RawJSONOutput(json.RawMessage(`{"condition":"sunny","temp":25}`)),
517+
Result: sdk.ToolOutput{JSON: json.RawMessage(`{"condition":"sunny","temp":25}`)},
518518
}},
519519
},
520520
},
@@ -582,7 +582,7 @@ func TestDoGenerate_ToolCallEmptyInput(t *testing.T) {
582582
Content: []sdk.MessagePart{sdk.ToolResultPart{
583583
ToolCallID: "toolu_empty",
584584
ToolName: "list_schedule",
585-
Result: sdk.RawJSONOutput(json.RawMessage(`{"count":0}`)),
585+
Result: sdk.ToolOutput{JSON: json.RawMessage(`{"count":0}`)},
586586
}},
587587
},
588588
},

‎provider/google/generativeai/generativeai_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,7 @@ func TestDoGenerate_ToolCallMultiTurn(t *testing.T) {
342342
Content: []sdk.MessagePart{sdk.ToolResultPart{
343343
ToolCallID: "call_abc",
344344
ToolName: "get_weather",
345-
Result: sdk.RawJSONOutput(json.RawMessage(`{"condition":"sunny","temp":25}`)),
345+
Result: sdk.ToolOutput{JSON: json.RawMessage(`{"condition":"sunny","temp":25}`)},
346346
}},
347347
},
348348
},

‎provider/openai/completions/completions_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,7 @@ func TestDoGenerate_ToolCallMultiTurn(t *testing.T) {
662662
Content: []sdk.MessagePart{sdk.ToolResultPart{
663663
ToolCallID: "call_abc",
664664
ToolName: "get_weather",
665-
Result: sdk.RawJSONOutput(json.RawMessage(`{"condition":"sunny","temp":25}`)),
665+
Result: sdk.ToolOutput{JSON: json.RawMessage(`{"condition":"sunny","temp":25}`)},
666666
}},
667667
},
668668
},

‎provider/openai/responses/responses_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,7 @@ func TestResponsesDoGenerate_ToolCallMultiTurn(t *testing.T) {
501501
Content: []sdk.MessagePart{sdk.ToolResultPart{
502502
ToolCallID: "call_abc",
503503
ToolName: "get_weather",
504-
Result: sdk.RawJSONOutput(json.RawMessage(`{"condition":"sunny","temp":25}`)),
504+
Result: sdk.ToolOutput{JSON: json.RawMessage(`{"condition":"sunny","temp":25}`)},
505505
}},
506506
},
507507
},

‎sdk/canonical_json.go‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package sdk
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"unicode/utf8"
8+
9+
"github.com/gowebpki/jcs"
10+
)
11+
12+
// ErrInvalidJSON reports bytes that are not one JSON document, or a document
13+
// with a repeated object member or an escaped lone surrogate.
14+
var ErrInvalidJSON = errors.New("twilightai: not a canonical-encodable JSON document")
15+
16+
// CanonicalJSON re-encodes one JSON document in its RFC 8785 (JCS) form, so
17+
// that equal documents are equal bytes in every language that implements the
18+
// RFC: object members sorted by UTF-16 code units, no insignificant
19+
// whitespace, minimal string escaping, and numbers in their IEEE-754
20+
// binary64 form (2.0 becomes 2, 1e2 becomes 100). Numbers therefore have
21+
// binary64 semantics: an identifier or quantity that must stay exact belongs
22+
// in a JSON string, not a number.
23+
//
24+
// Input that is not valid UTF-8, not exactly one JSON value, an object with
25+
// a repeated member, or a string with an escaped lone surrogate is
26+
// ErrInvalidJSON. The last two have no single meaning, and the JCS parser
27+
// would otherwise resolve them (the last member wins, the surrogate becomes
28+
// U+FFFD) and let two different inputs canonicalize to the same bytes.
29+
func CanonicalJSON(raw []byte) (json.RawMessage, error) {
30+
if !utf8.Valid(raw) {
31+
return nil, fmt.Errorf("%w: invalid UTF-8", ErrInvalidJSON)
32+
}
33+
if err := rejectEscapedLoneSurrogates(raw); err != nil {
34+
return nil, fmt.Errorf("%w: %w", ErrInvalidJSON, err)
35+
}
36+
canonical, err := jcs.Transform(raw)
37+
if err != nil {
38+
return nil, fmt.Errorf("%w: %w", ErrInvalidJSON, err)
39+
}
40+
return json.RawMessage(canonical), nil
41+
}
42+
43+
// canonicalMarshal encodes v with encoding/json and canonicalizes the result,
44+
// so a value that embeds raw JSON (a json.RawMessage field, a map of them)
45+
// encodes canonically too.
46+
func canonicalMarshal(v any) (json.RawMessage, error) {
47+
raw, err := json.Marshal(v)
48+
if err != nil {
49+
return nil, err
50+
}
51+
return CanonicalJSON(raw)
52+
}
53+
54+
// CanonicalProviderOptions returns options with every value re-encoded by
55+
// CanonicalJSON, so a Request that carries them marshals to the same bytes
56+
// for the same options however they were written. Neither the map nor its
57+
// values are modified; a nil or empty map is returned as is.
58+
func CanonicalProviderOptions(options map[string]json.RawMessage) (map[string]json.RawMessage, error) {
59+
if len(options) == 0 {
60+
return options, nil
61+
}
62+
out := make(map[string]json.RawMessage, len(options))
63+
for namespace, raw := range options {
64+
canonical, err := CanonicalJSON(raw)
65+
if err != nil {
66+
return nil, fmt.Errorf("provider options for %q: %w", namespace, err)
67+
}
68+
out[namespace] = canonical
69+
}
70+
return out, nil
71+
}
72+
73+
var errLoneSurrogate = errors.New("escaped lone surrogate")
74+
75+
// rejectEscapedLoneSurrogates refuses strings whose \u escapes do not form
76+
// valid UTF-16, before the JCS parser replaces them with U+FFFD. It checks
77+
// only escaped UTF-16 structure; JSON syntax stays the parser's job, so a
78+
// truncated escape is left for it to report.
79+
func rejectEscapedLoneSurrogates(raw []byte) error {
80+
for i := 0; i < len(raw); i++ {
81+
if raw[i] != '"' {
82+
continue
83+
}
84+
i++
85+
for i < len(raw) {
86+
switch raw[i] {
87+
case '"':
88+
goto nextToken
89+
case '\\':
90+
if i+1 >= len(raw) {
91+
return nil
92+
}
93+
if raw[i+1] != 'u' && raw[i+1] != 'U' {
94+
i += 2
95+
continue
96+
}
97+
if i+6 > len(raw) {
98+
return nil
99+
}
100+
code, ok := parseHex4(raw[i+2 : i+6])
101+
if !ok {
102+
return nil
103+
}
104+
switch {
105+
case 0xd800 <= code && code <= 0xdbff:
106+
if i+12 > len(raw) || raw[i+6] != '\\' || (raw[i+7] != 'u' && raw[i+7] != 'U') {
107+
return errLoneSurrogate
108+
}
109+
low, ok := parseHex4(raw[i+8 : i+12])
110+
if !ok || low < 0xdc00 || low > 0xdfff {
111+
return errLoneSurrogate
112+
}
113+
i += 12
114+
case 0xdc00 <= code && code <= 0xdfff:
115+
return errLoneSurrogate
116+
default:
117+
i += 6
118+
}
119+
default:
120+
i++
121+
}
122+
}
123+
nextToken:
124+
}
125+
return nil
126+
}
127+
128+
func parseHex4(raw []byte) (rune, bool) {
129+
if len(raw) != 4 {
130+
return 0, false
131+
}
132+
var n rune
133+
for _, b := range raw {
134+
n <<= 4
135+
switch {
136+
case '0' <= b && b <= '9':
137+
n += rune(b - '0')
138+
case 'a' <= b && b <= 'f':
139+
n += rune(b-'a') + 10
140+
case 'A' <= b && b <= 'F':
141+
n += rune(b-'A') + 10
142+
default:
143+
return 0, false
144+
}
145+
}
146+
return n, true
147+
}

‎sdk/canonical_json_test.go‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package sdk
2+
3+
import (
4+
"errors"
5+
"testing"
6+
)
7+
8+
// CanonicalJSON is RFC 8785: equal documents are equal bytes and numbers take
9+
// their binary64 form; anything that is not one document is ErrInvalidJSON.
10+
func TestCanonicalJSON(t *testing.T) {
11+
cases := []struct {
12+
name string
13+
in string
14+
want string // "" means ErrInvalidJSON
15+
}{
16+
{"whitespace and member order", " { \"b\" : [ 1 , 2 ] ,\n\"a\" : { \"d\" : null , \"c\" : true } } ", `{"a":{"c":true,"d":null},"b":[1,2]}`},
17+
{"same document, other spelling", `{"a":{"c":true,"d":null},"b":[1,2]}`, `{"a":{"c":true,"d":null},"b":[1,2]}`},
18+
{"numbers take their binary64 form", `[2, 2.0, 1e2, 1E2, -0, 0.10]`, `[2,2,100,100,0,0.1]`},
19+
{"strings are minimally escaped", `{"s":"a<b>&é😀\n"}`, "{\"s\":\"a<b>&é😀\\n\"}"},
20+
{"members sort by UTF-16 code units", `{"b":1,"B":2,"aa":3,"a":4,"é":5}`, `{"B":2,"a":4,"aa":3,"b":1,"é":5}`},
21+
{"scalar document", ` "x" `, `"x"`},
22+
{"empty object", `{ } `, `{}`},
23+
{"empty array", `[ ]`, `[]`},
24+
{"repeated member", `{"a":1,"a":1}`, ""},
25+
{"nested repeated member", `{"o":{"a":1,"b":2,"a":3}}`, ""},
26+
{"escaped lone surrogate", `{"s":"\ud800"}`, ""},
27+
{"truncated", `{"a":`, ""},
28+
{"trailing data", `{} {}`, ""},
29+
{"empty input", ``, ""},
30+
{"invalid utf-8", "{\"a\":\"\xff\"}", ""},
31+
}
32+
for _, tc := range cases {
33+
t.Run(tc.name, func(t 9AC7 *testing.T) {
34+
got, err := CanonicalJSON([]byte(tc.in))
35+
if tc.want == "" {
36+
if !errors.Is(err, ErrInvalidJSON) {
37+
t.Fatalf("CanonicalJSON(%q) = %s, %v; want ErrInvalidJSON", tc.in, got, err)
38+
}
39+
return
40+
}
41+
if err != nil || string(got) != tc.want {
42+
t.Fatalf("CanonicalJSON(%q) = %s, %v; want %s", tc.in, got, err, tc.want)
43+
}
44+
again, err := CanonicalJSON(got)
45+
if err != nil || string(again) != tc.want {
46+
t.Fatalf("CanonicalJSON is not idempotent: %s -> %s, %v", got, again, err)
47+
}
48+
})
49+
}
50+
}

0 commit comments

Comments
 (0)
0