claudeproxy

package
v0.0.7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 27, 2026 License: MIT Imports: 18 Imported by: 0

Documentation

Overview

Package claudeproxy relays a CI/CD pipeline's Anthropic API calls through the hub, so the pipeline can run an agent harness without ever holding the hub's Claude credential.

The shape of the bargain

A pipeline that has federated its OIDC identity (see pkg/ciauth) receives a session token and a base URL. It exports them as ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN and runs Claude Code, or any Anthropic SDK, unchanged: both send their requests to the base URL and their credential in the Authorization header, and both are satisfied by a server that answers the Messages API. Nothing in the runner knows it is not talking to Anthropic.

What it is actually talking to is a monitor. The session token is worth only what its policy says — these models, this many requests, this long — and it stops being worth anything the moment the hub closes the session. The real credential never leaves the hub process.

This is the same construction pkg/gitproxy applies to a GitHub token and pkg/kubeguard applies to a kubeconfig: take custody of the credential, give out something narrower that points at a thing that reads every request, and make the audit trail a record of decisions rather than of deliveries.

Runs, not only pipelines

Since Task 20435 the same engine relays a sandboxed run's model calls (the hub's executors.model_relay). The shape is the same; three things differ. A run relay session carries its own upstream credential — the one the run's grant holds, which the hub withholds from the sandbox — rather than spending the proxy's (Options.SessionUpstreams). It stands on the run's secret lease and grant, is extended with the lease (Registry.Extend) and ends with it (CloseForLease, CloseForGrant). And its spend is the hub's to meter and to cap: Options.Admit holds the identity paying for the run to its budgets before a call is relayed, and Options.OnSpend books what the upstream reported after. Its tokens start "cloop_relay_", not "cloop_ci_".

What it deliberately does not do

It does not inspect or rewrite prompts, and it does not cache responses. A proxy that reads the conversation is a proxy an operator must reason about as a data processor; this one moves bytes and decides whether it should. The exception is `max_tokens`, which is clamped because it is the one field that bounds spend, and the model name, which is checked because it is the one field that selects it.

Index

Constants

View Source
const (
	// DefaultMaxBodyBytes bounds one request. Conversations with large
	// pasted files are legitimately big; 24 MiB is past anything the
	// Messages API will accept anyway, so this is a memory guard rather
	// than a policy.
	DefaultMaxBodyBytes int64 = 24 << 20

	// DefaultMaxOutputTokens clamps max_tokens when a policy sets none.
	// Generous enough for an agent turn, finite enough that a runaway loop
	// is bounded per call.
	DefaultMaxOutputTokens = 32000
)

Default body and output bounds.

View Source
const (
	MinTTL      = 1 * time.Minute
	MaxTTL      = 6 * time.Hour
	MaxSessions = 4096
)

Session bounds. MaxSessions is a memory guard: each session is a few hundred bytes, so the bound exists to make an unbounded mint path impossible rather than because the memory matters.

View Source
const DefaultUpstream = "https://api.anthropic.com"

DefaultUpstream is the Anthropic API base. The proxy appends the path the client asked for, so this is the origin plus nothing.

View Source
const OAuthBeta = "oauth-2025-04-20"

OAuthBeta is the anthropic-beta value the API documents for requests authenticated with a claude.ai OAuth token.

View Source
const TokenPrefix = "cloop_ci_"

TokenPrefix identifies a CI session token.

The prefix is load-bearing, exactly as it is for pkg/apitoken: it is what lets a secret scanner recognise one of these in a log or a commit, and what lets the authentication path tell a session token apart from an Anthropic key a confused pipeline sent instead.

Variables

View Source
var (
	// ErrUnauthenticated covers every reason a presented token is not usable:
	// malformed, unknown, expired, closed. They are one error on purpose —
	// a caller probing for a valid session learns nothing from the
	// difference — while the audit trail records which it was.
	ErrUnauthenticated = errors.New("claudeproxy: session token not accepted")

	// ErrSessionNotFound is returned by management calls, not by
	// authentication.
	ErrSessionNotFound = errors.New("claudeproxy: no such session")

	// ErrNoUpstream means no Anthropic credential is configured, so there is
	// nothing to relay to.
	ErrNoUpstream = errors.New("claudeproxy: no upstream Anthropic credential is configured")
)

