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
17 changes: 17 additions & 0 deletions .env.live.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,20 @@ QODER_SA_MODEL=auto
# Optional single-region factory input (QODER_SA_KEY), used only explicitly.
QODER_SA_KEY=
QODER_ACCESS_TOKEN=

# Explicit Realtime acceptance covers both regions × PAT/SA. Use existing
# pre-enabled Templates; the suite never changes their Realtime settings.
QODER_FORWARD_CN_PAT=
QODER_FORWARD_GLOBAL_PAT=
# Forward-issued bound SATs for Realtime. Platform Credential SATs are currently
# rejected by Realtime; omitting these retains the platform SA Key check above.
QODER_REALTIME_CN_SAT=
QODER_REALTIME_GLOBAL_SAT=
QODER_REALTIME_CN_PAT_TEMPLATE_ID=
QODER_REALTIME_GLOBAL_PAT_TEMPLATE_ID=
QODER_REALTIME_CN_SA_TEMPLATE_ID=
QODER_REALTIME_GLOBAL_SA_TEMPLATE_ID=
QODER_REALTIME_LIVE_ALLOW_WRITE=false
QODER_REALTIME_LIVE_ALLOW_EXECUTION=false
# Optional raw speech PCM16 LE, 16 kHz mono, at most 30s including trailing silence.
QODER_REALTIME_LIVE_PCM_FILE=
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ existing `0.1.0` release; earlier development prereleases are not listed.

- Forward admin and Identity SAT issuance and Forward SAT revocation, using explicit per-call SA Key authentication, typed issuance/revocation metadata, and no automatic retries. Existing platform credentials and PAT/SAT business authentication remain unchanged.

- Forward Realtime Beta: create Conversations with a stable idempotency key, connect over WebSocket using PAT/SAT or shared Credentials, exchange text and PCM16 audio, observe typed events and Work progress, report playback, interrupt, and confirm graceful closure.
- Realtime Conversation history with independent `before` pagination, persisted history IDs and preserved unknown event fields. Connections have separate stage timeouts, bounded receive queues, and explicit protocol failures; they do not follow redirects, reconnect, or replay input automatically.

## [0.4.0] - 2026-10-12

### Added
Expand Down
34 changes: 34 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,40 @@ When adding or changing an endpoint:

The fixtures are maintained manually. A passing fixture test proves consistency with this repository, not automatically with service routes or the other SDKs.

Realtime protocol regressions use a local WebSocket server, never an account or
model. Run `go test -race ./forward -run Realtime -count=1` when changing the
connection, shared upgrade authentication, history or event models. The
`forward/testdata/realtime-events.json` fixture records the `voice.realtime.v1`
contract and source snapshot. Real CN/Global Realtime checks additionally need a
pre-enabled Template and an allowed Identity; the standalone example can perform
create/ready/ping/close/history, with optional text or raw PCM input. It retains
Conversation IDs because no public deletion API exists. Mock tests do not prove
that either production region has deployed the protocol.

`make test-live-realtime` separately verifies CN/Global × PAT/SA with the two
`QODER_REALTIME_LIVE_ALLOW_WRITE=true` and
`QODER_REALTIME_LIVE_ALLOW_EXECUTION=true` gates. Configure
`QODER_FORWARD_{CN,GLOBAL}_PAT`, `QODER_REALTIME_{CN,GLOBAL}_SAT` and the four
`QODER_REALTIME_{CN,GLOBAL}_{PAT,SA}_TEMPLATE_ID` values in `.env.live` or the
process environment. Realtime currently requires Forward-issued bound SATs;
the platform SAT exchanged by `ServiceAccountCredential` does not create the
Forward binding. Without an explicit regional SAT, the suite falls back to the
corresponding `QODER_SA_{CN,GLOBAL}_KEY` to retain this platform compatibility
check, which currently receives 403 from Realtime. Per region, one SA credential
source must exist. All required inputs are checked before any request.
The suite creates unique test Identities, creates/connects Conversations,
checks ready and pong, sends a short text without fixed model wording, decodes
output PCM, confirms graceful closure, and exercises filtered history at page
size 1. It finishes the associated Forward Sessions and deletes only its test
Identities; it retains/logs Conversation IDs and never edits the supplied
Templates. This entry consumes model usage. Optionally set
`QODER_REALTIME_LIVE_PCM_FILE` to a raw 16 kHz mono PCM16 little-endian speech
fixture (at most 30 seconds, including trailing silence). The file is checked
before requests; the additional audio round requires a user transcript and an
assistant transcript/audio response. Without this setting, the suite tests text
input and does not prove microphone ASR. The standalone example also supports
`-pcm`.

