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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ existing `0.1.0` release; earlier development prereleases are not listed.

## [Unreleased]

### Fixed

- Align Managed Session budgets with the official decimal-string `max_credit_cost` contract, shared by Deployment budgets. The limit covers cumulative model and sandbox Credits; reaching it rejects the next user message without interrupting the current turn.
- Expose Managed and Forward Session `model_credits`, `sandbox_runtime_credits`, and `total_credits`; decode top-level `session.usage` Credits and per-call `model_usage.credits` / compaction `usage.credits`. Session usage may be absent. Legacy response token/list-cost fields remain available.
- Route Identity ownership through query parameters on existing Forward Environment, Skill/Version, Vault/Credential, and File operations, including multipart uploads and content downloads.
- Allow existing channel pairing creation with either a code or `channel_pairing_id`, and expose pairing scope details. No additional API endpoints are introduced.

### Migration

- **Breaking budget field change:** Replace `MaxListCost: managed.MonetaryAmountParam{...}` with `MaxCreditCost: "100.00"` in `ManagedAgentsBudgetLimitParam` (keep `Type: "limit"`). Amounts are positive decimal strings, not currency objects. Omitting a budget update preserves the limit; explicit null removes it.
- Consume the three top-level Credits fields from `session.usage` events. Replace cumulative snapshots rather than adding them; compaction Credits are already included in Session totals.
- Set `IdentityID: forward.String("idn_...")` in resource params; for methods without params, pass `forward.WithIdentityID("idn_...")`. Use this only with a PAT. With a SAT, omit `identity_id`; the server infers ownership from the token and rejects explicit values (including an empty string).
- Channel pairing requests must provide exactly one of `code` or `channel_pairing_id`; existing code-based calls remain supported.

## [0.6.0]

### Added
Expand Down
320 changes: 249 additions & 71 deletions docs/api/reference.md

Large diffs are not rendered by default.

34 changes: 23 additions & 11 deletions forward/channelpairing.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ func (r *ChannelPairingService) New(ctx context.Context, params ChannelPairingNe
}