Errors the hub distinguishes.

View Source
var (
	// ErrSessionExists: the registry already holds a session with that id.
	ErrSessionExists = errors.New("claudeproxy: a session with that id is already live")
	// ErrSessionLapsed: the session's TTL ran out before it could be restored.
	ErrSessionLapsed = errors.New("claudeproxy: session lapsed before it could be restored")
	// ErrRecordGone is what a Store answers a checkpoint whose record is no
	// longer this process's to write: closed by another hub process — its
	// rule was withdrawn, it was revoked — or taken over by one. The
	// registry stops serving the session: the record, not this process's
	// memory, says whether a session stands.
	ErrRecordGone = errors.New("claudeproxy: the session's record is closed or held by another hub process")
	// ErrClosedHere: this registry closed the session — revoked, expired,
	// its rule withdrawn — and does not bring back a session it ended, even
	// when the write of that end to the record failed.
	ErrClosedHere = errors.New("claudeproxy: this hub process closed the session")
	// ErrTooManySessions: MaxSessions are live. It says nothing about the
	// session asked for, which may be minted or restored once one ends.
	ErrTooManySessions = errors.New("claudeproxy: the session limit is reached")
)

Errors Restore distinguishes.

View Source
var ErrNotExtendable = errors.New("claudeproxy: session cannot be extended")

ErrNotExtendable is what Extend answers for a session it cannot move: not live here, already ended, or a deadline that does not move it forward.

Functions

func IntersectModels added in v0.0.5

func IntersectModels(a, b []string) []string

IntersectModels returns model patterns admitting only models both a and b admit, sorted. See Narrow for why it may drop a model both admit.

func SessionIDOf added in v0.0.5

func SessionIDOf(token string) (string, bool)

SessionIDOf returns the id half of a session token without checking the secret half, and whether the token has the shape of one.

Types

type Denial

type Denial struct {
	Reason  DenyReason
	Message string
	Status  int
}

Denial is a refusal with a reason and a message safe to return to the pipeline. The message names what is wrong with the request, never what the policy would have accepted instead: a caller mapping out an allowlist by probing it should learn nothing it did not already know.

func AllowsPath

func AllowsPath(method, p string) *Denial

AllowsPath reports whether method and path are in the API surface, and returns a denial explaining which of the two failed.

Path and method are separated because they fail for different reasons and an operator reading the audit trail wants to know which: an unknown path is usually an SDK reaching for a feature the policy does not cover, while a wrong method on a known path is usually a client bug.

func (*Denial) Error

func (d *Denial) Error() string

type DenyReason

type DenyReason string

DenyReason is a stable machine-readable refusal code. Audit consumers match on these; the human-readable message beside them is free to change.

const (
	DenyPathNotAllowed  DenyReason = "path_not_allowed"
	DenyMethodNotAllowd DenyReason = "method_not_allowed"
	DenyModelNotAllowed DenyReason = "model_not_allowed"
	DenyBodyTooLarge    DenyReason = "body_too_large"
	DenyBodyMalformed   DenyReason = "body_malformed"
	DenyBudget          DenyReason = "budget_exhausted"
)

type Event

type Event struct {
	Kind      EventKind `json:"kind"`
	SessionID string    `json:"session_id,omitempty"`

	// Which rule admitted this pipeline, and which pipeline it was.
	RuleID     string `json:"rule_id,omitempty"`
	RuleName   string `json:"rule_name,omitempty"`
	Project    string `json:"project,omitempty"`
	Subject    string `json:"subject,omitempty"`
	Repository string `json:"repository,omitempty"`
	Ref        string `json:"ref,omitempty"`
	Workflow   string `json:"workflow,omitempty"`
	Actor      string `json:"actor,omitempty"`
	RunID      string `json:"run_id,omitempty"`

	// What a run relay session stands on (Task 20435): the lease and grant
	// its credential came from, the executor its run was dispatched to, the
	// identity its spend is charged to, and the kind of credential it spends
	// ("oauth_token", "api_key") — never the credential.
	LeaseID      string `json:"lease_id,omitempty"`
	GrantID      string `json:"grant_id,omitempty"`
	ExecutorID   string `json:"executor_id,omitempty"`
	Payer        string `json:"payer,omitempty"`
	UpstreamKind string `json:"upstream_kind,omitempty"`

	// Request shape, for allowed and denied relays.
	Method string `json:"method,omitempty"`
	Path   string `json:"path,omitempty"`
	Model  string `json:"model,omitempty"`
	Status int    `json:"status,omitempty"`

	// Reason is set on a refusal.
	Reason DenyReason `json:"reason,omitempty"`

	// Spend attributable to this event.
	InputTokens  int64 `json:"input_tokens,omitempty"`
	OutputTokens int64 `json:"output_tokens,omitempty"`

	Detail string    `json:"detail,omitempty"`
	At     time.Time `json:"at"`
}