## Compatibility conventions

The SDK intentionally keeps Qoder-branded `X-Qoder-*` metadata headers and resumable session-event streams. Preserve those extensions unless the change explicitly revises the public contract. Breaking public API changes require a minor-version release while the SDK remains pre-1.0 and must include migration notes.
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ test: test-unit test-contract
test-unit:
go test ./...
go test -tags live -run 'Cleanup.*Offline$$' ./forward ./managed
go test -tags live -run '^TestSAConfiguration$$' ./integration
go test -tags live -run '^Test(SAConfiguration|RealtimeConfiguration)$$' ./integration

test-contract:
go test -v \
Expand Down Expand Up @@ -131,5 +131,9 @@ test-live-sa:
test-live-sa-execution:
LIVE_ENV_FILE="$(abspath $(LIVE_ENV_FILE))" go test -tags live -count=1 -v -run TestServiceAccountExecutionLive -timeout=15m ./integration

.PHONY: test-live-realtime
test-live-realtime:
LIVE_ENV_FILE="$(abspath $(LIVE_ENV_FILE))" go test -tags live -count=1 -v -run '^TestRealtimeLive$$' -timeout=25m ./integration

test-live-sa-tokens:
LIVE_ENV_FILE="$(abspath $(LIVE_ENV_FILE))" go test -tags live -count=1 -v -run '^TestForwardServiceAccountTokensLive$$' -timeout=10m ./integration
132 changes: 131 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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, Usage |
| Forward | `forward` | Identity, Template, Session, Schedule, Batch, Channel, Environment, File, Skill, Vault, MemoryStore, Model, Usage, Realtime |
| Managed | `managed` | Agent, Session, Deployment, Dream, Environment, File, Skill, Vault, MemoryStore, Model |