type ChannelPairingNewParams struct {
// Six-digit pairing code shown in the Channel message.
Code string `json:"code" api:"required"`
// Pairing code. Supply exactly one of Code or ChannelPairingID.
Code string `json:"code,omitzero"`
// Pending pairing ID. Supply exactly one of Code or ChannelPairingID.
ChannelPairingID string `json:"channel_pairing_id,omitzero"`
// Forward Identity ID to bind.
IdentityID string `json:"identity_id" api:"required"`
// Forward Template ID to bind.
Expand Down Expand Up @@ -86,6 +88,11 @@ func (r *DeletedChannelPairing) UnmarshalJSON(data []byte) error {
}

type ChannelPairing struct {
ScopeType string `json:"scope_type"`
ScopeExternalID string `json:"scope_external_id"`
ScopeDisplayName string `json:"scope_display_name"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// Pairing ID, used when removing the pairing.
ID string `json:"id"`
// Always `channel_pairing`.
Expand All @@ -101,15 +108,20 @@ type ChannelPairing struct {
// Time the pairing completed.
PairedAt string `json:"paired_at"`
JSON struct {
ID respjson.Field
Type respjson.Field
ChannelID respjson.Field
IdentityID respjson.Field
TemplateID respjson.Field
Status respjson.Field
PairedAt respjson.Field
ExtraFields map[string]respjson.Field
raw string
ScopeType respjson.Field
ScopeExternalID respjson.Field
ScopeDisplayName respjson.Field
CreatedAt respjson.Field
UpdatedAt respjson.Field
ID respjson.Field
Type respjson.Field
ChannelID respjson.Field
IdentityID respjson.Field
TemplateID respjson.Field
Status respjson.Field
PairedAt respjson.Field
ExtraFields map[string]respjson.Field
raw string
} `json:"-"`
}

Expand Down
14 changes: 14 additions & 0 deletions forward/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ func (r *EnvironmentService) ListAutoPaging(ctx context.Context, params Environm
}

type EnvironmentListParams struct {
// Select identity ownership with a PAT. Omit when using a SAT.
IdentityID param.Opt[string] `query:"identity_id,omitzero" json:"-"`
// Page size, maximum 100.
Limit param.Opt[int64] `query:"limit,omitzero" json:"-"`
// Pagination cursor (recommended); take the value from `next_page` in the previous
Expand Down Expand Up @@ -76,6 +78,8 @@ func (r *EnvironmentService) New(ctx context.Context, params EnvironmentNewParam
}

type EnvironmentNewParams struct {
// Select identity ownership with a PAT. Omit when using a SAT.
IdentityID param.Opt[string] `query:"identity_id,omitzero" json:"-"`
// Environment name; must not be empty after trimming surrounding whitespace.
Name string `json:"name" api:"required"`
// Description.
Expand Down Expand Up @@ -126,6 +130,8 @@ func (r *EnvironmentService) Update(ctx context.Context, id string, params Envir
}

type EnvironmentUpdateParams struct {
// Select identity ownership with a PAT. Omit when using a SAT.
IdentityID param.Opt[string] `query:"identity_id,omitzero" json:"-"`
// New name.
Name param.Opt[string] `json:"name,omitzero"`
// New description.
Expand Down Expand Up @@ -239,3 +245,11 @@ func (r EnvironmentConfigPackages) RawJSON() string { return r.JSON.raw }
func (r *EnvironmentConfigPackages) UnmarshalJSON(data []byte) error {
return apijson.UnmarshalRoot(data, r)
}

func (r EnvironmentNewParams) URLQuery() (url.Values, error) {
return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ArrayFormat: apiquery.ArrayQueryFormatRepeat, NestedFormat: apiquery.NestedQueryFormatBrackets})
}

func (r EnvironmentUpdateParams) URLQuery() (url.Values, error) {
return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ArrayFormat: apiquery.ArrayQueryFormatRepeat, NestedFormat: apiquery.NestedQueryFormatBrackets})
}
8 changes: 8 additions & 0 deletions forward/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ func (r *FileService) ListAutoPaging(ctx context.Context, params FileListParams,
}

type FileListParams struct {
// Select identity ownership with a PAT. Omit when using a SAT.
IdentityID param.Opt[string] `query:"identity_id,omitzero" json:"-"`
// Page size, maximum 100.
Limit param.Opt[int64] `query:"limit,omitzero" json:"-"`
// Pagination cursor (recommended), taken from `next_page` in the previous response;
Expand Down Expand Up @@ -83,6 +85,8 @@ func (r *FileService) Upload(ctx context.Context, params FileUploadParams, opts
}

type FileUploadParams struct {
// Select identity ownership with a PAT. Omit when using a SAT.
IdentityID param.Opt[string] `query:"identity_id,omitzero" json:"-"`
// Content of the file to upload. For accepted types see
// [Supported upload file types](./schemas.md).
File io.Reader `json:"file" api:"required" format:"binary"`
Expand Down Expand Up @@ -183,3 +187,7 @@ type FileMetadata struct {

func (r FileMetadata) RawJSON() string { return r.JSON.raw }
func (r *FileMetadata) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) }

func (r FileUploadParams) URLQuery() (url.Values, error) {
return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ArrayFormat: apiquery.ArrayQueryFormatRepeat, NestedFormat: apiquery.NestedQueryFormatBrackets})
}
245 changes: 245 additions & 0 deletions forward/identity_pairing_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
package forward_test

import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"

"github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option"
"github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/param"
"github.com/QoderAI/qoder-cloud-agents-sdk-go/forward"
)

// Official Forward ownership contracts (2026-10-20), independent of inventories.
func TestIdentityOwnershipContracts(t *testing.T) {
ctx := context.Background()
for _, scope := range []struct {
name string
identity param.Opt[string]
}{{"PAT identity", forward.String("idn_ /?&")}, {"explicit empty", forward.String("")}, {"omitted", param.Opt[string]{}}} {
t.Run(scope.name, func(t *testing.T) {
identity := scope.identity
var opts []option.RequestOption
if identity.Valid() {
opts = append(opts, forward.WithIdentityID(identity.Value))
}
cases := []struct {
method, path string
call func(forward.Client) error
}{
{"GET", "environments", func(c forward.Client) error {
_, err := c.Environments.List(ctx, forward.EnvironmentListParams{IdentityID: identity})
return err
}},
{"POST", "environments", func(c forward.Client) error {
_, err := c.Environments.New(ctx, forward.EnvironmentNewParams{Name: "env", IdentityID: identity})
return err
}},
{"GET", "environments/env", func(c forward.Client) error { _, err := c.Environments.Get(ctx, "env", opts...); return err }},
{"POST", "environments/env", func(c forward.Client) error {
_, err := c.Environments.Update(ctx, "env", forward.EnvironmentUpdateParams{Name: forward.String("renamed"), IdentityID: identity})
return err
}},
{"POST", "environments/env/archive", func(c forward.Client) error { _, err := c.Environments.Archive(ctx, "env", opts...); return err }},
{"DELETE", "environments/env", func(c forward.Client) error { return c.Environments.Delete(ctx, "env", opts...) }},
{"GET", "skills", func(c forward.Client) error {
_, err := c.Skills.List(ctx, forward.SkillListParams{IdentityID: identity})
return err
}},
{"POST", "skills", func(c forward.Client) error {
_, err := c.Skills.New(ctx, forward.SkillNewParams{Files: []io.Reader{strings.NewReader("# skill")}, IdentityID: identity})
return err
}},
{"GET", "skills/skill", func(c forward.Client) error {
_, err := c.Skills.Get(ctx, "skill", forward.SkillGetParams{IdentityID: identity})
return err
}},
{"PUT", "skills/skill", func(c forward.Client) error {
_, err := c.Skills.Update(ctx, "skill", forward.SkillUpdateParams{Description: forward.String("desc"), IdentityID: identity})
return err
}},
{"DELETE", "skills/skill", func(c forward.Client) error { return c.Skills.Delete(ctx, "skill", opts...) }},
{"GET", "skills/skill/versions", func(c forward.Client) error {
_, err := c.Skills.Versions.List(ctx, "skill", forward.SkillVersionListParams{IdentityID: identity})
return err
}},
{"POST", "skills/skill/versions", func(c forward.Client) error {
_, err := c.Skills.Versions.New(ctx, "skill", forward.SkillVersionNewParams{Files: []io.Reader{strings.NewReader("# skill")}, IdentityID: identity})
return err
}},
{"GET", "skills/skill/versions/1", func(c forward.Client) error { _, err := c.Skills.Versions.Get(ctx, "skill", "1", opts...); return err }},
{"DELETE", "skills/skill/versions/1", func(c forward.Client) error {
_, err := c.Skills.Versions.Delete(ctx, "skill", "1", opts...)
return err
}},
{"GET", "skills/skill/versions/1/content", func(c forward.Client) error {
res, err := c.Skills.Versions.Download(ctx, "skill", "1", opts...)
if err == nil {
res.Body.Close()
}
return err
}},
{"GET", "vaults", func(c forward.Client) error {
_, err := c.Vaults.List(ctx, forward.VaultListParams{IdentityID: identity})
return err
}},
{"POST", "vaults", func(c forward.Client) error {
_, err := c.Vaults.New(ctx, forward.VaultNewParams{DisplayName: "vault", IdentityID: identity})
return err
}},
{"GET", "vaults/vault", func(c forward.Client) error { _, err := c.Vaults.Get(ctx, "vault", opts...); return err }},
{"DELETE", "vaults/vault", func(c forward.Client) error { return c.Vaults.Delete(ctx, "vault", opts...) }},
{"GET", "vaults/vault/credentials", func(c forward.Client) error {
_, err := c.Vaults.Credentials.List(ctx, "vault", forward.VaultCredentialListParams{IdentityID: identity})
return err
}},
{"POST", "vaults/vault/credentials", func(c forward.Client) error {
_, err := c.Vaults.Credentials.New(ctx, "vault", forward.VaultCredentialNewParams{Auth: map[string]any{"type": "static_bearer", "token": "secret"}, IdentityID: identity})
return err
}},
{"GET", "vaults/vault/credentials/cred", func(c forward.Client) error {
_, err := c.Vaults.Credentials.Get(ctx, "vault", "cred", opts...)
return err
}},
{"POST", "vaults/vault/credentials/cred", func(c forward.Client) error {
_, err := c.Vaults.Credentials.Update(ctx, "vault", "cred", forward.VaultCredentialUpdateParams{Metadata: map[string]any{"note": "rotation"}, IdentityID: identity})
return err
}},
{"DELETE", "vaults/vault/credentials/cred", func(c forward.Client) error { return c.Vaults.Credentials.Delete(ctx, "vault", "cred", opts...) }},
{"GET", "files", func(c forward.Client) error {
_, err := c.Files.List(ctx, forward.FileListParams{IdentityID: identity})
return err
}},
{"POST", "files", func(c forward.Client) error {
_, err := c.Files.Upload(ctx, forward.FileUploadParams{File: strings.NewReader("content"), IdentityID: identity})
return err
}},
{"GET", "files/file", func(c forward.Client) error { _, err := c.Files.GetMetadata(ctx, "file", opts...); return err }},
{"DELETE", "files/file", func(c forward.Client) error { return c.Files.Delete(ctx, "file", opts...) }},
{"GET", "files/file/content", func(c forward.Client) error {
res, err := c.Files.Download(ctx, "file", opts...)
if err == nil {
res.Body.Close()
}
return err
}},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
calls := 0
c := testClient(func(r *http.Request) (*http.Response, error) {
if r.URL.Host == "storage.test" {
if r.URL.RawQuery != "signature=ok" || r.Header.Get("Authorization") != "" {
t.Fatal("scope or auth leaked to storage")
}
return reply(r, 200, "content"), nil
}
calls++
if r.Method != tc.method || r.URL.Path != "/api/v1/forward/"+tc.path {
t.Fatalf("route: %s %s", r.Method, r.URL)
}
query := r.URL.Query()
if query.Has("identity_id") != identity.Valid() || query.Get("identity_id") != identity.Value {
t.Fatalf("scope: %s", r.URL)
}
if len(query) > 1 {
t.Fatalf("body fields leaked to query: %s", r.URL)
}
if r.Body != nil {
body, _ := io.ReadAll(r.Body)
if strings.Contains(string(body), "identity_id") {
t.Fatalf("identity leaked into body: %s", body)
}
}
if tc.path == "files/file/content" {
return reply(r, 200, `{"url":"https://storage.test/content?signature=ok"}`), nil
}
return reply(r, 200, `{"id":"resource","data":[],"has_more":false}`), nil
})
if err := tc.call(c); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("API calls: %d", calls)
}
})
}
})
}
}

func TestPairChannelByCodeOrID(t *testing.T) {
for _, byID := range []bool{false, true} {
t.Run(map[bool]string{false: "code", true: "ID"}[byID], func(t *testing.T) {
params := forward.ChannelPairingNewParams{IdentityID: "idn_one", TemplateID: "tmpl_one", IdempotencyKey: forward.String("pair-once")}
expected := map[string]any{"identity_id": "idn_one", "template_id": "tmpl_one"}
if byID {
params.ChannelPairingID = "pair_one"
expected["channel_pairing_id"] = "pair_one"
} else {
params.Code = "K7MP92"
expected["code"] = "K7MP92"
}
c := testClient(func(r *http.Request) (*http.Response, error) {
if r.URL.RawQuery != "" || r.Header.Get("Idempotency-Key") != "pair-once" {
t.Fatal("pairing query/header")
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if len(body) != len(expected) {
t.Fatalf("unexpected body: %+v", body)
}
for k, v := range expected {
if body[k] != v {
t.Fatalf("body: %+v", body)
}
}
return reply(r, 200, `{"id": "pair_one", "type": "channel_pairing", "channel_id": "channel_one", "scope_type": "room", "scope_external_id": "room_one", "scope_display_name": "Team", "identity_id": "idn_one", "template_id": "tmpl_one", "status": "active", "paired_at": "2026-10-20T01:00:00Z"}`), nil
})
res, err := c.ChannelPairings.New(context.Background(), params)
if err != nil {
t.Fatal(err)
}
if res.ScopeType != "room" || res.ScopeExternalID != "room_one" || res.ScopeDisplayName != "Team" {
t.Fatalf("pairing=%+v", res)
}
})
}
}

func TestIdentityOwnershipPagingAndOverride(t *testing.T) {
calls := 0
c := testClient(func(r *http.Request) (*http.Response, error) {
if r.URL.Query().Get("identity_id") != "idn_override" {
t.Fatalf("lost scope: %s", r.URL)
}
calls++
if calls == 1 {
return reply(r, 200, `{"data":[{"id":"env_one"}],"has_more":true,"next_page":"next"}`), nil
}
if calls == 2 && r.URL.Query().Get("page") != "next" {
t.Fatalf("lost cursor: %s", r.URL)
}
return reply(r, 200, `{"data":[{"id":"env_two"}],"has_more":false}`), nil
})
pager := c.Environments.ListAutoPaging(context.Background(), forward.EnvironmentListParams{IdentityID: forward.String("idn_typed")}, forward.WithIdentityID("idn_override"))
count := 0
for pager.Next() {
count++
}
if err := pager.Err(); err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("items=%d", count)
}
_, err := c.Environments.New(context.Background(), forward.EnvironmentNewParams{Name: "env", IdentityID: forward.String("idn_typed")}, forward.WithIdentityID("idn_override"))
if err != nil {
t.Fatal(err)
}
}
Loading
Loading