Event is one thing the proxy decided, for the audit trail.

Events carry identifiers, model names, reasons and counts. They never carry a prompt, a completion, a session token or the upstream credential: an audit log that contains the conversation is a second copy of the data the proxy was built to avoid reading.

type EventKind

type EventKind string

EventKind names what happened.

const (
	// EventExchangeAccepted / EventExchangeRejected record the OIDC
	// federation step: a pipeline presented a token and either received a
	// session or did not. Both are emitted by the hub rather than by the
	// proxy, because that exchange happens before a session exists.
	EventExchangeAccepted EventKind = "ci.exchange.accepted"
	EventExchangeRejected EventKind = "ci.exchange.rejected"

	// EventSessionMinted / EventSessionClosed bracket a session's life.
	EventSessionMinted EventKind = "ci.session.minted"
	EventSessionClosed EventKind = "ci.session.closed"

	// EventSessionSuspended: a hub process stopping gracefully stopped
	// serving a recorded session without ending it, so the process that
	// receives the pipeline's next request can restore it (Task 20390).
	EventSessionSuspended EventKind = "ci.session.suspended"

	// EventSessionRestored: a hub process took over the record of a session
	// whose holder had stopped, held it to its rule as the rule stands now,
	// and serves it under its original id and token.
	EventSessionRestored EventKind = "ci.session.restored"

	// EventRelayAllowed is one forwarded request. It carries the token counts
	// the upstream reported, which is what makes per-pipeline spend
	// answerable.
	EventRelayAllowed EventKind = "ci.relay.allowed"

	// EventRelayDenied is one request the policy refused. The upstream never
	// saw it and the hub's credential was never attached.
	EventRelayDenied EventKind = "ci.relay.denied"

	// EventRejected is a request that never reached a session: an
	// unparseable, unknown, expired or revoked token.
	EventRejected EventKind = "ci.rejected"
)

type MessageRequest

type MessageRequest struct {
	Model     string `json:"model"`
	MaxTokens *int   `json:"max_tokens,omitempty"`
	Stream    bool   `json:"stream,omitempty"`
}

MessageRequest is the handful of fields the policy reads out of a Messages API request. Everything else is passed through untouched.

func InspectBody

func InspectBody(body []byte) (MessageRequest, error)

InspectBody decodes the policy-relevant fields of a request body.

It decodes into a struct with three fields rather than into a map, so a body carrying a 10 MB conversation is not materialised twice just to read the model name.

type MintRequest

type MintRequest struct {
	Policy Policy

	RuleID     string
	RuleName   string
	Project    string
	Subject    string
	Repository string
	Ref        string
	Workflow   string
	Actor      string
	RunID      string
	RunURL     string

	// TTL is the session lifetime, clamped to [MinTTL, MaxTTL].
	TTL time.Duration

	// Claims is the verified assertion the pipeline federated with, recorded
	// with the session for the hub that restores it (Task 20390). Opaque
	// here, never read, never retained in memory past the record's write.
	Claims json.RawMessage

	// A run relay session's binding (Task 20435); see Session.
	LeaseID    string
	GrantID    string
	ExecutorID string
	Payer      string

	// Upstream, when set, is the credential this session relays with in
	// place of the proxy's. It must validate.
	Upstream *Upstream
}

MintRequest describes the session to create.

type Minted

type Minted struct {
	Session *Session

	// Token is the only copy. It is not stored, and a caller that loses it
	// must mint again.
	Token string

	// BaseURL is what the pipeline sets ANTHROPIC_BASE_URL to.
	BaseURL string
}

Minted is the one-time result of creating a session.

type Options