- [Installation](#installation)
Expand All @@ -19,6 +19,7 @@ The SDK ships two clients that share authentication, transport, retries, error h
- [Conversations](#conversations)
- [System prompts and tools](#system-prompts-and-tools)
- [Streaming](#streaming)
- [Realtime Beta](#realtime-beta)
- [Request fields](#request-fields)
- [Response objects](#response-objects)
- [Error handling](#error-handling)
Expand Down Expand Up @@ -855,6 +856,135 @@ func readUnknownField(session *forward.Session) string {

## Examples

### Realtime Beta

`client.Realtime` connects to the public Forward gateway. Realtime must already
be enabled on the Template; this SDK does not change its Realtime configuration.
Creating a Conversation requires a stable `Idempotency-Key` (1–256 UTF-8 bytes)
for that operation. It stays in a header during retries, rather than the JSON body.

```go
conversation, err := client.Realtime.Conversations.New(ctx,
forward.RealtimeConversationNewParams{
IdentityID: identityID,
TemplateID: templateID,
IdempotencyKey: forward.String(operationKey),
})
if err != nil { return err }
connection, err := client.Realtime.Connect(ctx, conversation.ID,
forward.RealtimeConnectParams{})
if err != nil { return err }
defer connection.Close()
// Connect returns after voice.ready; that event and earlier voice.state events
// remain in the receive queue. ctx spans the connection lifetime.
if err := connection.SendText(ctx, "Hello"); err != nil { return err }
event, err := connection.Recv(ctx)
if err != nil { return err }
var transcript forward.RealtimeTranscriptPayload
if event.Type == "transcript.final" {
if err := event.DecodePayload(&transcript); err != nil { return err }
}
// Stop capture/playback before graceful close. Ordinary Close only releases
// transport resources; it does not confirm that history was saved.
closed, err := connection.CloseGracefully(ctx)
if err != nil { return err }
fmt.Println(closed.Outcome)
```

REST and each new WebSocket connection use the same PAT, direct SAT, or shared
`Credential`. A credential provider is called on each new connection; an
established socket cannot update its Authorization header. The current Realtime
service requires an SA Token bound through Forward's Token issuance APIs.
The platform SAT obtained by `ServiceAccountCredential` alone does not create
this binding and is currently rejected by Realtime. Use
`option.WithAuthMode("sa")` and `option.WithAuthToken(boundSAT)` for a directly
supplied bound SAT, or a custom Credential that supplies one. SDK transport
support does not grant additional Token permissions. Credentials stay in headers,
and upgrade redirects are rejected. Native `*http.Client` transports (including
TLS and proxy configuration), headers and middleware apply to the upgrade;
arbitrary custom HTTP doers are unsupported. The client's HTTP `Timeout` and
`option.WithRequestTimeout` do not limit a Realtime connection's lifetime.

`RealtimeConnectParams` defaults to 30s handshake, 60s ready, 30s send, 10s
graceful close, 256 queued events / 4 MiB, and 1 MiB per received message.
Exceeding receive limits terminates the connection with an explicit error.
`Recv(ctx)` preserves order, including buffered events before a terminal error.
Known payload types cover transcripts, audio, state, errors, close and Work
events; `Payload`, `DecodePayload` and `RawJSON()` preserve unknown extensions.
The timestamp stays text, including nanosecond precision. `voice.replaced`
ends the old connection; a nonterminal `error` or `work.failed` remains an event.

`AppendAudio` takes raw **16 kHz mono PCM16 little-endian** bytes, not WAV, and
sends Base64 in JSON text frames. Output is **24 kHz mono PCM16**;
`RealtimeEvent.Audio()` decodes `audio.delta`. The final client JSON message
must fit within 256 KiB; text is limited to 16 KiB after trimming whitespace.
Applications own sampling, playback and its lifecycle: `audio.done` means
generation finished, while `PlaybackStarted`, `PlaybackEnded` and
`PlaybackCancelled` report what the speaker actually did. Copy `work_id` and
`announcement_id` from the corresponding audio event into the receipt.
`Interrupt` does not stop an application's speaker by itself.

The service limits a connection to 60 minutes. The SDK does not automatically
reconnect or replay text/audio; applications may reconnect to the same
Conversation. Closing a connection neither cancels background Work nor deletes
the Conversation. Graceful closure succeeds only after a matching request ID
and successful persistence outcome; timeouts and persistence failures stay errors.

History has its own `conversation / events / page` envelope and `before` cursor,
and iterates towards older records. Historical `id` values are independent of
the WebSocket `event_id` and sequence. Filters are `message`, `work` or
`message,work`, and page size is 1–100. `HasNextPage` / `GetNextPage(ctx)` expose
manual paging; `HistoryAutoPaging` also crosses empty pages and detects cursor cycles.

```go
history := client.Realtime.Conversations.HistoryAutoPaging(ctx, conversation.ID,
forward.RealtimeConversationHistoryParams{
Types: forward.String("message,work"), Limit: forward.Int(50),
})
for history.Next() {
event := history.Current()
fmt.Println(event.ID, event.Type, event.OccurredAt)
}
if err := history.Err(); err != nil { return err }
```

The standalone [Realtime example](examples/forward/realtime/main.go) uses
process environment variables and a pre-enabled Template/Identity. It defaults
to ping/close/history without sending model input. Optional `-text` and `-pcm`
use model quota; choose either text or raw speech PCM (at most 30 seconds,
including trailing silence), sent in 20 ms chunks. The demo does not play audio and
reports received output as cancelled. New Conversations remain stored because
the API exposes no Conversation deletion endpoint.

```sh
# PAT defaults; SA uses the same QODER_AUTH_MODE/SA_KEY/ACCESS_TOKEN convention.
QODER_SA_REGION=cn go run ./examples/forward/realtime \
-identity "$QODER_IDENTITY_ID" -template "$QODER_TEMPLATE_ID" \
-idempotency-key "$QODER_IDEMPOTENCY_KEY"
# Supply a Forward-issued bound SAT through QODER_ACCESS_TOKEN; do not set QODER_SA_KEY.
QODER_AUTH_MODE=sa QODER_SA_REGION=global go run ./examples/forward/realtime \
-conversation "$QODER_CONVERSATION_ID" -text "Hello"
go run ./examples/forward/realtime -conversation "$QODER_CONVERSATION_ID" -history-only
```

Maintainers can run `make test-live-realtime` for an explicit CN/Global × PAT/SA
acceptance suite. Configure the four pre-enabled
`QODER_REALTIME_{CN,GLOBAL}_{PAT,SA}_TEMPLATE_ID` values and matching
`QODER_FORWARD_{CN,GLOBAL}_PAT` / `QODER_REALTIME_{CN,GLOBAL}_SAT` credentials, then
set both `QODER_REALTIME_LIVE_ALLOW_WRITE=true` and
`QODER_REALTIME_LIVE_ALLOW_EXECUTION=true`. The suite validates all inputs
before creating test Identities, verifies text/output PCM and persisted history,
and cleans up its Sessions/Identities. It retains Conversation IDs and leaves
the supplied Templates intact. The SA cases use the supplied Forward-issued
bound SATs. Without them, they use `QODER_SA_{CN,GLOBAL}_KEY` through
`ServiceAccountCredential` to retain platform Token compatibility checks; the
current service rejects such unbound platform SATs. No Token issuance is
performed implicitly. Optionally set `QODER_REALTIME_LIVE_PCM_FILE` to
a raw 16 kHz mono PCM16 little-endian speech fixture of at most 30 seconds,
including trailing silence. It adds an audio round requiring a user transcript
and an assistant transcript/audio response. Without it, acceptance covers text
input; the standalone example also supports `-pcm`.

`examples/forward/` and `examples/managed/` hold 18 business programs, one `main.go` per scenario, each runnable directly. They read `.env.live` from the working directory — run them from the repository root, or point `-env` elsewhere. Copy [.env.live.example](.env.live.example) and configure PAT or SA authentication. Examples default to CN; `-region cn|global` overrides the configured Qoder hostname.

```sh
Expand Down
46 changes: 46 additions & 0 deletions convention/websocket.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package convention

import (
"fmt"
"net/http"
)

// WebSocketHandshake prepares a single upgrade using the same headers,
// authentication and middleware as HTTP requests. RequestTimeout and the HTTP
// client's Timeout do not limit the lifetime of the upgraded connection.
// Custom HTTP doers cannot guarantee WebSocket upgrades; use a native http.Client.
func (cfg *RequestConfig) WebSocketHandshake() (*http.Client, *http.Request, error) {
if cfg.CustomHTTPDoer != nil {
return nil, nil, fmt.Errorf("qca: Realtime requires an *http.Client, not a custom HTTP doer")
}
base := cfg.BaseURL
if base == nil {
base = cfg.DefaultBaseURL
}
if base == nil || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" || (base.Scheme != "http" && base.Scheme != "https") {
return nil, nil, fmt.Errorf("qca: Realtime base URL must be an HTTP(S) URL without user info, query or fragment")
}
request := cfg.Request.Clone(cfg.Request.Context())
var err error
request.URL, err = base.Parse(request.URL.String())
if err != nil {
return nil, nil, err
}
client := *cfg.HTTPClient
client.Timeout = 0
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
transport := client.Transport
if transport == nil {
transport = http.DefaultTransport
}
handler := middlewareNext(transport.RoundTrip)
for i := len(cfg.Middlewares) - 1; i >= 0; i-- {
handler = applyMiddleware(cfg.Middlewares[i], handler)
}
client.Transport = websocketRoundTrip(handler)
return &client, request, nil
}

type websocketRoundTrip middlewareNext

func (f websocketRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
Loading
Loading