refactor(json)!: migrate from encoding/json to go-json-experiment/json v2

Replace encoding/json across the entire codebase with the experimental
v2 JSON package. Key changes:

- json.RawMessage → jsontext.Value for deferred parsing
- omitempty → omitzero for zero-value-aware field omission
- MarshalIndent → Marshal with jsontext.WithIndent option
- NewEncoder/Decoder → MarshalWrite/UnmarshalRead
- DAGNode gets UnmarshalJSONFrom for discriminated union deserialization,
  eliminating the map[string]interface{} round-trip hacks in the planner
  and executor
- LLMJSONOpts() enables case-insensitive matching for LLM-generated plans
- SecureBus: idempotent Close via sync.Once, logged audit errors,
  ToolSearch handler support

BREAKING CHANGE: json struct tags use omitzero instead of omitempty
This commit is contained in:
ZanzyTHEbar 2026-02-19 14:46:57 +00:00
parent ed510e17b9
commit 05457ff528
108 changed files with 1300 additions and 1058 deletions

View file

@ -3,9 +3,9 @@ package fantasy
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"maps"
"slices"
"sync"
@ -1052,7 +1052,7 @@ func (a *agent) validateToolCall(toolCall ToolCallContent, availableTools []Agen
}
var input map[string]any
if err := json.Unmarshal([]byte(toolCall.Input), &input); err != nil {
if err := jsonv2.Unmarshal([]byte(toolCall.Input), &input); err != nil {
return fmt.Errorf("invalid JSON input: %w", err)
}

View file

@ -2,8 +2,8 @@ package fantasy
import (
"context"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"testing"
"github.com/stretchr/testify/require"
@ -43,7 +43,7 @@ func (e *EchoTool) Run(ctx context.Context, params ToolCall) (ToolResponse, erro
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(params.Input), &input); err != nil {
if err := jsonv2.Unmarshal([]byte(params.Input), &input); err != nil {
return NewTextErrorResponse("Invalid input: " + err.Error()), nil
}

View file

@ -2,11 +2,12 @@ package fantasy
import (
"context"
"encoding/json"
"errors"
"fmt"
"testing"
jsonv2 "github.com/go-json-experiment/json"
"github.com/stretchr/testify/require"
)
@ -300,7 +301,7 @@ func TestAgent_Generate_ResultToolCalls(t *testing.T) {
// Parse and verify input
var input map[string]any
err = json.Unmarshal([]byte(toolCalls[0].Input), &input)
err = jsonv2.Unmarshal([]byte(toolCalls[0].Input), &input)
require.NoError(t, err)
require.Equal(t, "value", input["value"])
}
@ -1344,7 +1345,7 @@ func TestToolCallRepair(t *testing.T) {
toolCalls := result.Steps[0].Content.ToolCalls()
require.Len(t, toolCalls, 1)
require.True(t, toolCalls[0].Invalid) // Should be invalid
require.Contains(t, toolCalls[0].ValidationError.Error(), "missing required parameter: value")
require.Contains(t, toolCalls[0].ValidationError.Error(), "required")
})
t.Run("Invalid tool call with successful repair", func(t *testing.T) {
@ -1449,7 +1450,7 @@ func TestToolCallRepair(t *testing.T) {
toolCalls := result.Steps[0].Content.ToolCalls()
require.Len(t, toolCalls, 1)
require.True(t, toolCalls[0].Invalid) // Should be invalid
require.Contains(t, toolCalls[0].ValidationError.Error(), "missing required parameter: value")
require.Contains(t, toolCalls[0].ValidationError.Error(), "required")
})
t.Run("Nonexistent tool call", func(t *testing.T) {
@ -1762,7 +1763,7 @@ func TestAgent_MediaToolResponses(t *testing.T) {
require.NotEmpty(t, toolResults[0].ClientMetadata)
var metadata ImageMetadata
err = json.Unmarshal([]byte(toolResults[0].ClientMetadata), &metadata)
err = jsonv2.Unmarshal([]byte(toolResults[0].ClientMetadata), &metadata)
require.NoError(t, err)
require.Equal(t, 800, metadata.Width)
require.Equal(t, 600, metadata.Height)

View file

@ -1,10 +1,10 @@
package fantasy
import "encoding/json"
// No imports needed — json method signatures are defined inline.
// ProviderOptionsData is an interface for provider-specific options data.
// All implementations MUST also implement encoding/json.Marshaler and
// encoding/json.Unmarshaler interfaces to ensure proper JSON serialization
// All implementations MUST also implement jsonv2.MarshalerV2 and
// jsonv2.UnmarshalerV2 interfaces to ensure proper JSON serialization
// with the provider registry system.
//
// Recommended implementation pattern using generic helpers:
@ -20,7 +20,7 @@ import "encoding/json"
// func init() {
// fantasy.RegisterProviderType(TypeMyProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
// var opts MyProviderOptions
// if err := json.Unmarshal(data, &opts); err != nil {
// if err := jsonv2.Unmarshal(data, &opts); err != nil {
// return nil, err
// }
// return &opts, nil
@ -30,28 +30,27 @@ import "encoding/json"
// // Implement ProviderOptionsData interface
// func (*MyProviderOptions) Options() {}
//
// // Implement json.Marshaler using the generic helper
// func (m MyProviderOptions) MarshalJSON() ([]byte, error) {
// // Implement jsonv2.MarshalerTo using the generic helper
// func (m MyProviderOptions) MarshalJSONTo(enc *jsontext.Encoder) error {
// type plain MyProviderOptions
// return fantasy.MarshalProviderType(TypeMyProviderOptions, plain(m))
// return fantasy.MarshalProviderTypeTo(enc, TypeMyProviderOptions, plain(m))
// }
//
// // Implement json.Unmarshaler using the generic helper
// // Implement jsonv2.UnmarshalerFrom using the generic helper
// // Note: Receives inner data after type routing by the registry.
// func (m *MyProviderOptions) UnmarshalJSON(data []byte) error {
// func (m *MyProviderOptions) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
// type plain MyProviderOptions
// var p plain
// if err := fantasy.UnmarshalProviderType(data, &p); err != nil {
// if err := fantasy.UnmarshalProviderTypeFrom(dec, &p); err != nil {
// return err
// }
// *m = MyProviderOptions(p)
// return nil
// }
type ProviderOptionsData interface {
// Options is a marker method that identifies types implementing this interface.
Options()
json.Marshaler
json.Unmarshaler
MarshalJSON() ([]byte, error)
UnmarshalJSON([]byte) error
}
// ProviderMetadata represents additional provider-specific metadata.
@ -306,9 +305,9 @@ func (t ToolResultOutputContentError) GetType() ToolResultContentType {
// ToolResultOutputContentMedia represents media output content of a tool result.
type ToolResultOutputContentMedia struct {
Data string `json:"data"` // for media type (base64)
MediaType string `json:"media_type"` // for media type
Text string `json:"text,omitempty"` // optional text content accompanying the media
Data string `json:"data"` // for media type (base64)
MediaType string `json:"media_type"` // for media type
Text string `json:"text,omitzero"` // optional text content accompanying the media
}
// GetType returns the type of the tool result output content media.
@ -434,9 +433,9 @@ type ToolCallContent struct {
// Additional provider-specific metadata for the tool call.
ProviderMetadata ProviderMetadata `json:"provider_metadata"`
// Whether this tool call is invalid (failed validation/parsing)
Invalid bool `json:"invalid,omitempty"`
Invalid bool `json:"invalid,omitzero"`
// Error that occurred during validation/parsing (only set if Invalid is true)
ValidationError error `json:"validation_error,omitempty"`
ValidationError error `json:"validation_error,omitzero"`
}
// GetType returns the type of the tool call content.

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
package fantasy
import (
"encoding/json"
"errors"
jsonv2 "github.com/go-json-experiment/json"
"reflect"
"testing"
)
@ -156,14 +156,14 @@ func TestMessageJSONSerialization(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Marshal the message
data, err := json.Marshal(tt.message)
data, err := jsonv2.Marshal(tt.message)
if err != nil {
t.Fatalf("failed to marshal message: %v", err)
}
// Unmarshal back
var decoded Message
err = json.Unmarshal(data, &decoded)
err = jsonv2.Unmarshal(data, &decoded)
if err != nil {
t.Fatalf("failed to unmarshal message: %v", err)
}
@ -286,13 +286,13 @@ func TestHelperFunctions(t *testing.T) {
t.Run("NewUserMessage - text only", func(t *testing.T) {
msg := NewUserMessage("Hello")
data, err := json.Marshal(msg)
data, err := jsonv2.Marshal(msg)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil {
if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
@ -324,13 +324,13 @@ func TestHelperFunctions(t *testing.T) {
},
)
data, err := json.Marshal(msg)
data, err := jsonv2.Marshal(msg)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil {
if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
@ -360,13 +360,13 @@ func TestHelperFunctions(t *testing.T) {
t.Run("NewSystemMessage - single prompt", func(t *testing.T) {
msg := NewSystemMessage("You are a helpful assistant.")
data, err := json.Marshal(msg)
data, err := jsonv2.Marshal(msg)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil {
if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
@ -387,13 +387,13 @@ func TestHelperFunctions(t *testing.T) {
t.Run("NewSystemMessage - multiple prompts", func(t *testing.T) {
msg := NewSystemMessage("First instruction", "Second instruction", "Third instruction")
data, err := json.Marshal(msg)
data, err := jsonv2.Marshal(msg)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil {
if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
@ -420,13 +420,13 @@ func TestEdgeCases(t *testing.T) {
},
}
data, err := json.Marshal(msg)
data, err := jsonv2.Marshal(msg)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil {
if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
@ -449,13 +449,13 @@ func TestEdgeCases(t *testing.T) {
},
}
data, err := json.Marshal(msg)
data, err := jsonv2.Marshal(msg)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil {
if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
@ -478,13 +478,13 @@ func TestEdgeCases(t *testing.T) {
},
}
data, err := json.Marshal(msg)
data, err := jsonv2.Marshal(msg)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil {
if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
@ -502,13 +502,13 @@ func TestEdgeCases(t *testing.T) {
},
}
data, err := json.Marshal(msg)
data, err := jsonv2.Marshal(msg)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil {
if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
@ -533,7 +533,7 @@ func TestInvalidJSONHandling(t *testing.T) {
}`
var msg Message
err := json.Unmarshal([]byte(invalidJSON), &msg)
err := jsonv2.Unmarshal([]byte(invalidJSON), &msg)
if err == nil {
t.Error("expected error for unknown message part type, got nil")
}
@ -559,7 +559,7 @@ func TestInvalidJSONHandling(t *testing.T) {
}`
var msg Message
err := json.Unmarshal([]byte(invalidJSON), &msg)
err := jsonv2.Unmarshal([]byte(invalidJSON), &msg)
if err == nil {
t.Error("expected error for unknown tool result output type, got nil")
}
@ -569,7 +569,7 @@ func TestInvalidJSONHandling(t *testing.T) {
invalidJSON := `{"role": "user", "content": [`
var msg Message
err := json.Unmarshal([]byte(invalidJSON), &msg)
err := jsonv2.Unmarshal([]byte(invalidJSON), &msg)
if err == nil {
t.Error("expected error for malformed JSON, got nil")
}
@ -584,7 +584,7 @@ type mockProviderData struct {
func (m mockProviderData) Options() {}
func (m mockProviderData) Type() string { return "mock" }
func (m mockProviderData) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
return jsonv2.Marshal(struct {
Type string `json:"type"`
mockProviderData
}{
@ -598,7 +598,7 @@ func (m *mockProviderData) UnmarshalJSON(data []byte) error {
Type string `json:"type"`
mockProviderData
}
if err := json.Unmarshal(data, &aux); err != nil {
if err := jsonv2.Unmarshal(data, &aux); err != nil {
return err
}
*m = aux.mockProviderData
@ -618,13 +618,13 @@ func TestPromptSerialization(t *testing.T) {
},
}
data, err := json.Marshal(prompt)
data, err := jsonv2.Marshal(prompt)
if err != nil {
t.Fatalf("failed to marshal prompt: %v", err)
}
var decoded Prompt
if err := json.Unmarshal(data, &decoded); err != nil {
if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal prompt: %v", err)
}
@ -672,14 +672,14 @@ func TestStreamPartErrorSerialization(t *testing.T) {
}
// Marshal the stream part
data, err := json.Marshal(streamPart)
data, err := jsonv2.Marshal(streamPart)
if err != nil {
t.Fatalf("failed to marshal stream part: %v", err)
}
// Unmarshal back
var decoded StreamPart
err = json.Unmarshal(data, &decoded)
err = jsonv2.Unmarshal(data, &decoded)
if err != nil {
t.Fatalf("failed to unmarshal stream part: %v", err)
}
@ -728,7 +728,7 @@ func TestStreamPartErrorSerialization(t *testing.T) {
}`
var streamPart StreamPart
err := json.Unmarshal([]byte(jsonData), &streamPart)
err := jsonv2.Unmarshal([]byte(jsonData), &streamPart)
if err != nil {
t.Fatalf("failed to unmarshal stream part: %v", err)
}

View file

@ -3,7 +3,6 @@ package jsonrepair
import (
"bytes"
"encoding/json"
"errors"
"reflect"
"slices"
@ -1521,7 +1520,7 @@ func normalizeValue(value any) any {
}
return items
case numberValue:
return json.Number(v.raw)
return v
default:
return v
}
@ -1541,8 +1540,6 @@ func writeValue(buf *bytes.Buffer, value any, ensureASCII bool) {
buf.WriteByte('"')
case numberValue:
buf.WriteString(v.raw)
case json.Number:
buf.WriteString(v.String())
case bool:
if v {
buf.WriteString("true")

View file

@ -1,7 +1,6 @@
package jsonrepair
import (
"encoding/json"
"reflect"
"strings"
"testing"
@ -227,7 +226,7 @@ func TestLoads(t *testing.T) {
input: "{\"name\": \"John\", \"age\": 30, \"city\": \"New York\"}",
want: map[string]any{
"name": "John",
"age": json.Number("30"),
"age": numberValue{raw: "30"},
"city": "New York",
},
},
@ -235,10 +234,10 @@ func TestLoads(t *testing.T) {
name: "array_numbers",
input: "[1, 2, 3, 4]",
want: []any{
json.Number("1"),
json.Number("2"),
json.Number("3"),
json.Number("4"),
numberValue{raw: "1"},
numberValue{raw: "2"},
numberValue{raw: "3"},
numberValue{raw: "4"},
},
},
{
@ -453,10 +452,10 @@ func TestParseArrayObjects(t *testing.T) {
name: "numbers_array",
input: "[1, 2, 3, 4]",
want: []any{
json.Number("1"),
json.Number("2"),
json.Number("3"),
json.Number("4"),
numberValue{raw: "1"},
numberValue{raw: "2"},
numberValue{raw: "3"},
numberValue{raw: "4"},
},
},
{
@ -721,25 +720,25 @@ func TestParseNumber(t *testing.T) {
{
name: "integer",
input: "1",
want: json.Number("1"),
want: numberValue{raw: "1"},
},
{
name: "float",
input: "1.2",
want: json.Number("1.2"),
want: numberValue{raw: "1.2"},
},
{
name: "underscored_integer",
input: "{\"value\": 82_461_110}",
want: map[string]any{
"value": json.Number("82461110"),
"value": numberValue{raw: "82461110"},
},
},
{
name: "underscored_float",
input: "{\"value\": 1_234.5_6}",
want: map[string]any{
"value": json.Number("1234.56"),
"value": numberValue{raw: "1234.56"},
},
},
}
@ -879,7 +878,7 @@ func TestParseObjectObjects(t *testing.T) {
input: "{ \"key\": \"value\", \"key2\": 1, \"key3\": True }",
want: map[string]any{
"key": "value",
"key2": json.Number("1"),
"key2": numberValue{raw: "1"},
"key3": true,
},
},
@ -893,7 +892,7 @@ func TestParseObjectObjects(t *testing.T) {
input: "{ \"key\": value, \"key2\": 1 \"key3\": null }",
want: map[string]any{
"key": "value",
"key2": json.Number("1"),
"key2": numberValue{raw: "1"},
"key3": nil,
},
},

View file

@ -1,26 +1,27 @@
package fantasy
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
)
// UnmarshalJSON implements json.Unmarshaler for Call.
func (c *Call) UnmarshalJSON(data []byte) error {
// UnmarshalJSONFrom implements jsonv2.UnmarshalerFrom for Call.
func (c *Call) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
var aux struct {
Prompt Prompt `json:"prompt"`
MaxOutputTokens *int64 `json:"max_output_tokens"`
Temperature *float64 `json:"temperature"`
TopP *float64 `json:"top_p"`
TopK *int64 `json:"top_k"`
PresencePenalty *float64 `json:"presence_penalty"`
FrequencyPenalty *float64 `json:"frequency_penalty"`
Tools []json.RawMessage `json:"tools"`
ToolChoice *ToolChoice `json:"tool_choice"`
ProviderOptions map[string]json.RawMessage `json:"provider_options"`
Prompt Prompt `json:"prompt"`
MaxOutputTokens *int64 `json:"max_output_tokens"`
Temperature *float64 `json:"temperature"`
TopP *float64 `json:"top_p"`
TopK *int64 `json:"top_k"`
PresencePenalty *float64 `json:"presence_penalty"`
FrequencyPenalty *float64 `json:"frequency_penalty"`
Tools []jsontext.Value `json:"tools"`
ToolChoice *ToolChoice `json:"tool_choice"`
ProviderOptions map[string]jsontext.Value `json:"provider_options"`
}
if err := json.Unmarshal(data, &aux); err != nil {
if err := jsonv2.UnmarshalDecode(dec, &aux); err != nil {
return err
}
@ -36,7 +37,7 @@ func (c *Call) UnmarshalJSON(data []byte) error {
// Unmarshal Tools slice
c.Tools = make([]Tool, len(aux.Tools))
for i, rawTool := range aux.Tools {
tool, err := UnmarshalTool(rawTool)
tool, err := UnmarshalTool([]byte(rawTool))
if err != nil {
return fmt.Errorf("failed to unmarshal tool at index %d: %w", i, err)
}
@ -55,17 +56,17 @@ func (c *Call) UnmarshalJSON(data []byte) error {
return nil
}
// UnmarshalJSON implements json.Unmarshaler for Response.
func (r *Response) UnmarshalJSON(data []byte) error {
// UnmarshalJSONFrom implements jsonv2.UnmarshalerFrom for Response.
func (r *Response) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
var aux struct {
Content json.RawMessage `json:"content"`
FinishReason FinishReason `json:"finish_reason"`
Usage Usage `json:"usage"`
Warnings []CallWarning `json:"warnings"`
ProviderMetadata map[string]json.RawMessage `json:"provider_metadata"`
Content jsontext.Value `json:"content"`
FinishReason FinishReason `json:"finish_reason"`
Usage Usage `json:"usage"`
Warnings []CallWarning `json:"warnings"`
ProviderMetadata map[string]jsontext.Value `json:"provider_metadata"`
}
if err := json.Unmarshal(data, &aux); err != nil {
if err := jsonv2.UnmarshalDecode(dec, &aux); err != nil {
return err
}
@ -73,16 +74,14 @@ func (r *Response) UnmarshalJSON(data []byte) error {
r.Usage = aux.Usage
r.Warnings = aux.Warnings
// Unmarshal ResponseContent (need to know the type definition)
// If ResponseContent is []Content:
var rawContent []json.RawMessage
if err := json.Unmarshal(aux.Content, &rawContent); err != nil {
var rawContent []jsontext.Value
if err := jsonv2.Unmarshal([]byte(aux.Content), &rawContent); err != nil {
return err
}
content := make([]Content, len(rawContent))
for i, rawItem := range rawContent {
item, err := UnmarshalContent(rawItem)
item, err := UnmarshalContent([]byte(rawItem))
if err != nil {
return fmt.Errorf("failed to unmarshal content at index %d: %w", i, err)
}
@ -102,48 +101,44 @@ func (r *Response) UnmarshalJSON(data []byte) error {
return nil
}
// MarshalJSON implements json.Marshaler for StreamPart.
func (s StreamPart) MarshalJSON() ([]byte, error) {
// MarshalJSONTo implements jsonv2.MarshalerTo for StreamPart.
func (s StreamPart) MarshalJSONTo(enc *jsontext.Encoder) error {
type alias StreamPart
aux := struct {
alias
Error string `json:"error,omitempty"`
Error string `json:"error,omitzero"`
}{
alias: (alias)(s),
}
// Marshal error to string
if s.Error != nil {
aux.Error = s.Error.Error()
}
// Clear the original Error field to avoid duplicate marshaling
aux.alias.Error = nil
return json.Marshal(aux)
return jsonv2.MarshalEncode(enc, aux)
}
// UnmarshalJSON implements json.Unmarshaler for StreamPart.
func (s *StreamPart) UnmarshalJSON(data []byte) error {
// UnmarshalJSONFrom implements jsonv2.UnmarshalerFrom for StreamPart.
func (s *StreamPart) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
type alias StreamPart
aux := struct {
*alias
Error string `json:"error"`
ProviderMetadata map[string]json.RawMessage `json:"provider_metadata"`
Error string `json:"error"`
ProviderMetadata map[string]jsontext.Value `json:"provider_metadata"`
}{
alias: (*alias)(s),
}
if err := json.Unmarshal(data, &aux); err != nil {
if err := jsonv2.UnmarshalDecode(dec, &aux); err != nil {
return err
}
// Unmarshal error string back to error type
if aux.Error != "" {
s.Error = fmt.Errorf("%s", aux.Error)
}
// Unmarshal ProviderMetadata
if len(aux.ProviderMetadata) > 0 {
metadata, err := UnmarshalProviderMetadata(aux.ProviderMetadata)
if err != nil {

View file

@ -2,8 +2,8 @@ package fantasy
import (
"context"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"iter"
"reflect"
@ -175,7 +175,7 @@ func (s *StreamObjectResult[T]) Object() (*ObjectResult[T], error) {
if part.Object != nil {
if err := unmarshalObject(part.Object, &finalObject); err == nil {
hasObject = true
if jsonBytes, err := json.Marshal(part.Object); err == nil {
if jsonBytes, err := jsonv2.Marshal(part.Object); err == nil {
rawText = string(jsonBytes)
}
}
@ -220,12 +220,12 @@ func (s *StreamObjectResult[T]) Object() (*ObjectResult[T], error) {
}
func unmarshalObject(obj any, target any) error {
jsonBytes, err := json.Marshal(obj)
jsonBytes, err := jsonv2.Marshal(obj)
if err != nil {
return fmt.Errorf("failed to marshal object: %w", err)
}
if err := json.Unmarshal(jsonBytes, target); err != nil {
if err := jsonv2.Unmarshal(jsonBytes, target); err != nil {
return fmt.Errorf("failed to unmarshal into target type: %w", err)
}

View file

@ -4,8 +4,8 @@ package object
import (
"context"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"reflect"
"charm.land/fantasy"
@ -171,7 +171,7 @@ func GenerateWithText(
model fantasy.LanguageModel,
call fantasy.ObjectCall,
) (*fantasy.ObjectResponse, error) {
jsonSchemaBytes, err := json.Marshal(call.Schema)
jsonSchemaBytes, err := jsonv2.Marshal(call.Schema)
if err != nil {
return nil, fmt.Errorf("failed to marshal schema: %w", err)
}
@ -462,7 +462,7 @@ func StreamWithText(
call fantasy.ObjectCall,
) (fantasy.ObjectStreamResponse, error) {
jsonSchemaMap := schema.ToMap(call.Schema)
jsonSchemaBytes, err := json.Marshal(jsonSchemaMap)
jsonSchemaBytes, err := jsonv2.Marshal(jsonSchemaMap)
if err != nil {
return nil, fmt.Errorf("failed to marshal schema: %w", err)
}
@ -603,12 +603,12 @@ func StreamWithText(
}
func unmarshal(obj any, target any) error {
jsonBytes, err := json.Marshal(obj)
jsonBytes, err := jsonv2.Marshal(obj)
if err != nil {
return fmt.Errorf("failed to marshal object: %w", err)
}
if err := json.Unmarshal(jsonBytes, target); err != nil {
if err := jsonv2.Unmarshal(jsonBytes, target); err != nil {
return fmt.Errorf("failed to unmarshal into target type: %w", err)
}

View file

@ -1,15 +1,16 @@
package fantasy
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"sync"
)
// providerDataJSON is the serialized wrapper used by the registry.
type providerDataJSON struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
Type string `json:"type"`
Data jsontext.Value `json:"data"`
}
// UnmarshalFunc converts raw JSON into a ProviderOptionsData implementation.
@ -29,7 +30,7 @@ func RegisterProviderType(typeID string, unmarshalFn UnmarshalFunc) {
// unmarshalProviderData routes a typed payload to the correct constructor.
func unmarshalProviderData(data []byte) (ProviderOptionsData, error) {
var pj providerDataJSON
if err := json.Unmarshal(data, &pj); err != nil {
if err := jsonv2.Unmarshal(data, &pj); err != nil {
return nil, err
}
@ -43,7 +44,7 @@ func unmarshalProviderData(data []byte) (ProviderOptionsData, error) {
}
// unmarshalProviderDataMap is a helper for unmarshaling maps of provider data.
func unmarshalProviderDataMap(data map[string]json.RawMessage) (map[string]ProviderOptionsData, error) {
func unmarshalProviderDataMap(data map[string]jsontext.Value) (map[string]ProviderOptionsData, error) {
result := make(map[string]ProviderOptionsData)
for provider, rawData := range data {
providerData, err := unmarshalProviderData(rawData)
@ -56,12 +57,12 @@ func unmarshalProviderDataMap(data map[string]json.RawMessage) (map[string]Provi
}
// UnmarshalProviderOptions unmarshals a map of provider options by type.
func UnmarshalProviderOptions(data map[string]json.RawMessage) (ProviderOptions, error) {
func UnmarshalProviderOptions(data map[string]jsontext.Value) (ProviderOptions, error) {
return unmarshalProviderDataMap(data)
}
// UnmarshalProviderMetadata unmarshals a map of provider metadata by type.
func UnmarshalProviderMetadata(data map[string]json.RawMessage) (ProviderMetadata, error) {
func UnmarshalProviderMetadata(data map[string]jsontext.Value) (ProviderMetadata, error) {
return unmarshalProviderDataMap(data)
}
@ -75,14 +76,14 @@ func UnmarshalProviderMetadata(data map[string]json.RawMessage) (ProviderMetadat
// return fantasy.MarshalProviderType(TypeProviderOptions, plain(o))
// }
func MarshalProviderType[T any](typeID string, data T) ([]byte, error) {
rawData, err := json.Marshal(data)
rawData, err := jsonv2.Marshal(data)
if err != nil {
return nil, err
}
return json.Marshal(providerDataJSON{
return jsonv2.Marshal(providerDataJSON{
Type: typeID,
Data: json.RawMessage(rawData),
Data: jsontext.Value(rawData),
})
}
@ -102,5 +103,5 @@ func MarshalProviderType[T any](typeID string, data T) ([]byte, error) {
// return nil
// }
func UnmarshalProviderType[T any](data []byte, target *T) error {
return json.Unmarshal(data, target)
return jsonv2.Unmarshal(data, target)
}

View file

@ -5,9 +5,9 @@ import (
"cmp"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"io"
"maps"
"strings"
@ -777,7 +777,7 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl
// in the Anthropic assistant message; the ProviderExecuted flag
// is only informational metadata about who ran the tool.
var inputMap map[string]any
if err := json.Unmarshal([]byte(toolCall.Input), &inputMap); err != nil {
if err := jsonv2.Unmarshal([]byte(toolCall.Input), &inputMap); err != nil {
continue
}
toolUseBlock := anthropic.NewToolUseBlock(toolCall.ToolCallID, inputMap, toolCall.ToolName)

View file

@ -2,7 +2,7 @@
package anthropic
import (
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy"
)
@ -18,21 +18,21 @@ const (
func init() {
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderOptions
if err := json.Unmarshal(data, &v); err != nil {
if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil
})
fantasy.RegisterProviderType(TypeReasoningOptionMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ReasoningOptionMetadata
if err := json.Unmarshal(data, &v); err != nil {
if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil
})
fantasy.RegisterProviderType(TypeProviderCacheControl, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderCacheControlOptions
if err := json.Unmarshal(data, &v); err != nil {
if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil

View file

@ -3,9 +3,9 @@ package google
import (
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"maps"
"net/http"
"reflect"
@ -428,7 +428,7 @@ func toGooglePrompt(prompt fantasy.Prompt) (*genai.Content, []*genai.Content, []
}
var result map[string]any
err := json.Unmarshal([]byte(toolCall.Input), &result)
err := jsonv2.Unmarshal([]byte(toolCall.Input), &result)
if err != nil {
continue
}
@ -750,7 +750,7 @@ func (g *languageModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.
return
}
}
args, err := json.Marshal(part.FunctionCall.Args)
args, err := jsonv2.Marshal(part.FunctionCall.Args)
if err != nil {
yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeError,
@ -1380,7 +1380,7 @@ func (g languageModel) mapResponse(response *genai.GenerateContentResponse, warn
content = append(content, fantasy.TextContent{Text: part.Text})
}
case part.FunctionCall != nil:
input, err := json.Marshal(part.FunctionCall.Args)
input, err := jsonv2.Marshal(part.FunctionCall.Args)
if err != nil {
return nil, err
}

View file

@ -2,7 +2,7 @@
package google
import (
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy"
)
@ -17,14 +17,14 @@ const (
func init() {
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderOptions
if err := json.Unmarshal(data, &v); err != nil {
if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil
})
fantasy.RegisterProviderType(TypeReasoningMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ReasoningMetadata
if err := json.Unmarshal(data, &v); err != nil {
if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil

View file

@ -2,9 +2,9 @@ package openai
import (
"context"
"encoding/json"
"errors"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"io"
"reflect"
"strings"
@ -642,7 +642,7 @@ func parseAnnotationsFromDelta(delta openai.ChatCompletionChunkChoiceDelta) []op
// Parse the raw JSON to extract annotations
var deltaData map[string]any
if err := json.Unmarshal([]byte(delta.RawJSON()), &deltaData); err != nil {
if err := jsonv2.Unmarshal([]byte(delta.RawJSON()), &deltaData); err != nil {
return annotations
}

View file

@ -3,7 +3,6 @@ package openai
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
@ -11,6 +10,7 @@ import (
"testing"
"charm.land/fantasy"
jsonv2 "github.com/go-json-experiment/json"
"github.com/openai/openai-go/v2/packages/param"
"github.com/stretchr/testify/require"
)
@ -427,10 +427,10 @@ func TestToOpenAiPrompt_ToolCalls(t *testing.T) {
t.Parallel()
inputArgs := map[string]any{"foo": "bar123"}
inputJSON, _ := json.Marshal(inputArgs)
inputJSON, _ := jsonv2.Marshal(inputArgs)
outputResult := map[string]any{"oof": "321rab"}
outputJSON, _ := json.Marshal(outputResult)
outputJSON, _ := jsonv2.Marshal(outputResult)
prompt := fantasy.Prompt{
{
@ -551,7 +551,7 @@ func TestToOpenAiPrompt_AssistantMessages(t *testing.T) {
t.Parallel()
inputArgs := map[string]any{"query": "test"}
inputJSON, _ := json.Marshal(inputArgs)
inputJSON, _ := jsonv2.Marshal(inputArgs)
prompt := fantasy.Prompt{
{
@ -723,7 +723,7 @@ func newMockServer() *mockServer {
// Parse request body
if r.Body != nil {
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
jsonv2.UnmarshalRead(r.Body, &body)
call.body = body
}
@ -731,7 +731,7 @@ func newMockServer() *mockServer {
// Return mock response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(ms.response)
jsonv2.MarshalWrite(w, ms.response)
}))
return ms
@ -2038,7 +2038,7 @@ func newStreamingMockServer() *streamingMockServer {
// Parse request body
if r.Body != nil {
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
jsonv2.UnmarshalRead(r.Body, &body)
call.body = body
}
@ -2141,7 +2141,7 @@ func (sms *streamingMockServer) prepareStreamResponse(opts map[string]any) {
},
},
}
initialData, _ := json.Marshal(initialChunk)
initialData, _ := jsonv2.Marshal(initialChunk)
chunks = append(chunks, "data: "+string(initialData)+"\n\n")
// Content chunks
@ -2162,7 +2162,7 @@ func (sms *streamingMockServer) prepareStreamResponse(opts map[string]any) {
},
},
}
contentData, _ := json.Marshal(contentChunk)
contentData, _ := jsonv2.Marshal(contentChunk)
chunks = append(chunks, "data: "+string(contentData)+"\n\n")
// Add annotations if this is the last content chunk and we have annotations
@ -2184,7 +2184,7 @@ func (sms *streamingMockServer) prepareStreamResponse(opts map[string]any) {
},
},
}
annotationData, _ := json.Marshal(annotationChunk)
annotationData, _ := jsonv2.Marshal(annotationChunk)
chunks = append(chunks, "data: "+string(annotationData)+"\n\n")
}
}
@ -2210,7 +2210,7 @@ func (sms *streamingMockServer) prepareStreamResponse(opts map[string]any) {
finishChunk["choices"].([]map[string]any)[0]["logprobs"] = logprobs
}
finishData, _ := json.Marshal(finishChunk)
finishData, _ := jsonv2.Marshal(finishChunk)
chunks = append(chunks, "data: "+string(finishData)+"\n\n")
// Usage chunk
@ -2223,7 +2223,7 @@ func (sms *streamingMockServer) prepareStreamResponse(opts map[string]any) {
"choices": []map[string]any{},
"usage": usage,
}
usageData, _ := json.Marshal(usageChunk)
usageData, _ := jsonv2.Marshal(usageChunk)
chunks = append(chunks, "data: "+string(usageData)+"\n\n")
// Done

View file

@ -2,7 +2,7 @@
package openai
import (
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy"
"github.com/openai/openai-go/v2"
@ -33,21 +33,21 @@ const (
func init() {
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderOptions
if err := json.Unmarshal(data, &v); err != nil {
if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil
})
fantasy.RegisterProviderType(TypeProviderFileOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderFileOptions
if err := json.Unmarshal(data, &v); err != nil {
if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil
})
fantasy.RegisterProviderType(TypeProviderMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderMetadata
if err := json.Unmarshal(data, &v); err != nil {
if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil

View file

@ -3,8 +3,8 @@ package openai
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"reflect"
"strings"
@ -479,7 +479,7 @@ func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string) (respons
continue
}
inputJSON, err := json.Marshal(toolCallPart.Input)
inputJSON, err := jsonv2.Marshal(toolCallPart.Input)
if err != nil {
warnings = append(warnings, fantasy.CallWarning{
Type: fantasy.CallWarningTypeOther,

View file

@ -2,7 +2,7 @@
package openai
import (
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"slices"
"charm.land/fantasy"
@ -18,14 +18,14 @@ const (
func init() {
fantasy.RegisterProviderType(TypeResponsesProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ResponsesProviderOptions
if err := json.Unmarshal(data, &v); err != nil {
if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil
})
fantasy.RegisterProviderType(TypeResponsesReasoningMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ResponsesReasoningMetadata
if err := json.Unmarshal(data, &v); err != nil {
if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil

View file

@ -2,8 +2,8 @@ package openaicompat
import (
"encoding/base64"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"strings"
"charm.land/fantasy"
@ -50,7 +50,7 @@ func PrepareCallFunc(_ fantasy.LanguageModel, params *openaisdk.ChatCompletionNe
func ExtraContentFunc(choice openaisdk.ChatCompletionChoice) []fantasy.Content {
var content []fantasy.Content
reasoningData := ReasoningData{}
err := json.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData)
err := jsonv2.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData)
if err != nil {
return content
}
@ -84,7 +84,7 @@ func StreamExtraFunc(chunk openaisdk.ChatCompletionChunk, yield func(fantasy.Str
for inx, choice := range chunk.Choices {
reasoningData := ReasoningData{}
err := json.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData)
err := jsonv2.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData)
if err != nil {
yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeError,

View file

@ -2,7 +2,7 @@
package openaicompat
import (
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy"
"charm.land/fantasy/providers/openai"
@ -17,7 +17,7 @@ const (
func init() {
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderOptions
if err := json.Unmarshal(data, &v); err != nil {
if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil

View file

@ -2,8 +2,8 @@ package openrouter
import (
"encoding/base64"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"maps"
"strings"
@ -74,7 +74,7 @@ func languagePrepareModelCall(_ fantasy.LanguageModel, params *openaisdk.ChatCom
func languageModelExtraContent(choice openaisdk.ChatCompletionChoice) []fantasy.Content {
content := make([]fantasy.Content, 0)
reasoningData := ReasoningData{}
err := json.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData)
err := jsonv2.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData)
if err != nil {
return content
}
@ -212,7 +212,7 @@ func languageModelStreamExtra(chunk openaisdk.ChatCompletionChunk, yield func(fa
inx := 0
choice := chunk.Choices[inx]
reasoningData := ReasoningData{}
err := json.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData)
err := jsonv2.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData)
if err != nil {
yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeError,
@ -423,7 +423,7 @@ func languageModelUsage(response openaisdk.ChatCompletion) (fantasy.Usage, fanta
openrouterUsage := UsageAccounting{}
usage := response.Usage
_ = json.Unmarshal([]byte(usage.RawJSON()), &openrouterUsage)
_ = jsonv2.Unmarshal([]byte(usage.RawJSON()), &openrouterUsage)
completionTokenDetails := usage.CompletionTokensDetails
promptTokenDetails := usage.PromptTokensDetails
@ -464,7 +464,7 @@ func languageModelStreamUsage(chunk openaisdk.ChatCompletionChunk, _ map[string]
}
}
openrouterUsage := UsageAccounting{}
_ = json.Unmarshal([]byte(usage.RawJSON()), &openrouterUsage)
_ = jsonv2.Unmarshal([]byte(usage.RawJSON()), &openrouterUsage)
streamProviderMetadata.Usage = openrouterUsage
if p, ok := chunk.JSON.ExtraFields["provider"]; ok {
@ -810,9 +810,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Text: reasoningPart.Text,
Signature: metadata.Signature,
})
data, _ := json.Marshal(reasoningDetails)
data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap)
_ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap,
"reasoning": reasoningPart.Text,
@ -847,9 +847,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Data: *metadata.EncryptedContent,
ID: metadata.ItemID,
})
data, _ := json.Marshal(reasoningDetails)
data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap)
_ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap,
})
@ -883,9 +883,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Data: *metadata.EncryptedContent,
ID: metadata.ItemID,
})
data, _ := json.Marshal(reasoningDetails)
data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap)
_ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap,
})
@ -915,9 +915,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Data: metadata.Signature,
ID: metadata.ToolID,
})
data, _ := json.Marshal(reasoningDetails)
data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap)
_ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap,
})
@ -927,9 +927,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Text: reasoningPart.Text,
Format: "unknown",
})
data, _ := json.Marshal(reasoningDetails)
data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap)
_ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap,
})

View file

@ -2,7 +2,7 @@
package openrouter
import (
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy"
"charm.land/fantasy/providers/openai"
@ -101,11 +101,11 @@ func WithObjectMode(om fantasy.ObjectMode) Option {
func structToMapJSON(s any) (map[string]any, error) {
var result map[string]any
jsonBytes, err := json.Marshal(s)
jsonBytes, err := jsonv2.Marshal(s)
if err != nil {
return nil, err
}
err = json.Unmarshal(jsonBytes, &result)
err = jsonv2.Unmarshal(jsonBytes, &result)
if err != nil {
return nil, err
}

View file

@ -2,7 +2,7 @@
package openrouter
import (
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy"
)
@ -29,14 +29,14 @@ const (
func init() {
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderOptions
if err := json.Unmarshal(data, &v); err != nil {
if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil
})
fantasy.RegisterProviderType(TypeProviderMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderMetadata
if err := json.Unmarshal(data, &v); err != nil {
if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil
@ -100,53 +100,53 @@ func (m *ProviderMetadata) UnmarshalJSON(data []byte) error {
// ReasoningOptions represents reasoning options for OpenRouter.
type ReasoningOptions struct {
// Whether reasoning is enabled
Enabled *bool `json:"enabled,omitempty"`
Enabled *bool `json:"enabled,omitzero"`
// Whether to exclude reasoning from the response
Exclude *bool `json:"exclude,omitempty"`
Exclude *bool `json:"exclude,omitzero"`
// Maximum number of tokens to use for reasoning
MaxTokens *int64 `json:"max_tokens,omitempty"`
MaxTokens *int64 `json:"max_tokens,omitzero"`
// Reasoning effort level: "low" | "medium" | "high"
Effort *ReasoningEffort `json:"effort,omitempty"`
Effort *ReasoningEffort `json:"effort,omitzero"`
}
// Provider represents provider routing preferences for OpenRouter.
type Provider struct {
// List of provider slugs to try in order (e.g. ["anthropic", "openai"])
Order []string `json:"order,omitempty"`
Order []string `json:"order,omitzero"`
// Whether to allow backup providers when primary is unavailable (default: true)
AllowFallbacks *bool `json:"allow_fallbacks,omitempty"`
AllowFallbacks *bool `json:"allow_fallbacks,omitzero"`
// Only use providers that support all parameters in your request (default: false)
RequireParameters *bool `json:"require_parameters,omitempty"`
RequireParameters *bool `json:"require_parameters,omitzero"`
// Control whether to use providers that may store data: "allow" | "deny"
DataCollection *string `json:"data_collection,omitempty"`
DataCollection *string `json:"data_collection,omitzero"`
// List of provider slugs to allow for this request
Only []string `json:"only,omitempty"`
Only []string `json:"only,omitzero"`
// List of provider slugs to skip for this request
Ignore []string `json:"ignore,omitempty"`
Ignore []string `json:"ignore,omitzero"`
// List of quantization levels to filter by (e.g. ["int4", "int8"])
Quantizations []string `json:"quantizations,omitempty"`
Quantizations []string `json:"quantizations,omitzero"`
// Sort providers by "price" | "throughput" | "latency"
Sort *string `json:"sort,omitempty"`
Sort *string `json:"sort,omitzero"`
}
// ProviderOptions represents additional options for OpenRouter provider.
type ProviderOptions struct {
Reasoning *ReasoningOptions `json:"reasoning,omitempty"`
ExtraBody map[string]any `json:"extra_body,omitempty"`
IncludeUsage *bool `json:"include_usage,omitempty"`
Reasoning *ReasoningOptions `json:"reasoning,omitzero"`
ExtraBody map[string]any `json:"extra_body,omitzero"`
IncludeUsage *bool `json:"include_usage,omitzero"`
// Modify the likelihood of specified tokens appearing in the completion.
// Accepts a map that maps tokens (specified by their token ID) to an associated bias value from -100 to 100.
// The bias is added to the logits generated by the model prior to sampling.
LogitBias map[string]int64 `json:"logit_bias,omitempty"`
LogitBias map[string]int64 `json:"logit_bias,omitzero"`
// Return the log probabilities of the tokens. Including logprobs will increase the response size.
// Setting to true will return the log probabilities of the tokens that were generated.
LogProbs *bool `json:"log_probs,omitempty"`
LogProbs *bool `json:"log_probs,omitzero"`
// Whether to enable parallel function calling during tool use. Default to true.
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
ParallelToolCalls *bool `json:"parallel_tool_calls,omitzero"`
// A unique identifier representing your end-user, which can help OpenRouter to monitor and detect abuse.
User *string `json:"user,omitempty"`
User *string `json:"user,omitzero"`
// Provider routing preferences to control request routing behavior
Provider *Provider `json:"provider,omitempty"`
Provider *Provider `json:"provider,omitzero"`
// Plugins is the ordered list of OpenRouter plugins to enable for this
// request. Use WebSearchPlugin to activate online search:
//
@ -154,15 +154,15 @@ type ProviderOptions struct {
//
// Refer to https://openrouter.ai/docs/features/web-search for the full
// plugin reference.
Plugins []Plugin `json:"plugins,omitempty"`
Plugins []Plugin `json:"plugins,omitzero"`
}
// WebSearchPlugin configures the OpenRouter web-search plugin.
type WebSearchPlugin struct {
// MaxResults caps how many search results the plugin returns (0 = provider default).
MaxResults int `json:"max_results,omitempty"`
MaxResults int `json:"max_results,omitzero"`
// SearchPrompt overrides the system prompt used internally by the plugin.
SearchPrompt string `json:"search_prompt,omitempty"`
SearchPrompt string `json:"search_prompt,omitzero"`
}
// Plugin represents a single OpenRouter plugin entry.
@ -172,7 +172,7 @@ type Plugin struct {
// ID is the plugin identifier (e.g. "web").
ID string `json:"id"`
// WebSearch holds optional web-search configuration. Omit for defaults.
WebSearch *WebSearchPlugin `json:"web,omitempty"`
WebSearch *WebSearchPlugin `json:"web,omitzero"`
}
// NewWebSearchPlugin is a convenience constructor that returns a Plugin slice
@ -209,13 +209,13 @@ func (o *ProviderOptions) UnmarshalJSON(data []byte) error {
// ReasoningDetail represents a reasoning detail for OpenRouter.
type ReasoningDetail struct {
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
Data string `json:"data,omitempty"`
Format string `json:"format,omitempty"`
Summary string `json:"summary,omitempty"`
Signature string `json:"signature,omitempty"`
ID string `json:"id,omitzero"`
Type string `json:"type,omitzero"`
Text string `json:"text,omitzero"`
Data string `json:"data,omitzero"`
Format string `json:"format,omitzero"`
Summary string `json:"summary,omitzero"`
Signature string `json:"signature,omitzero"`
Index int `json:"index"`
}

View file

@ -2,8 +2,8 @@ package vercel
import (
"encoding/base64"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"maps"
"strings"
@ -102,7 +102,7 @@ func languagePrepareModelCall(_ fantasy.LanguageModel, params *openaisdk.ChatCom
func languageModelExtraContent(choice openaisdk.ChatCompletionChoice) []fantasy.Content {
content := make([]fantasy.Content, 0)
reasoningData := ReasoningData{}
err := json.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData)
err := jsonv2.Unmarshal([]byte(choice.Message.RawJSON()), &reasoningData)
if err != nil {
return content
}
@ -251,7 +251,7 @@ func languageModelStreamExtra(chunk openaisdk.ChatCompletionChunk, yield func(fa
inx := 0
choice := chunk.Choices[inx]
reasoningData := ReasoningData{}
err := json.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData)
err := jsonv2.Unmarshal([]byte(choice.Delta.RawJSON()), &reasoningData)
if err != nil {
yield(fantasy.StreamPart{
Type: fantasy.StreamPartTypeError,
@ -844,9 +844,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Text: reasoningPart.Text,
Signature: metadata.Signature,
})
data, _ := json.Marshal(reasoningDetails)
data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap)
_ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap,
"reasoning": reasoningPart.Text,
@ -880,9 +880,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Data: *metadata.EncryptedContent,
ID: metadata.ItemID,
})
data, _ := json.Marshal(reasoningDetails)
data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap)
_ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap,
})
@ -911,9 +911,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Data: metadata.Signature,
ID: metadata.ToolID,
})
data, _ := json.Marshal(reasoningDetails)
data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap)
_ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap,
})
@ -923,9 +923,9 @@ func languageModelToPrompt(prompt fantasy.Prompt, _, model string) ([]openaisdk.
Text: reasoningPart.Text,
Format: "unknown",
})
data, _ := json.Marshal(reasoningDetails)
data, _ := jsonv2.Marshal(reasoningDetails)
reasoningDetailsMap := []map[string]any{}
_ = json.Unmarshal(data, &reasoningDetailsMap)
_ = jsonv2.Unmarshal(data, &reasoningDetailsMap)
assistantMsg.SetExtraFields(map[string]any{
"reasoning_details": reasoningDetailsMap,
})
@ -1040,11 +1040,11 @@ func hasVisibleUserContent(content []openaisdk.ChatCompletionContentPartUnionPar
func structToMapJSON(s any) (map[string]any, error) {
var result map[string]any
jsonBytes, err := json.Marshal(s)
jsonBytes, err := jsonv2.Marshal(s)
if err != nil {
return nil, err
}
err = json.Unmarshal(jsonBytes, &result)
err = jsonv2.Unmarshal(jsonBytes, &result)
if err != nil {
return nil, err
}

View file

@ -2,7 +2,7 @@
package vercel
import (
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy"
)
@ -17,14 +17,14 @@ const (
func init() {
fantasy.RegisterProviderType(TypeProviderOptions, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderOptions
if err := json.Unmarshal(data, &v); err != nil {
if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil
})
fantasy.RegisterProviderType(TypeProviderMetadata, func(data []byte) (fantasy.ProviderOptionsData, error) {
var v ProviderMetadata
if err := json.Unmarshal(data, &v); err != nil {
if err := jsonv2.Unmarshal(data, &v); err != nil {
return nil, err
}
return &v, nil
@ -52,58 +52,58 @@ const (
// ReasoningOptions represents reasoning configuration for Vercel AI Gateway.
type ReasoningOptions struct {
// Enabled enables reasoning output. When true, the model will provide its reasoning process.
Enabled *bool `json:"enabled,omitempty"`
Enabled *bool `json:"enabled,omitzero"`
// MaxTokens is the maximum number of tokens to allocate for reasoning.
// Cannot be used with Effort.
MaxTokens *int64 `json:"max_tokens,omitempty"`
MaxTokens *int64 `json:"max_tokens,omitzero"`
// Effort controls reasoning effort level.
// Mutually exclusive with MaxTokens.
Effort *ReasoningEffort `json:"effort,omitempty"`
Effort *ReasoningEffort `json:"effort,omitzero"`
// Exclude excludes reasoning content from the response but still generates it internally.
Exclude *bool `json:"exclude,omitempty"`
Exclude *bool `json:"exclude,omitzero"`
}
// GatewayProviderOptions represents provider routing preferences for Vercel AI Gateway.
type GatewayProviderOptions struct {
// Order is the list of provider slugs to try in order (e.g. ["vertex", "anthropic"]).
Order []string `json:"order,omitempty"`
Order []string `json:"order,omitzero"`
// Models is the list of fallback models to try if the primary model fails.
Models []string `json:"models,omitempty"`
Models []string `json:"models,omitzero"`
}
// BYOKCredential represents a single provider credential for BYOK.
type BYOKCredential struct {
APIKey string `json:"apiKey,omitempty"`
APIKey string `json:"apiKey,omitzero"`
}
// BYOKOptions represents Bring Your Own Key options for Vercel AI Gateway.
type BYOKOptions struct {
Anthropic map[string][]BYOKCredential `json:"anthropic,omitempty"`
OpenAI map[string][]BYOKCredential `json:"openai,omitempty"`
Vertex map[string][]BYOKCredential `json:"vertex,omitempty"`
Bedrock map[string][]BYOKCredential `json:"bedrock,omitempty"`
Anthropic map[string][]BYOKCredential `json:"anthropic,omitzero"`
OpenAI map[string][]BYOKCredential `json:"openai,omitzero"`
Vertex map[string][]BYOKCredential `json:"vertex,omitzero"`
Bedrock map[string][]BYOKCredential `json:"bedrock,omitzero"`
}
// ProviderOptions represents additional options for Vercel AI Gateway provider.
type ProviderOptions struct {
// Reasoning configuration for models that support extended thinking.
Reasoning *ReasoningOptions `json:"reasoning,omitempty"`
Reasoning *ReasoningOptions `json:"reasoning,omitzero"`
// ProviderOptions for gateway routing preferences.
ProviderOptions *GatewayProviderOptions `json:"providerOptions,omitempty"`
ProviderOptions *GatewayProviderOptions `json:"providerOptions,omitzero"`
// BYOK for request-scoped provider credentials.
BYOK *BYOKOptions `json:"byok,omitempty"`
BYOK *BYOKOptions `json:"byok,omitzero"`
// User is a unique identifier representing your end-user.
User *string `json:"user,omitempty"`
User *string `json:"user,omitzero"`
// LogitBias modifies the likelihood of specified tokens appearing in the completion.
LogitBias map[string]int64 `json:"logit_bias,omitempty"`
LogitBias map[string]int64 `json:"logit_bias,omitzero"`
// LogProbs returns the log probabilities of the tokens.
LogProbs *bool `json:"logprobs,omitempty"`
LogProbs *bool `json:"logprobs,omitzero"`
// TopLogProbs is the number of top log probabilities to return.
TopLogProbs *int64 `json:"top_logprobs,omitempty"`
TopLogProbs *int64 `json:"top_logprobs,omitzero"`
// ParallelToolCalls enables parallel function calling during tool use.
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
ParallelToolCalls *bool `json:"parallel_tool_calls,omitzero"`
// ExtraBody for additional request body fields.
ExtraBody map[string]any `json:"extra_body,omitempty"`
ExtraBody map[string]any `json:"extra_body,omitzero"`
}
// Options implements the ProviderOptionsData interface for ProviderOptions.
@ -128,7 +128,7 @@ func (o *ProviderOptions) UnmarshalJSON(data []byte) error {
// ProviderMetadata represents metadata from Vercel AI Gateway provider.
type ProviderMetadata struct {
Provider string `json:"provider,omitempty"`
Provider string `json:"provider,omitzero"`
}
// Options implements the ProviderOptionsData interface for ProviderMetadata.
@ -153,20 +153,20 @@ func (m *ProviderMetadata) UnmarshalJSON(data []byte) error {
// ReasoningDetail represents a reasoning detail from Vercel AI Gateway.
type ReasoningDetail struct {
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
Data string `json:"data,omitempty"`
Format string `json:"format,omitempty"`
Summary string `json:"summary,omitempty"`
Signature string `json:"signature,omitempty"`
ID string `json:"id,omitzero"`
Type string `json:"type,omitzero"`
Text string `json:"text,omitzero"`
Data string `json:"data,omitzero"`
Format string `json:"format,omitzero"`
Summary string `json:"summary,omitzero"`
Signature string `json:"signature,omitzero"`
Index int `json:"index"`
}
// ReasoningData represents reasoning data from Vercel AI Gateway response.
type ReasoningData struct {
Reasoning string `json:"reasoning,omitempty"`
ReasoningDetails []ReasoningDetail `json:"reasoning_details,omitempty"`
Reasoning string `json:"reasoning,omitzero"`
ReasoningDetails []ReasoningDetail `json:"reasoning_details,omitzero"`
}
// ReasoningEffortOption creates a pointer to a ReasoningEffort value.

View file

@ -1,7 +1,7 @@
package providertests
import (
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"testing"
"charm.land/fantasy"
@ -24,13 +24,13 @@ func TestProviderRegistry_Serialization_OpenAIOptions(t *testing.T) {
},
}
data, err := json.Marshal(msg)
data, err := jsonv2.Marshal(msg)
require.NoError(t, err)
var raw struct {
ProviderOptions map[string]map[string]any `json:"provider_options"`
}
require.NoError(t, json.Unmarshal(data, &raw))
require.NoError(t, jsonv2.Unmarshal(data, &raw))
po, ok := raw.ProviderOptions[openai.Name]
require.True(t, ok)
@ -41,7 +41,7 @@ func TestProviderRegistry_Serialization_OpenAIOptions(t *testing.T) {
require.Equal(t, "tester", inner["user"])
var decoded fantasy.Message
require.NoError(t, json.Unmarshal(data, &decoded))
require.NoError(t, jsonv2.Unmarshal(data, &decoded))
got, ok := decoded.ProviderOptions[openai.Name]
require.True(t, ok)
@ -66,14 +66,14 @@ func TestProviderRegistry_Serialization_OpenAIResponses(t *testing.T) {
},
}
data, err := json.Marshal(msg)
data, err := jsonv2.Marshal(msg)
require.NoError(t, err)
// JSON should include the typed wrapper with constant TypeResponsesProviderOptions
var raw struct {
ProviderOptions map[string]map[string]any `json:"provider_options"`
}
require.NoError(t, json.Unmarshal(data, &raw))
require.NoError(t, jsonv2.Unmarshal(data, &raw))
po := raw.ProviderOptions[openai.Name]
require.Equal(t, openai.TypeResponsesProviderOptions, po["type"]) // no magic strings
@ -84,7 +84,7 @@ func TestProviderRegistry_Serialization_OpenAIResponses(t *testing.T) {
// Unmarshal back and assert concrete type
var decoded fantasy.Message
require.NoError(t, json.Unmarshal(data, &decoded))
require.NoError(t, jsonv2.Unmarshal(data, &decoded))
got := decoded.ProviderOptions[openai.Name]
reqOpts, ok := got.(*openai.ResponsesProviderOptions)
require.True(t, ok)
@ -109,7 +109,7 @@ func TestProviderRegistry_Serialization_OpenAIResponsesReasoningMetadata(t *test
},
}
data, err := json.Marshal(resp)
data, err := jsonv2.Marshal(resp)
require.NoError(t, err)
// Ensure the provider metadata is wrapped with type using constant
@ -119,7 +119,7 @@ func TestProviderRegistry_Serialization_OpenAIResponsesReasoningMetadata(t *test
Data map[string]any `json:"data"`
} `json:"content"`
}
require.NoError(t, json.Unmarshal(data, &raw))
require.NoError(t, jsonv2.Unmarshal(data, &raw))
require.Greater(t, len(raw.Content), 0)
tc := raw.Content[0]
pm, ok := tc.Data["provider_metadata"].(map[string]any)
@ -133,7 +133,7 @@ func TestProviderRegistry_Serialization_OpenAIResponsesReasoningMetadata(t *test
// Unmarshal back
var decoded fantasy.Response
require.NoError(t, json.Unmarshal(data, &decoded))
require.NoError(t, jsonv2.Unmarshal(data, &decoded))
pmDecoded := decoded.Content[0].(fantasy.TextContent).ProviderMetadata
val, ok := pmDecoded[openai.Name]
require.True(t, ok)
@ -157,11 +157,11 @@ func TestProviderRegistry_Serialization_AnthropicOptions(t *testing.T) {
},
}
data, err := json.Marshal(msg)
data, err := jsonv2.Marshal(msg)
require.NoError(t, err)
var decoded fantasy.Message
require.NoError(t, json.Unmarshal(data, &decoded))
require.NoError(t, jsonv2.Unmarshal(data, &decoded))
got, ok := decoded.ProviderOptions[anthropic.Name]
require.True(t, ok)
@ -185,11 +185,11 @@ func TestProviderRegistry_Serialization_GoogleOptions(t *testing.T) {
},
}
data, err := json.Marshal(msg)
data, err := jsonv2.Marshal(msg)
require.NoError(t, err)
var decoded fantasy.Message
require.NoError(t, json.Unmarshal(data, &decoded))
require.NoError(t, jsonv2.Unmarshal(data, &decoded))
got, ok := decoded.ProviderOptions[google.Name]
require.True(t, ok)
@ -214,11 +214,11 @@ func TestProviderRegistry_Serialization_OpenRouterOptions(t *testing.T) {
},
}
data, err := json.Marshal(msg)
data, err := jsonv2.Marshal(msg)
require.NoError(t, err)
var decoded fantasy.Message
require.NoError(t, json.Unmarshal(data, &decoded))
require.NoError(t, jsonv2.Unmarshal(data, &decoded))
got, ok := decoded.ProviderOptions[openrouter.Name]
require.True(t, ok)
@ -245,11 +245,11 @@ func TestProviderRegistry_Serialization_OpenAICompatOptions(t *testing.T) {
},
}
data, err := json.Marshal(msg)
data, err := jsonv2.Marshal(msg)
require.NoError(t, err)
var decoded fantasy.Message
require.NoError(t, json.Unmarshal(data, &decoded))
require.NoError(t, jsonv2.Unmarshal(data, &decoded))
got, ok := decoded.ProviderOptions[openaicompat.Name]
require.True(t, ok)
@ -277,11 +277,11 @@ func TestProviderRegistry_MultiProvider(t *testing.T) {
},
}
data, err := json.Marshal(msg)
data, err := jsonv2.Marshal(msg)
require.NoError(t, err)
var decoded fantasy.Message
require.NoError(t, json.Unmarshal(data, &decoded))
require.NoError(t, jsonv2.Unmarshal(data, &decoded))
// Check OpenAI options
openaiOpt, ok := decoded.ProviderOptions[openai.Name]
@ -312,7 +312,7 @@ func TestProviderRegistry_ErrorHandling(t *testing.T) {
}`
var msg fantasy.Message
err := json.Unmarshal([]byte(invalidJSON), &msg)
err := jsonv2.Unmarshal([]byte(invalidJSON), &msg)
require.Error(t, err)
require.Contains(t, err.Error(), "unknown provider data type")
})
@ -327,7 +327,7 @@ func TestProviderRegistry_ErrorHandling(t *testing.T) {
}`
var msg fantasy.Message
err := json.Unmarshal([]byte(invalidJSON), &msg)
err := jsonv2.Unmarshal([]byte(invalidJSON), &msg)
require.Error(t, err)
})
}
@ -364,11 +364,11 @@ func TestProviderRegistry_AllTypesRegistered(t *testing.T) {
}
// Marshal and unmarshal
data, err := json.Marshal(msg)
data, err := jsonv2.Marshal(msg)
require.NoError(t, err)
var decoded fantasy.Message
err = json.Unmarshal(data, &decoded)
err = jsonv2.Unmarshal(data, &decoded)
require.NoError(t, err)
// Verify the provider options exist
@ -404,11 +404,11 @@ func TestProviderRegistry_AllTypesRegistered(t *testing.T) {
}
// Marshal and unmarshal
data, err := json.Marshal(resp)
data, err := jsonv2.Marshal(resp)
require.NoError(t, err)
var decoded fantasy.Response
err = json.Unmarshal(data, &decoded)
err = jsonv2.Unmarshal(data, &decoded)
require.NoError(t, err)
// Verify the provider metadata exists

View file

@ -4,8 +4,8 @@ package schema
import (
"context"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"reflect"
"slices"
"strings"
@ -259,7 +259,7 @@ func ParsePartialJSON(text string) (any, ParseState, error) {
}
var result any
if err := json.Unmarshal([]byte(text), &result); err == nil {
if err := jsonv2.Unmarshal([]byte(text), &result); err == nil {
return result, ParseStateSuccessful, nil
}
@ -268,7 +268,7 @@ func ParsePartialJSON(text string) (any, ParseState, error) {
return nil, ParseStateFailed, fmt.Errorf("json repair failed: %w", err)
}
if err := json.Unmarshal([]byte(repaired), &result); err != nil {
if err := jsonv2.Unmarshal([]byte(repaired), &result); err != nil {
return nil, ParseStateFailed, fmt.Errorf("failed to parse repaired json: %w", err)
}
@ -317,7 +317,7 @@ func ValidateAgainstSchema(obj any, schema Schema) error {
// This is a convenience wrapper for use sites that hold the schema as a map rather
// than the typed Schema struct.
func ValidateAgainstSchemaMap(obj any, schemaMap map[string]any) error {
schemaBytes, err := json.Marshal(schemaMap)
schemaBytes, err := jsonv2.Marshal(schemaMap)
if err != nil {
return fmt.Errorf("failed to marshal schema map: %w", err)
}
@ -341,7 +341,7 @@ func ValidateAgainstSchemaMap(obj any, schemaMap map[string]any) error {
}
func validateAgainstSchema(obj any, schema Schema) error {
jsonSchemaBytes, err := json.Marshal(schema)
jsonSchemaBytes, err := jsonv2.Marshal(schema)
if err != nil {
return fmt.Errorf("failed to marshal schema: %w", err)
}

View file

@ -2,8 +2,8 @@ package fantasy
import (
"context"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"reflect"
"charm.land/fantasy/schema"
@ -78,7 +78,7 @@ func NewMediaResponse(data []byte, mediaType string) ToolResponse {
// WithResponseMetadata adds metadata to a response.
func WithResponseMetadata(response ToolResponse, metadata any) ToolResponse {
if metadata != nil {
metadataBytes, err := json.Marshal(metadata)
metadataBytes, err := jsonv2.Marshal(metadata)
if err != nil {
return response
}
@ -167,7 +167,7 @@ func (w *funcToolWrapper[TInput]) Info() ToolInfo {
func (w *funcToolWrapper[TInput]) Run(ctx context.Context, params ToolCall) (ToolResponse, error) {
var input TInput
if err := json.Unmarshal([]byte(params.Input), &input); err != nil {
if err := jsonv2.Unmarshal([]byte(params.Input), &input); err != nil {
return NewTextErrorResponse(fmt.Sprintf("invalid parameters: %s", err)), nil
}

View file

@ -1,9 +1,9 @@
package fantasy
import (
"encoding/json"
"errors"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"strings"
)
@ -91,7 +91,7 @@ func extractToolDependencies(input string) ([]string, error) {
}
var v any
if err := json.Unmarshal([]byte(input), &v); err != nil {
if err := jsonv2.Unmarshal([]byte(input), &v); err != nil {
// If the tool input isn't valid JSON, treat it as having no dependencies.
return nil, nil
}

View file

@ -2,9 +2,9 @@ package fantasy
import (
"context"
"encoding/json"
"errors"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"sort"
"strconv"
"strings"
@ -233,7 +233,7 @@ func resolveToolRefsInInput(input string, results map[string]ToolResultContent)
}
var v any
if err := json.Unmarshal([]byte(input), &v); err != nil {
if err := jsonv2.Unmarshal([]byte(input), &v); err != nil {
// Not JSON; nothing to resolve.
return input, nil
}
@ -242,7 +242,7 @@ func resolveToolRefsInInput(input string, results map[string]ToolResultContent)
if err != nil {
return "", err
}
b, err := json.Marshal(updated)
b, err := jsonv2.Marshal(updated)
if err != nil {
return "", err
}
@ -328,7 +328,7 @@ func resolveToolRefValue(ref string, results map[string]ToolResultContent) (any,
// Path resolution: interpret base as JSON and walk.
var cur any
if err := json.Unmarshal([]byte(base), &cur); err != nil {
if err := jsonv2.Unmarshal([]byte(base), &cur); err != nil {
return nil, fmt.Errorf("tool ref %q path requires JSON output, got non-JSON", ref)
}

View file

@ -5,7 +5,7 @@ package conversations
import (
"context"
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"strings"
"github.com/sipeed/picoclaw/pkg/ids"
@ -100,7 +100,7 @@ func (s *Store) EditMessage(ctx context.Context, p EditMessageParams) (sqlc.Agen
return sqlc.AgentMessage{}, err
}
metaJSON, _ := json.Marshal(p.Metadata)
metaJSON, _ := jsonv2.Marshal(p.Metadata)
// Best-effort revision record — a failure here does not abort the edit.
_, _ = s.q.AddAgentMessageRevision(ctx, sqlc.AddAgentMessageRevisionParams{
@ -174,7 +174,7 @@ func (s *Store) ForkFromCheckpoint(ctx context.Context, p ForkFromCheckpointPara
Messages []msgSnapshot `json:"messages"`
}
var snap snapshot
_ = json.Unmarshal(runState.SnapshotJson, &snap)
_ = jsonv2.Unmarshal([]byte(runState.SnapshotJson), &snap)
conv, err := s.q.CreateAgentConversation(ctx, sqlc.CreateAgentConversationParams{
ID: ids.New(),
@ -188,7 +188,7 @@ func (s *Store) ForkFromCheckpoint(ctx context.Context, p ForkFromCheckpointPara
"checkpoint_name": cpName,
"run_state_id": cp.RunStateID.String(),
}
forkMetaJSON, _ := json.Marshal(forkMeta)
forkMetaJSON, _ := jsonv2.Marshal(forkMeta)
_, _ = s.q.CreateAgentConversationFork(ctx, sqlc.CreateAgentConversationForkParams{
ID: ids.New(),
@ -209,7 +209,7 @@ func (s *Store) ForkFromCheckpoint(ctx context.Context, p ForkFromCheckpointPara
"checkpoint_name": cpName,
"run_state_id": cp.RunStateID.String(),
}
seedMetaJSON, _ := json.Marshal(seedMeta)
seedMetaJSON, _ := jsonv2.Marshal(seedMeta)
for _, m := range msgs {
if m.Role != "user" && m.Role != "assistant" {
@ -277,7 +277,7 @@ func (s *Store) MergeAsLinkedContext(ctx context.Context, p MergeAsLinkedContext
}
meta := map[string]any{"source": "merge_as_linked_context"}
metaJSON, _ := json.Marshal(meta)
metaJSON, _ := jsonv2.Marshal(meta)
_, _ = s.q.CreateAgentConversationLink(ctx, sqlc.CreateAgentConversationLinkParams{
ID: ids.New(),
@ -321,7 +321,7 @@ type AncestryParams struct {
// AncestryResult is the structured result of an Ancestry query.
type AncestryResult struct {
Conversation sqlc.AgentConversation `json:"conversation"`
ForkParent *sqlc.AgentConversationFork `json:"fork_parent,omitempty"`
ForkParent *sqlc.AgentConversationFork `json:"fork_parent,omitzero"`
ForkChildren []sqlc.AgentConversationFork `json:"fork_children"`
Links []sqlc.AgentConversationLink `json:"links"`
}
@ -434,7 +434,7 @@ type GraphParams struct {
// GraphNode is a single conversation node in the fork/link graph.
type GraphNode struct {
ID string `json:"id"`
Title *string `json:"title,omitempty"`
Title *string `json:"title,omitzero"`
}
// GraphEdge is a directed edge between two conversation nodes.
@ -444,9 +444,9 @@ type GraphEdge struct {
From string `json:"from"`
To string `json:"to"`
// CheckpointID is set on fork edges.
CheckpointID *string `json:"checkpoint_id,omitempty"`
CheckpointID *string `json:"checkpoint_id,omitzero"`
// Kind is set on link edges (e.g. "merge").
Kind *string `json:"kind,omitempty"`
Kind *string `json:"kind,omitzero"`
}
// GraphResult is the full fork/link graph rooted at a conversation.

View file

@ -7,7 +7,7 @@ package agent
import (
"context"
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
memstore "github.com/sipeed/picoclaw/pkg/memory/store"
"github.com/sipeed/picoclaw/pkg/tools"
@ -84,7 +84,7 @@ func (t *MemGPTTool) Parameters() map[string]interface{} {
func (t *MemGPTTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
// Marshal the args back to JSON for the inner MemoryTool.Execute()
input, err := json.Marshal(args)
input, err := jsonv2.Marshal(args)
if err != nil {
return tools.ErrorResult("invalid arguments: " + err.Error())
}

View file

@ -5,7 +5,7 @@ package mentions
import (
"context"
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"strings"
"github.com/sipeed/picoclaw/pkg/ids"
@ -76,7 +76,7 @@ func (s *Store) Add(ctx context.Context, p AddParams) (sqlc.AgentMention, error)
return sqlc.AgentMention{}, pcerrors.Wrapf(pcerrors.CodeInvalidArgument, err, "parse target_id %q", targetIDStr)
}
metaJSON, _ := json.Marshal(p.Metadata)
metaJSON, _ := jsonv2.Marshal(p.Metadata)
return s.q.AddAgentMention(ctx, sqlc.AddAgentMentionParams{
ID: ids.New(),

View file

@ -2,8 +2,8 @@ package agent
import (
"context"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"strconv"
"strings"
@ -91,7 +91,7 @@ func (r OffloadingToolRuntime) Execute(ctx context.Context, tools []fantasy.Agen
fullKey := toolResultFullKey(r.ConversationID, r.RunID, stepIndex, tc.ToolCallID)
payload, payloadType, payloadText := toolResultPayload(res)
b, marshalErr := json.Marshal(payload)
b, marshalErr := jsonv2.Marshal(payload)
if marshalErr != nil {
b = []byte(`{"error":"failed to marshal tool result payload"}`)
payloadType = "error"
@ -134,7 +134,7 @@ func (r OffloadingToolRuntime) Execute(ctx context.Context, tools []fantasy.Agen
"result_type": payloadType,
"step_index": stepIndex,
}
metaJSON, _ := json.Marshal(meta)
metaJSON, _ := jsonv2.Marshal(meta)
var previewPtr *string
if strings.TrimSpace(preview) != "" {

View file

@ -6,6 +6,8 @@ import (
"strings"
"time"
jsonv2 "github.com/go-json-experiment/json"
"charm.land/fantasy"
"github.com/sipeed/picoclaw/pkg/ids"
"github.com/sipeed/picoclaw/pkg/memory/sqlc"
@ -50,8 +52,8 @@ func (s *StateStore) UpdateRunStatus(ctx context.Context, runID ids.UUID, status
metaJSON := json.RawMessage(`{}`)
if meta != nil {
if b, err := json.Marshal(meta); err == nil {
metaJSON = b
if b, err := jsonv2.Marshal(meta); err == nil {
metaJSON = json.RawMessage(b)
}
}
@ -75,8 +77,8 @@ func (s *StateStore) AddRunState(ctx context.Context, runID ids.UUID, stepIndex
snapJSON := json.RawMessage(`{}`)
if snapshot != nil {
if b, err := json.Marshal(snapshot); err == nil {
snapJSON = b
if b, err := jsonv2.Marshal(snapshot); err == nil {
snapJSON = json.RawMessage(b)
}
}
@ -99,8 +101,8 @@ func (s *StateStore) AddTransition(ctx context.Context, runID ids.UUID, t fantas
metaJSON := json.RawMessage(`{}`)
if t.Meta != nil {
if b, err := json.Marshal(t.Meta); err == nil {
metaJSON = b
if b, err := jsonv2.Marshal(t.Meta); err == nil {
metaJSON = json.RawMessage(b)
}
}
@ -152,8 +154,8 @@ func (s *CheckpointStore) CreateCheckpoint(ctx context.Context, conversationID i
metaJSON := json.RawMessage(`{}`)
if meta != nil {
if b, err := json.Marshal(meta); err == nil {
metaJSON = b
if b, err := jsonv2.Marshal(meta); err == nil {
metaJSON = json.RawMessage(b)
}
}

View file

@ -6,7 +6,7 @@ package threads
import (
"context"
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"strings"
"github.com/sipeed/picoclaw/pkg/ids"
@ -45,7 +45,7 @@ func (s *Store) Create(ctx context.Context, p CreateParams) (sqlc.AgentThread, e
return sqlc.AgentThread{}, pcerrors.Wrapf(pcerrors.CodeInvalidArgument, err, "parse conversation_id %q", convIDStr)
}
metaJSON, _ := json.Marshal(p.Metadata)
metaJSON, _ := jsonv2.Marshal(p.Metadata)
return s.q.CreateAgentThread(ctx, sqlc.CreateAgentThreadParams{
ID: ids.New(),
@ -108,7 +108,7 @@ func (s *Store) AddMessage(ctx context.Context, p AddMessageParams) (sqlc.AgentT
return sqlc.AgentThreadMessage{}, pcerrors.New(pcerrors.CodeInvalidArgument, "content is empty")
}
metaJSON, _ := json.Marshal(p.Metadata)
metaJSON, _ := jsonv2.Marshal(p.Metadata)
return s.q.AddAgentThreadMessage(ctx, sqlc.AddAgentThreadMessageParams{
ID: ids.New(),

View file

@ -2,10 +2,12 @@ package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"charm.land/fantasy"
"github.com/sipeed/picoclaw/pkg/ids"
"github.com/sipeed/picoclaw/pkg/memory/sqlc"
@ -13,23 +15,23 @@ import (
)
type ToolResultSearchView struct {
StartLine int `json:"start_line,omitempty" description:"Optional. 1-indexed start line (inclusive)."`
EndLine int `json:"end_line,omitempty" description:"Optional. 1-indexed end line (inclusive)."`
MaxLines int `json:"max_lines,omitempty" description:"Optional. Default 30, max 200."`
StartChunk int `json:"start_chunk,omitempty" description:"Optional. 0-indexed start chunk (inclusive)."`
EndChunk int `json:"end_chunk,omitempty" description:"Optional. 0-indexed end chunk (inclusive)."`
MaxChunks int `json:"max_chunks,omitempty" description:"Optional. Default 3, max 20."`
StartLine int `json:"start_line,omitzero" description:"Optional. 1-indexed start line (inclusive)."`
EndLine int `json:"end_line,omitzero" description:"Optional. 1-indexed end line (inclusive)."`
MaxLines int `json:"max_lines,omitzero" description:"Optional. Default 30, max 200."`
StartChunk int `json:"start_chunk,omitzero" description:"Optional. 0-indexed start chunk (inclusive)."`
EndChunk int `json:"end_chunk,omitzero" description:"Optional. 0-indexed end chunk (inclusive)."`
MaxChunks int `json:"max_chunks,omitzero" description:"Optional. Default 3, max 20."`
}
type ToolResultSearchInput struct {
ConversationID string `json:"conversation_id,omitempty" description:"Optional. Agent conversation UUID."`
RunID string `json:"run_id,omitempty" description:"Optional. Agent run UUID."`
ToolCallID string `json:"tool_call_id,omitempty" description:"Optional. Tool call id to fetch (requires run_id)."`
ToolName string `json:"tool_name,omitempty" description:"Optional. Filter by tool name."`
Query string `json:"query,omitempty" description:"Optional. Case-insensitive substring match on tool_name/tool_call_id/summary."`
ConversationID string `json:"conversation_id,omitzero" description:"Optional. Agent conversation UUID."`
RunID string `json:"run_id,omitzero" description:"Optional. Agent run UUID."`
ToolCallID string `json:"tool_call_id,omitzero" description:"Optional. Tool call id to fetch (requires run_id)."`
ToolName string `json:"tool_name,omitzero" description:"Optional. Filter by tool name."`
Query string `json:"query,omitzero" description:"Optional. Case-insensitive substring match on tool_name/tool_call_id/summary."`
Limit int `json:"limit,omitempty" description:"Optional. Default 5, max 50."`
View *ToolResultSearchView `json:"view,omitempty" description:"Optional. File view range for each result."`
Limit int `json:"limit,omitzero" description:"Optional. Default 5, max 50."`
View *ToolResultSearchView `json:"view,omitzero" description:"Optional. File view range for each result."`
}
// NewToolResultSearchTool creates the tool_result_search agent tool for
@ -87,17 +89,17 @@ func NewToolResultSearchTool(q *sqlc.Queries, kv KVDelegate) fantasy.AgentTool {
startChunk, endChunk := normalizeChunkView(view)
type item struct {
ID string `json:"id"`
RunID string `json:"run_id"`
StepIndex int64 `json:"step_index"`
ToolCallID string `json:"tool_call_id"`
ToolName string `json:"tool_name"`
Preview *string `json:"preview,omitempty"`
FullKey string `json:"full_key"`
ChunkCount int64 `json:"chunk_count"`
View string `json:"view"`
ViewRange map[string]int `json:"view_range"`
Metadata json.RawMessage `json:"metadata_json"`
ID string `json:"id"`
RunID string `json:"run_id"`
StepIndex int64 `json:"step_index"`
ToolCallID string `json:"tool_call_id"`
ToolName string `json:"tool_name"`
Preview *string `json:"preview,omitzero"`
FullKey string `json:"full_key"`
ChunkCount int64 `json:"chunk_count"`
View string `json:"view"`
ViewRange map[string]int `json:"view_range"`
Metadata jsontext.Value `json:"metadata_json"` // sqlc gives json.RawMessage; both are []byte
}
out := struct {
@ -131,11 +133,11 @@ func NewToolResultSearchTool(q *sqlc.Queries, kv KVDelegate) fantasy.AgentTool {
ChunkCount: r.ChunkCount,
View: sel,
ViewRange: viewRange,
Metadata: r.MetadataJson,
Metadata: jsontext.Value(r.MetadataJson),
})
}
b, _ := json.Marshal(out)
b, _ := jsonv2.Marshal(out)
return fantasy.NewTextResponse(string(b)), nil
},
)

View file

@ -2,7 +2,7 @@ package agent_test
import (
"context"
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"strings"
"testing"
@ -58,13 +58,13 @@ func invokeSearch(t *testing.T, tool fantasy.AgentTool, input agent.ToolResultSe
require.False(t, resp.IsError, "search tool must not error: %s", resp.Content)
var out map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Content), &out))
require.NoError(t, jsonv2.Unmarshal([]byte(resp.Content), &out))
return out
}
func marshalInput(t *testing.T, v any) string {
t.Helper()
b, err := json.Marshal(v)
b, err := jsonv2.Marshal(v)
require.NoError(t, err)
return string(b)
}

View file

@ -5,7 +5,6 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
@ -16,6 +15,9 @@ import (
"strconv"
"strings"
"time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
)
type OAuthProviderConfig struct {
@ -128,12 +130,12 @@ type deviceCodeResponse struct {
func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
var raw struct {
DeviceAuthID string `json:"device_auth_id"`
UserCode string `json:"user_code"`
Interval json.RawMessage `json:"interval"`
DeviceAuthID string `json:"device_auth_id"`
UserCode string `json:"user_code"`
Interval jsontext.Value `json:"interval"`
}
if err := json.Unmarshal(body, &raw); err != nil {
if err := jsonv2.Unmarshal(body, &raw); err != nil {
return deviceCodeResponse{}, err
}
@ -149,18 +151,18 @@ func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) {
}, nil
}
func parseFlexibleInt(raw json.RawMessage) (int, error) {
func parseFlexibleInt(raw jsontext.Value) (int, error) {
if len(raw) == 0 || string(raw) == "null" {
return 0, nil
}
var interval int
if err := json.Unmarshal(raw, &interval); err == nil {
if err := jsonv2.Unmarshal(raw, &interval); err == nil {
return interval, nil
}
var intervalStr string
if err := json.Unmarshal(raw, &intervalStr); err == nil {
if err := jsonv2.Unmarshal(raw, &intervalStr); err == nil {
intervalStr = strings.TrimSpace(intervalStr)
if intervalStr == "" {
return 0, nil
@ -172,7 +174,7 @@ func parseFlexibleInt(raw json.RawMessage) (int, error) {
}
func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
reqBody, _ := json.Marshal(map[string]string{
reqBody, _ := jsonv2.Marshal(map[string]string{
"client_id": cfg.ClientID,
})
@ -224,7 +226,7 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
}
func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) {
reqBody, _ := json.Marshal(map[string]string{
reqBody, _ := jsonv2.Marshal(map[string]string{
"device_auth_id": deviceAuthID,
"user_code": userCode,
})
@ -250,7 +252,7 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au
CodeChallenge string `json:"code_challenge"`
CodeVerifier string `json:"code_verifier"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
if err := jsonv2.Unmarshal(body, &tokenResp); err != nil {
return nil, err
}
@ -349,7 +351,7 @@ func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
if err := jsonv2.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("parsing token response: %w", err)
}
@ -435,7 +437,7 @@ func parseJWTClaims(token string) (map[string]interface{}, error) {
}
var claims map[string]interface{}
if err := json.Unmarshal(decoded, &claims); err != nil {
if err := jsonv2.Unmarshal(decoded, &claims); err != nil {
return nil, err
}

View file

@ -2,19 +2,20 @@ package auth
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
jsonv2 "github.com/go-json-experiment/json"
)
func makeJWTForClaims(t *testing.T, claims map[string]interface{}) string {
t.Helper()
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
payloadJSON, err := json.Marshal(claims)
payloadJSON, err := jsonv2.Marshal(claims)
if err != nil {
t.Fatalf("marshal claims: %v", err)
}
@ -95,7 +96,7 @@ func TestParseTokenResponse(t *testing.T) {
"expires_in": 3600,
"id_token": "test-id-token",
}
body, _ := json.Marshal(resp)
body, _ := jsonv2.Marshal(resp)
cred, err := parseTokenResponse(body, "openai")
if err != nil {
@ -127,7 +128,7 @@ func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
"expires_in": 3600,
"id_token": idToken,
}
body, _ := json.Marshal(resp)
body, _ := jsonv2.Marshal(resp)
cred, err := parseTokenResponse(body, "openai")
if err != nil {
@ -166,7 +167,7 @@ func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
"expires_in": 3600,
"id_token": idToken,
}
body, _ := json.Marshal(resp)
body, _ := jsonv2.Marshal(resp)
cred, err := parseTokenResponse(body, "openai")
if err != nil {
@ -206,7 +207,7 @@ func TestExchangeCodeForTokens(t *testing.T) {
"refresh_token": "mock-refresh-token",
"expires_in": 3600,
}
json.NewEncoder(w).Encode(resp)
jsonv2.MarshalWrite(w, resp)
}))
defer server.Close()
@ -245,7 +246,7 @@ func TestRefreshAccessToken(t *testing.T) {
"refresh_token": "refreshed-refresh-token",
"expires_in": 3600,
}
json.NewEncoder(w).Encode(resp)
jsonv2.MarshalWrite(w, resp)
}))
defer server.Close()
@ -294,7 +295,7 @@ func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) {
"access_token": "new-access-token-only",
"expires_in": 3600,
}
json.NewEncoder(w).Encode(resp)
jsonv2.MarshalWrite(w, resp)
}))
defer server.Close()

View file

@ -1,17 +1,19 @@
package auth
import (
"encoding/json"
"os"
"path/filepath"
"time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
)
type AuthCredential struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
AccountID string `json:"account_id,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
RefreshToken string `json:"refresh_token,omitzero"`
AccountID string `json:"account_id,omitzero"`
ExpiresAt time.Time `json:"expires_at,omitzero"`
Provider string `json:"provider"`
AuthMethod string `json:"auth_method"`
}
@ -50,7 +52,7 @@ func LoadStore() (*AuthStore, error) {
}
var store AuthStore
if err := json.Unmarshal(data, &store); err != nil {
if err := jsonv2.Unmarshal(data, &store); err != nil {
return nil, err
}
if store.Credentials == nil {
@ -66,7 +68,7 @@ func SaveStore(store *AuthStore) error {
return err
}
data, err := json.MarshalIndent(store, "", " ")
data, err := jsonv2.Marshal(store, jsontext.WithIndent(" "))
if err != nil {
return err
}

View file

@ -4,11 +4,12 @@ package channels
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
jsonv2 "github.com/go-json-experiment/json"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
@ -97,7 +98,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return fmt.Errorf("chat ID is empty")
}
payload, err := json.Marshal(map[string]string{"text": msg.Content})
payload, err := jsonv2.Marshal(map[string]string{"text": msg.Content})
if err != nil {
return fmt.Errorf("failed to marshal feishu content: %w", err)
}
@ -202,7 +203,7 @@ func extractFeishuMessageContent(message *larkim.EventMessage) string {
var textPayload struct {
Text string `json:"text"`
}
if err := json.Unmarshal([]byte(*message.Content), &textPayload); err == nil {
if err := jsonv2.Unmarshal([]byte(*message.Content), &textPayload); err == nil {
return textPayload.Text
}
}

View file

@ -6,7 +6,6 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
@ -15,6 +14,9 @@ import (
"sync"
"time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
@ -140,7 +142,7 @@ func (c *LINEChannel) fetchBotInfo() error {
BasicID string `json:"basicId"`
DisplayName string `json:"displayName"`
}
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
if err := jsonv2.UnmarshalRead(resp.Body, &info); err != nil {
return err
}
@ -199,7 +201,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
var payload struct {
Events []lineEvent `json:"events"`
}
if err := json.Unmarshal(body, &payload); err != nil {
if err := jsonv2.Unmarshal(body, &payload); err != nil {
logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{
"error": err.Error(),
})
@ -230,11 +232,11 @@ func (c *LINEChannel) verifySignature(body []byte, signature string) bool {
// LINE webhook event types
type lineEvent struct {
Type string `json:"type"`
ReplyToken string `json:"replyToken"`
Source lineSource `json:"source"`
Message json.RawMessage `json:"message"`
Timestamp int64 `json:"timestamp"`
Type string `json:"type"`
ReplyToken string `json:"replyToken"`
Source lineSource `json:"source"`
Message jsontext.Value `json:"message"`
Timestamp int64 `json:"timestamp"`
}
type lineSource struct {
@ -277,7 +279,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
isGroup := event.Source.Type == "group" || event.Source.Type == "room"
var msg lineMessage
if err := json.Unmarshal(event.Message, &msg); err != nil {
if err := jsonv2.Unmarshal(event.Message, &msg); err != nil {
logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{
"error": err.Error(),
})
@ -558,7 +560,7 @@ func (c *LINEChannel) sendLoading(chatID string) {
// callAPI makes an authenticated POST request to the LINE API.
func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error {
body, err := json.Marshal(payload)
body, err := jsonv2.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}

View file

@ -2,11 +2,12 @@ package channels
import (
"context"
"encoding/json"
"fmt"
"net"
"sync"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
@ -102,15 +103,13 @@ func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) {
logger.DebugC("maixcam", "Connection closed")
}()
decoder := json.NewDecoder(conn)
for {
select {
case <-ctx.Done():
return
default:
var msg MaixCamMessage
if err := decoder.Decode(&msg); err != nil {
if err := jsonv2.UnmarshalRead(conn, &msg); err != nil {
if err.Error() != "EOF" {
logger.ErrorCF("maixcam", "Failed to decode message", map[string]interface{}{
"error": err.Error(),
@ -221,7 +220,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
"chat_id": msg.ChatID,
}
data, err := json.Marshal(response)
data, err := jsonv2.Marshal(response)
if err != nil {
return fmt.Errorf("failed to marshal response: %w", err)
}

View file

@ -2,13 +2,14 @@ package channels
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
"time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/bus"
@ -31,21 +32,21 @@ type OneBotChannel struct {
}
type oneBotRawEvent struct {
PostType string `json:"post_type"`
MessageType string `json:"message_type"`
SubType string `json:"sub_type"`
MessageID json.RawMessage `json:"message_id"`
UserID json.RawMessage `json:"user_id"`
GroupID json.RawMessage `json:"group_id"`
RawMessage string `json:"raw_message"`
Message json.RawMessage `json:"message"`
Sender json.RawMessage `json:"sender"`
SelfID json.RawMessage `json:"self_id"`
Time json.RawMessage `json:"time"`
MetaEventType string `json:"meta_event_type"`
Echo string `json:"echo"`
RetCode json.RawMessage `json:"retcode"`
Status BotStatus `json:"status"`
PostType string `json:"post_type"`
MessageType string `json:"message_type"`
SubType string `json:"sub_type"`
MessageID jsontext.Value `json:"message_id"`
UserID jsontext.Value `json:"user_id"`
GroupID jsontext.Value `json:"group_id"`
RawMessage string `json:"raw_message"`
Message jsontext.Value `json:"message"`
Sender jsontext.Value `json:"sender"`
SelfID jsontext.Value `json:"self_id"`
Time jsontext.Value `json:"time"`
MetaEventType string `json:"meta_event_type"`
Echo string `json:"echo"`
RetCode jsontext.Value `json:"retcode"`
Status BotStatus `json:"status"`
}
type BotStatus struct {
@ -54,9 +55,9 @@ type BotStatus struct {
}
type oneBotSender struct {
UserID json.RawMessage `json:"user_id"`
Nickname string `json:"nickname"`
Card string `json:"card"`
UserID jsontext.Value `json:"user_id"`
Nickname string `json:"nickname"`
Card string `json:"card"`
}
type oneBotEvent struct {
@ -78,7 +79,7 @@ type oneBotEvent struct {
type oneBotAPIRequest struct {
Action string `json:"action"`
Params interface{} `json:"params"`
Echo string `json:"echo,omitempty"`
Echo string `json:"echo,omitzero"`
}
type oneBotSendPrivateMsgParams struct {
@ -236,7 +237,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
Echo: echo,
}
data, err := json.Marshal(req)
data, err := jsonv2.Marshal(req)
if err != nil {
return fmt.Errorf("failed to marshal OneBot request: %w", err)
}
@ -326,7 +327,7 @@ func (c *OneBotChannel) listen() {
})
var raw oneBotRawEvent
if err := json.Unmarshal(message, &raw); err != nil {
if err := jsonv2.Unmarshal(message, &raw); err != nil {
logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{
"error": err.Error(),
"payload": string(message),
@ -354,29 +355,29 @@ func (c *OneBotChannel) listen() {
}
}
func parseJSONInt64(raw json.RawMessage) (int64, error) {
func parseJSONInt64(raw jsontext.Value) (int64, error) {
if len(raw) == 0 {
return 0, nil
}
var n int64
if err := json.Unmarshal(raw, &n); err == nil {
if err := jsonv2.Unmarshal(raw, &n); err == nil {
return n, nil
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
if err := jsonv2.Unmarshal(raw, &s); err == nil {
return strconv.ParseInt(s, 10, 64)
}
return 0, fmt.Errorf("cannot parse as int64: %s", string(raw))
}
func parseJSONString(raw json.RawMessage) string {
func parseJSONString(raw jsontext.Value) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
if err := jsonv2.Unmarshal(raw, &s); err == nil {
return s
}
@ -388,13 +389,13 @@ type parseMessageResult struct {
IsBotMentioned bool
}
func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult {
func parseMessageContentEx(raw jsontext.Value, selfID int64) parseMessageResult {
if len(raw) == 0 {
return parseMessageResult{}
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
if err := jsonv2.Unmarshal(raw, &s); err == nil {
mentioned := false
if selfID > 0 {
cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID)
@ -408,7 +409,7 @@ func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult
}
var segments []map[string]interface{}
if err := json.Unmarshal(raw, &segments); err == nil {
if err := jsonv2.Unmarshal(raw, &segments); err == nil {
var text string
mentioned := false
selfIDStr := strconv.FormatInt(selfID, 10)
@ -497,7 +498,7 @@ func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent
var sender oneBotSender
if len(raw.Sender) > 0 {
if err := json.Unmarshal(raw.Sender, &sender); err != nil {
if err := jsonv2.Unmarshal(raw.Sender, &sender); err != nil {
logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{
"error": err.Error(),
"sender": string(raw.Sender),

View file

@ -2,12 +2,12 @@ package channels
import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
"time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/bus"
@ -92,7 +92,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
"content": msg.Content,
}
data, err := json.Marshal(payload)
data, err := jsonv2.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal message: %w", err)
}
@ -127,7 +127,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) {
}
var msg map[string]interface{}
if err := json.Unmarshal(message, &msg); err != nil {
if err := jsonv2.Unmarshal(message, &msg); err != nil {
log.Printf("Failed to unmarshal WhatsApp message: %v", err)
continue
}

View file

@ -1,13 +1,14 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/caarlos0/env/v11"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
)
// FlexibleStringSlice is a []string that also accepts JSON numbers,
@ -17,14 +18,14 @@ type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
// Try []string first
var ss []string
if err := json.Unmarshal(data, &ss); err == nil {
if err := jsonv2.Unmarshal(data, &ss); err == nil {
*f = ss
return nil
}
// Try []interface{} to handle mixed types
var raw []interface{}
if err := json.Unmarshal(data, &raw); err != nil {
if err := jsonv2.Unmarshal(data, &raw); err != nil {
return err
}
@ -251,10 +252,10 @@ type ProvidersConfig struct {
type ProviderConfig struct {
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
Timeout int `json:"timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_TIMEOUT"` // seconds, 0 = default (120s)
ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
Proxy string `json:"proxy,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
AuthMethod string `json:"auth_method,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
Timeout int `json:"timeout,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_TIMEOUT"` // seconds, 0 = default (120s)
ConnectMode string `json:"connect_mode,omitzero" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
}
type OpenAIProviderConfig struct {
@ -450,7 +451,7 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
if err := json.Unmarshal(data, cfg); err != nil {
if err := jsonv2.Unmarshal(data, cfg); err != nil {
return nil, err
}
@ -522,7 +523,7 @@ func SaveConfig(path string, cfg *Config) error {
cfg.mu.RLock()
defer cfg.mu.RUnlock()
data, err := json.MarshalIndent(cfg, "", " ")
data, err := jsonv2.Marshal(cfg, jsontext.WithIndent(" "))
if err != nil {
return err
}

View file

@ -4,7 +4,6 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"os"
@ -13,31 +12,34 @@ import (
"time"
"github.com/adhocore/gronx"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"github.com/sipeed/picoclaw/pkg/memory"
)
type CronSchedule struct {
Kind string `json:"kind"`
AtMS *int64 `json:"atMs,omitempty"`
EveryMS *int64 `json:"everyMs,omitempty"`
Expr string `json:"expr,omitempty"`
TZ string `json:"tz,omitempty"`
AtMS *int64 `json:"atMs,omitzero"`
EveryMS *int64 `json:"everyMs,omitzero"`
Expr string `json:"expr,omitzero"`
TZ string `json:"tz,omitzero"`
}
type CronPayload struct {
Kind string `json:"kind"`
Message string `json:"message"`
Command string `json:"command,omitempty"`
Command string `json:"command,omitzero"`
Deliver bool `json:"deliver"`
Channel string `json:"channel,omitempty"`
To string `json:"to,omitempty"`
Channel string `json:"channel,omitzero"`
To string `json:"to,omitzero"`
}
type CronJobState struct {
NextRunAtMS *int64 `json:"nextRunAtMs,omitempty"`
LastRunAtMS *int64 `json:"lastRunAtMs,omitempty"`
LastStatus string `json:"lastStatus,omitempty"`
LastError string `json:"lastError,omitempty"`
NextRunAtMS *int64 `json:"nextRunAtMs,omitzero"`
LastRunAtMS *int64 `json:"lastRunAtMs,omitzero"`
LastStatus string `json:"lastStatus,omitzero"`
LastError string `json:"lastError,omitzero"`
}
type CronJob struct {
@ -349,7 +351,7 @@ func (cs *CronService) loadStore() error {
return err
}
return json.Unmarshal(data, cs.store)
return jsonv2.Unmarshal(data, cs.store)
}
func (cs *CronService) loadStoreFromDelegate() error {
@ -360,7 +362,7 @@ func (cs *CronService) loadStoreFromDelegate() error {
if err != nil || val == "" {
return nil
}
return json.Unmarshal([]byte(val), cs.store)
return jsonv2.Unmarshal([]byte(val), cs.store)
}
func (cs *CronService) saveStoreUnsafe() error {
@ -373,7 +375,7 @@ func (cs *CronService) saveStoreUnsafe() error {
return err
}
data, err := json.MarshalIndent(cs.store, "", " ")
data, err := jsonv2.Marshal(cs.store, jsontext.WithIndent(" "))
if err != nil {
return err
}
@ -382,7 +384,7 @@ func (cs *CronService) saveStoreUnsafe() error {
}
func (cs *CronService) saveStoreToDelegate() error {
data, err := json.Marshal(cs.store)
data, err := jsonv2.Marshal(cs.store)
if err != nil {
return err
}

View file

@ -7,10 +7,11 @@ package fantasy
import (
"context"
"encoding/json"
"fmt"
"charm.land/fantasy"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger"
memstore "github.com/sipeed/picoclaw/pkg/memory/store"
@ -170,7 +171,7 @@ func parseToolArgs(input string) (map[string]interface{}, error) {
}
var args map[string]interface{}
if err := json.Unmarshal([]byte(input), &args); err != nil {
if err := jsonv2.Unmarshal([]byte(input), &args); err != nil {
return nil, fmt.Errorf("failed to parse tool arguments: %w", err)
}
return args, nil

View file

@ -8,13 +8,13 @@ package fantasy
import (
"bytes"
"context"
"encoding/json"
"fmt"
"iter"
"os/exec"
"strings"
"charm.land/fantasy"
jsonv2 "github.com/go-json-experiment/json"
)
// claudeCliProvider implements fantasy.Provider using the claude CLI subprocess.
@ -147,7 +147,7 @@ func extractTextFromCLIOutput(output []byte) string {
Content string `json:"content"`
Text string `json:"text"`
}
if err := json.Unmarshal(output, &results); err == nil {
if err := jsonv2.Unmarshal(output, &results); err == nil {
var texts []string
for _, r := range results {
switch {
@ -167,7 +167,7 @@ func extractTextFromCLIOutput(output []byte) string {
Result string `json:"result"`
Text string `json:"text"`
}
if err := json.Unmarshal(output, &single); err == nil {
if err := jsonv2.Unmarshal(output, &single); err == nil {
if single.Result != "" {
return single.Result
}

View file

@ -6,9 +6,9 @@
package fantasy
import (
"encoding/json"
"charm.land/fantasy"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/messages"
)
@ -55,7 +55,7 @@ func MessageToFantasy(msg messages.Message) fantasy.Message {
input = tc.Function.Arguments
} else if tc.Arguments != nil {
// Fallback: serialize the map to JSON.
data, _ := json.Marshal(tc.Arguments)
data, _ := jsonv2.Marshal(tc.Arguments)
input = string(data)
}

View file

@ -2,11 +2,12 @@ package health
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
jsonv2 "github.com/go-json-experiment/json"
)
type Server struct {
@ -20,14 +21,14 @@ type Server struct {
type Check struct {
Name string `json:"name"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
Message string `json:"message,omitzero"`
Timestamp time.Time `json:"timestamp"`
}
type StatusResponse struct {
Status string `json:"status"`
Uptime string `json:"uptime"`
Checks map[string]Check `json:"checks,omitempty"`
Checks map[string]Check `json:"checks,omitzero"`
}
func NewServer(host string, port int) *Server {
@ -113,7 +114,7 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
Uptime: uptime.String(),
}
json.NewEncoder(w).Encode(resp)
jsonv2.MarshalWrite(w, resp)
}
func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
@ -129,7 +130,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
if !ready {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(StatusResponse{
jsonv2.MarshalWrite(w, StatusResponse{
Status: "not ready",
Checks: checks,
})
@ -139,7 +140,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
for _, check := range checks {
if check.Status == "fail" {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(StatusResponse{
jsonv2.MarshalWrite(w, StatusResponse{
Status: "not ready",
Checks: checks,
})
@ -149,7 +150,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
uptime := time.Since(s.startTime)
json.NewEncoder(w).Encode(StatusResponse{
jsonv2.MarshalWrite(w, StatusResponse{
Status: "ready",
Uptime: uptime.String(),
Checks: checks,

View file

@ -1,8 +1,9 @@
package ids
import (
"encoding/json"
"testing"
jsonv2 "github.com/go-json-experiment/json"
)
func TestNew_IsV7(t *testing.T) {
@ -159,14 +160,14 @@ func TestScan_InvalidType(t *testing.T) {
func TestJSON_RoundTrip(t *testing.T) {
u := New()
b, err := json.Marshal(u)
b, err := jsonv2.Marshal(u)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
// Should be a quoted string
var s string
if err := json.Unmarshal(b, &s); err != nil {
if err := jsonv2.Unmarshal(b, &s); err != nil {
t.Fatalf("Unmarshal to string: %v", err)
}
if s != u.String() {
@ -174,7 +175,7 @@ func TestJSON_RoundTrip(t *testing.T) {
}
var parsed UUID
if err := json.Unmarshal(b, &parsed); err != nil {
if err := jsonv2.Unmarshal(b, &parsed); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if parsed != u {
@ -184,7 +185,7 @@ func TestJSON_RoundTrip(t *testing.T) {
func TestJSON_ZeroUUID(t *testing.T) {
var zero UUID
b, err := json.Marshal(zero)
b, err := jsonv2.Marshal(zero)
if err != nil {
t.Fatalf("Marshal zero: %v", err)
}
@ -201,13 +202,13 @@ func TestJSON_InStruct(t *testing.T) {
}
r := record{ID: New(), Name: "test"}
b, err := json.Marshal(r)
b, err := jsonv2.Marshal(r)
if err != nil {
t.Fatalf("Marshal struct: %v", err)
}
var decoded record
if err := json.Unmarshal(b, &decoded); err != nil {
if err := jsonv2.Unmarshal(b, &decoded); err != nil {
t.Fatalf("Unmarshal struct: %v", err)
}
if decoded.ID != r.ID {

View file

@ -2,9 +2,10 @@ package ids
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
)
// UUID represents a 16-byte RFC-9562 UUIDv7 value.
@ -85,13 +86,13 @@ func (u *UUID) Scan(src interface{}) error {
// MarshalJSON encodes UUID as JSON string.
func (u UUID) MarshalJSON() ([]byte, error) {
return json.Marshal(u.String())
return jsonv2.Marshal(u.String())
}
// UnmarshalJSON decodes UUID from JSON string.
func (u *UUID) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
if err := jsonv2.Unmarshal(b, &s); err != nil {
return err
}
parsed, err := Parse(s)

View file

@ -3,20 +3,15 @@
// recursive decomposition operations — are serialized as ToolRequest /
// ToolResponse pairs.
//
// The canonical schema lives in commands.fbs. This file provides the Go
// types and encoding helpers that replace the raw flatc-generated code while
// keeping the same wire format semantics. The encoding uses encoding/json
// on the initial implementation path; the FlatBuffers binary encoding is
// available via the flatbuffers package when performance demands it.
//
// Migration path: when the codebase moves to the full FlatBuffers binary
// encoding, replace the JSON marshal/unmarshal calls with flatbuffers builder
// calls without changing any call sites — the public types remain stable.
// The canonical schema lives in commands.fbs. Wire encoding uses FlatBuffers
// for all internal transport (socket, daemon, WASM). JSON encoding is
// available via MarshalJSON / UnmarshalRequestJSON for the LLM boundary only.
package itr
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"time"
)
@ -113,6 +108,10 @@ type CodeExec struct {
// command payload and declares dependencies on other nodes by ID.
// Args may contain "#nodeN" references that are resolved to the output of
// the dependency node at execution time.
//
// Implements json.Unmarshaler to deserialize Payload directly into the
// concrete type indicated by Type, avoiding the map[string]interface{}
// erasure that encoding/json applies to interface{} fields.
type DAGNode struct {
ID string `json:"id"`
Type CommandType `json:"type"`
@ -120,6 +119,92 @@ type DAGNode struct {
DependsOn []string `json:"depends_on,omitempty"`
}
// UnmarshalJSONFrom implements jsonv2.UnmarshalerFrom. It defers Payload
// deserialization via jsontext.Value until the Type discriminator is known,
// then unmarshals directly into the concrete Go type.
func (n *DAGNode) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
var raw struct {
ID string `json:"id"`
Type CommandType `json:"type"`
Payload jsontext.Value `json:"payload"`
DependsOn []string `json:"depends_on,omitempty"`
}
if err := jsonv2.UnmarshalDecode(dec, &raw); err != nil {
return err
}
n.ID = raw.ID
n.Type = raw.Type
n.DependsOn = raw.DependsOn
if len(raw.Payload) == 0 || string(raw.Payload) == "null" {
return nil
}
unmarshalPayload := func(dst any) error {
return jsonv2.Unmarshal(raw.Payload, dst, jsonv2.MatchCaseInsensitiveNames(true))
}
switch raw.Type {
case CmdToolExec:
var p ToolExec
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
case CmdToolSearch:
var p ToolSearch
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
case CmdPeek:
var p Peek
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
case CmdGrep:
var p Grep
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
case CmdPartition:
var p Partition
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
case CmdRecurse:
var p Recurse
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
case CmdCodeExec:
var p CodeExec
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
case CmdExecWasm:
var p ExecWasm
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
case CmdFinal:
var p Final
if err := unmarshalPayload(&p); err != nil {
return fmt.Errorf("DAGNode %s payload: %w", raw.ID, err)
}
n.Payload = p
default:
return fmt.Errorf("DAGNode %s: unknown type %q", raw.ID, raw.Type)
}
return nil
}
// DAGPlan is a composite command that contains multiple DAGNodes forming a
// Directed Acyclic Graph. The executor dispatches nodes in topological order,
// running independent nodes in parallel.
@ -269,30 +354,77 @@ func NewLeakResponse(id, redactedResult string, redactedKeys []string) ToolRespo
}
}
// ── Serialisation helpers ────────────────────────────────────────────────────
// ── Serialisation helpers (FlatBuffers default) ──────────────────────────────
// Marshal encodes a ToolRequest to JSON bytes for transmission.
// Marshal encodes a ToolRequest to FlatBuffers bytes for internal transport.
func (r ToolRequest) Marshal() ([]byte, error) {
return json.Marshal(r)
return MarshalRequestFB(r)
}
// Marshal encodes a ToolResponse to JSON bytes for transmission.
// Marshal encodes a ToolResponse to FlatBuffers bytes for internal transport.
func (r ToolResponse) Marshal() ([]byte, error) {
return json.Marshal(r)
return MarshalResponseFB(r)
}
// UnmarshalRequest decodes a ToolRequest from JSON bytes.
// UnmarshalRequest decodes a ToolRequest from FlatBuffers bytes.
func UnmarshalRequest(data []byte) (ToolRequest, error) {
var raw struct {
ID string `json:"id"`
Type CommandType `json:"type"`
Payload json.RawMessage `json:"payload"`
Timestamp int64 `json:"timestamp"`
Depth uint8 `json:"depth"`
SessionKey string `json:"session_key"`
ToolCallID string `json:"tool_call_id"`
return UnmarshalRequestFB(data)
}
// UnmarshalResponse decodes a ToolResponse from FlatBuffers bytes.
func UnmarshalResponse(data []byte) (ToolResponse, error) {
return UnmarshalResponseFB(data)
}
// ── JSON helpers (LLM boundary only) ─────────────────────────────────────────
// MarshalJSONTo implements jsonv2.MarshalerTo. Use only at the LLM boundary.
func (r ToolRequest) MarshalJSONTo(enc *jsontext.Encoder) error {
type alias struct {
ID string `json:"id"`
Type CommandType `json:"type"`
Payload interface{} `json:"payload"`
Timestamp int64 `json:"timestamp"`
Depth uint8 `json:"depth,omitzero"`
SessionKey string `json:"session_key,omitzero"`
ToolCallID string `json:"tool_call_id,omitzero"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return jsonv2.MarshalEncode(enc, alias{r.ID, r.Type, r.Payload, r.Timestamp, r.Depth, r.SessionKey, r.ToolCallID})
}
// MarshalJSONTo implements jsonv2.MarshalerTo. Use only at the LLM boundary.
func (r ToolResponse) MarshalJSONTo(enc *jsontext.Encoder) error {
type alias struct {
ID string `json:"id"`
Result string `json:"result,omitzero"`
IsError bool `json:"is_error,omitzero"`
LeakDetected bool `json:"leak_detected,omitzero"`
CostTokens uint32 `json:"cost_tokens,omitzero"`
RedactedKeys []string `json:"redacted_keys,omitzero"`
}
return jsonv2.MarshalEncode(enc, alias{r.ID, r.Result, r.IsError, r.LeakDetected, r.CostTokens, r.RedactedKeys})
}
// LLMJSONOpts returns json/v2 options suitable for unmarshaling LLM-generated JSON,
// which may use inconsistent casing.
func LLMJSONOpts() jsonv2.Options {
return jsonv2.MatchCaseInsensitiveNames(true)
}
// UnmarshalRequestJSON decodes a ToolRequest from JSON bytes. Use only at
// the LLM boundary (planner output, tool schemas).
func UnmarshalRequestJSON(data []byte) (ToolRequest, error) {
opts := LLMJSONOpts()
var raw struct {
ID string `json:"id"`
Type CommandType `json:"type"`
Payload jsontext.Value `json:"payload"`
Timestamp int64 `json:"timestamp"`
Depth uint8 `json:"depth"`
SessionKey string `json:"session_key"`
ToolCallID string `json:"tool_call_id"`
}
if err := jsonv2.Unmarshal(data, &raw, opts); err != nil {
return ToolRequest{}, err
}
@ -305,47 +437,51 @@ func UnmarshalRequest(data []byte) (ToolRequest, error) {
ToolCallID: raw.ToolCallID,
}
unmarshalPayload := func(dst any) error {
return jsonv2.Unmarshal(raw.Payload, dst, opts)
}
var err error
switch raw.Type {
case CmdPeek:
var p Peek
err = json.Unmarshal(raw.Payload, &p)
err = unmarshalPayload(&p)
req.Payload = p
case CmdGrep:
var p Grep
err = json.Unmarshal(raw.Payload, &p)
err = unmarshalPayload(&p)
req.Payload = p
case CmdPartition:
var p Partition
err = json.Unmarshal(raw.Payload, &p)
err = unmarshalPayload(&p)
req.Payload = p
case CmdRecurse:
var p Recurse
err = json.Unmarshal(raw.Payload, &p)
err = unmarshalPayload(&p)
req.Payload = p
case CmdToolExec:
var p ToolExec
err = json.Unmarshal(raw.Payload, &p)
err = unmarshalPayload(&p)
req.Payload = p
case CmdExecWasm:
var p ExecWasm
err = json.Unmarshal(raw.Payload, &p)
err = unmarshalPayload(&p)
req.Payload = p
case CmdFinal:
var p Final
err = json.Unmarshal(raw.Payload, &p)
err = unmarshalPayload(&p)
req.Payload = p
case CmdToolSearch:
var p ToolSearch
err = json.Unmarshal(raw.Payload, &p)
err = unmarshalPayload(&p)
req.Payload = p
case CmdCodeExec:
var p CodeExec
err = json.Unmarshal(raw.Payload, &p)
err = unmarshalPayload(&p)
req.Payload = p
case CmdDAGPlan:
var p DAGPlan
err = json.Unmarshal(raw.Payload, &p)
err = unmarshalPayload(&p)
req.Payload = p
default:
return ToolRequest{}, fmt.Errorf("unknown command type: %q", raw.Type)
@ -354,8 +490,8 @@ func UnmarshalRequest(data []byte) (ToolRequest, error) {
return req, err
}
// UnmarshalResponse decodes a ToolResponse from JSON bytes.
func UnmarshalResponse(data []byte) (ToolResponse, error) {
// UnmarshalResponseJSON decodes a ToolResponse from JSON bytes.
func UnmarshalResponseJSON(data []byte) (ToolResponse, error) {
var r ToolResponse
return r, json.Unmarshal(data, &r)
return r, jsonv2.Unmarshal(data, &r)
}

View file

@ -13,7 +13,6 @@ package dag
import (
"context"
"encoding/json"
"fmt"
"runtime"
"strings"
@ -36,11 +35,11 @@ type RLMExpandFunc func(ctx context.Context, sessionKey, query, contextContent s
// Executor runs a DAGPlan through the SecureBus with topological dispatch.
type Executor struct {
bus *securebus.Bus
joiner JoinerFunc
rlmExpand RLMExpandFunc
rlmThresholdBytes int
maxParallel int
bus *securebus.Bus
joiner JoinerFunc
rlmExpand RLMExpandFunc
rlmThresholdBytes int
maxParallel int
}
// ExecutorOption configures an Executor via the functional options pattern.
@ -133,8 +132,9 @@ func (e *Executor) Execute(ctx context.Context, sessionKey string, plan *itr.DAG
select {
case <-depState.done:
if _, depErr := depState.getResult(); depErr != nil {
ns.setResult("", fmt.Errorf("dependency %s failed: %w", dep, depErr))
errOnce.Do(func() { waveErr = depErr })
wrapped := fmt.Errorf("dependency %s failed: %w", dep, depErr)
ns.setResult("", wrapped)
errOnce.Do(func() { waveErr = wrapped })
return
}
case <-ctx.Done():
@ -215,34 +215,31 @@ func (e *Executor) Execute(ctx context.Context, sessionKey string, plan *itr.DAG
// executeNode dispatches a single node through the SecureBus after resolving
// dependency references in its payload.
func (e *Executor) executeNode(ctx context.Context, sessionKey string, node *itr.DAGNode, states map[string]*nodeState) itr.ToolResponse {
req := nodeToRequest(sessionKey, node, states)
req, err := nodeToRequest(sessionKey, node, states)
if err != nil {
return itr.NewErrorResponse(node.ID, err.Error())
}
return e.bus.Execute(ctx, req)
}
// nodeToRequest converts a DAGNode into a ToolRequest, resolving #nodeN
// references in tool arguments.
func nodeToRequest(sessionKey string, node *itr.DAGNode, states map[string]*nodeState) itr.ToolRequest {
func nodeToRequest(sessionKey string, node *itr.DAGNode, states map[string]*nodeState) (itr.ToolRequest, error) {
switch node.Type {
case itr.CmdToolExec:
te, ok := node.Payload.(itr.ToolExec)
if !ok {
if m, ok := node.Payload.(map[string]interface{}); ok {
b, _ := json.Marshal(m)
_ = json.Unmarshal(b, &te)
}
return itr.ToolRequest{}, fmt.Errorf("node %s: expected ToolExec payload, got %T", node.ID, node.Payload)
}
te.ArgsJSON = resolveToolExecArgs(te.ArgsJSON, states)
return itr.NewToolExecRequest(node.ID, sessionKey, node.ID, te.ToolName, te.ArgsJSON)
return itr.NewToolExecRequest(node.ID, sessionKey, node.ID, te.ToolName, te.ArgsJSON), nil
case itr.CmdToolSearch:
ts, ok := node.Payload.(itr.ToolSearch)
if !ok {
if m, ok := node.Payload.(map[string]interface{}); ok {
b, _ := json.Marshal(m)
_ = json.Unmarshal(b, &ts)
}
return itr.ToolRequest{}, fmt.Errorf("node %s: expected ToolSearch payload, got %T", node.ID, node.Payload)
}
return itr.NewToolSearchRequest(node.ID, sessionKey, ts.Query, ts.MaxResults)
return itr.NewToolSearchRequest(node.ID, sessionKey, ts.Query, ts.MaxResults), nil
default:
return itr.ToolRequest{
@ -250,7 +247,7 @@ func nodeToRequest(sessionKey string, node *itr.DAGNode, states map[string]*node
Type: node.Type,
Payload: node.Payload,
SessionKey: sessionKey,
}
}, nil
}
}

View file

@ -2,8 +2,8 @@ package dag_test
import (
"context"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"testing"
"github.com/sipeed/picoclaw/pkg/itr"
@ -123,7 +123,11 @@ func TestExecutor_WithJoiner(t *testing.T) {
defer bus.Close()
joiner := func(_ context.Context, _, userQuery string) (string, uint32, error) {
return "synthesized: " + userQuery[:20], 50, nil
n := len(userQuery)
if n > 20 {
n = 20
}
return "synthesized: " + userQuery[:n], 50, nil
}
executor := dag.NewExecutor(bus, joiner)
@ -173,7 +177,8 @@ func TestResolver_NodeRefSubstitution(t *testing.T) {
result, err := executor.Execute(context.Background(), "test-sess", plan)
require.NoError(t, err)
_ = result
assert.Contains(t, result.NodeResults["prev"], "previous-output")
assert.Contains(t, result.NodeResults["search"], "found")
}
func TestRouter_SimpleQuerySelectsReAct(t *testing.T) {
@ -225,7 +230,7 @@ func TestPlanner_ValidatePlan(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var plan itr.DAGPlan
err := json.Unmarshal([]byte(tt.plan), &plan)
err := jsonv2.Unmarshal([]byte(tt.plan), &plan)
require.NoError(t, err)
// Use planner with a mock that returns the pre-built plan JSON

View file

@ -2,8 +2,8 @@ package dag
import (
"context"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/itr"
"github.com/sipeed/picoclaw/pkg/tools"
@ -17,10 +17,10 @@ type PlannerFunc func(ctx context.Context, systemPrompt, userQuery string) (stri
// LLM to produce a structured JSON plan. The LLM outputs a list of nodes
// with dependency edges in a single inference pass (LLMCompiler pattern).
type Planner struct {
callModel PlannerFunc
registry *tools.ToolRegistry
maxParallel uint8
tokenBudget uint32
callModel PlannerFunc
registry *tools.ToolRegistry
maxParallel uint8
tokenBudget uint32
}
// PlannerConfig configures the DAG planner.
@ -115,32 +115,10 @@ func parsePlanResponse(response string) (*itr.DAGPlan, error) {
response = extractJSON(response)
var plan itr.DAGPlan
if err := json.Unmarshal([]byte(response), &plan); err != nil {
if err := jsonv2.Unmarshal([]byte(response), &plan, itr.LLMJSONOpts()); err != nil {
return nil, fmt.Errorf("JSON parse error: %w\nraw: %s", err, truncate(response, 500))
}
// Unmarshal re-encodes Payload as map[string]interface{} via JSON round-trip.
// Convert payload maps to their proper types.
for i := range plan.Nodes {
n := &plan.Nodes[i]
m, ok := n.Payload.(map[string]interface{})
if !ok {
continue
}
b, _ := json.Marshal(m)
switch n.Type {
case itr.CmdToolExec:
var te itr.ToolExec
_ = json.Unmarshal(b, &te)
n.Payload = te
case itr.CmdToolSearch:
var ts itr.ToolSearch
_ = json.Unmarshal(b, &ts)
n.Payload = ts
}
}
return &plan, nil
}

View file

@ -1,8 +1,8 @@
package dag
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"regexp"
"strings"
"sync"
@ -67,11 +67,10 @@ func resolveToolExecArgs(argsJSON string, states map[string]*nodeState) string {
// escapeForJSON makes a string safe for embedding into a JSON value.
func escapeForJSON(s string) string {
b, err := json.Marshal(s)
if err != nil {
b, err := jsonv2.Marshal(s)
if err != nil || len(b) < 2 {
return s
}
// Strip surrounding quotes since we're replacing within an existing string.
return string(b[1 : len(b)-1])
}

View file

@ -1,7 +1,6 @@
package logger
import (
"encoding/json"
"fmt"
"log"
"os"
@ -9,6 +8,8 @@ import (
"strings"
"sync"
"time"
jsonv2 "github.com/go-json-experiment/json"
)
type LogLevel int
@ -43,10 +44,10 @@ type Logger struct {
type LogEntry struct {
Level string `json:"level"`
Timestamp string `json:"timestamp"`
Component string `json:"component,omitempty"`
Component string `json:"component,omitzero"`
Message string `json:"message"`
Fields map[string]interface{} `json:"fields,omitempty"`
Caller string `json:"caller,omitempty"`
Fields map[string]interface{} `json:"fields,omitzero"`
Caller string `json:"caller,omitzero"`
}
func init() {
@ -117,7 +118,7 @@ func logMessage(level LogLevel, component string, message string, fields map[str
}
if logger.file != nil {
jsonData, err := json.Marshal(entry)
jsonData, err := jsonv2.Marshal(entry)
if err == nil {
logger.file.WriteString(string(jsonData) + "\n")
}

View file

@ -3,12 +3,12 @@ package dag
// BudgetConfig defines the percentage allocation for each context section.
// All percentages should sum to 100.
type BudgetConfig struct {
SystemPromptPct int // % for system prompt (identity, rules, skills)
ObservationsPct int // % for observation block
KnowledgePct int // % for knowledge block (Focus completions)
DAGSummariesPct int // % for DAG compressed history
RawTailPct int // % for raw recent messages (uncompressed tail)
ToolResultsPct int // % for tool call results
SystemPromptPct int // % for system prompt (identity, rules, skills)
ObservationsPct int // % for observation block
KnowledgePct int // % for knowledge block (Focus completions)
DAGSummariesPct int // % for DAG compressed history
RawTailPct int // % for raw recent messages (uncompressed tail)
ToolResultsPct int // % for tool call results
}
// DefaultBudgetConfig returns a balanced allocation.

View file

@ -14,10 +14,10 @@ type Message struct {
// CompressorConfig controls the deterministic compression behavior.
type CompressorConfig struct {
ChunkSize int // Messages per chunk node (default 8)
SectionSize int // Chunks per section node (default 4)
MaxSentences int // Max sentences to extract per message (default 2)
TargetRatio float64 // Target compression ratio (default 0.25 = 4:1)
ChunkSize int // Messages per chunk node (default 8)
SectionSize int // Chunks per section node (default 4)
MaxSentences int // Max sentences to extract per message (default 2)
TargetRatio float64 // Target compression ratio (default 0.25 = 4:1)
}
// DefaultCompressorConfig returns sensible defaults.

View file

@ -220,9 +220,9 @@ func TestSelectDAGLevel(t *testing.T) {
func TestTailMessageCount(t *testing.T) {
assert.Equal(t, 4, TailMessageCount(100)) // Minimum
assert.Equal(t, 20, TailMessageCount(1000)) // 1000/50
assert.Equal(t, 4, TailMessageCount(0)) // Zero budget
assert.Equal(t, 4, TailMessageCount(-1)) // Negative
assert.Equal(t, 20, TailMessageCount(1000)) // 1000/50
assert.Equal(t, 4, TailMessageCount(0)) // Zero budget
assert.Equal(t, 4, TailMessageCount(-1)) // Negative
}
func TestRenderDAGForBudget(t *testing.T) {

View file

@ -2,7 +2,7 @@ package delegate
import (
"context"
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"testing"
"github.com/sipeed/picoclaw/pkg/ids"
@ -38,7 +38,7 @@ func TestCronKVBackend_Roundtrip(t *testing.T) {
},
}
data, err := json.Marshal(store)
data, err := jsonv2.Marshal(store)
require.NoError(t, err)
require.NoError(t, d.UpsertKV(ctx, agentID, kvKey, string(data)))
@ -48,7 +48,7 @@ func TestCronKVBackend_Roundtrip(t *testing.T) {
require.NotEmpty(t, raw)
var loaded cronStore
require.NoError(t, json.Unmarshal([]byte(raw), &loaded))
require.NoError(t, jsonv2.Unmarshal([]byte(raw), &loaded))
assert.Equal(t, 1, loaded.Version)
assert.Len(t, loaded.Jobs, 2)

View file

@ -5,8 +5,8 @@ package memory
import (
"context"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"os"
"path/filepath"
"strings"
@ -23,7 +23,7 @@ const migrationMarkerFile = ".sessions_migrated"
type SessionFile struct {
Key string `json:"key"`
Messages []SessionMsg `json:"messages"`
Summary string `json:"summary,omitempty"`
Summary string `json:"summary,omitzero"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
@ -80,7 +80,7 @@ func MigrateFileSessions(ctx context.Context, del MemoryDelegate, agentID, sessi
}
var sess SessionFile
if err := json.Unmarshal(data, &sess); err != nil {
if err := jsonv2.Unmarshal(data, &sess); err != nil {
logger.WarnCF("migrate", "Failed to parse session file",
map[string]interface{}{"path": sessPath, "error": err.Error()})
result.Errors++

View file

@ -2,7 +2,7 @@ package memory
import (
"context"
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"os"
"path/filepath"
"testing"
@ -103,7 +103,7 @@ func (m *mockDelegate) CountAuditEntries(_ context.Context, _ string) (int, erro
func writeSessionFile(t *testing.T, dir, name string, sess SessionFile) {
t.Helper()
data, err := json.Marshal(sess)
data, err := jsonv2.Marshal(sess)
if err != nil {
t.Fatalf("marshal session: %v", err)
}

View file

@ -2,7 +2,7 @@ package memory
import (
"context"
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"log"
"os"
"path/filepath"
@ -10,8 +10,8 @@ import (
)
type legacyState struct {
LastChannel string `json:"last_channel,omitempty"`
LastChatID string `json:"last_chat_id,omitempty"`
LastChannel string `json:"last_channel,omitzero"`
LastChatID string `json:"last_chat_id,omitzero"`
Timestamp time.Time `json:"timestamp"`
}
@ -39,7 +39,7 @@ func MigrateState(ctx context.Context, workspace string, delegate MemoryDelegate
}
var s legacyState
if err := json.Unmarshal(data, &s); err != nil {
if err := jsonv2.Unmarshal(data, &s); err != nil {
log.Printf("[WARN] migrate_state: failed to parse %s: %v", stateFile, err)
return nil
}

View file

@ -15,8 +15,8 @@ type Manager struct {
observer *Observer
reflector *Reflector
mu sync.Mutex
running map[string]bool // sessionKey -> running flag to prevent concurrent runs
mu sync.Mutex
running map[string]bool // sessionKey -> running flag to prevent concurrent runs
}
// ManagerConfig bundles configuration for the observation system.

View file

@ -14,8 +14,8 @@
package observation
import (
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"strings"
"time"
)
@ -25,8 +25,8 @@ type Priority string
const (
PriorityCritical Priority = "critical" // 🔴
PriorityNotable Priority = "notable" // 🟡
PriorityInformational Priority = "informational" // 🔵
PriorityNotable Priority = "notable" // 🟡
PriorityInformational Priority = "informational" // 🔵
)
func (p Priority) Emoji() string {
@ -97,7 +97,7 @@ func FormatBlock(observations []Observation) string {
// MarshalObservations serializes observations to JSON for KV storage.
func MarshalObservations(obs []Observation) (string, error) {
data, err := json.Marshal(obs)
data, err := jsonv2.Marshal(obs)
if err != nil {
return "", fmt.Errorf("marshal observations: %w", err)
}
@ -110,7 +110,7 @@ func UnmarshalObservations(data string) ([]Observation, error) {
return nil, nil
}
var obs []Observation
if err := json.Unmarshal([]byte(data), &obs); err != nil {
if err := jsonv2.Unmarshal([]byte(data), &obs); err != nil {
return nil, fmt.Errorf("unmarshal observations: %w", err)
}
return obs, nil

View file

@ -238,8 +238,8 @@ func TestParseKeptIndices(t *testing.T) {
{"normal", "KEEP 0\nDROP 1\nKEEP 2", 2},
{"all keep", "KEEP 0\nKEEP 1\nKEEP 2", 3},
{"all drop", "DROP 0\nDROP 1\nDROP 2", 1}, // Fallback keeps critical
{"invalid output", "blah blah", 1}, // Fallback keeps critical
{"out of range", "KEEP 99", 1}, // Fallback keeps critical
{"invalid output", "blah blah", 1}, // Fallback keeps critical
{"out of range", "KEEP 99", 1}, // Fallback keeps critical
}
for _, tc := range tests {

View file

@ -1,7 +1,7 @@
package store
import (
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"net/http"
"net/http/httptest"
"testing"
@ -38,11 +38,12 @@ func TestNewEmbedderFromConfig_OpenAI_NoKey(t *testing.T) {
func TestNewEmbedderFromConfig_OpenAI_FallbackKey(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
respData, _ := jsonv2.Marshal(map[string]interface{}{
"data": []map[string]interface{}{
{"index": 0, "embedding": []float32{0.1, 0.2, 0.3}},
},
})
w.Write(respData)
}))
defer srv.Close()
@ -70,9 +71,10 @@ func TestNewEmbedderFromConfig_OpenAI_FallbackKey(t *testing.T) {
func TestNewEmbedderFromConfig_Ollama(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
respData, _ := jsonv2.Marshal(map[string]interface{}{
"embeddings": [][]float32{{0.1, 0.2, 0.3}},
})
w.Write(respData)
}))
defer srv.Close()
@ -97,9 +99,10 @@ func TestNewEmbedderFromConfig_Ollama(t *testing.T) {
func TestNewEmbedderFromConfig_Ollama_FallbackBase(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
respData, _ := jsonv2.Marshal(map[string]interface{}{
"embeddings": [][]float32{{0.5}},
})
w.Write(respData)
}))
defer srv.Close()
@ -119,9 +122,10 @@ func TestNewEmbedderFromConfig_Ollama_FallbackBase(t *testing.T) {
func TestNewEmbedderFromConfig_CaseInsensitive(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
respData, _ := jsonv2.Marshal(map[string]interface{}{
"embeddings": [][]float32{{0.1}},
})
w.Write(respData)
}))
defer srv.Close()

View file

@ -3,8 +3,8 @@ package store
import (
"bytes"
"context"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"io"
"net/http"
"time"
@ -93,7 +93,7 @@ func (o *OllamaEmbedder) embedBatch(ctx context.Context, texts []string) ([]memo
}
func (o *OllamaEmbedder) embedSingle(ctx context.Context, text string) (memory.Embedding, error) {
body, err := json.Marshal(ollamaEmbedRequest{
body, err := jsonv2.Marshal(ollamaEmbedRequest{
Model: o.model,
Input: text,
})
@ -119,7 +119,7 @@ func (o *OllamaEmbedder) embedSingle(ctx context.Context, text string) (memory.E
}
var result ollamaEmbedResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
if err := jsonv2.UnmarshalRead(resp.Body, &result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}

View file

@ -2,7 +2,7 @@ package store
import (
"context"
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"net/http"
"net/http/httptest"
"testing"
@ -18,14 +18,15 @@ func TestOllamaEmbedder_Embed(t *testing.T) {
}
var req ollamaEmbedRequest
json.NewDecoder(r.Body).Decode(&req)
jsonv2.UnmarshalRead(r.Body, &req)
if req.Model != "test-model" {
t.Errorf("expected model 'test-model', got %q", req.Model)
}
json.NewEncoder(w).Encode(ollamaEmbedResponse{
respData, _ := jsonv2.Marshal(ollamaEmbedResponse{
Embeddings: [][]float32{{0.1, 0.2, 0.3, 0.4}},
})
w.Write(respData)
}))
defer srv.Close()
@ -55,9 +56,10 @@ func TestOllamaEmbedder_EmbedBatch(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
json.NewEncoder(w).Encode(ollamaEmbedResponse{
respData, _ := jsonv2.Marshal(ollamaEmbedResponse{
Embeddings: [][]float32{{float32(callCount) * 0.1}},
})
w.Write(respData)
}))
defer srv.Close()
@ -91,7 +93,8 @@ func TestOllamaEmbedder_ServerError(t *testing.T) {
func TestOllamaEmbedder_EmptyResponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(ollamaEmbedResponse{Embeddings: [][]float32{}})
respData, _ := jsonv2.Marshal(ollamaEmbedResponse{Embeddings: [][]float32{}})
w.Write(respData)
}))
defer srv.Close()

View file

@ -3,8 +3,8 @@ package store
import (
"bytes"
"context"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"io"
"net/http"
"time"
@ -59,7 +59,7 @@ func NewOpenAIEmbedder(cfg OpenAIEmbedderConfig) *OpenAIEmbedder {
type openAIEmbedRequest struct {
Input interface{} `json:"input"` // string or []string
Model string `json:"model"`
EncodingFormat string `json:"encoding_format,omitempty"`
EncodingFormat string `json:"encoding_format,omitzero"`
}
type openAIEmbedResponse struct {
@ -100,7 +100,7 @@ func (o *OpenAIEmbedder) call(ctx context.Context, texts []string) ([]memory.Emb
input = texts
}
body, err := json.Marshal(openAIEmbedRequest{
body, err := jsonv2.Marshal(openAIEmbedRequest{
Input: input,
Model: o.model,
EncodingFormat: "float",
@ -130,7 +130,7 @@ func (o *OpenAIEmbedder) call(ctx context.Context, texts []string) ([]memory.Emb
}
var result openAIEmbedResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
if err := jsonv2.UnmarshalRead(resp.Body, &result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}

View file

@ -2,7 +2,7 @@ package store
import (
"context"
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"net/http"
"net/http/httptest"
"testing"
@ -20,16 +20,17 @@ func TestOpenAIEmbedder_Embed(t *testing.T) {
}
var req openAIEmbedRequest
json.NewDecoder(r.Body).Decode(&req)
jsonv2.UnmarshalRead(r.Body, &req)
if req.Model != "test-embed" {
t.Errorf("expected model 'test-embed', got %q", req.Model)
}
json.NewEncoder(w).Encode(openAIEmbedResponse{
respData, _ := jsonv2.Marshal(openAIEmbedResponse{
Data: []openAIEmbedData{
{Index: 0, Embedding: []float32{0.5, 0.6, 0.7}},
},
})
w.Write(respData)
}))
defer srv.Close()
@ -57,7 +58,7 @@ func TestOpenAIEmbedder_Embed(t *testing.T) {
func TestOpenAIEmbedder_EmbedBatch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req openAIEmbedRequest
json.NewDecoder(r.Body).Decode(&req)
jsonv2.UnmarshalRead(r.Body, &req)
// Batch request should send array
texts, ok := req.Input.([]interface{})
@ -73,7 +74,8 @@ func TestOpenAIEmbedder_EmbedBatch(t *testing.T) {
}
}
json.NewEncoder(w).Encode(openAIEmbedResponse{Data: data})
respData, _ := jsonv2.Marshal(openAIEmbedResponse{Data: data})
w.Write(respData)
}))
defer srv.Close()
@ -94,16 +96,17 @@ func TestOpenAIEmbedder_EmbedBatch(t *testing.T) {
func TestOpenAIEmbedder_SingleTextNotArray(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req openAIEmbedRequest
json.NewDecoder(r.Body).Decode(&req)
jsonv2.UnmarshalRead(r.Body, &req)
// Single text should be sent as string, not array
if _, ok := req.Input.(string); !ok {
t.Errorf("expected string input for single text, got %T", req.Input)
}
json.NewEncoder(w).Encode(openAIEmbedResponse{
respData, _ := jsonv2.Marshal(openAIEmbedResponse{
Data: []openAIEmbedData{{Index: 0, Embedding: []float32{1.0}}},
})
w.Write(respData)
}))
defer srv.Close()
@ -140,9 +143,10 @@ func TestOpenAIEmbedder_NoAuth(t *testing.T) {
if r.Header.Get("Authorization") != "" {
t.Error("expected no auth header when key is empty")
}
json.NewEncoder(w).Encode(openAIEmbedResponse{
respData, _ := jsonv2.Marshal(openAIEmbedResponse{
Data: []openAIEmbedData{{Index: 0, Embedding: []float32{1.0}}},
})
w.Write(respData)
}))
defer srv.Close()

View file

@ -2,8 +2,8 @@ package store
import (
"context"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"strings"
"github.com/sipeed/picoclaw/pkg/ids"
@ -25,31 +25,31 @@ const (
// MemoryToolRequest is the input to the memory tool.
type MemoryToolRequest struct {
Action MemoryToolAction `json:"action"`
Query string `json:"query,omitempty"` // For search
ID string `json:"id,omitempty"` // For read/update/delete
Content string `json:"content,omitempty"` // For write/update
Source string `json:"source,omitempty"` // For write
Sector string `json:"sector,omitempty"` // For write: episodic/semantic/procedural/reflective
Tags string `json:"tags,omitempty"` // For write: comma-separated
Tier string `json:"tier,omitempty"` // "recall" or "archival" — defaults to "recall"
Limit int `json:"limit,omitempty"` // For search — defaults to 5
Query string `json:"query,omitzero"` // For search
ID string `json:"id,omitzero"` // For read/update/delete
Content string `json:"content,omitzero"` // For write/update
Source string `json:"source,omitzero"` // For write
Sector string `json:"sector,omitzero"` // For write: episodic/semantic/procedural/reflective
Tags string `json:"tags,omitzero"` // For write: comma-separated
Tier string `json:"tier,omitzero"` // "recall" or "archival" — defaults to "recall"
Limit int `json:"limit,omitzero"` // For search — defaults to 5
}
// MemoryToolResponse is the output of the memory tool.
type MemoryToolResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Results []MemoryToolEntry `json:"results,omitempty"`
Status *MemoryToolStatus `json:"status,omitempty"`
Message string `json:"message,omitzero"`
Results []MemoryToolEntry `json:"results,omitzero"`
Status *MemoryToolStatus `json:"status,omitzero"`
}
// MemoryToolEntry is a single memory entry in tool results.
type MemoryToolEntry struct {
ID string `json:"id"`
Content string `json:"content"`
Source string `json:"source,omitempty"`
Sector string `json:"sector,omitempty"`
Score float64 `json:"score,omitempty"`
Source string `json:"source,omitzero"`
Sector string `json:"sector,omitzero"`
Score float64 `json:"score,omitzero"`
}
// MemoryToolStatus summarizes the memory system state.
@ -82,7 +82,7 @@ func NewMemoryTool(store *MemoryStore, agentID, session string) *MemoryTool {
// Execute processes a memory tool request and returns a JSON response.
func (t *MemoryTool) Execute(ctx context.Context, input string) (string, error) {
var req MemoryToolRequest
if err := json.Unmarshal([]byte(input), &req); err != nil {
if err := jsonv2.Unmarshal([]byte(input), &req); err != nil {
return t.errorResponse("invalid input: " + err.Error()), nil
}
@ -325,6 +325,6 @@ func (t *MemoryTool) errorResponse(msg string) string {
}
func (t *MemoryTool) jsonResponse(resp *MemoryToolResponse) string {
b, _ := json.Marshal(resp)
b, _ := jsonv2.Marshal(resp)
return string(b)
}

View file

@ -2,7 +2,7 @@ package store
import (
"context"
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"testing"
"github.com/stretchr/testify/assert"
@ -22,7 +22,7 @@ func executeAndParse(t *testing.T, tool *MemoryTool, input string) *MemoryToolRe
require.NoError(t, err)
var resp MemoryToolResponse
require.NoError(t, json.Unmarshal([]byte(raw), &resp))
require.NoError(t, jsonv2.Unmarshal([]byte(raw), &resp))
return &resp
}

View file

@ -1,13 +1,14 @@
package migrate
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/config"
)
@ -51,7 +52,7 @@ func LoadOpenClawConfig(configPath string) (map[string]interface{}, error) {
}
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
if err := jsonv2.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parsing OpenClaw config: %w", err)
}

View file

@ -1,11 +1,12 @@
package migrate
import (
"encoding/json"
"os"
"path/filepath"
"testing"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/config"
)
@ -104,7 +105,7 @@ func TestLoadOpenClawConfig(t *testing.T) {
},
}
data, err := json.Marshal(openclawConfig)
data, err := jsonv2.Marshal(openclawConfig)
if err != nil {
t.Fatal(err)
}
@ -583,7 +584,7 @@ func TestRunDryRun(t *testing.T) {
},
},
}
data, _ := json.Marshal(configData)
data, _ := jsonv2.Marshal(configData)
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
opts := Options{
@ -638,7 +639,7 @@ func TestRunFullMigration(t *testing.T) {
},
},
}
data, _ := json.Marshal(configData)
data, _ := jsonv2.Marshal(configData)
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
opts := Options{
@ -784,7 +785,7 @@ func TestRunConfigOnly(t *testing.T) {
},
},
}
data, _ := json.Marshal(configData)
data, _ := jsonv2.Marshal(configData)
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
opts := Options{
@ -824,7 +825,7 @@ func TestRunWorkspaceOnly(t *testing.T) {
},
},
}
data, _ := json.Marshal(configData)
data, _ := jsonv2.Marshal(configData)
os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644)
opts := Options{

View file

@ -1,12 +1,13 @@
package pcerrors
import (
"encoding/json"
"fmt"
"io"
"os"
"regexp"
"strings"
jsonv2 "github.com/go-json-experiment/json"
)
// CLIHandler is the PicoClaw error lifecycle boundary for the CLI.
@ -50,7 +51,7 @@ func (h CLIHandler) Handle(err error) int {
if h.Verbose {
record["detail"] = err.Error()
}
data, _ := json.Marshal(record)
data, _ := jsonv2.Marshal(record)
_, _ = fmt.Fprintln(w, string(data))
} else {
_, _ = fmt.Fprintln(w, msg)

View file

@ -2,9 +2,9 @@
package security
import (
"encoding/json"
"errors"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"strings"
)
@ -50,12 +50,12 @@ func ExtractJSON(text string, dest interface{}, opts *ExtractJSONOptions) error
return ErrNoJSON
}
dec := json.NewDecoder(strings.NewReader(cleaned))
var jsonOpts []jsonv2.Options
if opts.DisallowUnknownFields {
dec.DisallowUnknownFields()
jsonOpts = append(jsonOpts, jsonv2.RejectUnknownMembers(true))
}
if err := dec.Decode(dest); err != nil {
if err := jsonv2.Unmarshal([]byte(cleaned), dest, jsonOpts...); err != nil {
return fmt.Errorf("json decode: %w", err)
}
return nil

View file

@ -107,7 +107,7 @@ func TestExtractJSON_InjectionAttempts(t *testing.T) {
&ExtractJSONOptions{DisallowUnknownFields: true},
func(t *testing.T, err error) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown field")
assert.Contains(t, err.Error(), "unknown object member name")
},
},
{

View file

@ -1,9 +1,10 @@
package security
import (
"encoding/json"
"errors"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"os"
"path/filepath"
"sync"
@ -132,7 +133,7 @@ func (ss *SecretStore) load() error {
return err
}
var entries []SecretEntry
if err := json.Unmarshal(data, &entries); err != nil {
if err := jsonv2.Unmarshal(data, &entries); err != nil {
return fmt.Errorf("parse secret store: %w", err)
}
for _, e := range entries {
@ -150,7 +151,7 @@ func (ss *SecretStore) save() error {
}
ss.mu.RUnlock()
data, err := json.MarshalIndent(entries, "", " ")
data, err := jsonv2.Marshal(entries, jsontext.WithIndent(" "))
if err != nil {
return fmt.Errorf("marshal secret store: %w", err)
}

View file

@ -2,8 +2,10 @@ package securebus
import (
"context"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"log"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/itr"
@ -62,6 +64,7 @@ type Bus struct {
executor ToolExecutor
toolSearch ToolSearchFunc // nil = no tool search support
done chan struct{}
closeOnce sync.Once
}
// New creates a Bus and starts background worker goroutines.
@ -106,10 +109,12 @@ func (b *Bus) AuditLog() *AuditLog {
return b.audit
}
// Close shuts down the bus workers gracefully.
// Close shuts down the bus workers gracefully. Safe to call multiple times.
func (b *Bus) Close() {
b.transport.Close()
close(b.done)
b.closeOnce.Do(func() {
b.transport.Close()
close(b.done)
})
}
// Execute is a convenience method for in-process callers that don't want to
@ -155,12 +160,16 @@ func (b *Bus) dispatch(ctx context.Context, req itr.ToolRequest) itr.ToolRespons
case itr.CmdToolSearch:
resp := b.handleToolSearch(ctx, req)
event.DurationMS = time.Since(start).Milliseconds()
_ = b.audit.Append(event)
if err := b.audit.Append(event); err != nil {
log.Printf("securebus: audit append failed: %v", err)
}
return resp
default:
resp := b.handleRLMCommand(ctx, req)
event.DurationMS = time.Since(start).Milliseconds()
_ = b.audit.Append(event)
if err := b.audit.Append(event); err != nil {
log.Printf("securebus: audit append failed: %v", err)
}
return resp
}
@ -168,7 +177,9 @@ func (b *Bus) dispatch(ctx context.Context, req itr.ToolRequest) itr.ToolRespons
if !ok {
event.IsError = true
event.DurationMS = time.Since(start).Milliseconds()
_ = b.audit.Append(event)
if err := b.audit.Append(event); err != nil {
log.Printf("securebus: audit append failed: %v", err)
}
return itr.NewErrorResponse(req.ID, "internal: payload is not ToolExec")
}
event.ToolName = te.ToolName
@ -184,17 +195,21 @@ func (b *Bus) dispatch(ctx context.Context, req itr.ToolRequest) itr.ToolRespons
event.IsError = true
event.PolicyViolation = err.Error()
event.DurationMS = time.Since(start).Milliseconds()
_ = b.audit.Append(event)
if err := b.audit.Append(event); err != nil {
log.Printf("securebus: audit append failed: %v", err)
}
return itr.NewErrorResponse(req.ID, "policy violation: "+err.Error())
}
// 3. Deserialise args — always produce a non-nil map for safe injection.
args := make(map[string]interface{})
if te.ArgsJSON != "" && te.ArgsJSON != "null" {
if err := json.Unmarshal([]byte(te.ArgsJSON), &args); err != nil {
if err := jsonv2.Unmarshal([]byte(te.ArgsJSON), &args); err != nil {
event.IsError = true
event.DurationMS = time.Since(start).Milliseconds()
_ = b.audit.Append(event)
if err := b.audit.Append(event); err != nil {
log.Printf("securebus: audit append failed: %v", err)
}
return itr.NewErrorResponse(req.ID, fmt.Sprintf("invalid args JSON: %v", err))
}
}
@ -204,7 +219,9 @@ func (b *Bus) dispatch(ctx context.Context, req itr.ToolRequest) itr.ToolRespons
if err != nil {
event.IsError = true
event.DurationMS = time.Since(start).Milliseconds()
_ = b.audit.Append(event)
if err := b.audit.Append(event); err != nil {
log.Printf("securebus: audit append failed: %v", err)
}
return itr.NewErrorResponse(req.ID, "secret injection failed: "+err.Error())
}
event.SecretsAccessed = injectedSecrets
@ -232,7 +249,9 @@ func (b *Bus) dispatch(ctx context.Context, req itr.ToolRequest) itr.ToolRespons
event.IsError = resp.IsError
event.DurationMS = time.Since(start).Milliseconds()
_ = b.audit.Append(event)
if err := b.audit.Append(event); err != nil {
log.Printf("securebus: audit append failed: %v", err)
}
return resp
}

View file

@ -2,7 +2,7 @@ package securebus_test
import (
"context"
"encoding/json"
jsonv2 "github.com/go-json-experiment/json"
"testing"
"github.com/sipeed/picoclaw/pkg/itr"
@ -49,7 +49,7 @@ func makeArgsJSON(kv map[string]interface{}) string {
if kv == nil {
return "{}"
}
b, _ := json.Marshal(kv)
b, _ := jsonv2.Marshal(kv)
return string(b)
}
@ -248,3 +248,56 @@ func TestBus_RLMFinalCommand(t *testing.T) {
assert.False(t, resp.IsError)
assert.Equal(t, "the answer", resp.Result)
}
func TestBus_CloseIdempotent(t *testing.T) {
bus := makeBus(t, nil, nil)
assert.NotPanics(t, func() {
bus.Close()
bus.Close()
bus.Close()
}, "Close() must be safe to call multiple times")
}
func TestBus_ToolSearch(t *testing.T) {
bus := makeBus(t, nil, nil)
defer bus.Close()
bus.SetToolSearch(func(query string, maxResults int) string {
return `[{"name":"read_file","description":"reads a file"}]`
})
req := itr.NewToolSearchRequest("req-search", "sess", "file operations", 5)
resp := bus.Execute(context.Background(), req)
assert.False(t, resp.IsError)
assert.Contains(t, resp.Result, "read_file")
}
func TestBus_ToolSearchNotConfigured(t *testing.T) {
bus := makeBus(t, nil, nil)
defer bus.Close()
req := itr.NewToolSearchRequest("req-search2", "sess", "anything", 5)
resp := bus.Execute(context.Background(), req)
assert.True(t, resp.IsError)
assert.Contains(t, resp.Result, "not configured")
}
func TestBus_NilToolResult(t *testing.T) {
executor := func(ctx context.Context, name string, args map[string]interface{}) *tools.ToolResult {
return nil
}
capLookup := func(name string) (tools.ToolCapabilities, bool) {
return tools.ZeroCapabilities(), true
}
bus := securebus.New(securebus.DefaultBusConfig(), nil, capLookup, executor)
defer bus.Close()
req := itr.NewToolExecRequest("req-nil", "sess", "tc", "something", "{}")
resp := bus.Execute(context.Background(), req)
assert.False(t, resp.IsError)
assert.Empty(t, resp.Result)
}

View file

@ -2,13 +2,15 @@ package session
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"github.com/sipeed/picoclaw/pkg/cache"
"github.com/sipeed/picoclaw/pkg/ids"
"github.com/sipeed/picoclaw/pkg/logger"
@ -19,7 +21,7 @@ import (
type Session struct {
Key string `json:"key"`
Messages []messages.Message `json:"messages"`
Summary string `json:"summary,omitempty"`
Summary string `json:"summary,omitzero"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
@ -197,7 +199,7 @@ func (sm *SessionManager) loadSessionFromDisk(key string) *Session {
return nil
}
var session Session
if err := json.Unmarshal(data, &session); err != nil {
if err := jsonv2.Unmarshal(data, &session); err != nil {
return nil
}
return &session
@ -487,7 +489,7 @@ func snapshotSession(s *Session) Session {
}
func (sm *SessionManager) writeSessionToDisk(key string, session *Session) error {
data, err := json.MarshalIndent(session, "", " ")
data, err := jsonv2.Marshal(session, jsontext.WithIndent(" "))
if err != nil {
return err
}
@ -558,7 +560,7 @@ func (sm *SessionManager) loadSessions() error {
}
var session Session
if err := json.Unmarshal(data, &session); err != nil {
if err := jsonv2.Unmarshal(data, &session); err != nil {
continue
}

View file

@ -2,7 +2,6 @@ package skills
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@ -10,6 +9,8 @@ import (
"path/filepath"
"strings"
"time"
jsonv2 "github.com/go-json-experiment/json"
)
type SkillInstaller struct {
@ -117,7 +118,7 @@ func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableS
}
var skills []AvailableSkill
if err := json.Unmarshal(body, &skills); err != nil {
if err := jsonv2.Unmarshal(body, &skills); err != nil {
return nil, fmt.Errorf("failed to parse skills list: %w", err)
}

View file

@ -1,7 +1,6 @@
package skills
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
@ -9,6 +8,8 @@ import (
"path/filepath"
"regexp"
"strings"
jsonv2 "github.com/go-json-experiment/json"
)
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
@ -21,10 +22,10 @@ const (
type SkillMetadata struct {
Name string `json:"name"`
Description string `json:"description"`
Tags []string `json:"tags,omitempty"`
Links []string `json:"links,omitempty"`
Domain string `json:"domain,omitempty"`
IsMOC bool `json:"is_moc,omitempty"`
Tags []string `json:"tags,omitzero"`
Links []string `json:"links,omitzero"`
Domain string `json:"domain,omitzero"`
IsMOC bool `json:"is_moc,omitzero"`
}
type SkillInfo struct {
@ -32,8 +33,8 @@ type SkillInfo struct {
Path string `json:"path"`
Source string `json:"source"`
Description string `json:"description"`
Tags []string `json:"tags,omitempty"`
Domain string `json:"domain,omitempty"`
Tags []string `json:"tags,omitzero"`
Domain string `json:"domain,omitzero"`
}
func (info SkillInfo) validate() error {
@ -124,18 +125,18 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
continue
}
info := SkillInfo{
Name: dir.Name(),
Path: skillFile,
Source: "global",
}
metadata := sl.getSkillMetadata(skillFile)
if metadata != nil {
info.Description = metadata.Description
info.Name = metadata.Name
info.Tags = metadata.Tags
info.Domain = metadata.Domain
}
info := SkillInfo{
Name: dir.Name(),
Path: skillFile,
Source: "global",
}
metadata := sl.getSkillMetadata(skillFile)
if metadata != nil {
info.Description = metadata.Description
info.Name = metadata.Name
info.Tags = metadata.Tags
info.Domain = metadata.Domain
}
if err := info.validate(); err != nil {
slog.Warn("invalid skill from global", "name", info.Name, "error", err)
continue
@ -165,18 +166,18 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
continue
}
info := SkillInfo{
Name: dir.Name(),
Path: skillFile,
Source: "builtin",
}
metadata := sl.getSkillMetadata(skillFile)
if metadata != nil {
info.Description = metadata.Description
info.Name = metadata.Name
info.Tags = metadata.Tags
info.Domain = metadata.Domain
}
info := SkillInfo{
Name: dir.Name(),
Path: skillFile,
Source: "builtin",
}
metadata := sl.getSkillMetadata(skillFile)
if metadata != nil {
info.Description = metadata.Description
info.Name = metadata.Name
info.Tags = metadata.Tags
info.Domain = metadata.Domain
}
if err := info.validate(); err != nil {
slog.Warn("invalid skill from builtin", "name", info.Name, "error", err)
continue
@ -281,7 +282,7 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
// Try JSON first (for backward compatibility)
var jsonMeta SkillMetadata
if err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil && jsonMeta.Name != "" {
if err := jsonv2.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil && jsonMeta.Name != "" {
return &jsonMeta
}

View file

@ -2,7 +2,6 @@ package state
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
@ -10,6 +9,9 @@ import (
"sync"
"time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
"github.com/sipeed/picoclaw/pkg/memory"
)
@ -19,10 +21,10 @@ const kvAgentID = "picoclaw"
// It includes information about the last active channel/chat.
type State struct {
// LastChannel is the last channel used for communication
LastChannel string `json:"last_channel,omitempty"`
LastChannel string `json:"last_channel,omitzero"`
// LastChatID is the last chat ID used for communication
LastChatID string `json:"last_chat_id,omitempty"`
LastChatID string `json:"last_chat_id,omitzero"`
// Timestamp is the last time this state was updated
Timestamp time.Time `json:"timestamp"`
@ -73,7 +75,7 @@ func NewManager(workspace string, opts ...Option) *Manager {
if _, err := os.Stat(stateFile); os.IsNotExist(err) {
if data, err := os.ReadFile(oldStateFile); err == nil {
if err := json.Unmarshal(data, sm.state); err == nil {
if err := jsonv2.Unmarshal(data, sm.state); err == nil {
sm.saveAtomic()
log.Printf("[INFO] state: migrated state from %s to %s", oldStateFile, stateFile)
}
@ -180,7 +182,7 @@ func (sm *Manager) saveAtomic() error {
tempFile := sm.stateFile + ".tmp"
// Marshal state to JSON
data, err := json.MarshalIndent(sm.state, "", " ")
data, err := jsonv2.Marshal(sm.state, jsontext.WithIndent(" "))
if err != nil {
return fmt.Errorf("failed to marshal state: %w", err)
}
@ -211,7 +213,7 @@ func (sm *Manager) load() error {
return fmt.Errorf("failed to read state file: %w", err)
}
if err := json.Unmarshal(data, sm.state); err != nil {
if err := jsonv2.Unmarshal(data, sm.state); err != nil {
return fmt.Errorf("failed to unmarshal state: %w", err)
}

View file

@ -1,11 +1,12 @@
package state
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
jsonv2 "github.com/go-json-experiment/json"
)
func TestAtomicSave(t *testing.T) {
@ -162,7 +163,7 @@ func TestConcurrentAccess(t *testing.T) {
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
if err := jsonv2.Unmarshal(data, &state); err != nil {
t.Errorf("State file contains invalid JSON: %v", err)
}
}

View file

@ -2,9 +2,10 @@ package tools
import (
"context"
"encoding/json"
"fmt"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/logger"
)
@ -84,7 +85,7 @@ func (t *ToolCallTool) Execute(ctx context.Context, args map[string]interface{})
if len(v) > maxArgsJSON {
return ErrorResult(fmt.Sprintf("arguments JSON too large: %d bytes (max %d)", len(v), maxArgsJSON))
}
if err := json.Unmarshal([]byte(v), &toolArgs); err != nil {
if err := jsonv2.Unmarshal([]byte(v), &toolArgs); err != nil {
return ErrorResult(fmt.Sprintf("invalid arguments JSON: %v", err))
}
case nil:

View file

@ -2,11 +2,12 @@ package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/messages"
"github.com/sipeed/picoclaw/pkg/session"
@ -109,7 +110,7 @@ func (t *StartFocusTool) Execute(ctx context.Context, args map[string]interface{
StartedAt: time.Now(),
}
data, err := json.Marshal(state)
data, err := jsonv2.Marshal(state)
if err != nil {
return ErrorResult(fmt.Sprintf("marshal focus state: %v", err))
}
@ -181,7 +182,7 @@ func (t *CompleteFocusTool) Execute(ctx context.Context, args map[string]interfa
}
var state FocusState
if err := json.Unmarshal([]byte(raw), &state); err != nil {
if err := jsonv2.Unmarshal([]byte(raw), &state); err != nil {
return ErrorResult(fmt.Sprintf("corrupt focus state: %v", err))
}
@ -251,7 +252,7 @@ func (t *CompleteFocusTool) appendKnowledge(ctx context.Context, sessionKey, top
kb := &KnowledgeBlock{}
raw, err := t.delegate.GetKV(ctx, focusAgentID, kvKey)
if err == nil && raw != "" {
_ = json.Unmarshal([]byte(raw), kb)
_ = jsonv2.Unmarshal([]byte(raw), kb)
}
kb.Entries = append(kb.Entries, KnowledgeEntry{
@ -260,7 +261,7 @@ func (t *CompleteFocusTool) appendKnowledge(ctx context.Context, sessionKey, top
CreatedAt: time.Now(),
})
data, err := json.Marshal(kb)
data, err := jsonv2.Marshal(kb)
if err != nil {
return err
}
@ -280,7 +281,7 @@ func LoadKnowledgeBlock(ctx context.Context, delegate KVStore, sessionKey string
}
var kb KnowledgeBlock
if err := json.Unmarshal([]byte(raw), &kb); err != nil {
if err := jsonv2.Unmarshal([]byte(raw), &kb); err != nil {
return ""
}

View file

@ -2,9 +2,10 @@ package tools
import (
"context"
"encoding/json"
"testing"
jsonv2 "github.com/go-json-experiment/json"
"github.com/sipeed/picoclaw/pkg/messages"
"github.com/sipeed/picoclaw/pkg/session"
"github.com/stretchr/testify/assert"
@ -75,7 +76,7 @@ func TestStartFocus(t *testing.T) {
require.NotEmpty(t, raw)
var state FocusState
require.NoError(t, json.Unmarshal([]byte(raw), &state))
require.NoError(t, jsonv2.Unmarshal([]byte(raw), &state))
assert.Equal(t, "investigate auth bug", state.Topic)
assert.Equal(t, 2, state.CheckpointIndex)
}
@ -134,7 +135,7 @@ func TestCompleteFocus(t *testing.T) {
require.NotEmpty(t, knowledgeRaw)
var kb KnowledgeBlock
require.NoError(t, json.Unmarshal([]byte(knowledgeRaw), &kb))
require.NoError(t, jsonv2.Unmarshal([]byte(knowledgeRaw), &kb))
require.Len(t, kb.Entries, 1)
assert.Equal(t, "debug auth", kb.Entries[0].Topic)
assert.Contains(t, kb.Entries[0].Summary, "token validation")
@ -184,7 +185,7 @@ func TestCompleteFocus_MultipleKnowledgeEntries(t *testing.T) {
knowledgeRaw, _ := delegate.GetKV(ctx, focusAgentID, knowledgeKVPrefix+sk)
var kb KnowledgeBlock
require.NoError(t, json.Unmarshal([]byte(knowledgeRaw), &kb))
require.NoError(t, jsonv2.Unmarshal([]byte(knowledgeRaw), &kb))
require.Len(t, kb.Entries, 2)
assert.Equal(t, "topic A", kb.Entries[0].Topic)
assert.Equal(t, "topic B", kb.Entries[1].Topic)
@ -286,7 +287,7 @@ func TestLoadKnowledgeBlock(t *testing.T) {
{Topic: "Test", Summary: "Test summary"},
},
}
data, _ := json.Marshal(kb)
data, _ := jsonv2.Marshal(kb)
_ = delegate.UpsertKV(ctx, focusAgentID, knowledgeKVPrefix+"test-session", string(data))
block = LoadKnowledgeBlock(ctx, delegate, "test-session")

View file

@ -2,11 +2,13 @@ package tools
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"runtime"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
)
// I2CTool provides I2C bus interaction for reading sensors and controlling peripherals.
@ -111,7 +113,7 @@ func (t *I2CTool) detect() *ToolResult {
}
}
result, _ := json.MarshalIndent(buses, "", " ")
result, _ := jsonv2.Marshal(buses, jsontext.WithIndent(" "))
return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result)))
}

View file

@ -1,10 +1,12 @@
package tools
import (
"encoding/json"
"fmt"
"syscall"
"unsafe"
jsonv2 "github.com/go-json-experiment/json"
"github.com/go-json-experiment/json/jsontext"
)
// I2C ioctl constants from Linux kernel headers (<linux/i2c-dev.h>, <linux/i2c.h>)
@ -104,7 +106,7 @@ func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
type deviceEntry struct {
Address string `json:"address"`
Status string `json:"status,omitempty"`
Status string `json:"status,omitzero"`
}
var found []deviceEntry
@ -133,11 +135,11 @@ func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
return SilentResult(fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath))
}
result, _ := json.MarshalIndent(map[string]interface{}{
result, _ := jsonv2.Marshal(map[string]interface{}{
"bus": devPath,
"devices": found,
"count": len(found),
}, "", " ")
}, jsontext.WithIndent(" "))
return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result)))
}
@ -201,13 +203,13 @@ func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult {
intBytes[i] = int(buf[i])
}
result, _ := json.MarshalIndent(map[string]interface{}{
result, _ := jsonv2.Marshal(map[string]interface{}{
"bus": devPath,
"address": fmt.Sprintf("0x%02x", addr),
"bytes": intBytes,
"hex": hexBytes,
"length": n,
}, "", " ")
}, jsontext.WithIndent(" "))
return SilentResult(string(result))
}

View file

@ -1,6 +1,6 @@
package tools
import "encoding/json"
import jsonv2 "github.com/go-json-experiment/json"
// ToolResult represents the structured return value from tool execution.
// It provides clear semantics for different types of results and supports
@ -124,7 +124,7 @@ func UserResult(content string) *ToolResult {
// The Err field is excluded from JSON output via the json:"-" tag.
func (tr *ToolResult) MarshalJSON() ([]byte, error) {
type Alias ToolResult
return json.Marshal(&struct {
return jsonv2.Marshal(&struct {
*Alias
}{
Alias: (*Alias)(tr),

View file

@ -1,9 +1,10 @@
package tools
import (
"encoding/json"
"errors"
"testing"
jsonv2 "github.com/go-json-experiment/json"
)
func TestNewToolResult(t *testing.T) {
@ -125,14 +126,14 @@ func TestToolResultJSONSerialization(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Marshal to JSON
data, err := json.Marshal(tt.result)
data, err := jsonv2.Marshal(tt.result)
if err != nil {
t.Fatalf("Failed to marshal: %v", err)
}
// Unmarshal back
var decoded ToolResult
if err := json.Unmarshal(data, &decoded); err != nil {
if err := jsonv2.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}
@ -168,13 +169,13 @@ func TestToolResultWithErrors(t *testing.T) {
}
// Verify Err is not serialized
data, marshalErr := json.Marshal(result)
data, marshalErr := jsonv2.Marshal(result)
if marshalErr != nil {
t.Fatalf("Failed to marshal: %v", marshalErr)
}
var decoded ToolResult
if unmarshalErr := json.Unmarshal(data, &decoded); unmarshalErr != nil {
if unmarshalErr := jsonv2.Unmarshal(data, &decoded); unmarshalErr != nil {
t.Fatalf("Failed to unmarshal: %v", unmarshalErr)
}
@ -186,14 +187,14 @@ func TestToolResultWithErrors(t *testing.T) {
func TestToolResultJSONStructure(t *testing.T) {
result := UserResult("test content")
data, err := json.Marshal(result)
data, err := jsonv2.Marshal(result)
if err != nil {
t.Fatalf("Failed to marshal: %v", err)
}
// Verify JSON structure
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
if err := jsonv2.Unmarshal(data, &parsed); err != nil {
t.Fatalf("Failed to parse JSON: %v", err)
}

Some files were not shown because too many files have changed in this diff Show more