type Options struct {
	// Upstream is the credential and endpoint to relay to.
	Upstream Upstream

	// PathPrefix is stripped from the incoming path before the API surface
	// is matched, e.g. "/api/ci/anthropic".
	PathPrefix string

	// Transport overrides the upstream HTTP transport. Tests point this at
	// an httptest server.
	Transport http.RoundTripper

	// Now overrides the clock.
	Now func() time.Time

	// Fallback, when set, is offered every request whose credential names a
	// session this registry has never heard of, before it is refused
	// (Task 20354). Several hub processes may serve one control plane, each
	// with its own registry, and a sandbox's request reaches whichever of
	// them the network sends it to; the hub forwards it to the process that
	// minted the session. It reports whether it answered the request.
	// Credentials naming a session this registry knows — right or wrong
	// token, live or expired — are always decided here.
	Fallback func(w http.ResponseWriter, r *http.Request, sessionID string) bool

	// Admit, when set, is asked before a Messages API request is relayed —
	// after the policy accepted it, before its request is counted — and a
	// denial it returns refuses the request as the policy's do. A run relay
	// holds the identity paying for the run to its daily budgets here (Task
	// 20435). Counting tokens and listing models spend nothing and are not
	// asked about.
	Admit func(s *Session, model string) *Denial

	// OnSpend, when set, is told what each relayed response spent, as the
	// upstream reported it, once the response has been relayed — on the
	// request's goroutine, after the session's counters include it.
	OnSpend func(s *Session, sp Spend)

	// ExplainClosed answers a token whose session this registry closed —
	// revoked, its lease ended, its run over — with a 403 naming why, rather
	// than the 401 an unknown token gets (Task 20435). Measured against
	// Claude Code, a 401 is retried 11 times over about three minutes before
	// the harness gives up, and a 403 ends it at once. Only the holder of the
	// session's token learns the reason: the token must hash to the closed
	// session's.
	ExplainClosed bool

	// UserAgent replaces the client's User-Agent upstream. Empty uses
	// "cloop-ci-proxy/1".
	UserAgent string

	// Unauthenticated is the message a request with no usable token gets.
	// Empty uses the CI relay's, which tells a pipeline to federate again.
	Unauthenticated string

	// SessionUpstreams says every session carries the credential it relays
	// with (MintRequest.Upstream) and the proxy holds none of its own (Task
	// 20435): a run relay spends the credential the run's grant holds. The
	// proxy's Upstream may then be empty, and a session without a credential
	// of its own is refused rather than relayed with something else.
	SessionUpstreams bool
}

Options configures a Proxy.

type Policy

type Policy struct {
	// Models is a glob allowlist of model IDs. Empty denies everything: the
	// caller resolves "unset" to the hub default before minting.
	Models []string `json:"models"`

	// MaxOutputTokens clamps `max_tokens` on every request. Zero uses
	// DefaultMaxOutputTokens.
	MaxOutputTokens int `json:"max_output_tokens,omitempty"`

	// MaxRequests caps the session's lifetime request count. Zero means
	// unlimited, which the minting path does not produce.
	MaxRequests int `json:"max_requests,omitempty"`

	// MaxBodyBytes bounds one request body. Zero uses DefaultMaxBodyBytes.
	MaxBodyBytes int64 `json:"max_body_bytes,omitempty"`
}

Policy is what one CI session may do.

func Narrow added in v0.0.5

func Narrow(recorded, now Policy) (Policy, error)

Narrow holds a recorded policy to the one its rule grants now: it returns a policy no wider than either. A model is permitted only when both allowlists permit it, and every cap is the lower of the two. It refuses when no model the session was minted for is permitted any more — a session that could authenticate and do nothing.

The model intersection is computed on the patterns, conservatively: a pair of patterns whose common models cannot be proved to be one of them contributes nothing, so the result can be narrower than the exact intersection but never wider.

func (Policy) AllowsModel

func (p Policy) AllowsModel(model string) bool

AllowsModel reports whether a model ID is in the allowlist.

func (Policy) BodyCap

func (p Policy) BodyCap() int64

BodyCap returns the effective request body limit.

func (Policy) DecideMessages

func (p Policy) DecideMessages(body []byte) ([]byte, MessageRequest, *Denial)

DecideMessages applies the policy to a Messages API request and returns the body to forward.

