clients/go: initial Go SDK for clawdforge
Idiomatic Go client wrapping the FastAPI surface in server.py — Healthz, Run, UploadFile/UploadReader, and admin token CRUD. stdlib net/http only, context-first signatures, typed errors (ErrAuth sentinel, RunFailure for /run 502s, APIError for other 4xx/5xx, TransportError for network/EOF). RunResult.Result is captured as json.RawMessage and materialized via .AsJSON(out) / .AsText() because claude returns either parsed JSON or plain text depending on prompt. UploadFile streams via io.Pipe + multipart without buffering the file in memory. Module: gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go Includes cmd/cf-cli demo binary and httptest-based test suite (13 tests).
This commit is contained in:
parent
062d405a9e
commit
3c62613c30
7 changed files with 1274 additions and 0 deletions
263
clients/go/README.md
Normal file
263
clients/go/README.md
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
# clawdforge Go SDK
|
||||||
|
|
||||||
|
Idiomatic Go client for the LAN-only `clawdforge` HTTP service.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
This module is hosted on Sulkta's internal Gitea, **not GitHub**. To `go get`
|
||||||
|
it you have two reasonable options.
|
||||||
|
|
||||||
|
### Option 1 — replace directive (vendored / sibling checkout)
|
||||||
|
|
||||||
|
In your consumer's `go.mod`:
|
||||||
|
|
||||||
|
```
|
||||||
|
require gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go v0.0.0
|
||||||
|
replace gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go => ../clawdforge/clients/go
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2 — `GOPRIVATE` + git insteadOf
|
||||||
|
|
||||||
|
```
|
||||||
|
export GOPRIVATE=gitea.sulkta.com/*
|
||||||
|
git config --global url."http://192.168.0.5:3001/".insteadOf "https://gitea.sulkta.com/"
|
||||||
|
go get gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Either way, the module path is `gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go`
|
||||||
|
and the recommended import alias is `cf`.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cf "gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
client := cf.New("http://192.168.0.5:8800", os.Getenv("CLAWDFORGE_TOKEN"))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
h, err := client.Healthz(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("claude:", h.ClaudeVersion)
|
||||||
|
|
||||||
|
res, err := client.Run(ctx, cf.RunRequest{
|
||||||
|
Prompt: `Reply with JSON: {"hello": "world"}`,
|
||||||
|
Model: "sonnet",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var data map[string]string
|
||||||
|
if err := res.AsJSON(&data); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println(data["hello"]) // "world"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API surface
|
||||||
|
|
||||||
|
The SDK mirrors the FastAPI surface in `clawdforge/server.py` 1:1.
|
||||||
|
|
||||||
|
### `New(baseURL, token string) *Client`
|
||||||
|
|
||||||
|
Constructs a Client with a default `*http.Client` (no wall-clock timeout —
|
||||||
|
control timing via context). Trailing slash on `baseURL` is trimmed.
|
||||||
|
|
||||||
|
### `NewWithClient(baseURL, token string, hc *http.Client) *Client`
|
||||||
|
|
||||||
|
Same as `New` but lets you pass a tuned `*http.Client` (custom transport,
|
||||||
|
proxy, TLS config, connection pool, etc.).
|
||||||
|
|
||||||
|
### `(*Client).Healthz(ctx) (*Healthz, error)`
|
||||||
|
|
||||||
|
`GET /healthz` — liveness + `claude --version` smoke. Returns
|
||||||
|
`Healthz{OK, ClaudePresent, ClaudeVersion}`.
|
||||||
|
|
||||||
|
```go
|
||||||
|
h, err := client.Healthz(ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `(*Client).Run(ctx, RunRequest) (*RunResult, error)`
|
||||||
|
|
||||||
|
`POST /run`. Sends a prompt, returns whatever `claude -p --output-format json`
|
||||||
|
produced. The `Result` field is `json.RawMessage` because claude may return
|
||||||
|
parsed JSON or plain text.
|
||||||
|
|
||||||
|
```go
|
||||||
|
res, err := client.Run(ctx, cf.RunRequest{
|
||||||
|
Prompt: "Sterilize: 'about 2 cups cooked white rice'",
|
||||||
|
Model: "sonnet",
|
||||||
|
System: "Reply ONLY with JSON.",
|
||||||
|
Files: []string{"ff_abc123"},
|
||||||
|
TimeoutSecs: 30,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `(*RunResult).AsJSON(out any) error`
|
||||||
|
|
||||||
|
Unmarshal `Result` into a typed value:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var ing struct {
|
||||||
|
Qty float64 `json:"qty"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Food string `json:"food"`
|
||||||
|
}
|
||||||
|
if err := res.AsJSON(&ing); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `(*RunResult).AsText() (string, error)`
|
||||||
|
|
||||||
|
Materialize `Result` as a string. Works for both quoted-string results
|
||||||
|
(`"hello"`) and structured JSON (returns the raw JSON encoding).
|
||||||
|
|
||||||
|
### `(*Client).UploadFile(ctx, path string, ttlSecs int) (*FileToken, error)`
|
||||||
|
|
||||||
|
`POST /files` — multipart upload, streamed via `io.Pipe` so large files don't
|
||||||
|
buffer in memory. `ttlSecs == 0` uses the server default (3600); valid range
|
||||||
|
is 60..86400. Returns `FileToken{FileToken, TTLSecs, Size}`. Pass
|
||||||
|
`ft.FileToken` in `RunRequest.Files`.
|
||||||
|
|
||||||
|
```go
|
||||||
|
ft, err := client.UploadFile(ctx, "/srv/recipes/recipe.png", 3600)
|
||||||
|
res, err := client.Run(ctx, cf.RunRequest{
|
||||||
|
Prompt: "Extract recipe data",
|
||||||
|
Files: []string{ft.FileToken},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### `(*Client).UploadReader(ctx, filename string, r io.Reader, ttlSecs int) (*FileToken, error)`
|
||||||
|
|
||||||
|
Same as `UploadFile` but takes any `io.Reader` and a filename — useful for
|
||||||
|
in-memory blobs or piped data.
|
||||||
|
|
||||||
|
### Admin (require admin bootstrap token)
|
||||||
|
|
||||||
|
```go
|
||||||
|
admin := cf.New("http://192.168.0.5:8800", os.Getenv("CLAWDFORGE_ADMIN_TOKEN"))
|
||||||
|
|
||||||
|
tok, err := admin.CreateToken(ctx, cf.CreateTokenRequest{
|
||||||
|
Name: "petalparse",
|
||||||
|
IPCidrs: []string{"172.24.0.0/16"},
|
||||||
|
})
|
||||||
|
// tok.Token is the plaintext — store it now, you can't see it again.
|
||||||
|
|
||||||
|
list, err := admin.ListTokens(ctx)
|
||||||
|
|
||||||
|
err = admin.RevokeToken(ctx, "petalparse")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
Three typed error families. Use `errors.Is` / `errors.As`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
res, err := client.Run(ctx, cf.RunRequest{Prompt: "x"})
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, cf.ErrAuth):
|
||||||
|
// 401/403 — bad bearer or IP not allowlisted
|
||||||
|
case errors.Is(err, context.DeadlineExceeded),
|
||||||
|
errors.Is(err, context.Canceled):
|
||||||
|
// caller's context died
|
||||||
|
|
||||||
|
default:
|
||||||
|
var rf *cf.RunFailure
|
||||||
|
var apiErr *cf.APIError
|
||||||
|
var transportErr *cf.TransportError
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &rf):
|
||||||
|
// 502 — clawdforge took the request but `claude` failed
|
||||||
|
// (timeout, non-zero exit). rf.StopReason in {"timeout","error"}.
|
||||||
|
log.Printf("claude failed after %dms: %s", rf.DurationMS, rf.Err)
|
||||||
|
case errors.As(err, &apiErr):
|
||||||
|
// generic 4xx/5xx — apiErr.StatusCode + apiErr.Body
|
||||||
|
case errors.As(err, &transportErr):
|
||||||
|
// network/transport — DNS, connect refused, TLS, EOF, ...
|
||||||
|
default:
|
||||||
|
log.Printf("unexpected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Error | When | Detect with |
|
||||||
|
|---|---|---|
|
||||||
|
| `cf.ErrAuth` | HTTP 401, 403 | `errors.Is(err, cf.ErrAuth)` |
|
||||||
|
| `*cf.RunFailure` | HTTP 502 from `/run` | `errors.As(err, &rf)` |
|
||||||
|
| `*cf.APIError` | other HTTP >= 400 | `errors.As(err, &apiErr)` |
|
||||||
|
| `*cf.TransportError` | DNS / connect / TLS / EOF / context | `errors.As(err, &te)` |
|
||||||
|
|
||||||
|
## Context discipline
|
||||||
|
|
||||||
|
Every public method takes `context.Context` as the first argument. The SDK's
|
||||||
|
underlying `*http.Client` does **not** set a wall-clock timeout — long
|
||||||
|
`claude` runs (curate jobs, big recipe corpora) can legitimately take
|
||||||
|
multiple minutes. Set timeouts on the caller side:
|
||||||
|
|
||||||
|
```go
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSecs+30)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
```
|
||||||
|
|
||||||
|
The `+30s margin` over `timeout_secs` mirrors the Python `Forge` client so
|
||||||
|
the HTTP call doesn't bail while clawdforge is still working.
|
||||||
|
|
||||||
|
## CLI demo
|
||||||
|
|
||||||
|
`cmd/cf-cli` is a small demo binary:
|
||||||
|
|
||||||
|
```
|
||||||
|
go install gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go/cmd/cf-cli@latest
|
||||||
|
|
||||||
|
export CLAWDFORGE_URL=http://192.168.0.5:8800
|
||||||
|
export CLAWDFORGE_TOKEN=cf_...
|
||||||
|
|
||||||
|
cf-cli health
|
||||||
|
cf-cli run 'Reply with JSON: {"hello":"world"}' sonnet 30
|
||||||
|
cf-cli upload /path/to/file.png 3600
|
||||||
|
cf-cli tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```
|
||||||
|
cd clients/go
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
All tests use `httptest.NewServer` — no network required.
|
||||||
|
|
||||||
|
## Ambiguities resolved (mirrored from `server.py`)
|
||||||
|
|
||||||
|
- `RunRequest.timeout_secs` is `omitempty` — server defaults to its
|
||||||
|
`default_timeout_secs` config when absent. Server validates `5 <= n <= 600`.
|
||||||
|
- `result` field is captured as `json.RawMessage` because the server
|
||||||
|
returns either parsed JSON or a plain string depending on what
|
||||||
|
`claude -p` produced.
|
||||||
|
- `502` is the only "logical failure" status the SDK special-cases; any
|
||||||
|
other 4xx/5xx becomes a generic `*APIError`.
|
||||||
|
- `Healthz` does not require a bearer token (the server only enforces the
|
||||||
|
global IP allowlist on it). The SDK still sends the token if present —
|
||||||
|
the server ignores it.
|
||||||
|
- File uploads stream via `io.Pipe` + `mime/multipart` — neither the file
|
||||||
|
body nor the multipart envelope is buffered in memory.
|
||||||
320
clients/go/client.go
Normal file
320
clients/go/client.go
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
// Package clawdforge is the Go SDK for the LAN-only clawdforge HTTP service —
|
||||||
|
// a thin REST wrapper around `claude -p` subprocess invocations.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// client := clawdforge.New("http://192.168.0.5:8800", os.Getenv("CLAWDFORGE_TOKEN"))
|
||||||
|
// ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
// defer cancel()
|
||||||
|
// res, err := client.Run(ctx, clawdforge.RunRequest{Prompt: "hi"})
|
||||||
|
//
|
||||||
|
// All public methods take a context.Context as the first argument.
|
||||||
|
// Cancellation and per-request timeouts come from the caller's context;
|
||||||
|
// the underlying *http.Client is left without a wall-clock timeout so
|
||||||
|
// long claude runs aren't cut off prematurely.
|
||||||
|
package clawdforge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is an immutable handle to a clawdforge instance. Construct with
|
||||||
|
// New or NewWithClient. Methods are safe for concurrent use.
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
Token string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Client configured against baseURL with the given bearer
|
||||||
|
// token and a default *http.Client (no wall-clock timeout — control
|
||||||
|
// timeouts via context.Context on each call).
|
||||||
|
//
|
||||||
|
// baseURL should NOT include a trailing slash; if one is present it is
|
||||||
|
// trimmed.
|
||||||
|
func New(baseURL, token string) *Client {
|
||||||
|
return NewWithClient(baseURL, token, &http.Client{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithClient is like New but takes a caller-supplied *http.Client so
|
||||||
|
// transport, proxy, TLS, and connection-pool settings can be tuned.
|
||||||
|
func NewWithClient(baseURL, token string, hc *http.Client) *Client {
|
||||||
|
if hc == nil {
|
||||||
|
hc = &http.Client{}
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
Token: token,
|
||||||
|
HTTPClient: hc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- public methods --------------------------------------------------
|
||||||
|
|
||||||
|
// Healthz issues GET /healthz. Does not require a bearer token, but the
|
||||||
|
// caller's IP must satisfy the global allowlist on the server.
|
||||||
|
func (c *Client) Healthz(ctx context.Context) (*Healthz, error) {
|
||||||
|
req, err := c.newRequest(ctx, http.MethodGet, "/healthz", nil, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out Healthz
|
||||||
|
if err := c.do(req, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run issues POST /run.
|
||||||
|
//
|
||||||
|
// On HTTP 200 it returns a *RunResult. On HTTP 502 (clawdforge accepted
|
||||||
|
// the request but claude failed) it returns a *RunFailure error — use
|
||||||
|
// errors.As to extract it. Auth failures (401/403) return ErrAuth.
|
||||||
|
// Other 4xx/5xx return *APIError.
|
||||||
|
func (c *Client) Run(ctx context.Context, body RunRequest) (*RunResult, error) {
|
||||||
|
if body.Prompt == "" {
|
||||||
|
return nil, errors.New("clawdforge: Run: prompt is required")
|
||||||
|
}
|
||||||
|
buf, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("clawdforge: marshal RunRequest: %w", err)
|
||||||
|
}
|
||||||
|
req, err := c.newRequest(ctx, http.MethodPost, "/run", bytes.NewReader(buf), "application/json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out RunResult
|
||||||
|
if err := c.do(req, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadFile streams a file from disk to POST /files and returns the
|
||||||
|
// resulting *FileToken. The token can be passed in RunRequest.Files for
|
||||||
|
// subsequent /run calls.
|
||||||
|
//
|
||||||
|
// ttlSecs is clamped server-side to [60, 86400]; pass 0 to use the
|
||||||
|
// server default of 3600.
|
||||||
|
func (c *Client) UploadFile(ctx context.Context, path string, ttlSecs int) (*FileToken, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("clawdforge: open %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return c.uploadReader(ctx, filepath.Base(path), f, ttlSecs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadReader is like UploadFile but takes an io.Reader and a filename.
|
||||||
|
// Useful for in-memory uploads or piping from another source.
|
||||||
|
func (c *Client) UploadReader(ctx context.Context, filename string, r io.Reader, ttlSecs int) (*FileToken, error) {
|
||||||
|
return c.uploadReader(ctx, filename, r, ttlSecs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateToken mints a new per-app token. The plaintext token is in the
|
||||||
|
// returned AppToken.Token and will not be retrievable again. Requires
|
||||||
|
// the admin bootstrap token.
|
||||||
|
func (c *Client) CreateToken(ctx context.Context, body CreateTokenRequest) (*AppToken, error) {
|
||||||
|
if body.Name == "" {
|
||||||
|
return nil, errors.New("clawdforge: CreateToken: name is required")
|
||||||
|
}
|
||||||
|
buf, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("clawdforge: marshal CreateTokenRequest: %w", err)
|
||||||
|
}
|
||||||
|
req, err := c.newRequest(ctx, http.MethodPost, "/admin/tokens", bytes.NewReader(buf), "application/json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out AppToken
|
||||||
|
if err := c.do(req, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTokens returns the configured app tokens (no plaintexts). Requires
|
||||||
|
// the admin bootstrap token.
|
||||||
|
func (c *Client) ListTokens(ctx context.Context) ([]AppToken, error) {
|
||||||
|
req, err := c.newRequest(ctx, http.MethodGet, "/admin/tokens", nil, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out TokenList
|
||||||
|
if err := c.do(req, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out.Tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeToken deletes the token with the given name. Requires the admin
|
||||||
|
// bootstrap token.
|
||||||
|
func (c *Client) RevokeToken(ctx context.Context, name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return errors.New("clawdforge: RevokeToken: name is required")
|
||||||
|
}
|
||||||
|
req, err := c.newRequest(ctx, http.MethodDelete, "/admin/tokens/"+url.PathEscape(name), nil, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.do(req, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- internals -------------------------------------------------------
|
||||||
|
|
||||||
|
func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Request, error) {
|
||||||
|
u := c.BaseURL + path
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, u, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &TransportError{Op: "build-request", Err: err}
|
||||||
|
}
|
||||||
|
if c.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||||
|
}
|
||||||
|
if contentType != "" {
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// do executes req, decodes a JSON response into out (if non-nil), and
|
||||||
|
// translates any non-2xx status into the appropriate typed error.
|
||||||
|
func (c *Client) do(req *http.Request, out any) error {
|
||||||
|
resp, err := c.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// context cancellation / deadline exceeded show up here too;
|
||||||
|
// preserve them via Unwrap so errors.Is works.
|
||||||
|
return &TransportError{Op: req.Method + " " + req.URL.Path, Err: err}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Cap the body read at 8 MiB to keep error paths bounded; success
|
||||||
|
// payloads from /run can be larger so we don't cap on the happy path.
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
|
return c.translateError(req, resp.StatusCode, bodyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out == nil {
|
||||||
|
// Drain so the connection can be reused.
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||||
|
return &TransportError{Op: "decode " + req.URL.Path, Err: err}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) translateError(req *http.Request, status int, body []byte) error {
|
||||||
|
switch status {
|
||||||
|
case http.StatusUnauthorized, http.StatusForbidden:
|
||||||
|
return fmt.Errorf("%w: HTTP %d: %s", ErrAuth, status, summarizeBody(body))
|
||||||
|
case http.StatusBadGateway:
|
||||||
|
// /run failure shape: {"ok":false, "error":"...", "stderr":"...", "duration_ms":..., "stop_reason":"..."}
|
||||||
|
// Only treat it as RunFailure if the body matches that shape.
|
||||||
|
if req.URL.Path == "/run" {
|
||||||
|
var rf RunFailure
|
||||||
|
if err := json.Unmarshal(body, &rf); err == nil && rf.Err != "" {
|
||||||
|
rf.StatusCode = status
|
||||||
|
return &rf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: status,
|
||||||
|
Body: string(body),
|
||||||
|
Message: summarizeBody(body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// summarizeBody pulls a short message out of a JSON error body. Looks for
|
||||||
|
// "error" or "detail" keys; falls back to the raw body trimmed.
|
||||||
|
func summarizeBody(body []byte) string {
|
||||||
|
if len(body) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(body, &m); err == nil {
|
||||||
|
for _, k := range []string{"error", "detail", "message"} {
|
||||||
|
if v, ok := m[k]; ok {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(string(body))
|
||||||
|
if len(s) > 500 {
|
||||||
|
s = s[:500] + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- multipart upload ------------------------------------------------
|
||||||
|
|
||||||
|
// uploadReader streams to /files via multipart/form-data without buffering
|
||||||
|
// the whole file in memory. It uses an io.Pipe + goroutine pattern so the
|
||||||
|
// HTTP request body is consumed as fast as the upstream can read it.
|
||||||
|
func (c *Client) uploadReader(ctx context.Context, filename string, r io.Reader, ttlSecs int) (*FileToken, error) {
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
mw := multipart.NewWriter(pw)
|
||||||
|
|
||||||
|
// Goroutine writes the multipart payload into the pipe; the http
|
||||||
|
// client reads from the other end and ships it upstream.
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
defer pw.Close()
|
||||||
|
defer mw.Close()
|
||||||
|
|
||||||
|
if ttlSecs > 0 {
|
||||||
|
if err := mw.WriteField("ttl_secs", strconv.Itoa(ttlSecs)); err != nil {
|
||||||
|
errCh <- err
|
||||||
|
_ = pw.CloseWithError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fw, err := mw.CreateFormFile("file", filename)
|
||||||
|
if err != nil {
|
||||||
|
errCh <- err
|
||||||
|
_ = pw.CloseWithError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(fw, r); err != nil {
|
||||||
|
errCh <- err
|
||||||
|
_ = pw.CloseWithError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errCh <- nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
req, err := c.newRequest(ctx, http.MethodPost, "/files", pr, mw.FormDataContentType())
|
||||||
|
if err != nil {
|
||||||
|
_ = pr.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out FileToken
|
||||||
|
if err := c.do(req, &out); err != nil {
|
||||||
|
// Drain the producer so the goroutine doesn't leak.
|
||||||
|
_ = pr.CloseWithError(err)
|
||||||
|
<-errCh
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if perr := <-errCh; perr != nil {
|
||||||
|
return nil, &TransportError{Op: "multipart-write", Err: perr}
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
369
clients/go/client_test.go
Normal file
369
clients/go/client_test.go
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
package clawdforge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// helper: spin up an httptest.Server with the supplied handler and return
|
||||||
|
// (client, teardown).
|
||||||
|
func newTestClient(t *testing.T, h http.HandlerFunc) (*Client, func()) {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(h)
|
||||||
|
c := New(srv.URL, "cf_test_token")
|
||||||
|
return c, srv.Close
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthz(t *testing.T) {
|
||||||
|
c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/healthz" {
|
||||||
|
t.Errorf("path = %q, want /healthz", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
t.Errorf("method = %q, want GET", r.Method)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"claude_present":true,"claude_version":"1.2.3"}`))
|
||||||
|
})
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
h, err := c.Healthz(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Healthz: %v", err)
|
||||||
|
}
|
||||||
|
if !h.OK || !h.ClaudePresent || h.ClaudeVersion != "1.2.3" {
|
||||||
|
t.Errorf("got %+v", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSuccessJSON(t *testing.T) {
|
||||||
|
c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/run" {
|
||||||
|
t.Errorf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer cf_test_token" {
|
||||||
|
t.Errorf("Authorization = %q", got)
|
||||||
|
}
|
||||||
|
var body RunRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if body.Prompt != "say hi" || body.Model != "sonnet" {
|
||||||
|
t.Errorf("body = %+v", body)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"result":{"hello":"world"},"duration_ms":42,"stop_reason":"end_turn"}`))
|
||||||
|
})
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
res, err := c.Run(context.Background(), RunRequest{Prompt: "say hi", Model: "sonnet"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if !res.OK || res.DurationMS != 42 || res.StopReason != "end_turn" {
|
||||||
|
t.Errorf("got %+v", res)
|
||||||
|
}
|
||||||
|
var data map[string]string
|
||||||
|
if err := res.AsJSON(&data); err != nil {
|
||||||
|
t.Fatalf("AsJSON: %v", err)
|
||||||
|
}
|
||||||
|
if data["hello"] != "world" {
|
||||||
|
t.Errorf("AsJSON: got %v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunSuccessText(t *testing.T) {
|
||||||
|
c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true,"result":"plain text reply","duration_ms":10,"stop_reason":"end_turn"}`))
|
||||||
|
})
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
res, err := c.Run(context.Background(), RunRequest{Prompt: "hi"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
s, err := res.AsText()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AsText: %v", err)
|
||||||
|
}
|
||||||
|
if s != "plain text reply" {
|
||||||
|
t.Errorf("AsText = %q", s)
|
||||||
|
}
|
||||||
|
// AsJSON on a string result should still work — string is valid JSON
|
||||||
|
var got string
|
||||||
|
if err := res.AsJSON(&got); err != nil || got != "plain text reply" {
|
||||||
|
t.Errorf("AsJSON-on-string: got=%q err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunFailure502(t *testing.T) {
|
||||||
|
c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_, _ = w.Write([]byte(`{"ok":false,"error":"timeout after 30s","stderr":"...","duration_ms":30000,"stop_reason":"timeout"}`))
|
||||||
|
})
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
_, err := c.Run(context.Background(), RunRequest{Prompt: "loop forever"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
var rf *RunFailure
|
||||||
|
if !errors.As(err, &rf) {
|
||||||
|
t.Fatalf("err is not *RunFailure: %T %v", err, err)
|
||||||
|
}
|
||||||
|
if rf.StopReason != "timeout" || rf.DurationMS != 30000 {
|
||||||
|
t.Errorf("got %+v", rf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunAuthFailure(t *testing.T) {
|
||||||
|
c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"detail":"missing bearer"}`))
|
||||||
|
})
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
_, err := c.Run(context.Background(), RunRequest{Prompt: "x"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrAuth) {
|
||||||
|
t.Errorf("err is not ErrAuth: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunGenericAPIError(t *testing.T) {
|
||||||
|
c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"unknown file token: ff_xyz"}`))
|
||||||
|
})
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
_, err := c.Run(context.Background(), RunRequest{Prompt: "x", Files: []string{"ff_xyz"}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
var apiErr *APIError
|
||||||
|
if !errors.As(err, &apiErr) {
|
||||||
|
t.Fatalf("err is not *APIError: %T %v", err, err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != 404 || !strings.Contains(apiErr.Message, "ff_xyz") {
|
||||||
|
t.Errorf("got %+v", apiErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunEmptyPromptRejected(t *testing.T) {
|
||||||
|
c := New("http://nowhere.invalid", "tok")
|
||||||
|
_, err := c.Run(context.Background(), RunRequest{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty prompt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadFile(t *testing.T) {
|
||||||
|
c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/files" {
|
||||||
|
t.Errorf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
ct := r.Header.Get("Content-Type")
|
||||||
|
if !strings.HasPrefix(ct, "multipart/form-data") {
|
||||||
|
t.Fatalf("Content-Type = %q", ct)
|
||||||
|
}
|
||||||
|
mr, err := r.MultipartReader()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MultipartReader: %v", err)
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
gotTTL string
|
||||||
|
gotFile string
|
||||||
|
gotData []byte
|
||||||
|
)
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NextPart: %v", err)
|
||||||
|
}
|
||||||
|
switch part.FormName() {
|
||||||
|
case "ttl_secs":
|
||||||
|
b, _ := io.ReadAll(part)
|
||||||
|
gotTTL = string(b)
|
||||||
|
case "file":
|
||||||
|
gotFile = part.FileName()
|
||||||
|
gotData, _ = io.ReadAll(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gotTTL != "7200" {
|
||||||
|
t.Errorf("ttl_secs = %q", gotTTL)
|
||||||
|
}
|
||||||
|
if gotFile != "recipe.txt" {
|
||||||
|
t.Errorf("filename = %q", gotFile)
|
||||||
|
}
|
||||||
|
if string(gotData) != "hello world" {
|
||||||
|
t.Errorf("data = %q", string(gotData))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"file_token":"ff_abc","ttl_secs":7200,"size":11}`))
|
||||||
|
})
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
ft, err := c.UploadReader(context.Background(), "recipe.txt", strings.NewReader("hello world"), 7200)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadReader: %v", err)
|
||||||
|
}
|
||||||
|
if ft.FileToken != "ff_abc" || ft.TTLSecs != 7200 || ft.Size != 11 {
|
||||||
|
t.Errorf("got %+v", ft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAndListAndRevokeToken(t *testing.T) {
|
||||||
|
created := false
|
||||||
|
listed := false
|
||||||
|
revoked := false
|
||||||
|
|
||||||
|
c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/admin/tokens":
|
||||||
|
var body CreateTokenRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
if body.Name != "petalparse" {
|
||||||
|
t.Errorf("name = %q", body.Name)
|
||||||
|
}
|
||||||
|
created = true
|
||||||
|
_, _ = w.Write([]byte(`{"name":"petalparse","token":"cf_freshplaintext","ip_cidrs":["172.24.0.0/16"]}`))
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/admin/tokens":
|
||||||
|
listed = true
|
||||||
|
_, _ = w.Write([]byte(`{"tokens":[{"name":"petalparse","ip_cidrs":["172.24.0.0/16"],"created_at":1700000000}]}`))
|
||||||
|
case r.Method == http.MethodDelete && r.URL.Path == "/admin/tokens/petalparse":
|
||||||
|
revoked = true
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
tok, err := c.CreateToken(context.Background(), CreateTokenRequest{
|
||||||
|
Name: "petalparse",
|
||||||
|
IPCidrs: []string{"172.24.0.0/16"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateToken: %v", err)
|
||||||
|
}
|
||||||
|
if tok.Token != "cf_freshplaintext" {
|
||||||
|
t.Errorf("plaintext not returned: %+v", tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := c.ListTokens(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListTokens: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].Name != "petalparse" {
|
||||||
|
t.Errorf("list = %+v", list)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.RevokeToken(context.Background(), "petalparse"); err != nil {
|
||||||
|
t.Fatalf("RevokeToken: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !created || !listed || !revoked {
|
||||||
|
t.Errorf("not all endpoints hit: c=%v l=%v r=%v", created, listed, revoked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContextCancellation(t *testing.T) {
|
||||||
|
// Block server-side handler until the request context dies OR a hard
|
||||||
|
// safety timer fires (so a misbehaving client can't hang the test).
|
||||||
|
handlerDone := make(chan struct{})
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
close(handlerDone)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
c := New(srv.URL, "tok")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := c.Run(ctx, RunRequest{Prompt: "x"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
var te *TransportError
|
||||||
|
if !errors.As(err, &te) {
|
||||||
|
t.Fatalf("err is not *TransportError: %T %v", err, err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Errorf("err does not wrap DeadlineExceeded: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force the abandoned connection closed so the handler's r.Context()
|
||||||
|
// fires immediately rather than waiting for the safety timer.
|
||||||
|
srv.CloseClientConnections()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-handlerDone:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Error("server handler did not exit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseURLTrailingSlashTrimmed(t *testing.T) {
|
||||||
|
c := New("http://example.com:8800/", "tok")
|
||||||
|
if c.BaseURL != "http://example.com:8800" {
|
||||||
|
t.Errorf("BaseURL = %q", c.BaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWithClientNilFallback(t *testing.T) {
|
||||||
|
c := NewWithClient("http://x", "tok", nil)
|
||||||
|
if c.HTTPClient == nil {
|
||||||
|
t.Fatal("HTTPClient should be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity check: multipart payload is constructed correctly even when ttlSecs=0.
|
||||||
|
func TestUploadReaderDefaultTTL(t *testing.T) {
|
||||||
|
c, done := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mr, _ := r.MultipartReader()
|
||||||
|
sawTTL := false
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if part.FormName() == "ttl_secs" {
|
||||||
|
sawTTL = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sawTTL {
|
||||||
|
t.Error("ttl_secs should be omitted when 0")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"file_token":"ff_x","ttl_secs":3600,"size":3}`))
|
||||||
|
})
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
if _, err := c.UploadReader(context.Background(), "a.bin", strings.NewReader("abc"), 0); err != nil {
|
||||||
|
t.Fatalf("UploadReader: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference unused symbol so multipart import in test file is exercised.
|
||||||
|
var _ = multipart.NewWriter
|
||||||
162
clients/go/cmd/cf-cli/main.go
Normal file
162
clients/go/cmd/cf-cli/main.go
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
// cf-cli is a tiny demo client for clawdforge.
|
||||||
|
//
|
||||||
|
// cf-cli health
|
||||||
|
// cf-cli run "Reply with JSON: {\"hello\":\"world\"}"
|
||||||
|
// cf-cli upload /path/to/file.png
|
||||||
|
//
|
||||||
|
// Configuration via env:
|
||||||
|
//
|
||||||
|
// CLAWDFORGE_URL base URL, default http://192.168.0.5:8800
|
||||||
|
// CLAWDFORGE_TOKEN bearer token (required for run/upload)
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cf "gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := envOr("CLAWDFORGE_URL", "http://192.168.0.5:8800")
|
||||||
|
token := os.Getenv("CLAWDFORGE_TOKEN")
|
||||||
|
client := cf.New(baseURL, token)
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := os.Args[1]
|
||||||
|
switch cmd {
|
||||||
|
case "health", "healthz":
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
h, err := client.Healthz(ctx)
|
||||||
|
fail(err)
|
||||||
|
printJSON(h)
|
||||||
|
|
||||||
|
case "run":
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: cf-cli run <prompt> [model] [timeout_secs]")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
prompt := os.Args[2]
|
||||||
|
model := ""
|
||||||
|
timeoutSecs := 60
|
||||||
|
if len(os.Args) >= 4 {
|
||||||
|
model = os.Args[3]
|
||||||
|
}
|
||||||
|
if len(os.Args) >= 5 {
|
||||||
|
n, err := strconv.Atoi(os.Args[4])
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "timeout_secs must be int")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
timeoutSecs = n
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSecs+30)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
res, err := client.Run(ctx, cf.RunRequest{
|
||||||
|
Prompt: prompt,
|
||||||
|
Model: model,
|
||||||
|
TimeoutSecs: timeoutSecs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
var rf *cf.RunFailure
|
||||||
|
if errors.As(err, &rf) {
|
||||||
|
fmt.Fprintf(os.Stderr, "run failed (stop_reason=%s): %s\n", rf.StopReason, rf.Err)
|
||||||
|
if rf.Stderr != "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "--- stderr ---")
|
||||||
|
fmt.Fprintln(os.Stderr, rf.Stderr)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
printJSON(res)
|
||||||
|
|
||||||
|
case "upload":
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: cf-cli upload <path> [ttl_secs]")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
path := os.Args[2]
|
||||||
|
ttl := 0
|
||||||
|
if len(os.Args) >= 4 {
|
||||||
|
n, err := strconv.Atoi(os.Args[3])
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "ttl_secs must be int")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
ttl = n
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
ft, err := client.UploadFile(ctx, path, ttl)
|
||||||
|
fail(err)
|
||||||
|
printJSON(ft)
|
||||||
|
|
||||||
|
case "tokens":
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
toks, err := client.ListTokens(ctx)
|
||||||
|
fail(err)
|
||||||
|
printJSON(toks)
|
||||||
|
|
||||||
|
default:
|
||||||
|
usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage() {
|
||||||
|
fmt.Fprintln(os.Stderr, `cf-cli — clawdforge demo client
|
||||||
|
|
||||||
|
usage:
|
||||||
|
cf-cli health
|
||||||
|
cf-cli run <prompt> [model] [timeout_secs]
|
||||||
|
cf-cli upload <path> [ttl_secs]
|
||||||
|
cf-cli tokens
|
||||||
|
|
||||||
|
env:
|
||||||
|
CLAWDFORGE_URL (default http://192.168.0.5:8800)
|
||||||
|
CLAWDFORGE_TOKEN (required)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(err error) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
if errors.Is(err, cf.ErrAuth) {
|
||||||
|
fmt.Fprintln(os.Stderr, "(auth — check CLAWDFORGE_TOKEN)")
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printJSON(v any) {
|
||||||
|
b, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "marshal:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println(string(b))
|
||||||
|
}
|
||||||
62
clients/go/errors.go
Normal file
62
clients/go/errors.go
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
package clawdforge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrAuth is the sentinel returned for 401/403 responses from clawdforge.
|
||||||
|
// Use errors.Is(err, ErrAuth) to detect auth failures.
|
||||||
|
var ErrAuth = errors.New("clawdforge: authentication failed")
|
||||||
|
|
||||||
|
// APIError carries a non-2xx response from clawdforge — status code plus
|
||||||
|
// the response body (truncated to a sane size). It is returned for any
|
||||||
|
// HTTP status >= 400 that isn't an auth failure.
|
||||||
|
type APIError struct {
|
||||||
|
StatusCode int
|
||||||
|
// Body is the raw response body (truncated). Useful for debugging.
|
||||||
|
Body string
|
||||||
|
// Message is a short human-readable summary derived from the body
|
||||||
|
// when the body is JSON of the form {"error": "..."} or {"detail": "..."}.
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
if e.Message != "" {
|
||||||
|
return fmt.Sprintf("clawdforge: HTTP %d: %s", e.StatusCode, e.Message)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("clawdforge: HTTP %d: %s", e.StatusCode, e.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransportError wraps a low-level network/transport failure (DNS, connect
|
||||||
|
// refused, TLS, EOF, context cancellation, etc.). The wrapped error is
|
||||||
|
// available via errors.Unwrap.
|
||||||
|
type TransportError struct {
|
||||||
|
Op string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *TransportError) Error() string {
|
||||||
|
return fmt.Sprintf("clawdforge: transport %s: %v", e.Op, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *TransportError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
// RunFailure represents a 502 response from /run — clawdforge accepted the
|
||||||
|
// request but `claude -p` failed (timeout, non-zero exit, etc.). The body
|
||||||
|
// fields mirror the server's failure shape.
|
||||||
|
//
|
||||||
|
// RunFailure satisfies APIError-like semantics by also embedding the status
|
||||||
|
// code via the underlying APIError, but it's a distinct type so callers can
|
||||||
|
// branch on errors.As(err, &cf.RunFailure{}).
|
||||||
|
type RunFailure struct {
|
||||||
|
StatusCode int
|
||||||
|
Err string `json:"error"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
DurationMS int `json:"duration_ms"`
|
||||||
|
StopReason string `json:"stop_reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RunFailure) Error() string {
|
||||||
|
return fmt.Sprintf("clawdforge: run failed (stop_reason=%s): %s", e.StopReason, e.Err)
|
||||||
|
}
|
||||||
3
clients/go/go.mod
Normal file
3
clients/go/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module gitea.sulkta.com/Sulkta-Coop/clawdforge/clients/go
|
||||||
|
|
||||||
|
go 1.22
|
||||||
95
clients/go/models.go
Normal file
95
clients/go/models.go
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
package clawdforge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Healthz is the parsed response from GET /healthz.
|
||||||
|
type Healthz struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
ClaudePresent bool `json:"claude_present"`
|
||||||
|
ClaudeVersion string `json:"claude_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunRequest is the body for POST /run. Matches server.py RunRequest exactly.
|
||||||
|
//
|
||||||
|
// Only Prompt is required. Model, System, Files, TimeoutSecs are zero-value
|
||||||
|
// optional — empty strings/slices and zero ints are omitted from the wire.
|
||||||
|
type RunRequest struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
System string `json:"system,omitempty"`
|
||||||
|
Files []string `json:"files,omitempty"`
|
||||||
|
TimeoutSecs int `json:"timeout_secs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunResult is the parsed response from POST /run on success (HTTP 200).
|
||||||
|
//
|
||||||
|
// Result is captured raw because the upstream may return either a parsed
|
||||||
|
// JSON value (object/array/etc) or a plain string. Use AsJSON or AsText
|
||||||
|
// to materialize it.
|
||||||
|
type RunResult struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
DurationMS int `json:"duration_ms"`
|
||||||
|
StopReason string `json:"stop_reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AsJSON unmarshals the Result field into out. Use this when your prompt
|
||||||
|
// asked claude for JSON and you have a target type.
|
||||||
|
//
|
||||||
|
// var data map[string]string
|
||||||
|
// if err := res.AsJSON(&data); err != nil { ... }
|
||||||
|
func (r *RunResult) AsJSON(out any) error {
|
||||||
|
if len(r.Result) == 0 {
|
||||||
|
return fmt.Errorf("clawdforge: empty result")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(r.Result, out); err != nil {
|
||||||
|
return fmt.Errorf("clawdforge: result is not JSON: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AsText returns the Result as a string. Works for both JSON-string results
|
||||||
|
// (e.g. claude returned "hello") and structured JSON (returns the raw JSON
|
||||||
|
// representation as a string in that case).
|
||||||
|
func (r *RunResult) AsText() (string, error) {
|
||||||
|
if len(r.Result) == 0 {
|
||||||
|
return "", fmt.Errorf("clawdforge: empty result")
|
||||||
|
}
|
||||||
|
// First try to decode as a plain JSON string
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(r.Result, &s); err == nil {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
// Otherwise return the raw JSON encoding
|
||||||
|
return string(r.Result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileToken is the parsed response from POST /files.
|
||||||
|
type FileToken struct {
|
||||||
|
FileToken string `json:"file_token"`
|
||||||
|
TTLSecs int `json:"ttl_secs"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppToken is one entry from GET /admin/tokens, also returned (with the
|
||||||
|
// plaintext Token populated) by POST /admin/tokens.
|
||||||
|
type AppToken struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Token string `json:"token,omitempty"` // only set on create
|
||||||
|
IPCidrs []string `json:"ip_cidrs,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenList is the response from GET /admin/tokens.
|
||||||
|
type TokenList struct {
|
||||||
|
Tokens []AppToken `json:"tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTokenRequest is the body for POST /admin/tokens.
|
||||||
|
type CreateTokenRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
IPCidrs []string `json:"ip_cidrs,omitempty"`
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue