From f6d8db8d559112d3066706f2d7dec6fd891308ba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:27:07 +0800 Subject: [PATCH] feat(sdk): expose stable usage and managed control APIs Add Forward credential rotation and hourly Usage aggregation, plus Managed Session cancellation and deployment-scoped Run queries. Include typed public exports, contract fixtures, live scenarios, generated API documentation, and changelog entries. --- CHANGELOG.md | 6 + CONTRIBUTING.md | 14 + README.md | 37 +- docs/api/reference.md | 620 ++++++++++++++++++++++ forward/api_expansion_live_test.go | 93 ++++ forward/api_expansion_test.go | 148 ++++++ forward/client.go | 2 + forward/client_test.go | 4 +- forward/test_helpers_test.go | 7 +- forward/testdata/api-contracts.json | 96 ++++ forward/testdata/api-operation-cases.json | 158 +++++- forward/usage.go | 125 +++++ forward/vault_live_test.go | 27 +- forward/vaultcredential.go | 109 ++++ managed/api_expansion_test.go | 77 +++ managed/client_test.go | 2 +- managed/deployment.go | 2 + managed/deployment_execution_live_test.go | 15 + managed/deployment_live_test.go | 6 + managed/deploymentrun_live_test.go | 35 ++ managed/deploymentscopedrun.go | 100 ++++ managed/session.go | 43 ++ managed/session_cancel_live_test.go | 50 ++ managed/session_live_test.go | 7 + managed/test_helpers_test.go | 2 +- managed/testdata/api-contracts.json | 72 +++ 26 files changed, 1848 insertions(+), 9 deletions(-) create mode 100644 forward/api_expansion_live_test.go create mode 100644 forward/api_expansion_test.go create mode 100644 forward/usage.go create mode 100644 managed/api_expansion_test.go create mode 100644 managed/deploymentscopedrun.go create mode 100644 managed/session_cancel_live_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 572d5bc..16b7440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ existing `0.1.0` release; earlier development prereleases are not listed. ## [Unreleased] +### Added + +- Forward Vault Credential updates for secret rotation and metadata merge patches, with automatic retries disabled for write-only updates. +- Forward Usage aggregation by Identity and Template using hourly `start_at` / `end_at` windows in Asia/Shanghai, with fractional `active_seconds` and multi-ID filters. Legacy timestamp parameters are not exposed. +- Managed Session cancellation with the lightweight acknowledgement for both active and idle sessions, plus deployment-scoped Run listing and retrieval. + ## [0.2.0] - 2026-09-24 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a0b3e21..49595aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,20 @@ go build ./examples/... Never commit `.env.live`, tokens, credentials, generated logs, or test output. Live tests must register cleanup immediately after creating a resource. +`make test-live-all` includes Forward hourly Usage, Credential merge patches +and secret redaction, and Managed Session cancellation before and after sending +a turn. Managed deployment tests cover scoped Run listing/retrieval. Active +cancellation and scoped Run execution require the corresponding +`LIVE_ALLOW_WRITE=true` and `LIVE_ALLOW_EXECUTION=true` gates. Use a separate +`LIVE_ENV_FILE` with matching URL and PAT for CN and Global. + +Usage queries the last 24 completed whole hours in Asia/Shanghai in both regions; +empty pages verify only the collection. A Session may finish before cancellation +and return HTTP 200 instead of 202; tests record which response occurred. Those +responses do not prove active cancellation occurred. Cleanup failures remain +failures; offline replay exercises assertions and cleanup without account +credentials. + ## API and contract changes When adding or changing an endpoint: diff --git a/README.md b/README.md index 1ac877c..ca57b9e 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,13 @@ The SDK ships two clients that share authentication, transport, retries, error h | Mode | Package | Resources | | --- | --- | --- | -| Forward | `forward` | Identity, Template, Session, Schedule, Batch, Channel, Environment, File, Skill, Vault, MemoryStore, Model | +| Forward | `forward` | Identity, Template, Session, Schedule, Batch, Channel, Environment, File, Skill, Vault, MemoryStore, Model, Usage | | Managed | `managed` | Agent, Session, Deployment, Dream, Environment, File, Skill, Vault, MemoryStore, Model | - [Installation](#installation) - [Requirements](#requirements) - [Usage](#usage) +- [Usage and cancellation](#usage-and-cancellation) - [Conversations](#conversations) - [System prompts and tools](#system-prompts-and-tools) - [Streaming](#streaming) @@ -115,6 +116,40 @@ The remaining examples are function fragments meant to be dropped into an applic | `option` | `github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option` | | `param` | `github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/param` | +## Usage and cancellation + +Forward Usage requires PAT or Admin SAT. + +```go +usage, err := client.Usage.ListIdentities(ctx, forward.UsageListParams{ + StartAt: "2026-09-14T09:00:00", + EndAt: "2026-09-14T12:00:00", + IdentityIDs: []string{"idn_one", "idn_two"}, +}) +if err != nil { + return err +} +for _, item := range usage.Data { + fmt.Println(item.IdentityID, item.ActiveSeconds, item.Credits) +} +``` + +`Usage.ListTemplates` uses the same filters. Both bounds are whole hours in +Asia/Shanghai, including Global: the start is inclusive, the end exclusive, and +the maximum window is 744 hours. Use `ActiveSeconds` without truncating fractions. +Legacy `start_time`, `end_time`, and `duration_seconds` are not exposed. + +Rotate Forward secrets with `client.Vaults.Credentials.Update`; only `auth` and +`metadata` are patched, and omitted fields are preserved. Use +`param.NullMap[map[string]any]()` to clear metadata, or a map value of `nil` to remove +a key. Updates never retry automatically; secret values are not returned by GET. + +Managed adds `client.Sessions.Cancel(ctx, sessionID, managed.SessionCancelParams{})`, +which returns a lightweight `canceling` acknowledgement (HTTP 202 for active work, +200 for an idle no-op). It also exposes `client.Deployments.Runs.List` and `Get`, +with the deployment ID as a path argument; the existing global `DeploymentRuns` +resource remains available. + ## Conversations Forward creates a Session from an Identity and a Template. The fragments below assume both already exist, with a usable execution environment configured on the Template; [the Forward session example](examples/forward/session/main.go) shows how to create them. diff --git a/docs/api/reference.md b/docs/api/reference.md index 641251b..df68d84 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -185,6 +185,9 @@ import "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward" - [type EnvironmentVariableOverrideParam](<#EnvironmentVariableOverrideParam>) - [func \(r EnvironmentVariableOverrideParam\) MarshalJSON\(\) \(\[\]byte, error\)](<#EnvironmentVariableOverrideParam.MarshalJSON>) - [func \(r \*EnvironmentVariableOverrideParam\) UnmarshalJSON\(data \[\]byte\) error](<#EnvironmentVariableOverrideParam.UnmarshalJSON>) +- [type EnvironmentVariableUpdateParam](<#EnvironmentVariableUpdateParam>) + - [func \(r EnvironmentVariableUpdateParam\) MarshalJSON\(\) \(\[\]byte, error\)](<#EnvironmentVariableUpdateParam.MarshalJSON>) + - [func \(r \*EnvironmentVariableUpdateParam\) UnmarshalJSON\(data \[\]byte\) error](<#EnvironmentVariableUpdateParam.UnmarshalJSON>) - [type EnvironmentVariablesUnionParam](<#EnvironmentVariablesUnionParam>) - [func \(r EnvironmentVariablesUnionParam\) MarshalJSON\(\) \(\[\]byte, error\)](<#EnvironmentVariablesUnionParam.MarshalJSON>) - [func \(r \*EnvironmentVariablesUnionParam\) UnmarshalJSON\(data \[\]byte\) error](<#EnvironmentVariablesUnionParam.UnmarshalJSON>) @@ -288,12 +291,21 @@ import "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward" - [type IdentityUpdateParams](<#IdentityUpdateParams>) - [func \(r IdentityUpdateParams\) MarshalJSON\(\) \(\[\]byte, error\)](<#IdentityUpdateParams.MarshalJSON>) - [func \(r \*IdentityUpdateParams\) UnmarshalJSON\(data \[\]byte\) error](<#IdentityUpdateParams.UnmarshalJSON>) +- [type IdentityUsage](<#IdentityUsage>) + - [func \(r IdentityUsage\) RawJSON\(\) string](<#IdentityUsage.RawJSON>) + - [func \(r \*IdentityUsage\) UnmarshalJSON\(data \[\]byte\) error](<#IdentityUsage.UnmarshalJSON>) - [type ImageSource](<#ImageSource>) - [func \(r ImageSource\) RawJSON\(\) string](<#ImageSource.RawJSON>) - [func \(r \*ImageSource\) UnmarshalJSON\(data \[\]byte\) error](<#ImageSource.UnmarshalJSON>) - [type ImageSourceParam](<#ImageSourceParam>) - [func \(r ImageSourceParam\) MarshalJSON\(\) \(\[\]byte, error\)](<#ImageSourceParam.MarshalJSON>) - [func \(r \*ImageSourceParam\) UnmarshalJSON\(data \[\]byte\) error](<#ImageSourceParam.UnmarshalJSON>) +- [type MCPOAuthRefreshUpdateParam](<#MCPOAuthRefreshUpdateParam>) + - [func \(r MCPOAuthRefreshUpdateParam\) MarshalJSON\(\) \(\[\]byte, error\)](<#MCPOAuthRefreshUpdateParam.MarshalJSON>) + - [func \(r \*MCPOAuthRefreshUpdateParam\) UnmarshalJSON\(data \[\]byte\) error](<#MCPOAuthRefreshUpdateParam.UnmarshalJSON>) +- [type MCPOAuthUpdateParam](<#MCPOAuthUpdateParam>) + - [func \(r MCPOAuthUpdateParam\) MarshalJSON\(\) \(\[\]byte, error\)](<#MCPOAuthUpdateParam.MarshalJSON>) + - [func \(r \*MCPOAuthUpdateParam\) UnmarshalJSON\(data \[\]byte\) error](<#MCPOAuthUpdateParam.UnmarshalJSON>) - [type MCPServer](<#MCPServer>) - [func \(r MCPServer\) RawJSON\(\) string](<#MCPServer.RawJSON>) - [func \(r \*MCPServer\) UnmarshalJSON\(data \[\]byte\) error](<#MCPServer.UnmarshalJSON>) @@ -630,6 +642,9 @@ import "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward" - [func \(r \*SkillVersionService\) List\(ctx context.Context, id string, params SkillVersionListParams, opts ...option.RequestOption\) \(res \*pagination.PageCursor\[SkillVersion\], err error\)](<#SkillVersionService.List>) - [func \(r \*SkillVersionService\) ListAutoPaging\(ctx context.Context, id string, params SkillVersionListParams, opts ...option.RequestOption\) \*pagination.PageCursorAutoPager\[SkillVersion\]](<#SkillVersionService.ListAutoPaging>) - [func \(r \*SkillVersionService\) New\(ctx context.Context, id string, params SkillVersionNewParams, opts ...option.RequestOption\) \(res \*SkillVersion, err error\)](<#SkillVersionService.New>) +- [type StaticBearerUpdateParam](<#StaticBearerUpdateParam>) + - [func \(r StaticBearerUpdateParam\) MarshalJSON\(\) \(\[\]byte, error\)](<#StaticBearerUpdateParam.MarshalJSON>) + - [func \(r \*StaticBearerUpdateParam\) UnmarshalJSON\(data \[\]byte\) error](<#StaticBearerUpdateParam.UnmarshalJSON>) - [type SystemOverride](<#SystemOverride>) - [func \(r SystemOverride\) RawJSON\(\) string](<#SystemOverride.RawJSON>) - [func \(r \*SystemOverride\) UnmarshalJSON\(data \[\]byte\) error](<#SystemOverride.UnmarshalJSON>) @@ -661,6 +676,9 @@ import "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward" - [type TemplateUpdateParams](<#TemplateUpdateParams>) - [func \(r TemplateUpdateParams\) MarshalJSON\(\) \(\[\]byte, error\)](<#TemplateUpdateParams.MarshalJSON>) - [func \(r \*TemplateUpdateParams\) UnmarshalJSON\(data \[\]byte\) error](<#TemplateUpdateParams.UnmarshalJSON>) +- [type TemplateUsage](<#TemplateUsage>) + - [func \(r TemplateUsage\) RawJSON\(\) string](<#TemplateUsage.RawJSON>) + - [func \(r \*TemplateUsage\) UnmarshalJSON\(data \[\]byte\) error](<#TemplateUsage.UnmarshalJSON>) - [type Tool](<#Tool>) - [func \(r Tool\) RawJSON\(\) string](<#Tool.RawJSON>) - [func \(r \*Tool\) UnmarshalJSON\(data \[\]byte\) error](<#Tool.UnmarshalJSON>) @@ -679,6 +697,15 @@ import "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward" - [type ToolParam](<#ToolParam>) - [func \(r ToolParam\) MarshalJSON\(\) \(\[\]byte, error\)](<#ToolParam.MarshalJSON>) - [func \(r \*ToolParam\) UnmarshalJSON\(data \[\]byte\) error](<#ToolParam.UnmarshalJSON>) +- [type UsageListParams](<#UsageListParams>) + - [func \(r UsageListParams\) URLQuery\(\) \(url.Values, error\)](<#UsageListParams.URLQuery>) + - [func \(r \*UsageListParams\) UnmarshalJSON\(data \[\]byte\) error](<#UsageListParams.UnmarshalJSON>) +- [type UsageService](<#UsageService>) + - [func NewUsageService\(opts ...option.RequestOption\) UsageService](<#NewUsageService>) + - [func \(r \*UsageService\) ListIdentities\(ctx context.Context, params UsageListParams, opts ...option.RequestOption\) \(res \*pagination.Page\[IdentityUsage\], err error\)](<#UsageService.ListIdentities>) + - [func \(r \*UsageService\) ListIdentitiesAutoPaging\(ctx context.Context, params UsageListParams, opts ...option.RequestOption\) \*pagination.PageAutoPager\[IdentityUsage\]](<#UsageService.ListIdentitiesAutoPaging>) + - [func \(r \*UsageService\) ListTemplates\(ctx context.Context, params UsageListParams, opts ...option.RequestOption\) \(res \*pagination.Page\[TemplateUsage\], err error\)](<#UsageService.ListTemplates>) + - [func \(r \*UsageService\) ListTemplatesAutoPaging\(ctx context.Context, params UsageListParams, opts ...option.RequestOption\) \*pagination.PageAutoPager\[TemplateUsage\]](<#UsageService.ListTemplatesAutoPaging>) - [type Vault](<#Vault>) - [func \(r Vault\) RawJSON\(\) string](<#Vault.RawJSON>) - [func \(r \*Vault\) UnmarshalJSON\(data \[\]byte\) error](<#Vault.UnmarshalJSON>) @@ -700,6 +727,14 @@ import "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward" - [func \(r \*VaultCredentialService\) List\(ctx context.Context, id string, params VaultCredentialListParams, opts ...option.RequestOption\) \(res \*pagination.PageCursor\[VaultCredential\], err error\)](<#VaultCredentialService.List>) - [func \(r \*VaultCredentialService\) ListAutoPaging\(ctx context.Context, id string, params VaultCredentialListParams, opts ...option.RequestOption\) \*pagination.PageCursorAutoPager\[VaultCredential\]](<#VaultCredentialService.ListAutoPaging>) - [func \(r \*VaultCredentialService\) New\(ctx context.Context, id string, params VaultCredentialNewParams, opts ...option.RequestOption\) \(res \*VaultCredential, err error\)](<#VaultCredentialService.New>) + - [func \(r \*VaultCredentialService\) Update\(ctx context.Context, id string, credID string, params VaultCredentialUpdateParams, opts ...option.RequestOption\) \(res \*VaultCredential, err error\)](<#VaultCredentialService.Update>) +- [type VaultCredentialUpdateAuthUnionParam](<#VaultCredentialUpdateAuthUnionParam>) + - [func \(r VaultCredentialUpdateAuthUnionParam\) MarshalJSON\(\) \(\[\]byte, error\)](<#VaultCredentialUpdateAuthUnionParam.MarshalJSON>) + - [func \(r \*VaultCredentialUpdateAuthUnionParam\) UnmarshalJSON\(data \[\]byte\) error](<#VaultCredentialUpdateAuthUnionParam.UnmarshalJSON>) +- [type VaultCredentialUpdateParams](<#VaultCredentialUpdateParams>) + - [func \(r VaultCredentialUpdateParams\) MarshalJSON\(\) \(\[\]byte, error\)](<#VaultCredentialUpdateParams.MarshalJSON>) + - [func \(r VaultCredentialUpdateParams\) URLQuery\(\) \(url.Values, error\)](<#VaultCredentialUpdateParams.URLQuery>) + - [func \(r \*VaultCredentialUpdateParams\) UnmarshalJSON\(data \[\]byte\) error](<#VaultCredentialUpdateParams.UnmarshalJSON>) - [type VaultListParams](<#VaultListParams>) - [func \(r VaultListParams\) URLQuery\(\) \(url.Values, error\)](<#VaultListParams.URLQuery>) - [type VaultNewParams](<#VaultNewParams>) @@ -2000,6 +2035,7 @@ type Client struct { Skills SkillService Vaults VaultService MemoryStores MemoryStoreService + Usage UsageService Models ModelService } ``` @@ -3011,6 +3047,37 @@ func (r *EnvironmentVariableOverrideParam) UnmarshalJSON(data []byte) error + +## type [EnvironmentVariableUpdateParam]() + + + +```go +type EnvironmentVariableUpdateParam struct { + Type string `json:"type" api:"required"` + SecretValue param.Opt[string] `json:"secret_value,omitzero"` + // contains filtered or unexported fields +} +``` + + +### func \(EnvironmentVariableUpdateParam\) [MarshalJSON]() + +```go +func (r EnvironmentVariableUpdateParam) MarshalJSON() ([]byte, error) +``` + + + + +### func \(\*EnvironmentVariableUpdateParam\) [UnmarshalJSON]() + +```go +func (r *EnvironmentVariableUpdateParam) UnmarshalJSON(data []byte) error +``` + + + ## type [EnvironmentVariablesUnionParam]() @@ -4300,6 +4367,48 @@ func (r *IdentityUpdateParams) UnmarshalJSON(data []byte) error + +## type [IdentityUsage]() + + + +```go +type IdentityUsage struct { + Type string `json:"type"` + IdentityID string `json:"identity_id"` + SessionCount int64 `json:"session_count"` + ActiveSeconds float64 `json:"active_seconds"` + Credits float64 `json:"credits"` + JSON struct { + Type respjson.Field + IdentityID respjson.Field + SessionCount respjson.Field + ActiveSeconds respjson.Field + Credits respjson.Field + ExtraFields map[string]respjson.Field + // contains filtered or unexported fields + } `json:"-"` +} +``` + + +### func \(IdentityUsage\) [RawJSON]() + +```go +func (r IdentityUsage) RawJSON() string +``` + + + + +### func \(\*IdentityUsage\) [UnmarshalJSON]() + +```go +func (r *IdentityUsage) UnmarshalJSON(data []byte) error +``` + + + ## type [ImageSource]() @@ -4376,6 +4485,71 @@ func (r *ImageSourceParam) UnmarshalJSON(data []byte) error + +## type [MCPOAuthRefreshUpdateParam]() + + + +```go +type MCPOAuthRefreshUpdateParam struct { + RefreshToken param.Opt[string] `json:"refresh_token,omitzero"` + Scope param.Opt[string] `json:"scope,omitzero"` + TokenEndpointAuth map[string]any `json:"token_endpoint_auth,omitzero"` + // contains filtered or unexported fields +} +``` + + +### func \(MCPOAuthRefreshUpdateParam\) [MarshalJSON]() + +```go +func (r MCPOAuthRefreshUpdateParam) MarshalJSON() ([]byte, error) +``` + + + + +### func \(\*MCPOAuthRefreshUpdateParam\) [UnmarshalJSON]() + +```go +func (r *MCPOAuthRefreshUpdateParam) UnmarshalJSON(data []byte) error +``` + + + + +## type [MCPOAuthUpdateParam]() + + + +```go +type MCPOAuthUpdateParam struct { + Type string `json:"type" api:"required"` + AccessToken param.Opt[string] `json:"access_token,omitzero"` + ExpiresAt param.Opt[string] `json:"expires_at,omitzero"` + Refresh MCPOAuthRefreshUpdateParam `json:"refresh,omitzero"` + // contains filtered or unexported fields +} +``` + + +### func \(MCPOAuthUpdateParam\) [MarshalJSON]() + +```go +func (r MCPOAuthUpdateParam) MarshalJSON() ([]byte, error) +``` + + + + +### func \(\*MCPOAuthUpdateParam\) [UnmarshalJSON]() + +```go +func (r *MCPOAuthUpdateParam) UnmarshalJSON(data []byte) error +``` + + + ## type [MCPServer]() @@ -8653,6 +8827,37 @@ func (r *SkillVersionService) New(ctx context.Context, id string, params SkillVe Create Skill version + +## type [StaticBearerUpdateParam]() + + + +```go +type StaticBearerUpdateParam struct { + Type string `json:"type" api:"required"` + Token param.Opt[string] `json:"token,omitzero"` + // contains filtered or unexported fields +} +``` + + +### func \(StaticBearerUpdateParam\) [MarshalJSON]() + +```go +func (r StaticBearerUpdateParam) MarshalJSON() ([]byte, error) +``` + + + + +### func \(\*StaticBearerUpdateParam\) [UnmarshalJSON]() + +```go +func (r *StaticBearerUpdateParam) UnmarshalJSON(data []byte) error +``` + + + ## type [SystemOverride]() @@ -9098,6 +9303,50 @@ func (r *TemplateUpdateParams) UnmarshalJSON(data []byte) error + +## type [TemplateUsage]() + + + +```go +type TemplateUsage struct { + Type string `json:"type"` + TemplateID string `json:"template_id"` + ActiveIdentities int64 `json:"active_identities"` + SessionCount int64 `json:"session_count"` + ActiveSeconds float64 `json:"active_seconds"` + Credits float64 `json:"credits"` + JSON struct { + Type respjson.Field + TemplateID respjson.Field + ActiveIdentities respjson.Field + SessionCount respjson.Field + ActiveSeconds respjson.Field + Credits respjson.Field + ExtraFields map[string]respjson.Field + // contains filtered or unexported fields + } `json:"-"` +} +``` + + +### func \(TemplateUsage\) [RawJSON]() + +```go +func (r TemplateUsage) RawJSON() string +``` + + + + +### func \(\*TemplateUsage\) [UnmarshalJSON]() + +```go +func (r *TemplateUsage) UnmarshalJSON(data []byte) error +``` + + + ## type [Tool]() @@ -9320,6 +9569,100 @@ func (r *ToolParam) UnmarshalJSON(data []byte) error + +## type [UsageListParams]() + +UsageListParams accepts only hourly parameters. StartAt is inclusive; EndAt is exclusive, with a maximum span of 744 hours. Both use YYYY\-MM\-DDTHH:00:00 in Asia/Shanghai for CN and Global. + +```go +type UsageListParams struct { + StartAt string `query:"start_at" json:"-" api:"required"` + EndAt string `query:"end_at" json:"-" api:"required"` + Limit param.Opt[int64] `query:"limit,omitzero" json:"-"` + AfterID param.Opt[string] `query:"after_id,omitzero" json:"-"` + BeforeID param.Opt[string] `query:"before_id,omitzero" json:"-"` + IdentityID param.Opt[string] `query:"identity_id,omitzero" json:"-"` + // Repeated query values; an entry may also contain comma-separated IDs. + IdentityIDs []string `query:"identity_ids,omitzero" json:"-"` + TemplateID param.Opt[string] `query:"template_id,omitzero" json:"-"` + // Repeated query values; an entry may also contain comma-separated IDs. + TemplateIDs []string `query:"template_ids,omitzero" json:"-"` + // contains filtered or unexported fields +} +``` + + +### func \(UsageListParams\) [URLQuery]() + +```go +func (r UsageListParams) URLQuery() (url.Values, error) +``` + + + + +### func \(\*UsageListParams\) [UnmarshalJSON]() + +```go +func (r *UsageListParams) UnmarshalJSON(data []byte) error +``` + + + + +## type [UsageService]() + + + +```go +type UsageService struct{ Options []option.RequestOption } +``` + + +### func [NewUsageService]() + +```go +func NewUsageService(opts ...option.RequestOption) UsageService +``` + + + + +### func \(\*UsageService\) [ListIdentities]() + +```go +func (r *UsageService) ListIdentities(ctx context.Context, params UsageListParams, opts ...option.RequestOption) (res *pagination.Page[IdentityUsage], err error) +``` + +ListIdentities aggregates an hourly Asia/Shanghai window. PAT or Admin SAT required. + + +### func \(\*UsageService\) [ListIdentitiesAutoPaging]() + +```go +func (r *UsageService) ListIdentitiesAutoPaging(ctx context.Context, params UsageListParams, opts ...option.RequestOption) *pagination.PageAutoPager[IdentityUsage] +``` + + + + +### func \(\*UsageService\) [ListTemplates]() + +```go +func (r *UsageService) ListTemplates(ctx context.Context, params UsageListParams, opts ...option.RequestOption) (res *pagination.Page[TemplateUsage], err error) +``` + +ListTemplates aggregates an hourly Asia/Shanghai window. PAT or Admin SAT required. + + +### func \(\*UsageService\) [ListTemplatesAutoPaging]() + +```go +func (r *UsageService) ListTemplatesAutoPaging(ctx context.Context, params UsageListParams, opts ...option.RequestOption) *pagination.PageAutoPager[TemplateUsage] +``` + + + ## type [Vault]() @@ -9392,6 +9735,8 @@ type VaultCredential struct { DisplayName string `json:"display_name"` // Credential metadata. Metadata map[string]any `json:"metadata"` + // Archive time, null while active. + ArchivedAt time.Time `json:"archived_at" api:"nullable" format:"date-time"` // Creation time in RFC 3339 format. CreatedAt time.Time `json:"created_at" format:"date-time"` // Last update time in RFC 3339 format. @@ -9403,6 +9748,7 @@ type VaultCredential struct { Auth respjson.Field DisplayName respjson.Field Metadata respjson.Field + ArchivedAt respjson.Field CreatedAt respjson.Field UpdatedAt respjson.Field ExtraFields map[string]respjson.Field @@ -9438,9 +9784,11 @@ func (r *VaultCredential) UnmarshalJSON(data []byte) error type VaultCredentialAuth struct { Type string `json:"type"` MCPServerURL string `json:"mcp_server_url"` + SecretName string `json:"secret_name"` JSON struct { Type respjson.Field MCPServerURL respjson.Field + SecretName respjson.Field ExtraFields map[string]respjson.Field // contains filtered or unexported fields } `json:"-"` @@ -9602,6 +9950,88 @@ func (r *VaultCredentialService) New(ctx context.Context, id string, params Vaul Create Credential + +### func \(\*VaultCredentialService\) [Update]() + +```go +func (r *VaultCredentialService) Update(ctx context.Context, id string, credID string, params VaultCredentialUpdateParams, opts ...option.RequestOption) (res *VaultCredential, err error) +``` + +Update merges auth or metadata. Write\-only secret rotation is never automatically retried. + + +## type [VaultCredentialUpdateAuthUnionParam]() + + + +```go +type VaultCredentialUpdateAuthUnionParam struct { + OfStaticBearer *StaticBearerUpdateParam `json:",omitzero,inline"` + OfMCPOAuth *MCPOAuthUpdateParam `json:",omitzero,inline"` + OfEnvironmentVariable *EnvironmentVariableUpdateParam `json:",omitzero,inline"` + // contains filtered or unexported fields +} +``` + + +### func \(VaultCredentialUpdateAuthUnionParam\) [MarshalJSON]() + +```go +func (r VaultCredentialUpdateAuthUnionParam) MarshalJSON() ([]byte, error) +``` + + + + +### func \(\*VaultCredentialUpdateAuthUnionParam\) [UnmarshalJSON]() + +```go +func (r *VaultCredentialUpdateAuthUnionParam) UnmarshalJSON(data []byte) error +``` + + + + +## type [VaultCredentialUpdateParams]() + + + +```go +type VaultCredentialUpdateParams struct { + Auth VaultCredentialUpdateAuthUnionParam `json:"auth,omitzero"` + Metadata map[string]any `json:"metadata,omitzero"` + IdentityID param.Opt[string] `query:"identity_id,omitzero" json:"-"` + // contains filtered or unexported fields +} +``` + + +### func \(VaultCredentialUpdateParams\) [MarshalJSON]() + +```go +func (r VaultCredentialUpdateParams) MarshalJSON() ([]byte, error) +``` + + + + +### func \(VaultCredentialUpdateParams\) [URLQuery]() + +```go +func (r VaultCredentialUpdateParams) URLQuery() (url.Values, error) +``` + + + + +### func \(\*VaultCredentialUpdateParams\) [UnmarshalJSON]() + +```go +func (r *VaultCredentialUpdateParams) UnmarshalJSON(data []byte) error +``` + + + ## type [VaultListParams]() @@ -10080,6 +10510,16 @@ Qoder managed API definitions. - [func \(r \*DeploymentRunService\) Get\(ctx context.Context, deploymentRunID string, query DeploymentRunGetParams, opts ...option.RequestOption\) \(res \*ManagedAgentsDeploymentRun, err error\)](<#DeploymentRunService.Get>) - [func \(r \*DeploymentRunService\) List\(ctx context.Context, params DeploymentRunListParams, opts ...option.RequestOption\) \(res \*pagination.PageCursor\[ManagedAgentsDeploymentRun\], err error\)](<#DeploymentRunService.List>) - [func \(r \*DeploymentRunService\) ListAutoPaging\(ctx context.Context, params DeploymentRunListParams, opts ...option.RequestOption\) \*pagination.PageCursorAutoPager\[ManagedAgentsDeploymentRun\]](<#DeploymentRunService.ListAutoPaging>) +- [type DeploymentScopedRunGetParams](<#DeploymentScopedRunGetParams>) + - [func \(r \*DeploymentScopedRunGetParams\) UnmarshalJSON\(data \[\]byte\) error](<#DeploymentScopedRunGetParams.UnmarshalJSON>) +- [type DeploymentScopedRunListParams](<#DeploymentScopedRunListParams>) + - [func \(r DeploymentScopedRunListParams\) URLQuery\(\) \(url.Values, error\)](<#DeploymentScopedRunListParams.URLQuery>) + - [func \(r \*DeploymentScopedRunListParams\) UnmarshalJSON\(data \[\]byte\) error](<#DeploymentScopedRunListParams.UnmarshalJSON>) +- [type DeploymentScopedRunService](<#DeploymentScopedRunService>) + - [func NewDeploymentScopedRunService\(opts ...option.RequestOption\) DeploymentScopedRunService](<#NewDeploymentScopedRunService>) + - [func \(r \*DeploymentScopedRunService\) Get\(ctx context.Context, deploymentID string, runID string, params DeploymentScopedRunGetParams, opts ...option.RequestOption\) \(res \*ManagedAgentsDeploymentRun, err error\)](<#DeploymentScopedRunService.Get>) + - [func \(r \*DeploymentScopedRunService\) List\(ctx context.Context, deploymentID string, params DeploymentScopedRunListParams, opts ...option.RequestOption\) \(res \*pagination.PageCursor\[ManagedAgentsDeploymentRun\], err error\)](<#DeploymentScopedRunService.List>) + - [func \(r \*DeploymentScopedRunService\) ListAutoPaging\(ctx context.Context, deploymentID string, params DeploymentScopedRunListParams, opts ...option.RequestOption\) \*pagination.PageCursorAutoPager\[ManagedAgentsDeploymentRun\]](<#DeploymentScopedRunService.ListAutoPaging>) - [type DeploymentService](<#DeploymentService>) - [func NewDeploymentService\(opts ...option.RequestOption\) \(r DeploymentService\)](<#NewDeploymentService>) - [func \(r \*DeploymentService\) Archive\(ctx context.Context, deploymentID string, body DeploymentArchiveParams, opts ...option.RequestOption\) \(res \*ManagedAgentsDeployment, err error\)](<#DeploymentService.Archive>) @@ -12497,6 +12937,11 @@ Qoder managed API definitions. - [func \(u \*ServerToolUseBlockParamCallerUnion\) UnmarshalJSON\(data \[\]byte\) error](<#ServerToolUseBlockParamCallerUnion.UnmarshalJSON>) - [type ServerToolUseBlockParamName](<#ServerToolUseBlockParamName>) - [type SessionArchiveParams](<#SessionArchiveParams>) +- [type SessionCancelParams](<#SessionCancelParams>) + - [func \(r \*SessionCancelParams\) UnmarshalJSON\(data \[\]byte\) error](<#SessionCancelParams.UnmarshalJSON>) +- [type SessionCancelResponse](<#SessionCancelResponse>) + - [func \(r SessionCancelResponse\) RawJSON\(\) string](<#SessionCancelResponse.RawJSON>) + - [func \(r \*SessionCancelResponse\) UnmarshalJSON\(data \[\]byte\) error](<#SessionCancelResponse.UnmarshalJSON>) - [type SessionDeleteParams](<#SessionDeleteParams>) - [type SessionEventListParams](<#SessionEventListParams>) - [func \(r SessionEventListParams\) URLQuery\(\) \(v url.Values, err error\)](<#SessionEventListParams.URLQuery>) @@ -12586,6 +13031,7 @@ Qoder managed API definitions. - [type SessionService](<#SessionService>) - [func NewSessionService\(opts ...option.RequestOption\) \(r SessionService\)](<#NewSessionService>) - [func \(r \*SessionService\) Archive\(ctx context.Context, sessionID string, body SessionArchiveParams, opts ...option.RequestOption\) \(res \*ManagedAgentsSession, err error\)](<#SessionService.Archive>) + - [func \(r \*SessionService\) Cancel\(ctx context.Context, sessionID string, params SessionCancelParams, opts ...option.RequestOption\) \(res \*SessionCancelResponse, err error\)](<#SessionService.Cancel>) - [func \(r \*SessionService\) Delete\(ctx context.Context, sessionID string, body SessionDeleteParams, opts ...option.RequestOption\) \(res \*ManagedAgentsDeletedSession, err error\)](<#SessionService.Delete>) - [func \(r \*SessionService\) Get\(ctx context.Context, sessionID string, query SessionGetParams, opts ...option.RequestOption\) \(res \*ManagedAgentsSession, err error\)](<#SessionService.Get>) - [func \(r \*SessionService\) List\(ctx context.Context, params SessionListParams, opts ...option.RequestOption\) \(res \*pagination.BidirectionalPageCursor\[ManagedAgentsSession\], err error\)](<#SessionService.List>) @@ -16279,6 +16725,110 @@ func (r *DeploymentRunService) ListAutoPaging(ctx context.Context, params Deploy List Deployment Runs + +## type [DeploymentScopedRunGetParams]() + + + +```go +type DeploymentScopedRunGetParams struct { + WorkspaceID param.Opt[string] `header:"qoder-workspace-id,omitzero" json:"-"` + Betas []QoderBeta `header:"x-qoder-beta,omitzero" json:"-"` + // contains filtered or unexported fields +} +``` + + +### func \(\*DeploymentScopedRunGetParams\) [UnmarshalJSON]() + +```go +func (r *DeploymentScopedRunGetParams) UnmarshalJSON(data []byte) error +``` + + + + +## type [DeploymentScopedRunListParams]() + + + +```go +type DeploymentScopedRunListParams struct { + Limit param.Opt[int64] `query:"limit,omitzero" json:"-"` + Page param.Opt[string] `query:"page,omitzero" json:"-"` + AfterID param.Opt[string] `query:"after_id,omitzero" json:"-"` + BeforeID param.Opt[string] `query:"before_id,omitzero" json:"-"` + TriggeredAfter param.Opt[string] `query:"triggered_after,omitzero" json:"-"` + TriggeredBefore param.Opt[string] `query:"triggered_before,omitzero" json:"-"` + WorkspaceID param.Opt[string] `header:"qoder-workspace-id,omitzero" json:"-"` + Betas []QoderBeta `header:"x-qoder-beta,omitzero" json:"-"` + // contains filtered or unexported fields +} +``` + + +### func \(DeploymentScopedRunListParams\) [URLQuery]() + +```go +func (r DeploymentScopedRunListParams) URLQuery() (url.Values, error) +``` + + + + +### func \(\*DeploymentScopedRunListParams\) [UnmarshalJSON]() + +```go +func (r *DeploymentScopedRunListParams) UnmarshalJSON(data []byte) error +``` + + + + +## type [DeploymentScopedRunService]() + + + +```go +type DeploymentScopedRunService struct{ Options []option.RequestOption } +``` + + +### func [NewDeploymentScopedRunService]() + +```go +func NewDeploymentScopedRunService(opts ...option.RequestOption) DeploymentScopedRunService +``` + + + + +### func \(\*DeploymentScopedRunService\) [Get]() + +```go +func (r *DeploymentScopedRunService) Get(ctx context.Context, deploymentID string, runID string, params DeploymentScopedRunGetParams, opts ...option.RequestOption) (res *ManagedAgentsDeploymentRun, err error) +``` + +Get DeploymentScopedRun. + + +### func \(\*DeploymentScopedRunService\) [List]() + +```go +func (r *DeploymentScopedRunService) List(ctx context.Context, deploymentID string, params DeploymentScopedRunListParams, opts ...option.RequestOption) (res *pagination.PageCursor[ManagedAgentsDeploymentRun], err error) +``` + +List DeploymentScopedRun. + + +### func \(\*DeploymentScopedRunService\) [ListAutoPaging]() + +```go +func (r *DeploymentScopedRunService) ListAutoPaging(ctx context.Context, deploymentID string, params DeploymentScopedRunListParams, opts ...option.RequestOption) *pagination.PageCursorAutoPager[ManagedAgentsDeploymentRun] +``` + + + ## type [DeploymentService]() @@ -16289,6 +16839,7 @@ Note, unlike clients, this service does not read variables from the environment ```go type DeploymentService struct { Options []option.RequestOption + Runs DeploymentScopedRunService } ``` @@ -49395,6 +49946,66 @@ type SessionArchiveParams struct { } ``` + +## type [SessionCancelParams]() + + + +```go +type SessionCancelParams struct { + WorkspaceID param.Opt[string] `header:"qoder-workspace-id,omitzero" json:"-"` + Betas []QoderBeta `header:"x-qoder-beta,omitzero" json:"-"` + // contains filtered or unexported fields +} +``` + + +### func \(\*SessionCancelParams\) [UnmarshalJSON]() + +```go +func (r *SessionCancelParams) UnmarshalJSON(data []byte) error +``` + + + + +## type [SessionCancelResponse]() + + + +```go +type SessionCancelResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + JSON struct { + ID respjson.Field + Type respjson.Field + Status respjson.Field + ExtraFields map[string]respjson.Field + // contains filtered or unexported fields + } `json:"-"` +} +``` + + +### func \(SessionCancelResponse\) [RawJSON]() + +```go +func (r SessionCancelResponse) RawJSON() string +``` + + + + +### func \(\*SessionCancelResponse\) [UnmarshalJSON]() + +```go +func (r *SessionCancelResponse) UnmarshalJSON(data []byte) error +``` + + + ## type [SessionDeleteParams]() @@ -50518,6 +51129,15 @@ func (r *SessionService) Archive(ctx context.Context, sessionID string, body Ses Archive Session + +### func \(\*SessionService\) [Cancel]() + +```go +func (r *SessionService) Cancel(ctx context.Context, sessionID string, params SessionCancelParams, opts ...option.RequestOption) (res *SessionCancelResponse, err error) +``` + +Cancel requests cancellation of the current turn \(202\); an idle session is a safe no\-op \(200\). + ### func \(\*SessionService\) [Delete]() diff --git a/forward/api_expansion_live_test.go b/forward/api_expansion_live_test.go new file mode 100644 index 0000000..8f568ee --- /dev/null +++ b/forward/api_expansion_live_test.go @@ -0,0 +1,93 @@ +//go:build live + +package forward_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/QoderAI/qoder-cloud-agents-sdk-go/forward" +) + +func TestForwardUsageHourlyLive(t *testing.T) { + s := newLiveSuite(t) + ctx := s.context(t) + end := time.Now().In(time.FixedZone("Asia/Shanghai", 8*60*60)).Truncate(time.Hour) + params := forward.UsageListParams{StartAt: end.Add(-24 * time.Hour).Format("2006-01-02T15:00:00"), EndAt: end.Format("2006-01-02T15:00:00"), Limit: forward.Int(2)} + identities, err := s.client.Usage.ListIdentities(ctx, params) + liveCheck(t, err) + checkUsageWindow(t, identities.RawJSON(), "identity_usage.list", params, len(identities.Data)) + for _, row := range identities.Data { + if row.Type != "identity_usage" || row.IdentityID == "" || !row.JSON.ActiveSeconds.Valid() || row.ActiveSeconds < 0 || row.Credits < 0 || row.SessionCount < 0 { + t.Fatal("invalid identity usage item") + } + params.IdentityIDs = append(params.IdentityIDs, row.IdentityID) + } + if identities.HasMore { + next, err := identities.GetNextPage() + liveCheck(t, err) + if next == nil { + t.Fatal("identity usage has_more has no usable cursor") + } + checkUsageWindow(t, next.RawJSON(), "identity_usage.list", params, len(next.Data)) + } + if len(params.IdentityIDs) > 0 { + filtered, err := s.client.Usage.ListIdentities(ctx, params) + liveCheck(t, err) + for _, row := range filtered.Data { + found := false + for _, id := range params.IdentityIDs { + found = found || row.IdentityID == id + } + if !found { + t.Fatal("identity usage ignored multi-ID filter") + } + } + } + params.IdentityIDs = nil + templates, err := s.client.Usage.ListTemplates(ctx, params) + liveCheck(t, err) + checkUsageWindow(t, templates.RawJSON(), "template_usage.list", params, len(templates.Data)) + for _, row := range templates.Data { + if row.Type != "template_usage" || row.TemplateID == "" || !row.JSON.ActiveSeconds.Valid() || row.ActiveSeconds < 0 || row.Credits < 0 || row.SessionCount < 0 || row.ActiveIdentities < 0 { + t.Fatal("invalid template usage item") + } + params.TemplateIDs = append(params.TemplateIDs, row.TemplateID) + } + if templates.HasMore { + next, err := templates.GetNextPage() + liveCheck(t, err) + if next == nil { + t.Fatal("template usage has_more has no usable cursor") + } + checkUsageWindow(t, next.RawJSON(), "template_usage.list", params, len(next.Data)) + } + if len(params.TemplateIDs) > 0 { + filtered, err := s.client.Usage.ListTemplates(ctx, params) + liveCheck(t, err) + for _, row := range filtered.Data { + found := false + for _, id := range params.TemplateIDs { + found = found || row.TemplateID == id + } + if !found { + t.Fatal("template usage ignored multi-ID filter") + } + } + } + t.Logf("usage identity_rows=%d template_rows=%d; empty pages cover the collection only", len(identities.Data), len(templates.Data)) +} + +func checkUsageWindow(t *testing.T, raw, kind string, params forward.UsageListParams, count int) { + t.Helper() + var window struct { + Type string `json:"type"` + StartAt string `json:"start_at"` + EndAt string `json:"end_at"` + } + liveCheck(t, json.Unmarshal([]byte(raw), &window)) + if window.Type != kind || window.StartAt != params.StartAt || window.EndAt != params.EndAt || count > 2 { + t.Fatal("usage response changed the type, window, or requested limit") + } +} diff --git a/forward/api_expansion_test.go b/forward/api_expansion_test.go new file mode 100644 index 0000000..7e7fcd2 --- /dev/null +++ b/forward/api_expansion_test.go @@ -0,0 +1,148 @@ +package forward_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "reflect" + "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" +) + +func TestForwardExpansionAPIContracts(t *testing.T) { + cases := []struct { + operation string + call func(forward.Client) (any, error) + }{ + {"updateVaultCredential", func(c forward.Client) (any, error) { + return c.Vaults.Credentials.Update(context.Background(), pathSegment, pathSegment, contractParams[forward.VaultCredentialUpdateParams](t, "updateVaultCredential")) + }}, + {"listIdentityUsage", func(c forward.Client) (any, error) { + return c.Usage.ListIdentities(context.Background(), contractParams[forward.UsageListParams](t, "listIdentityUsage")) + }}, + {"listTemplateUsage", func(c forward.Client) (any, error) { + return c.Usage.ListTemplates(context.Background(), contractParams[forward.UsageListParams](t, "listTemplateUsage")) + }}, + } + for _, c := range cases { + t.Run(c.operation, func(t *testing.T) { + for _, status := range []int{200, 400} { + client := contractClient(t, c.operation, status) + res, err := c.call(client) + checkError(t, status, err) + if status == 200 { + checkDecoded(t, res) + } + } + }) + } +} + +func TestForwardUsageHourlyPagination(t *testing.T) { + for _, backward := range []bool{false, true} { + t.Run(map[bool]string{false: "forward", true: "backward"}[backward], func(t *testing.T) { + calls := 0 + client := testClient(func(req *http.Request) (*http.Response, error) { + calls++ + q := req.URL.Query() + if q.Get("start_at") != "2026-09-14T09:00:00" || q.Get("end_at") != "2026-09-14T12:00:00" || !reflect.DeepEqual(q["identity_ids"], []string{"idn_one", "idn_two"}) || !reflect.DeepEqual(q["template_ids"], []string{"tmpl_one", "tmpl_two"}) { + t.Fatalf("query=%v", q) + } + if q.Has("start_time") || q.Has("end_time") || q.Has("identity_ids[]") { + t.Fatal("unexpected legacy parameters") + } + if calls == 2 { + key, next := "after_id", "last" + if backward { + key, next = "before_id", "first" + } + if q.Get(key) != next { + t.Fatalf("cursor=%v", q) + } + } + return reply(req, 200, `{"type":"identity_usage.list","start_at":"2026-09-14T09:00:00","end_at":"2026-09-14T12:00:00","first_id":"first","last_id":"last","has_more":true,"data":[{"type":"identity_usage","identity_id":"idn_one","session_count":3,"active_seconds":720.222,"credits":3.1}]}`), nil + }) + params := forward.UsageListParams{StartAt: "2026-09-14T09:00:00", EndAt: "2026-09-14T12:00:00", IdentityIDs: []string{"idn_one", "idn_two"}, TemplateIDs: []string{"tmpl_one", "tmpl_two"}} + if backward { + params.BeforeID = forward.String("initial") + } + page, err := client.Usage.ListIdentities(context.Background(), params) + if err != nil { + t.Fatal(err) + } + if page.Data[0].ActiveSeconds != 720.222 { + t.Fatal("fractional seconds lost") + } + if page.JSON.ExtraFields["start_at"].Raw() != `"2026-09-14T09:00:00"` { + t.Fatal("window metadata lost") + } + if _, err = page.GetNextPage(); err != nil { + t.Fatal(err) + } + if calls != 2 { + t.Fatal(calls) + } + }) + } +} + +func TestForwardCredentialUpdateMergePatch(t *testing.T) { + for _, clearAll := range []bool{false, true} { + t.Run(map[bool]string{false: "remove_key", true: "clear_metadata"}[clearAll], func(t *testing.T) { + params := forward.VaultCredentialUpdateParams{IdentityID: forward.String("idn_one"), Auth: forward.VaultCredentialUpdateAuthUnionParam{OfMCPOAuth: &forward.MCPOAuthUpdateParam{Type: "mcp_oauth", ExpiresAt: param.Null[string](), Refresh: forward.MCPOAuthRefreshUpdateParam{Scope: param.Null[string](), RefreshToken: forward.String("test-refresh")}}}, Metadata: map[string]any{"remove": nil}} + wantMetadata := any(map[string]any{"remove": nil}) + if clearAll { + params.Metadata = param.NullMap[map[string]any]() + wantMetadata = nil + } + client := testClient(func(req *http.Request) (*http.Response, error) { + if req.URL.Query().Get("identity_id") != "idn_one" { + t.Fatal(req.URL) + } + b, err := io.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err = json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + want := map[string]any{"auth": map[string]any{"type": "mcp_oauth", "expires_at": nil, "refresh": map[string]any{"scope": nil, "refresh_token": "test-refresh"}}, "metadata": wantMetadata} + if !reflect.DeepEqual(got, want) { + t.Fatalf("body=%s", b) + } + return reply(req, 200, string(contract(t, "updateVaultCredential").Response)), nil + }) + if _, err := client.Vaults.Credentials.Update(context.Background(), "vault_one", "cred_one", params); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestForwardCredentialUpdateDoesNotRetry(t *testing.T) { + for _, status := range []int{0, 429, 503} { + t.Run(statusName(status), func(t *testing.T) { + calls := 0 + client := testClient(func(req *http.Request) (*http.Response, error) { + calls++ + if status == 0 { + return nil, errors.New("connection failed") + } + res := reply(req, status, `{"error":{"type":"api_error","message":"rotation uncertain"}}`) + res.Header.Set("Retry-After-Ms", "1") + res.Header.Set("X-Should-Retry", "true") + return res, nil + }, option.WithMaxRetries(3)) + _, err := client.Vaults.Credentials.Update(context.Background(), "vault_one", "cred_one", forward.VaultCredentialUpdateParams{Auth: forward.VaultCredentialUpdateAuthUnionParam{OfStaticBearer: &forward.StaticBearerUpdateParam{Type: "static_bearer", Token: forward.String("test-secret")}}}, option.WithHeader("Idempotency-Key", "caller-key"), option.WithMaxRetries(3)) + if err == nil || calls != 1 { + t.Fatalf("calls=%d err=%v", calls, err) + } + }) + } +} diff --git a/forward/client.go b/forward/client.go index e835d92..c2a05bd 100644 --- a/forward/client.go +++ b/forward/client.go @@ -26,6 +26,7 @@ type Client struct { Skills SkillService Vaults VaultService MemoryStores MemoryStoreService + Usage UsageService Models ModelService } @@ -57,6 +58,7 @@ func NewClient(opts ...option.RequestOption) Client { Skills: NewSkillService(opts...), Vaults: NewVaultService(opts...), MemoryStores: NewMemoryStoreService(opts...), + Usage: NewUsageService(opts...), Models: NewModelService(opts...), } } diff --git a/forward/client_test.go b/forward/client_test.go index 5a0c872..8b2d629 100644 --- a/forward/client_test.go +++ b/forward/client_test.go @@ -34,8 +34,8 @@ func TestForwardAPIInventory(t *testing.T) { } } walk(root) - if len(actual) != 110 { - t.Fatalf("HTTP methods: %d want 110", len(actual)) + if len(actual) != 113 { + t.Fatalf("HTTP methods: %d want 113", len(actual)) } baseline := map[string]bool{} for _, c := range operations(t) { diff --git a/forward/test_helpers_test.go b/forward/test_helpers_test.go index f5586f4..19d5925 100644 --- a/forward/test_helpers_test.go +++ b/forward/test_helpers_test.go @@ -85,8 +85,8 @@ func operations(t *testing.T) []operationCase { Cases []operationCase `json:"cases"` } readJSON(t, "testdata/api-operation-cases.json", &fixture) - if fixture.Count != 110 || len(fixture.Cases) != 110 { - t.Fatal("Forward API scope must contain 110 operations") + if fixture.Count != 113 || len(fixture.Cases) != 113 { + t.Fatal("Forward API scope must contain 113 operations") } return fixture.Cases } @@ -120,6 +120,9 @@ func contract(t *testing.T, id string) contractCase { // The original fixture's converter represented identity_ids // as an empty object, though the published API accepts strings or arrays. func parameterValue(p parameterCase) any { + if p.Name == "template_ids" { + return []string{"tmpl_one", "tmpl_two"} + } if p.Name == "identity_ids" { return []string{"idn_one", "idn_two"} } diff --git a/forward/testdata/api-contracts.json b/forward/testdata/api-contracts.json index d7e7286..83c9b7d 100644 --- a/forward/testdata/api-contracts.json +++ b/forward/testdata/api-contracts.json @@ -3154,5 +3154,101 @@ "download": false, "raw": false, "empty": false + }, + { + "service": "VaultCredentialService", + "name": "Update", + "response": { + "id": "vcred_xxx", + "type": "vault_credential", + "vault_id": "vault_xxx", + "auth": { + "type": "static_bearer", + "mcp_server_url": "https://mcp.example.com" + }, + "display_name": "", + "metadata": { + "rotated_by": "console" + }, + "archived_at": null, + "created_at": "2026-07-23T10:00:00Z", + "updated_at": "2026-08-27T12:00:00Z" + }, + "operation_id": "updateVaultCredential", + "entry": "Vaults.Credentials", + "stream": false, + "download": false, + "raw": false, + "empty": false + }, + { + "service": "UsageService", + "name": "ListIdentities", + "response": { + "type": "identity_usage.list", + "start_at": "2026-09-14T09:00:00", + "end_at": "2026-09-14T12:00:00", + "has_more": false, + "first_id": "idn_abc", + "last_id": "idn_efg", + "data": [ + { + "type": "identity_usage", + "identity_id": "idn_abc", + "session_count": 3, + "active_seconds": 720.222, + "credits": 3.1 + }, + { + "type": "identity_usage", + "identity_id": "idn_efg", + "session_count": 2, + "active_seconds": 480.222, + "credits": 1.58 + } + ] + }, + "operation_id": "listIdentityUsage", + "entry": "Usage", + "stream": false, + "download": false, + "raw": false, + "empty": false + }, + { + "service": "UsageService", + "name": "ListTemplates", + "response": { + "type": "template_usage.list", + "start_at": "2026-09-14T09:00:00", + "end_at": "2026-09-14T12:00:00", + "has_more": false, + "first_id": "tmpl_123", + "last_id": "tmpl_456", + "data": [ + { + "type": "template_usage", + "template_id": "tmpl_123", + "active_identities": 2, + "session_count": 3, + "active_seconds": 900.321, + "credits": 3.45 + }, + { + "type": "template_usage", + "template_id": "tmpl_456", + "active_identities": 2, + "session_count": 2, + "active_seconds": 300.123, + "credits": 1.23 + } + ] + }, + "operation_id": "listTemplateUsage", + "entry": "Usage", + "stream": false, + "download": false, + "raw": false, + "empty": false } ] diff --git a/forward/testdata/api-operation-cases.json b/forward/testdata/api-operation-cases.json index 69cdbd7..9358fd7 100644 --- a/forward/testdata/api-operation-cases.json +++ b/forward/testdata/api-operation-cases.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "operation_count": 110, + "operation_count": 113, "cases": [ { "operation_id": "addSessionResource", @@ -4768,6 +4768,162 @@ ], "request_body": null, "source": "Forward router POST /environments/{id}/archive; verified against CN production on 2026-09-09" + }, + { + "operation_id": "updateVaultCredential", + "resource": "Vaults.Credentials", + "clients": { + "go": "Vaults.Credentials", + "typescript": "vaults.credentials" + }, + "methods": { + "go": "Update", + "typescript": "update" + }, + "http_method": "POST", + "path": "/vaults/{id}/credentials/{cred_id}", + "parameters": [ + { + "wire_name": "id", + "location": "path", + "required": true, + "value": "path_contract" + }, + { + "wire_name": "cred_id", + "location": "path", + "required": true, + "value": "path_contract" + }, + { + "wire_name": "identity_id", + "location": "query", + "required": false, + "value": "idn_contract" + } + ], + "request_body": { + "kind": "json", + "value": { + "auth": { + "type": "static_bearer", + "token": "rotated-test-token" + }, + "metadata": { + "remove": null, + "rotated_by": "sdk" + } + } + }, + "source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)" + }, + { + "operation_id": "listIdentityUsage", + "resource": "Usage", + "clients": { + "go": "Usage", + "typescript": "usage" + }, + "methods": { + "go": "ListIdentities", + "typescript": "listIdentities" + }, + "http_method": "GET", + "path": "/usage/identities", + "parameters": [ + { + "wire_name": "start_at", + "location": "query", + "required": true, + "value": "2026-09-14T09:00:00" + }, + { + "wire_name": "end_at", + "location": "query", + "required": true, + "value": "2026-09-14T12:00:00" + }, + { + "wire_name": "identity_ids", + "location": "query", + "required": false, + "value": [ + "idn_one", + "idn_two" + ] + }, + { + "wire_name": "template_ids", + "location": "query", + "required": false, + "value": [ + "tmpl_one", + "tmpl_two" + ] + }, + { + "wire_name": "limit", + "location": "query", + "required": false, + "value": 20 + } + ], + "request_body": null, + "source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)" + }, + { + "operation_id": "listTemplateUsage", + "resource": "Usage", + "clients": { + "go": "Usage", + "typescript": "usage" + }, + "methods": { + "go": "ListTemplates", + "typescript": "listTemplates" + }, + "http_method": "GET", + "path": "/usage/templates", + "parameters": [ + { + "wire_name": "start_at", + "location": "query", + "required": true, + "value": "2026-09-14T09:00:00" + }, + { + "wire_name": "end_at", + "location": "query", + "required": true, + "value": "2026-09-14T12:00:00" + }, + { + "wire_name": "identity_ids", + "location": "query", + "required": false, + "value": [ + "idn_one", + "idn_two" + ] + }, + { + "wire_name": "template_ids", + "location": "query", + "required": false, + "value": [ + "tmpl_one", + "tmpl_two" + ] + }, + { + "wire_name": "limit", + "location": "query", + "required": false, + "value": 20 + } + ], + "request_body": null, + "source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)" } ] } diff --git a/forward/usage.go b/forward/usage.go new file mode 100644 index 0000000..1b81854 --- /dev/null +++ b/forward/usage.go @@ -0,0 +1,125 @@ +package forward + +import ( + "context" + "net/http" + "net/url" + "slices" + + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/apijson" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/apiquery" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/pagination" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/param" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/respjson" +) + +type UsageService struct{ Options []option.RequestOption } + +func NewUsageService(opts ...option.RequestOption) UsageService { + return UsageService{Options: slices.Clone(opts)} +} + +// ListIdentities aggregates an hourly Asia/Shanghai window. PAT or Admin SAT required. +func (r *UsageService) ListIdentities(ctx context.Context, params UsageListParams, opts ...option.RequestOption) (res *pagination.Page[IdentityUsage], err error) { + opts = slices.Concat(r.Options, opts) + path := "usage/identities" + var raw *http.Response + opts = append([]option.RequestOption{option.WithResponseInto(&raw)}, opts...) + cfg, err := convention.NewRequestConfig(ctx, http.MethodGet, path, params, &res, opts...) + if err != nil { + return nil, err + } + if err = cfg.Execute(); err != nil { + return nil, err + } + res.SetPageConfig(cfg, raw) + return res, nil +} +func (r *UsageService) ListIdentitiesAutoPaging(ctx context.Context, params UsageListParams, opts ...option.RequestOption) *pagination.PageAutoPager[IdentityUsage] { + return pagination.NewPageAutoPager(r.ListIdentities(ctx, params, opts...)) +} + +// ListTemplates aggregates an hourly Asia/Shanghai window. PAT or Admin SAT required. +func (r *UsageService) ListTemplates(ctx context.Context, params UsageListParams, opts ...option.RequestOption) (res *pagination.Page[TemplateUsage], err error) { + opts = slices.Concat(r.Options, opts) + path := "usage/templates" + var raw *http.Response + opts = append([]option.RequestOption{option.WithResponseInto(&raw)}, opts...) + cfg, err := convention.NewRequestConfig(ctx, http.MethodGet, path, params, &res, opts...) + if err != nil { + return nil, err + } + if err = cfg.Execute(); err != nil { + return nil, err + } + res.SetPageConfig(cfg, raw) + return res, nil +} +func (r *UsageService) ListTemplatesAutoPaging(ctx context.Context, params UsageListParams, opts ...option.RequestOption) *pagination.PageAutoPager[TemplateUsage] { + return pagination.NewPageAutoPager(r.ListTemplates(ctx, params, opts...)) +} + +// UsageListParams accepts only hourly parameters. StartAt is inclusive; EndAt is exclusive, with a maximum span of 744 hours. Both use YYYY-MM-DDTHH:00:00 in Asia/Shanghai for CN and Global. +type UsageListParams struct { + StartAt string `query:"start_at" json:"-" api:"required"` + EndAt string `query:"end_at" json:"-" api:"required"` + Limit param.Opt[int64] `query:"limit,omitzero" json:"-"` + AfterID param.Opt[string] `query:"after_id,omitzero" json:"-"` + BeforeID param.Opt[string] `query:"before_id,omitzero" json:"-"` + IdentityID param.Opt[string] `query:"identity_id,omitzero" json:"-"` + // Repeated query values; an entry may also contain comma-separated IDs. + IdentityIDs []string `query:"identity_ids,omitzero" json:"-"` + TemplateID param.Opt[string] `query:"template_id,omitzero" json:"-"` + // Repeated query values; an entry may also contain comma-separated IDs. + TemplateIDs []string `query:"template_ids,omitzero" json:"-"` + paramObj +} + +func (r *UsageListParams) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } +func (r UsageListParams) URLQuery() (url.Values, error) { + return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ArrayFormat: apiquery.ArrayQueryFormatRepeat, NestedFormat: apiquery.NestedQueryFormatBrackets}) +} + +type IdentityUsage struct { + Type string `json:"type"` + IdentityID string `json:"identity_id"` + SessionCount int64 `json:"session_count"` + ActiveSeconds float64 `json:"active_seconds"` + Credits float64 `json:"credits"` + JSON struct { + Type respjson.Field + IdentityID respjson.Field + SessionCount respjson.Field + ActiveSeconds respjson.Field + Credits respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +func (r IdentityUsage) RawJSON() string { return r.JSON.raw } +func (r *IdentityUsage) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } + +type TemplateUsage struct { + Type string `json:"type"` + TemplateID string `json:"template_id"` + ActiveIdentities int64 `json:"active_identities"` + SessionCount int64 `json:"session_count"` + ActiveSeconds float64 `json:"active_seconds"` + Credits float64 `json:"credits"` + JSON struct { + Type respjson.Field + TemplateID respjson.Field + ActiveIdentities respjson.Field + SessionCount respjson.Field + ActiveSeconds respjson.Field + Credits respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +func (r TemplateUsage) RawJSON() string { return r.JSON.raw } +func (r *TemplateUsage) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } diff --git a/forward/vault_live_test.go b/forward/vault_live_test.go index ac01dcf..46fbe8d 100644 --- a/forward/vault_live_test.go +++ b/forward/vault_live_test.go @@ -4,6 +4,7 @@ package forward_test import ( "context" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/param" "strings" "testing" @@ -22,7 +23,8 @@ func TestVaultAndCredentialLifecycleLive(t *testing.T) { t.Fatal("vault did not round trip") } secret := "sdk-live-placeholder-secret" - credential, err := s.client.Vaults.Credentials.New(ctx, vault.ID, forward.VaultCredentialNewParams{Auth: map[string]any{"type": "static_bearer", "mcp_server_url": "https://example.com/" + liveName("mcp"), "token": secret}}) + mcpURL := "https://example.com/" + liveName("mcp") + credential, err := s.client.Vaults.Credentials.New(ctx, vault.ID, forward.VaultCredentialNewParams{Auth: map[string]any{"type": "static_bearer", "mcp_server_url": mcpURL, "token": secret}}) liveCheck(t, err) s.cleanup(t, "credential", func(ctx context.Context) error { return s.client.Vaults.Credentials.Delete(ctx, vault.ID, credential.ID) @@ -34,4 +36,27 @@ func TestVaultAndCredentialLifecycleLive(t *testing.T) { } _, err = s.client.Vaults.Credentials.List(ctx, vault.ID, forward.VaultCredentialListParams{}) liveCheck(t, err) + _, err = s.client.Vaults.Credentials.Update(ctx, vault.ID, credential.ID, forward.VaultCredentialUpdateParams{Metadata: map[string]any{"keep": "original", "remove": "old"}}) + liveCheck(t, err) + rotated := liveName("rotated-secret") + updated, err := s.client.Vaults.Credentials.Update(ctx, vault.ID, credential.ID, forward.VaultCredentialUpdateParams{ + Auth: forward.VaultCredentialUpdateAuthUnionParam{OfStaticBearer: &forward.StaticBearerUpdateParam{Type: "static_bearer", Token: forward.String(rotated)}}, + Metadata: map[string]any{"remove": nil, "added": "new"}, + }) + liveCheck(t, err) + saved, err := s.client.Vaults.Credentials.Get(ctx, vault.ID, credential.ID) + liveCheck(t, err) + for _, value := range []*forward.VaultCredential{updated, saved} { + _, removed := value.Metadata["remove"] + if value.ID != credential.ID || value.Auth.Type != "static_bearer" || value.Auth.MCPServerURL != mcpURL || value.Metadata["keep"] != "original" || value.Metadata["added"] != "new" || removed || strings.Contains(value.RawJSON(), secret) || strings.Contains(value.RawJSON(), rotated) { + t.Fatal("credential patch failed preservation or secret-redaction check") + } + } + cleared, err := s.client.Vaults.Credentials.Update(ctx, vault.ID, credential.ID, forward.VaultCredentialUpdateParams{Metadata: param.NullMap[map[string]any]()}) + liveCheck(t, err) + saved, err = s.client.Vaults.Credentials.Get(ctx, vault.ID, credential.ID) + liveCheck(t, err) + if len(cleared.Metadata) != 0 || len(saved.Metadata) != 0 || strings.Contains(cleared.RawJSON()+saved.RawJSON(), rotated) || strings.Contains(cleared.RawJSON()+saved.RawJSON(), secret) { + t.Fatal("null metadata did not clear metadata or disclosed the secret") + } } diff --git a/forward/vaultcredential.go b/forward/vaultcredential.go index 1f8bad7..c9c453f 100644 --- a/forward/vaultcredential.go +++ b/forward/vaultcredential.go @@ -150,6 +150,8 @@ type VaultCredential struct { DisplayName string `json:"display_name"` // Credential metadata. Metadata map[string]any `json:"metadata"` + // Archive time, null while active. + ArchivedAt time.Time `json:"archived_at" api:"nullable" format:"date-time"` // Creation time in RFC 3339 format. CreatedAt time.Time `json:"created_at" format:"date-time"` // Last update time in RFC 3339 format. @@ -161,6 +163,7 @@ type VaultCredential struct { Auth respjson.Field DisplayName respjson.Field Metadata respjson.Field + ArchivedAt respjson.Field CreatedAt respjson.Field UpdatedAt respjson.Field ExtraFields map[string]respjson.Field @@ -174,9 +177,11 @@ func (r *VaultCredential) UnmarshalJSON(data []byte) error { return apijson.Unma type VaultCredentialAuth struct { Type string `json:"type"` MCPServerURL string `json:"mcp_server_url"` + SecretName string `json:"secret_name"` JSON struct { Type respjson.Field MCPServerURL respjson.Field + SecretName respjson.Field ExtraFields map[string]respjson.Field raw string } `json:"-"` @@ -184,3 +189,107 @@ type VaultCredentialAuth struct { func (r VaultCredentialAuth) RawJSON() string { return r.JSON.raw } func (r *VaultCredentialAuth) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } + +// Update merges auth or metadata. Write-only secret rotation is never automatically retried. +func (r *VaultCredentialService) Update(ctx context.Context, id string, credID string, params VaultCredentialUpdateParams, opts ...option.RequestOption) (res *VaultCredential, err error) { + if id == "" { + return nil, fmt.Errorf("missing required id parameter") + } + if credID == "" { + return nil, fmt.Errorf("missing required credID parameter") + } + opts = slices.Concat(r.Options, opts) + opts = append(opts, option.WithMaxRetries(0)) + path := fmt.Sprintf("vaults/%s/credentials/%s", url.PathEscape(id), url.PathEscape(credID)) + err = convention.ExecuteNewRequest(ctx, http.MethodPost, path, params, &res, opts...) + return res, err +} + +type VaultCredentialUpdateParams struct { + Auth VaultCredentialUpdateAuthUnionParam `json:"auth,omitzero"` + Metadata map[string]any `json:"metadata,omitzero"` + IdentityID param.Opt[string] `query:"identity_id,omitzero" json:"-"` + paramObj +} + +func (r VaultCredentialUpdateParams) MarshalJSON() ([]byte, error) { + type shadow VaultCredentialUpdateParams + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *VaultCredentialUpdateParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} +func (r VaultCredentialUpdateParams) URLQuery() (url.Values, error) { + return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ArrayFormat: apiquery.ArrayQueryFormatRepeat, NestedFormat: apiquery.NestedQueryFormatBrackets}) +} + +type VaultCredentialUpdateAuthUnionParam struct { + OfStaticBearer *StaticBearerUpdateParam `json:",omitzero,inline"` + OfMCPOAuth *MCPOAuthUpdateParam `json:",omitzero,inline"` + OfEnvironmentVariable *EnvironmentVariableUpdateParam `json:",omitzero,inline"` + paramUnion +} + +func (r VaultCredentialUpdateAuthUnionParam) MarshalJSON() ([]byte, error) { + return param.MarshalUnion(r, r.OfStaticBearer, r.OfMCPOAuth, r.OfEnvironmentVariable) +} +func (r *VaultCredentialUpdateAuthUnionParam) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type StaticBearerUpdateParam struct { + Type string `json:"type" api:"required"` + Token param.Opt[string] `json:"token,omitzero"` + paramObj +} + +func (r StaticBearerUpdateParam) MarshalJSON() ([]byte, error) { + type shadow StaticBearerUpdateParam + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *StaticBearerUpdateParam) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type EnvironmentVariableUpdateParam struct { + Type string `json:"type" api:"required"` + SecretValue param.Opt[string] `json:"secret_value,omitzero"` + paramObj +} + +func (r EnvironmentVariableUpdateParam) MarshalJSON() ([]byte, error) { + type shadow EnvironmentVariableUpdateParam + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *EnvironmentVariableUpdateParam) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} + +type MCPOAuthUpdateParam struct { + Type string `json:"type" api:"required"` + AccessToken param.Opt[string] `json:"access_token,omitzero"` + ExpiresAt param.Opt[string] `json:"expires_at,omitzero"` + Refresh MCPOAuthRefreshUpdateParam `json:"refresh,omitzero"` + paramObj +} + +func (r MCPOAuthUpdateParam) MarshalJSON() ([]byte, error) { + type shadow MCPOAuthUpdateParam + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *MCPOAuthUpdateParam) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } + +type MCPOAuthRefreshUpdateParam struct { + RefreshToken param.Opt[string] `json:"refresh_token,omitzero"` + Scope param.Opt[string] `json:"scope,omitzero"` + TokenEndpointAuth map[string]any `json:"token_endpoint_auth,omitzero"` + paramObj +} + +func (r MCPOAuthRefreshUpdateParam) MarshalJSON() ([]byte, error) { + type shadow MCPOAuthRefreshUpdateParam + return param.MarshalObject(r, (*shadow)(&r)) +} +func (r *MCPOAuthRefreshUpdateParam) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} diff --git a/managed/api_expansion_test.go b/managed/api_expansion_test.go new file mode 100644 index 0000000..a11c866 --- /dev/null +++ b/managed/api_expansion_test.go @@ -0,0 +1,77 @@ +package managed_test + +import ( + "context" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/managed" + "net/http" + "reflect" + "testing" +) + +func TestManagedExpansionAPIContracts(t *testing.T) { + t.Run("cancel", func(t *testing.T) { + c := contractClient(t, "SessionService", "Cancel") + res, err := c.Sessions.Cancel(context.Background(), "segment /?%#", managed.SessionCancelParams{}) + if err != nil { + t.Fatal(err) + } + checkFields(t, reflect.ValueOf(res), "Session.Cancel") + }) + t.Run("list_runs", func(t *testing.T) { + c := contractClient(t, "DeploymentScopedRunService", "List") + res, err := c.Deployments.Runs.List(context.Background(), "segment /?%#", managed.DeploymentScopedRunListParams{}) + if err != nil { + t.Fatal(err) + } + checkFields(t, reflect.ValueOf(res), "Deployment.Runs.List") + }) + t.Run("get_run", func(t *testing.T) { + c := contractClient(t, "DeploymentScopedRunService", "Get") + res, err := c.Deployments.Runs.Get(context.Background(), "segment /?%#", "segment /?%#", managed.DeploymentScopedRunGetParams{}) + if err != nil { + t.Fatal(err) + } + checkFields(t, reflect.ValueOf(res), "Deployment.Runs.Get") + }) +} + +func TestManagedSessionCancelAcknowledgement(t *testing.T) { + for _, status := range []int{200, 202} { + t.Run(http.StatusText(status), func(t *testing.T) { + c := testClient(func(req *http.Request) (*http.Response, error) { + if req.Method != "POST" || req.URL.Path != "/api/v1/cloud/sessions/sess_one/cancel" { + t.Fatal(req.URL) + } + return reply(req, status, `{"id":"sess_one","type":"session","status":"canceling"}`), nil + }) + ack, err := c.Sessions.Cancel(context.Background(), "sess_one", managed.SessionCancelParams{}) + if err != nil { + t.Fatal(err) + } + if ack.ID != "sess_one" || ack.Type != "session" || ack.Status != "canceling" { + t.Fatalf("ack=%+v", ack) + } + }) + } +} + +func TestManagedDeploymentScopedRunPagination(t *testing.T) { + calls := 0 + c := testClient(func(req *http.Request) (*http.Response, error) { + calls++ + if req.URL.Path != "/api/v1/cloud/deployments/dep_one/runs" || req.URL.Query().Get("triggered_after") != "2026-06-01T00:00:00Z" || req.Header.Get("Qoder-Workspace-Id") != "workspace_one" { + t.Fatalf("request=%v %v", req.URL, req.Header) + } + if calls == 2 && req.URL.Query().Get("page") != "opaque +/=?" { + t.Fatal(req.URL) + } + if calls == 1 { + return reply(req, 200, `{"data":[],"has_more":true,"next_page":"opaque +/=?"}`), nil + } + return reply(req, 200, `{"data":[{"id":"drun_one","type":"deployment_run","deployment_id":"dep_one"}],"has_more":false,"next_page":null}`), nil + }) + pager := c.Deployments.Runs.ListAutoPaging(context.Background(), "dep_one", managed.DeploymentScopedRunListParams{TriggeredAfter: managed.String("2026-06-01T00:00:00Z"), WorkspaceID: managed.String("workspace_one")}) + if !pager.Next() || pager.Current().ID != "drun_one" || pager.Next() || pager.Err() != nil || calls != 2 { + t.Fatalf("calls=%d err=%v", calls, pager.Err()) + } +} diff --git a/managed/client_test.go b/managed/client_test.go index 28034f2..0d1ae14 100644 --- a/managed/client_test.go +++ b/managed/client_test.go @@ -14,7 +14,7 @@ import ( func TestManagedAPIInventory(t *testing.T) { fixtures := contracts(t) - if len(fixtures) != 95 { + if len(fixtures) != 98 { t.Fatalf("got %d API contracts", len(fixtures)) } expected := map[string]bool{} diff --git a/managed/deployment.go b/managed/deployment.go index f2cb514..cc2c345 100644 --- a/managed/deployment.go +++ b/managed/deployment.go @@ -28,6 +28,7 @@ import ( // the [NewDeploymentService] method instead. type DeploymentService struct { Options []option.RequestOption + Runs DeploymentScopedRunService } // NewDeploymentService generates a new service that applies the given options @@ -36,6 +37,7 @@ type DeploymentService struct { func NewDeploymentService(opts ...option.RequestOption) (r DeploymentService) { r = DeploymentService{} r.Options = opts + r.Runs = NewDeploymentScopedRunService(opts...) return } diff --git a/managed/deployment_execution_live_test.go b/managed/deployment_execution_live_test.go index a4ec900..232967a 100644 --- a/managed/deployment_execution_live_test.go +++ b/managed/deployment_execution_live_test.go @@ -35,4 +35,19 @@ func TestManagedDeploymentE2ELive(t *testing.T) { t.Fatal("run session changed") } s.waitTurn(t, run.SessionID, "", []string{marker}, false, false) + scoped := liveResult(s.client.Deployments.Runs.Get(ctx, deployment.ID, run.ID, managed.DeploymentScopedRunGetParams{})).require(t) + if scoped.ID != run.ID || scoped.DeploymentID != deployment.ID || scoped.SessionID != run.SessionID { + t.Fatal("scoped run changed its identity, deployment, or session") + } + runs := liveResult(s.client.Deployments.Runs.List(ctx, deployment.ID, managed.DeploymentScopedRunListParams{Limit: managed.Int(10)})).require(t) + found := false + for _, item := range runs.Data { + if item.DeploymentID != deployment.ID { + t.Fatal("scoped list returned another deployment") + } + found = found || item.ID == run.ID + } + if !found { + t.Fatal("scoped list did not include the created Run") + } } diff --git a/managed/deployment_live_test.go b/managed/deployment_live_test.go index 79378fd..a4a0017 100644 --- a/managed/deployment_live_test.go +++ b/managed/deployment_live_test.go @@ -33,5 +33,11 @@ func TestDeploymentLifecycleLive(t *testing.T) { liveResult(s.client.Deployments.Pause(ctx, created.ID, managed.DeploymentPauseParams{})).require(t) liveResult(s.client.Deployments.Unpause(ctx, created.ID, managed.DeploymentUnpauseParams{})).require(t) liveResult(s.client.DeploymentRuns.List(ctx, managed.DeploymentRunListParams{})).require(t) + runs := liveResult(s.client.Deployments.Runs.List(ctx, created.ID, managed.DeploymentScopedRunListParams{Limit: managed.Int(2)})).require(t) + for _, run := range runs.Data { + if run.DeploymentID != created.ID { + t.Fatal("scoped run belongs to another deployment") + } + } } diff --git a/managed/deploymentrun_live_test.go b/managed/deploymentrun_live_test.go index 4155062..b051695 100644 --- a/managed/deploymentrun_live_test.go +++ b/managed/deploymentrun_live_test.go @@ -3,6 +3,7 @@ package managed_test import ( + "context" "github.com/QoderAI/qoder-cloud-agents-sdk-go/managed" "testing" ) @@ -13,3 +14,37 @@ func TestDeploymentRunListLive(t *testing.T) { defer cancel() liveResult(s.client.DeploymentRuns.List(ctx, managed.DeploymentRunListParams{})).require(t) } + +func TestManagedDeploymentScopedRunsLive(t *testing.T) { + s := newManagedScenarioSuite(t) + s.requireExecution(t) + ctx, cancel := s.context() + defer cancel() + environment, agent := s.createEnvironment(t), s.createAgent(t) + params := liveJSON[managed.DeploymentNewParams](t, map[string]any{"name": managedUnique("scoped-runs"), "agent": agent.ID, "environment_id": environment.ID, "initial_events": []any{map[string]any{"type": "user.message", "content": []any{map[string]any{"type": "text", "text": "Reply with SDK-LIVE."}}}}}) + deployment := liveResult(s.client.Deployments.New(ctx, params)).require(t) + s.cleanup(t, "deployment", func(ctx context.Context) error { + _, err := s.client.Deployments.Archive(ctx, deployment.ID, managed.DeploymentArchiveParams{}) + return err + }) + run := liveResult(s.client.Deployments.Run(ctx, deployment.ID, managed.DeploymentRunParams{})).require(t) + if run.SessionID == "" { + t.Fatal("deployment run returned no session for cleanup") + } + s.cleanupSession(t, run.SessionID) + scoped := liveResult(s.client.Deployments.Runs.Get(ctx, deployment.ID, run.ID, managed.DeploymentScopedRunGetParams{})).require(t) + if scoped.ID != run.ID || scoped.DeploymentID != deployment.ID || scoped.SessionID != run.SessionID { + t.Fatal("scoped run changed identity, deployment, or session") + } + runs := liveResult(s.client.Deployments.Runs.List(ctx, deployment.ID, managed.DeploymentScopedRunListParams{Limit: managed.Int(10)})).require(t) + found := false + for _, item := range runs.Data { + if item.DeploymentID != deployment.ID { + t.Fatal("scoped list returned another deployment") + } + found = found || item.ID == run.ID + } + if !found { + t.Fatal("scoped list omitted the created Run") + } +} diff --git a/managed/deploymentscopedrun.go b/managed/deploymentscopedrun.go new file mode 100644 index 0000000..a2c2f55 --- /dev/null +++ b/managed/deploymentscopedrun.go @@ -0,0 +1,100 @@ +package managed + +import ( + "context" + "fmt" + "net/http" + "net/url" + "slices" + + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/apijson" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/apiquery" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/pagination" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/param" +) + +type DeploymentScopedRunService struct{ Options []option.RequestOption } + +func NewDeploymentScopedRunService(opts ...option.RequestOption) DeploymentScopedRunService { + return DeploymentScopedRunService{Options: slices.Clone(opts)} +} + +// List DeploymentScopedRun. +func (r *DeploymentScopedRunService) List(ctx context.Context, deploymentID string, params DeploymentScopedRunListParams, opts ...option.RequestOption) (res *pagination.PageCursor[ManagedAgentsDeploymentRun], err error) { + if deploymentID == "" { + return nil, fmt.Errorf("missing required deploymentID parameter") + } + if params.WorkspaceID.Valid() { + opts = append([]option.RequestOption{option.WithHeader("qoder-workspace-id", params.WorkspaceID.Value)}, opts...) + } + for _, beta := range params.Betas { + opts = append(opts, option.WithHeaderAdd("x-qoder-beta", string(beta))) + } + opts = slices.Concat(r.Options, opts) + path := fmt.Sprintf("deployments/%s/runs", url.PathEscape(deploymentID)) + var raw *http.Response + opts = append([]option.RequestOption{option.WithResponseInto(&raw)}, opts...) + cfg, err := convention.NewRequestConfig(ctx, http.MethodGet, path, params, &res, opts...) + if err != nil { + return nil, err + } + if err = cfg.Execute(); err != nil { + return nil, err + } + res.SetPageConfig(cfg, raw) + return res, nil +} +func (r *DeploymentScopedRunService) ListAutoPaging(ctx context.Context, deploymentID string, params DeploymentScopedRunListParams, opts ...option.RequestOption) *pagination.PageCursorAutoPager[ManagedAgentsDeploymentRun] { + return pagination.NewPageCursorAutoPager(r.List(ctx, deploymentID, params, opts...)) +} + +// Get DeploymentScopedRun. +func (r *DeploymentScopedRunService) Get(ctx context.Context, deploymentID string, runID string, params DeploymentScopedRunGetParams, opts ...option.RequestOption) (res *ManagedAgentsDeploymentRun, err error) { + if deploymentID == "" { + return nil, fmt.Errorf("missing required deploymentID parameter") + } + if runID == "" { + return nil, fmt.Errorf("missing required runID parameter") + } + if params.WorkspaceID.Valid() { + opts = append([]option.RequestOption{option.WithHeader("qoder-workspace-id", params.WorkspaceID.Value)}, opts...) + } + for _, beta := range params.Betas { + opts = append(opts, option.WithHeaderAdd("x-qoder-beta", string(beta))) + } + opts = slices.Concat(r.Options, opts) + path := fmt.Sprintf("deployments/%s/runs/%s", url.PathEscape(deploymentID), url.PathEscape(runID)) + err = convention.ExecuteNewRequest(ctx, http.MethodGet, path, nil, &res, opts...) + return res, err +} + +type DeploymentScopedRunListParams struct { + Limit param.Opt[int64] `query:"limit,omitzero" json:"-"` + Page param.Opt[string] `query:"page,omitzero" json:"-"` + AfterID param.Opt[string] `query:"after_id,omitzero" json:"-"` + BeforeID param.Opt[string] `query:"before_id,omitzero" json:"-"` + TriggeredAfter param.Opt[string] `query:"triggered_after,omitzero" json:"-"` + TriggeredBefore param.Opt[string] `query:"triggered_before,omitzero" json:"-"` + WorkspaceID param.Opt[string] `header:"qoder-workspace-id,omitzero" json:"-"` + Betas []QoderBeta `header:"x-qoder-beta,omitzero" json:"-"` + paramObj +} + +func (r *DeploymentScopedRunListParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} +func (r DeploymentScopedRunListParams) URLQuery() (url.Values, error) { + return apiquery.MarshalWithSettings(r, apiquery.QuerySettings{ArrayFormat: apiquery.ArrayQueryFormatRepeat, NestedFormat: apiquery.NestedQueryFormatBrackets}) +} + +type DeploymentScopedRunGetParams struct { + WorkspaceID param.Opt[string] `header:"qoder-workspace-id,omitzero" json:"-"` + Betas []QoderBeta `header:"x-qoder-beta,omitzero" json:"-"` + paramObj +} + +func (r *DeploymentScopedRunGetParams) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} diff --git a/managed/session.go b/managed/session.go index 47dfb5f..29d7604 100644 --- a/managed/session.go +++ b/managed/session.go @@ -2890,3 +2890,46 @@ type SessionArchiveParams struct { Betas []QoderBeta `header:"x-qoder-beta,omitzero" json:"-"` paramObj } + +// Cancel requests cancellation of the current turn (202); an idle session is a safe no-op (200). +func (r *SessionService) Cancel(ctx context.Context, sessionID string, params SessionCancelParams, opts ...option.RequestOption) (res *SessionCancelResponse, err error) { + if sessionID == "" { + return nil, fmt.Errorf("missing required sessionID parameter") + } + if params.WorkspaceID.Valid() { + opts = append([]option.RequestOption{option.WithHeader("qoder-workspace-id", params.WorkspaceID.Value)}, opts...) + } + for _, beta := range params.Betas { + opts = append(opts, option.WithHeaderAdd("x-qoder-beta", string(beta))) + } + opts = slices.Concat(r.Options, opts) + path := fmt.Sprintf("sessions/%s/cancel", url.PathEscape(sessionID)) + err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, nil, &res, opts...) + return res, err +} + +type SessionCancelParams struct { + WorkspaceID param.Opt[string] `header:"qoder-workspace-id,omitzero" json:"-"` + Betas []QoderBeta `header:"x-qoder-beta,omitzero" json:"-"` + paramObj +} + +func (r *SessionCancelParams) UnmarshalJSON(data []byte) error { return apijson.UnmarshalRoot(data, r) } + +type SessionCancelResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + JSON struct { + ID respjson.Field + Type respjson.Field + Status respjson.Field + ExtraFields map[string]respjson.Field + raw string + } `json:"-"` +} + +func (r SessionCancelResponse) RawJSON() string { return r.JSON.raw } +func (r *SessionCancelResponse) UnmarshalJSON(data []byte) error { + return apijson.UnmarshalRoot(data, r) +} diff --git a/managed/session_cancel_live_test.go b/managed/session_cancel_live_test.go new file mode 100644 index 0000000..19ef7f3 --- /dev/null +++ b/managed/session_cancel_live_test.go @@ -0,0 +1,50 @@ +//go:build live + +package managed_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/managed" +) + +func TestManagedSessionCancelActiveLive(t *testing.T) { + s := newManagedScenarioSuite(t) + s.requireExecution(t) + ctx, cancel := s.context() + defer cancel() + environment := s.createEnvironment(t) + agentParams := liveJSON[managed.AgentNewParams](t, map[string]any{"name": managedUnique("cancel-agent"), "model": map[string]any{"id": s.model(t)}, "tools": []any{map[string]any{"type": "agent_toolset_20260401"}}}) + agent := liveResult(s.client.Agents.New(ctx, agentParams)).require(t) + s.cleanup(t, "agent", func(ctx context.Context) error { + _, err := s.client.Agents.Archive(ctx, agent.ID, managed.AgentArchiveParams{}) + return err + }) + params := liveJSON[managed.SessionNewParams](t, map[string]any{"agent": agent.ID, "environment_id": environment.ID}) + session := liveResult(s.client.Sessions.New(ctx, params)).require(t) + s.cleanupSession(t, session.ID) + events := liveJSON[managed.SessionEventSendParams](t, map[string]any{"events": []any{map[string]any{"type": "user.message", "content": []any{map[string]any{"type": "text", "text": "Use a shell command to sleep 30 seconds, then reply with SDK-LIVE."}}}}}) + liveResult(s.client.Sessions.Events.Send(ctx, session.ID, events)).require(t) + var raw *http.Response + ack := liveResult(s.client.Sessions.Cancel(ctx, session.ID, managed.SessionCancelParams{}, option.WithResponseInto(&raw))).require(t) + // The turn can finish between send and cancel; 200 is the documented idle no-op. + if (raw.StatusCode != 200 && raw.StatusCode != 202) || ack.ID != session.ID || ack.Type != "session" || ack.Status != "canceling" { + t.Fatal("cancellation did not return the lightweight acknowledgement") + } + t.Logf("session=%s cancellation_http_status=%d", session.ID, raw.StatusCode) + for { + current := liveResult(s.client.Sessions.Get(ctx, session.ID, managed.SessionGetParams{})).require(t) + if current.Status == "idle" || current.Status == "terminated" { + break + } + select { + case <-ctx.Done(): + t.Fatal("session did not become idle after cancellation") + case <-time.After(time.Second): + } + } +} diff --git a/managed/session_live_test.go b/managed/session_live_test.go index cd4e63b..3d9d15e 100644 --- a/managed/session_live_test.go +++ b/managed/session_live_test.go @@ -4,7 +4,9 @@ package managed_test import ( "context" + "github.com/QoderAI/qoder-cloud-agents-sdk-go/convention/option" "github.com/QoderAI/qoder-cloud-agents-sdk-go/managed" + "net/http" "testing" ) @@ -33,5 +35,10 @@ func TestSessionLifecycleLive(t *testing.T) { liveResult(s.client.Sessions.Events.List(ctx, session.ID, managed.SessionEventListParams{})).require(t) liveResult(s.client.Sessions.Resources.List(ctx, session.ID, managed.SessionResourceListParams{})).require(t) liveResult(s.client.Sessions.Threads.List(ctx, session.ID, managed.SessionThreadListParams{})).require(t) + var raw *http.Response + ack := liveResult(s.client.Sessions.Cancel(ctx, session.ID, managed.SessionCancelParams{}, option.WithResponseInto(&raw))).require(t) + if raw.StatusCode != 200 || ack.ID != session.ID || ack.Type != "session" || ack.Status != "canceling" { + t.Fatal("idle cancellation did not return the lightweight acknowledgement") + } } diff --git a/managed/test_helpers_test.go b/managed/test_helpers_test.go index 417449d..5c32386 100644 --- a/managed/test_helpers_test.go +++ b/managed/test_helpers_test.go @@ -51,7 +51,7 @@ func services(v reflect.Value, into map[string]reflect.Value) { } // Contract fixtures come from official documentation, independently of the adapted services. -// The inventory also detects extra HTTP methods outside the authorized 95 endpoints. +// The inventory also detects extra HTTP methods outside the authorized 98 endpoints. func checkFields(t *testing.T, v reflect.Value, path string) { t.Helper() diff --git a/managed/testdata/api-contracts.json b/managed/testdata/api-contracts.json index 12cb015..6133edc 100644 --- a/managed/testdata/api-contracts.json +++ b/managed/testdata/api-contracts.json @@ -2724,5 +2724,77 @@ "environment_variables": {}, "vault_ids": [] } + }, + { + "service": "SessionService", + "name": "Cancel", + "response": { + "id": "sess_019ef30a4f0275678965d496ab542ef0", + "type": "session", + "status": "canceling" + }, + "method": "POST", + "path": "/sessions/{}/cancel", + "route": "/sessions/{session_id}/cancel", + "doc": "sessions__cancel.md", + "source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)" + }, + { + "service": "DeploymentScopedRunService", + "name": "List", + "response": { + "data": [ + { + "agent": { + "id": "agent_019ebb21ef8e7df6a559052c94875160", + "type": "agent", + "version": 1 + }, + "created_at": "2026-06-14T08:58:17Z", + "deployment_id": "dep_019ec55a2b687b3f94eee77dd77e4b2a", + "error": null, + "id": "drun_019ec55a68af73028afa5b87931cb2f3", + "session_id": "sess_019ec55a68b37e1e8d660691af161ab4", + "trigger_context": { + "type": "manual" + }, + "type": "deployment_run" + } + ], + "first_id": "drun_019ec55a68af73028afa5b87931cb2f3", + "has_more": false, + "last_id": "drun_019ec55a68af73028afa5b87931cb2f3", + "next_page": null + }, + "method": "GET", + "path": "/deployments/{}/runs", + "route": "/deployments/{deployment_id}/runs", + "doc": "deployments__list-runs.md", + "source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)" + }, + { + "service": "DeploymentScopedRunService", + "name": "Get", + "response": { + "agent": { + "id": "agent_019ebb21ef8e7df6a559052c94875160", + "type": "agent", + "version": 1 + }, + "created_at": "2026-06-14T08:58:17Z", + "deployment_id": "dep_019ec55a2b687b3f94eee77dd77e4b2a", + "error": null, + "id": "drun_019ec55a68af73028afa5b87931cb2f3", + "session_id": "sess_019ec55a68b37e1e8d660691af161ab4", + "trigger_context": { + "type": "manual" + }, + "type": "deployment_run" + }, + "method": "GET", + "path": "/deployments/{}/runs/{}", + "route": "/deployments/{deployment_id}/runs/{run_id}", + "doc": "deployments__get-run.md", + "source": "cloud-agent/cloud-agents-api-doc origin/master 1b3f3d023b8631395c3f65577a507050d3893d12 (2026-10-09)" } ]