The returned body differs from the input only when max_tokens had to be clamped. Rewriting rather than refusing is deliberate: a client that asked for more output than the policy allows is not misbehaving, it is using a default, and failing the request would make every stock SDK configuration unusable behind a policy that sets any cap at all.

func (Policy) OutputCap

func (p Policy) OutputCap() int

OutputCap returns the effective max_tokens ceiling.

type Proxy

type Proxy struct {
	// contains filtered or unexported fields
}

Proxy relays requests from CI sessions to the Anthropic API.

func New

func New(reg *Registry, opts Options) (*Proxy, error)

New returns a Proxy serving sessions from reg.

func (*Proxy) Registry

func (p *Proxy) Registry() *Registry

Registry returns the registry this proxy serves.

func (*Proxy) ServeHTTP

func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler.

type Registry

type Registry struct {

	// OnEvent receives audit events. It is called on the calling goroutine,
	// so a sink that blocks blocks a request.
	OnEvent func(Event)

	// Store, if set, records every session and keeps the record in step
	// (durable.go), so another hub process can restore it. Nil keeps
	// sessions in memory only, as every registry did before Task 20390.
	Store SessionStore
	// contains filtered or unexported fields
}

Registry holds live sessions.

func NewRegistry

func NewRegistry(baseURL string) *Registry

NewRegistry returns an empty registry. baseURL is the externally reachable mount point of the proxy, handed to pipelines as ANTHROPIC_BASE_URL.

func NewRegistryWithPrefix added in v0.0.7

func NewRegistryWithPrefix(baseURL, prefix string) *Registry

NewRegistryWithPrefix returns an empty registry whose tokens start with prefix — a run relay's are not a CI pipeline's (Task 20435), and a secret scanner tells them apart by it. The prefix must start "cloop_", end in an underscore and hold nothing a token's id or secret could.

func (*Registry) Authenticate

func (r *Registry) Authenticate(token string) (*Session, error)

cannot tell an unknown session from an expired or revoked one by probing.

func (*Registry) BaseURL

func (r *Registry) BaseURL() string

BaseURL returns the proxy mount point.

func (*Registry) CheckRestore added in v0.0.5

func (r *Registry) CheckRestore(rec SessionRecord) error

CheckRestore reports why Restore would refuse rec, or nil.

func (*Registry) Checkpoint added in v0.0.5

func (r *Registry) Checkpoint() int

Checkpoint writes to the Store what every durable session has spent since its last checkpoint, and returns how many it wrote. The hub calls it on a timer — so a process that dies loses at most one interval's counts — and when it stops. A session whose counters have not moved is skipped, and one whose write fails is tried again next time.

func (*Registry) Close

func (r *Registry) Close(id, reason string) error

Close revokes a session. It is idempotent.

func (*Registry) CloseAll

func (r *Registry) CloseAll(reason string) int

CloseAll revokes every session, returning how many were live. Used on hub shutdown, and when an operator turns CI federation off: a policy change that leaves already-minted sessions relaying is a policy change that did not happen.

func (*Registry) CloseByRule

func (r *Registry) CloseByRule(ruleID, reason string) int

CloseByRule revokes every session minted under a rule. Called when a rule is disabled or deleted, for the same reason CloseAll exists.

func (*Registry) CloseForGrant added in v0.0.7

func (r *Registry) CloseForGrant(leaseID, grantID, reason string) int

CloseForGrant ends every session standing on lease leaseID whose credential came from grant grantID — the grant was revoked, or narrowed past what the session holds, and taken back from that lease. An empty leaseID ends the grant's sessions on every lease.

func (*Registry) CloseForLease added in v0.0.7

func (r *Registry) CloseForLease(leaseID, reason string) int

CloseForLease ends every session standing on lease leaseID — the lease was released, revoked or lapsed, so what it fed ends with it (Task 20435) — and returns how many were live.

func (*Registry) ClosedReason added in v0.0.7

func (r *Registry) ClosedReason(token string) (string, bool)

ClosedReason reports why the session token names ended, when this registry closed it and token is that session's: a run's sandbox presenting a session its revocation ended learns why, where a caller merely holding the id learns nothing (Task 20435).

func (*Registry) DropUnheld added in v0.0.5

func (r *Registry) DropUnheld(ids []string, held map[string]bool) int

