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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ jobs:
runs-on: ${{ github.repository == 'stainless-sdks/stagehand-go' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: |-
github.repository == 'stainless-sdks/stagehand-go' &&
(github.event_name == 'push' || github.event.pull_request.head.repo.fork) &&
(github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata')
(github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata')
steps:
- uses: actions/checkout@v6

Expand Down
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "3.18.0"
".": "3.19.3"
}
6 changes: 3 additions & 3 deletions .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 8
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-bc309fd00fe0507f4cbe3bc77fa27d0fbffeaa6e71998778da34de42608a67e8.yml
openapi_spec_hash: 1db1af5c1b068bba1d652102f4454668
config_hash: d6c6f623d03971bdba921650e5eb7e5f
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-b969ce378479c79ee64c05127c0ed6c6ce2edbee017ecd037242fb618a5ebc9f.yml
openapi_spec_hash: a24aabaa5214effb679808b7f2be0ad4
config_hash: a962ae71493deb11a1c903256fb25386
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## 3.19.3 (2026-04-03)

Full Changelog: [v3.18.0...v3.19.3](https://github.com/browserbase/stagehand-go/compare/v3.18.0...v3.19.3)

### Features

* **internal:** support comma format in multipart form encoding ([35ad44d](https://github.com/browserbase/stagehand-go/commit/35ad44d9b83cd3d90caeb793a99adafaf0efdb3e))
* Replace default model used in server-v3 api spec examples ([f02ee1b](https://github.com/browserbase/stagehand-go/commit/f02ee1b76d5cf6faa30828f8dd3e90c46b59d04e))


### Bug Fixes

* prevent duplicate ? in query params ([dd60ddd](https://github.com/browserbase/stagehand-go/commit/dd60dddf6486331df3fc8b9b16d3396066277f12))


### Chores

* **ci:** support opting out of skipping builds on metadata-only commits ([624eaa5](https://github.com/browserbase/stagehand-go/commit/624eaa5cc894e0be5db88794077c890df0d503bc))
* **client:** fix multipart serialisation of Default() fields ([e8161f4](https://github.com/browserbase/stagehand-go/commit/e8161f41e1593e3793b2d128cf4af17247c311b4))
* **internal:** support default value struct tag ([e13c5ec](https://github.com/browserbase/stagehand-go/commit/e13c5ecc825fe1abca095f6a54f81fcfee42878c))
* remove unnecessary error check for url parsing ([f1225a4](https://github.com/browserbase/stagehand-go/commit/f1225a46ab1b4654584967c9240610eb830b0f71))
* update docs for api:"required" ([ebba897](https://github.com/browserbase/stagehand-go/commit/ebba897d119f51e848ef093069fde71fdcc1cbd6))

## 3.18.0 (2026-03-25)

Full Changelog: [v3.11.0...v3.18.0](https://github.com/browserbase/stagehand-go/compare/v3.11.0...v3.18.0)
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Or to pin the version:
<!-- x-release-please-start-version -->

```sh
go get -u 'github.com/browserbase/stagehand-go@v3.18.0'
go get -u 'github.com/browserbase/stagehand-go@v3.19.3'
```

<!-- x-release-please-end -->
Expand Down Expand Up @@ -227,7 +227,7 @@ go run main.go
The stagehand library uses the [`omitzero`](https://tip.golang.org/doc/go1.24#encodingjsonpkgencodingjson)
semantics from the Go 1.24+ `encoding/json` release for request fields.

Required primitive fields (`int64`, `string`, etc.) feature the tag <code>\`json:"...,required"\`</code>. These
Required primitive fields (`int64`, `string`, etc.) feature the tag <code>\`api:"required"\`</code>. These
fields are always serialized, even their zero values.

Optional primitive types are wrapped in a `param.Opt[T]`. These fields can be set with the provided constructors, `stagehand.String(string)`, `stagehand.Int(int64)`, etc.
Expand Down Expand Up @@ -455,7 +455,7 @@ To handle errors, we recommend that you use the `errors.As` pattern:

```go
_, err := client.Sessions.Start(context.TODO(), stagehand.SessionStartParams{
ModelName: "anthropic/claude-sonnet-4-6",
ModelName: "openai/gpt-5.4-mini",
})
if err != nil {
var apierr *stagehand.Error
Expand Down Expand Up @@ -484,7 +484,7 @@ defer cancel()
client.Sessions.Start(
ctx,
stagehand.SessionStartParams{
ModelName: "anthropic/claude-sonnet-4-6",
ModelName: "openai/gpt-5.4-mini",
},
// This sets the per-retry timeout
option.WithRequestTimeout(20*time.Second),
Expand Down Expand Up @@ -522,7 +522,7 @@ client := stagehand.NewClient(
client.Sessions.Start(
context.TODO(),
stagehand.SessionStartParams{
ModelName: "anthropic/claude-sonnet-4-6",
ModelName: "openai/gpt-5.4-mini",
},
option.WithMaxRetries(5),
)
Expand All @@ -539,7 +539,7 @@ var response *http.Response
response, err := client.Sessions.Start(
context.TODO(),
stagehand.SessionStartParams{
ModelName: "anthropic/claude-sonnet-4-6",
ModelName: "openai/gpt-5.4-mini",
},
option.WithResponseInto(&response),
)
Expand Down
16 changes: 8 additions & 8 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func TestUserAgentHeader(t *testing.T) {
}),
)
_, _ = client.Sessions.Start(context.Background(), stagehand.SessionStartParams{
ModelName: "openai/gpt-5-nano",
ModelName: "openai/gpt-5.4-mini",
})
if userAgent != fmt.Sprintf("Stagehand/Go %s", internal.PackageVersion) {
t.Errorf("Expected User-Agent to be correct, but got: %#v", userAgent)
Expand Down Expand Up @@ -70,7 +70,7 @@ func TestRetryAfter(t *testing.T) {
}),
)
_, err := client.Sessions.Start(context.Background(), stagehand.SessionStartParams{
ModelName: "openai/gpt-5-nano",
ModelName: "openai/gpt-5.4-mini",
})
if err == nil {
t.Error("Expected there to be a cancel error")
Expand Down Expand Up @@ -109,7 +109,7 @@ func TestDeleteRetryCountHeader(t *testing.T) {
option.WithHeaderDel("X-Stainless-Retry-Count"),
)
_, err := client.Sessions.Start(context.Background(), stagehand.SessionStartParams{
ModelName: "openai/gpt-5-nano",
ModelName: "openai/gpt-5.4-mini",
})
if err == nil {
t.Error("Expected there to be a cancel error")
Expand Down Expand Up @@ -143,7 +143,7 @@ func TestOverwriteRetryCountHeader(t *testing.T) {
option.WithHeader("X-Stainless-Retry-Count", "42"),
)
_, err := client.Sessions.Start(context.Background(), stagehand.SessionStartParams{
ModelName: "openai/gpt-5-nano",
ModelName: "openai/gpt-5.4-mini",
})
if err == nil {
t.Error("Expected there to be a cancel error")
Expand Down Expand Up @@ -176,7 +176,7 @@ func TestRetryAfterMs(t *testing.T) {
}),
)
_, err := client.Sessions.Start(context.Background(), stagehand.SessionStartParams{
ModelName: "openai/gpt-5-nano",
ModelName: "openai/gpt-5.4-mini",
})
if err == nil {
t.Error("Expected there to be a cancel error")
Expand All @@ -203,7 +203,7 @@ func TestContextCancel(t *testing.T) {
cancelCtx, cancel := context.WithCancel(context.Background())
cancel()
_, err := client.Sessions.Start(cancelCtx, stagehand.SessionStartParams{
ModelName: "openai/gpt-5-nano",
ModelName: "openai/gpt-5.4-mini",
})
if err == nil {
t.Error("Expected there to be a cancel error")
Expand All @@ -227,7 +227,7 @@ func TestContextCancelDelay(t *testing.T) {
cancelCtx, cancel := context.WithTimeout(context.Background(), 2*time.Millisecond)
defer cancel()
_, err := client.Sessions.Start(cancelCtx, stagehand.SessionStartParams{
ModelName: "openai/gpt-5-nano",
ModelName: "openai/gpt-5.4-mini",
})
if err == nil {
t.Error("expected there to be a cancel error")
Expand Down Expand Up @@ -257,7 +257,7 @@ func TestContextDeadline(t *testing.T) {
}),
)
_, err := client.Sessions.Start(deadlineCtx, stagehand.SessionStartParams{
ModelName: "openai/gpt-5-nano",
ModelName: "openai/gpt-5.4-mini",
})
if err == nil {
t.Error("expected there to be a deadline error")
Expand Down
20 changes: 20 additions & 0 deletions internal/apiform/encoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,18 @@ func (e *encoder) newPrimitiveTypeEncoder(t reflect.Type) encoderFunc {
func (e *encoder) newArrayTypeEncoder(t reflect.Type) encoderFunc {
itemEncoder := e.typeEncoder(t.Elem())
keyFn := e.arrayKeyEncoder()
if e.arrayFmt == "comma" {
return func(key string, v reflect.Value, writer *multipart.Writer) error {
if v.Len() == 0 {
return nil
}
elements := make([]string, v.Len())
for i := 0; i < v.Len(); i++ {
elements[i] = fmt.Sprint(v.Index(i).Interface())
}
return writer.WriteField(key, strings.Join(elements, ","))
}
}
return func(key string, v reflect.Value, writer *multipart.Writer) error {
if keyFn == nil {
return fmt.Errorf("apiform: unsupported array format")
Expand Down Expand Up @@ -265,6 +277,14 @@ func (e *encoder) newStructTypeEncoder(t reflect.Type) encoderFunc {
}
return typeEncoderFn(key, value, writer)
}
} else if ptag.defaultValue != nil {
typeEncoderFn := e.typeEncoder(field.Type)
encoderFn = func(key string, value reflect.Value, writer *multipart.Writer) error {
if value.IsZero() {
return typeEncoderFn(key, reflect.ValueOf(ptag.defaultValue), writer)
}
return typeEncoderFn(key, value, writer)
}
} else {
encoderFn = e.typeEncoder(field.Type)
}
Expand Down
36 changes: 36 additions & 0 deletions internal/apiform/form_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ type StructUnion struct {
param.APIUnion
}

type ConstantStruct struct {
Anchor string `form:"anchor" default:"created_at"`
Seconds int `form:"seconds"`
}

type MultipartMarshalerParent struct {
Middle MultipartMarshalerMiddleNext `form:"middle"`
}
Expand Down Expand Up @@ -554,6 +559,37 @@ Content-Disposition: form-data; name="union"
Union: UnionTime(time.Date(2010, 05, 23, 0, 0, 0, 0, time.UTC)),
},
},
"constant_zero_value": {
`--xxx
Content-Disposition: form-data; name="anchor"

created_at
--xxx
Content-Disposition: form-data; name="seconds"

3600
--xxx--
`,
ConstantStruct{
Seconds: 3600,
},
},
"constant_explicit_value": {
`--xxx
Content-Disposition: form-data; name="anchor"

created_at_override
--xxx
Content-Disposition: form-data; name="seconds"

3600
--xxx--
`,
ConstantStruct{
Anchor: "created_at_override",
Seconds: 3600,
},
},
"deeply-nested-struct,brackets": {
`--xxx
Content-Disposition: form-data; name="middle[middleNext][child]"
Expand Down
26 changes: 21 additions & 5 deletions internal/apiform/tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ const apiStructTag = "api"
const jsonStructTag = "json"
const formStructTag = "form"
const formatStructTag = "format"
const defaultStructTag = "default"

type parsedStructTag struct {
name string
required bool
extras bool
metadata bool
omitzero bool
name string
required bool
extras bool
metadata bool
omitzero bool
defaultValue any
}

func parseFormStructTag(field reflect.StructField) (tag parsedStructTag, ok bool) {
Expand Down Expand Up @@ -45,9 +47,23 @@ func parseFormStructTag(field reflect.StructField) (tag parsedStructTag, ok bool
}

parseApiStructTag(field, &tag)
parseDefaultStructTag(field, &tag)
return tag, ok
}

func parseDefaultStructTag(field reflect.StructField, tag *parsedStructTag) {
if field.Type.Kind() != reflect.String {
// Only strings are currently supported
return
}

raw, ok := field.Tag.Lookup(defaultStructTag)
if !ok {
return
}
tag.defaultValue = raw
}

func parseApiStructTag(field reflect.StructField, tag *parsedStructTag) {
raw, ok := field.Tag.Lookup(apiStructTag)
if !ok {
Expand Down
8 changes: 8 additions & 0 deletions internal/apijson/encoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"time"

"github.com/tidwall/sjson"

shimjson "github.com/browserbase/stagehand-go/v3/internal/encoding/json"
)

var encoders sync.Map // map[encoderEntry]encoderFunc
Expand Down Expand Up @@ -271,6 +273,12 @@ func (e *encoder) newStructTypeEncoder(t reflect.Type) encoderFunc {
if err != nil {
return nil, err
}
if ef.tag.defaultValue != nil && (!field.IsValid() || field.IsZero()) {
encoded, err = shimjson.Marshal(ef.tag.defaultValue)
if err != nil {
return nil, err
}
}
if encoded == nil {
continue
}
Expand Down
17 changes: 17 additions & 0 deletions internal/apijson/json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -614,3 +614,20 @@ func TestEncode(t *testing.T) {
})
}
}

type StructWithDefault struct {
Type string `json:"type" default:"foo"`
}

func TestDefault(t *testing.T) {
value := StructWithDefault{}
expected := `{"type":"foo"}`

raw, err := Marshal(value)
if err != nil {
t.Fatalf("serialization of %v failed with error %v", value, err)
}
if string(raw) != expected {
t.Fatalf("expected %+#v to serialize to %s but got %s", value, expected, string(raw))
}
}
26 changes: 21 additions & 5 deletions internal/apijson/tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import (
const apiStructTag = "api"
const jsonStructTag = "json"
const formatStructTag = "format"
const defaultStructTag = "default"

type parsedStructTag struct {
name string
required bool
extras bool
metadata bool
inline bool
name string
required bool
extras bool
metadata bool
inline bool
defaultValue any
}

func parseJSONStructTag(field reflect.StructField) (tag parsedStructTag, ok bool) {
Expand Down Expand Up @@ -42,9 +44,23 @@ func parseJSONStructTag(field reflect.StructField) (tag parsedStructTag, ok bool

// the `api` struct tag is only used alongside `json` for custom behaviour
parseApiStructTag(field, &tag)
parseDefaultStructTag(field, &tag)
return tag, ok
}

func parseDefaultStructTag(field reflect.StructField, tag *parsedStructTag) {
if field.Type.Kind() != reflect.String {
// Only strings are currently supported
return
}

raw, ok := field.Tag.Lookup(defaultStructTag)
if !ok {
return
}
tag.defaultValue = raw
}

func parseApiStructTag(field reflect.StructField, tag *parsedStructTag) {
raw, ok := field.Tag.Lookup(apiStructTag)
if !ok {
Expand Down
Loading
Loading