DropUnheld stops serving each session in ids whose record is not among held — the open records this process holds, read after ids were — and returns how many it dropped. A session whose record was closed elsewhere stops at once, whether or not its counters moved since the last checkpoint.

func (*Registry) DurableIDs added in v0.0.5

func (r *Registry) DurableIDs() []string

DurableIDs returns the ids of the recorded sessions this registry serves. With DropUnheld it is how the hub reconciles the registry with the records it holds: read the ids first, then the records, so a session minted between the two is not mistaken for one whose record went elsewhere.

func (*Registry) Extend added in v0.0.7

func (r *Registry) Extend(id string, until time.Time) (time.Time, error)

Extend moves a live session's deadline to until — never past MaxTTL from now, and never backwards — and records the renewal with the Store when the Store keeps renewals (SessionRenewer). A run relay session is minted for its lease's deadline and moved with it, as the run's keepalive extends the lease (Task 20435); a session whose lease is not extended lapses with it.

func (*Registry) Known added in v0.0.5

func (r *Registry) Known(id string) bool

Authenticate resolves a presented token to a live session.

Every failure returns ErrUnauthenticated with no further detail, so a caller Known reports whether id names a session this registry holds, live or not. See Options.Fallback.

func (*Registry) LeaseSessions added in v0.0.7

func (r *Registry) LeaseSessions(leaseID string) []*Session

LeaseSessions returns the live sessions standing on lease leaseID, sorted by id.

func (*Registry) Mint

func (r *Registry) Mint(req MintRequest) (*Minted, error)

Mint creates a session and returns its one-time token.

func (*Registry) Prefix added in v0.0.7

func (r *Registry) Prefix() string

Prefix is what every token this registry mints starts with.

func (*Registry) ReapExpired

func (r *Registry) ReapExpired() int

ReapExpired sweeps lapsed sessions and returns how many were removed. Expiry is enforced at authentication regardless, so this is hygiene.

func (*Registry) Restore added in v0.0.5

func (r *Registry) Restore(req RestoreRequest) (*Session, error)

Restore brings back a session another hub process minted, under the same id and token hash, with the counters it had reached. Its end is recorded in the Store like a minted session's.

It refuses a record it could not have minted — an id or a token hash of the wrong shape, a policy permitting no model, a deadline beyond MaxTTL from its issue — and a session already past its deadline or already live here: a restored session is checked as strictly as a new one, because the record it comes from was read back from a database rather than produced here.

func (*Registry) Session

func (r *Registry) Session(id string) (*Session, bool)

Session returns a session by ID.

func (*Registry) SessionIDOf added in v0.0.7

func (r *Registry) SessionIDOf(token string) (string, bool)

SessionIDOf returns the id half of a token of the shape this registry mints, without checking the secret half, and whether it has that shape.

func (*Registry) Sessions

func (r *Registry) Sessions() []*Session

Sessions lists live sessions, newest first.

func (*Registry) SetClock

func (r *Registry) SetClock(now func() time.Time)

SetClock overrides the registry clock. For tests.

func (*Registry) Suspend added in v0.0.5

func (r *Registry) Suspend(id, reason string) bool

Suspend stops serving a session without ending it: what it spent is written to its Store, it leaves the registry, and a request already authenticated against it finishes while any later one is refused here — but its record stays open, so the hub process that receives its next request can restore it. A session the Store holds no record of is closed instead, with reason: nothing could restore it. It reports whether the session was suspended.

func (*Registry) SuspendAll added in v0.0.5

func (r *Registry) SuspendAll(reason string) []string

SuspendAll suspends every session, as a hub stopping gracefully does, and returns the ids of the durable ones it suspended, sorted. Sessions with no record are closed with reason.

func (*Registry) SuspendForLease added in v0.0.7

func (r *Registry) SuspendForLease(leaseID, reason string) []string

SuspendForLease suspends every session standing on lease leaseID — the lease was taken over by the hub process that adopted its run, which restores them — and returns the ids of the recorded ones it suspended. One with no record is closed: nothing could restore it.

type RestoreRequest added in v0.0.5

type RestoreRequest struct {
	// Record is the session as its Store recorded it, with the policy and
	// deadline the caller holds it to now — never wider than recorded.
	Record SessionRecord
	// From names the hub process the session was taken over from, for the
	// restored row.
	From string
	// Detail, when set, is added to the restored row: what the caller
	// narrowed.
	Detail string
	// Upstream is the credential a run relay session relays with, re-derived
	// by the caller from the lease it took over (Task 20435) — never read
	// from the record, which holds none. Required when the record names an
	// upstream kind, and must be of that kind.
	Upstream *Upstream
}

RestoreRequest is a session to bring back.

type Session

type Session struct {
	ID     string
	Policy Policy

	// Provenance: which rule admitted which pipeline. Recorded at mint so an
	// audit entry describes the identity that was actually accepted, not
	// whatever the rule says today.
	RuleID     string
	RuleName   string
	Project    string
	Subject    string
	Repository string
	Ref        string
	Workflow   string
	Actor      string
	RunID      string
	RunURL     string

	// What a run relay session stands on (Task 20435): the secret lease and
	// grant its upstream credential came from, the executor the run was
	// dispatched to, and the identity its spend is charged to. Empty for a
	// CI session.
	LeaseID    string
	GrantID    string
	ExecutorID string
	Payer      string

	IssuedAt time.Time
	// ExpiresAt is the deadline the session was minted or restored with.
	// Extend moves the effective one; see Deadline.
	ExpiresAt time.Time
	// contains filtered or unexported fields
}

Session is one federated pipeline's authority to relay.

Counters are atomic and read without the registry lock, so a status page listing sessions cannot block a request in flight.

func (*Session) CloseReason

func (s *Session) CloseReason() string

CloseReason returns why the session was revoked, or "".

func (*Session) Closed

func (s *Session) Closed() bool

Closed reports whether the session was revoked.

func (*Session) Deadline added in v0.0.7

func (s *Session) Deadline() time.Time

Deadline is when the session lapses: ExpiresAt, or a later deadline an extension set.

func (*Session) Durable added in v0.0.5

func (s *Session) Durable() bool

Durable reports whether a Store holds a record of the session.

func (*Session) Expired

func (s *Session) Expired(now time.Time) bool

Expired reports whether the session has lapsed.

func (*Session) Label

func (s *Session) Label() string

Label is a credential-free description for audit entries.

func (*Session) LastUsed

func (s *Session) LastUsed() time.Time

LastUsed returns the last time a request authenticated against this session, or the zero time.

func (*Session) RemainingRequests

func (s *Session) RemainingRequests() int64

RemainingRequests reports how many relayed calls the session has left, or -1 when its policy sets no cap.

func (*Session) RenewedAt added in v0.0.7

func (s *Session) RenewedAt() time.Time

RenewedAt is when an extension last moved the deadline, zero for none.

func (*Session) UpstreamKind added in v0.0.7

func (s *Session) UpstreamKind() string

UpstreamKind names the credential the session relays with — "api_key", "oauth_token", "auth_token" — or "" for one relaying with its proxy's.

func (*Session) Usage

func (s *Session) Usage() Usage

Usage snapshots the counters.

type SessionRecord added in v0.0.5

type SessionRecord struct {
	ID string
	// TokenSHA256 is the hex SHA-256 of the session token.
	TokenSHA256 string
	Policy      Policy

	RuleID     string
	RuleName   string
	Project    string
	Subject    string
	Repository string
	Ref        string
	Workflow   string
	Actor      string
	RunID      string
	RunURL     string
	// Claims is the verified assertion the session was minted for, as the
	// hub chose to record it: opaque to this package, which never reads it.
	// The hub holds a restored session to its rule against it. A run relay
	// session's is the hub's account of the run it serves (Task 20435).
	Claims json.RawMessage

	// A run relay session's binding (Task 20435). UpstreamKind names the kind
	// of credential it spends — never the credential, which the process
	// restoring it re-derives from the lease.
	LeaseID      string
	GrantID      string
	ExecutorID   string
	Payer        string
	UpstreamKind string

	IssuedAt  time.Time
	ExpiresAt time.Time
	// RenewedAt is when an extension last moved ExpiresAt, zero for none.
	RenewedAt time.Time

	// Usage and LastUsed are what the session had spent when it was last
	// checkpointed.
	Usage    Usage
	LastUsed time.Time
}

SessionRecord is what a Store keeps of a session: everything Restore needs. It has no field a token or an upstream credential could travel in.

type SessionRenewer added in v0.0.7

type SessionRenewer interface {
	RenewSession(id string, expiresAt, renewedAt time.Time) error
}

SessionRenewer is a SessionStore that records the extensions Extend makes (Task 20435), so a process restoring a renewed session bounds its deadline from the renewal rather than refusing it as one no mint could have set. An answer of ErrRecordGone ends the session here, as a checkpoint's does.

type SessionStore added in v0.0.5

type SessionStore interface {
	// SaveSession records a session this process now serves.
	SaveSession(rec SessionRecord) error
	// CheckpointSession records what a session this process serves has
	// spent so far.
	CheckpointSession(id string, u Usage, lastUsed time.Time) error
	// CloseSession records that a session this process served has ended,
	// and why, with what it spent.
	CloseSession(id, reason string, at time.Time, u Usage) error
}

SessionStore persists the record of every session and keeps it in step.

Errors are the implementation's to report: the registry cannot do anything useful with one beyond what it does — a session whose record could not be written is served all the same, and its minted row says what was lost.

type Spend added in v0.0.7

type Spend struct {
	// Model is the model the upstream answered with, else the one asked for.
	Model string
	// Status is the upstream's HTTP status.
	Status int
	// Input, Output, CacheRead and CacheWrite are the response's token
	// counts; CacheWrite1h is the part of CacheWrite written to the
	// one-hour cache, which the API prices higher than the five-minute one.
	Input, Output, CacheRead, CacheWrite, CacheWrite1h int64
}

Spend is what one relayed response spent, as the upstream reported it.

func (Spend) Tokens added in v0.0.7

func (sp Spend) Tokens() int64

Tokens is every token the response accounts for.

type Upstream

type Upstream struct {
	// BaseURL is the API origin. Empty uses DefaultUpstream.
	BaseURL string

	// APIKey is sent as x-api-key.
	APIKey string

	// AuthToken is sent as Authorization: Bearer.
	AuthToken string

	// OAuth marks AuthToken as a claude.ai OAuth token — what `claude
	// setup-token` prints and CLAUDE_CODE_OAUTH_TOKEN carries — rather than a
	// gateway's bearer. The relay then names the API's OAuth beta beside
	// whatever betas the client asked for (OAuthBeta). Measured against the
	// real API on 2026-10-16 (Task 20435), a token was accepted with and
	// without it; the API's own documentation for OAuth callers asks for it,
	// and a header the API ignores costs nothing where one it starts to
	// require would cost every relayed run.
	OAuth bool
}

Upstream is the credential the hub holds and the endpoint it relays to.

Exactly one of APIKey or AuthToken must be set. The two are different things at the API: an API key is a workspace credential presented in x-api-key, an auth token is an OAuth bearer. Guessing from the shape of the string would be a guess about billing, so the caller says which it has.

func (Upstream) Base

func (u Upstream) Base() string

Base returns the upstream origin with no trailing slash.

func (Upstream) Configured

func (u Upstream) Configured() bool

Configured reports whether there is a credential to relay with.

func (Upstream) Kind added in v0.0.7

func (u Upstream) Kind() string

Kind names the credential for logs, audit rows and the dashboard: "api_key", "oauth_token" or "auth_token" (a bearer that is not a claude.ai OAuth token, as a gateway issues), "" with none.

func (Upstream) Redacted

func (u Upstream) Redacted() string

Redacted describes the upstream without revealing the credential, for logs and for the dashboard.

func (Upstream) Validate

func (u Upstream) Validate() error

Validate checks the upstream is usable.

type Usage

type Usage struct {
	Requests     int64 `json:"requests"`
	Denied       int64 `json:"denied"`
	InputTokens  int64 `json:"input_tokens"`
	OutputTokens int64 `json:"output_tokens"`
	CacheRead    int64 `json:"cache_read_input_tokens,omitempty"`
	CacheWrite   int64 `json:"cache_creation_input_tokens,omitempty"`
	BytesUp      int64 `json:"bytes_up"`
	BytesDown    int64 `json:"bytes_down"`
}

Usage is what a session spent. Every field is cumulative for the session's lifetime.

func (Usage) TotalTokens

func (u Usage) TotalTokens() int64

TotalTokens is the sum a spend report cares about.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL