mesh/delivery merged: source.main moved to novox/mesh-catalog@6d000647de2d
mesh/check being carried to cff1005422c1 on 6d000647de2d | from f9dcbc295723
The one bundle that did everything becomes the manager bundle, which runs long and answers the seat's ten verbs and the three acting tools, and a tools bundle of its own that answers the four reading tools and writes nothing, so they answer while the manager fails to load or stops. Both import the module's one package for the layouts of the kept data and the log entries, the key form, the entries' names and the settings with their validity; the settings file and the account with its directory stay as they are. Builds: design 39 parts §1, §2, §3, §4, §5, §6, §7, §8
193 lines
6.4 KiB
Go
193 lines
6.4 KiB
Go
package main
|
|
|
|
// The only file that talks to Anthropic (novox/hq ADR 0183): the token endpoint, which this module alone
|
|
// calls — one rotation source — and the usage endpoint. Ported from the predecessor's manager, whose
|
|
// client id and error handling were each earned by an incident.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
. "claude-licence-manager/internal/licences"
|
|
)
|
|
|
|
// The public Claude Code client's id: not a secret, and a hard-won constant — a metadata URL in its place
|
|
// answers 400, which the predecessor once misdiagnosed as a dead grant.
|
|
const clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
|
|
func tokenEndpoint() string {
|
|
if v := os.Getenv("MESH_ANTHROPIC_TOKEN_ENDPOINT"); v != "" {
|
|
return v
|
|
}
|
|
return "https://platform.claude.com/v1/oauth/token"
|
|
}
|
|
|
|
func usageEndpoint() string {
|
|
if v := os.Getenv("MESH_ANTHROPIC_USAGE_ENDPOINT"); v != "" {
|
|
return v
|
|
}
|
|
return "https://api.anthropic.com/api/oauth/usage"
|
|
}
|
|
|
|
// FullGrant is a subscription's grant as this module keeps it: what the agent's credentials file calls
|
|
// `claudeAiOauth`.
|
|
type FullGrant struct {
|
|
AccessToken string `json:"accessToken"`
|
|
RefreshToken string `json:"refreshToken"`
|
|
ExpiresAt int64 `json:"expiresAt"`
|
|
RefreshTokenExpiresAt *int64 `json:"refreshTokenExpiresAt,omitempty"`
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
SubscriptionType string `json:"subscriptionType,omitempty"`
|
|
RateLimitTier string `json:"rateLimitTier,omitempty"`
|
|
}
|
|
|
|
// Refreshed is what a refresh came to: the next grant and the account the vendor answered for, or why not.
|
|
type Refreshed struct {
|
|
OK bool
|
|
Grant FullGrant
|
|
Account string // empty when the vendor named none
|
|
Status int
|
|
Reason string
|
|
}
|
|
|
|
// Vendor is the vendor as the manager reaches it; a test stubs it.
|
|
type Vendor interface {
|
|
Refresh(ctx context.Context, g FullGrant) Refreshed
|
|
Usage(ctx context.Context, accessToken string) (map[string]any, error)
|
|
}
|
|
|
|
type tokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn *int64 `json:"expires_in"`
|
|
RefreshTokenExpiresIn *int64 `json:"refresh_token_expires_in"`
|
|
Scope string `json:"scope"`
|
|
Scopes []string `json:"scopes"`
|
|
SubscriptionType string `json:"subscription_type"`
|
|
Account *struct {
|
|
UUID string `json:"uuid"`
|
|
} `json:"account"`
|
|
}
|
|
|
|
// NextGrant is the grant a refresh answered, laid over the one refreshed: a refresh token the vendor did
|
|
// not rotate is kept, so a rotating vendor and one that does not are both handled.
|
|
func NextGrant(prev FullGrant, r tokenResponse, nowMs int64) Refreshed {
|
|
if r.AccessToken == "" {
|
|
return Refreshed{Status: 200, Reason: "the vendor answered without an access token"}
|
|
}
|
|
g := prev
|
|
g.AccessToken = r.AccessToken
|
|
if r.RefreshToken != "" {
|
|
g.RefreshToken = r.RefreshToken
|
|
}
|
|
if r.ExpiresIn != nil {
|
|
g.ExpiresAt = nowMs + *r.ExpiresIn*1000
|
|
}
|
|
if r.RefreshTokenExpiresIn != nil {
|
|
v := nowMs + *r.RefreshTokenExpiresIn*1000
|
|
g.RefreshTokenExpiresAt = &v
|
|
}
|
|
if len(r.Scopes) > 0 {
|
|
g.Scopes = r.Scopes
|
|
} else if r.Scope != "" {
|
|
g.Scopes = strings.Fields(r.Scope)
|
|
}
|
|
if r.SubscriptionType != "" {
|
|
g.SubscriptionType = r.SubscriptionType
|
|
}
|
|
out := Refreshed{OK: true, Grant: g}
|
|
if r.Account != nil {
|
|
out.Account = r.Account.UUID
|
|
}
|
|
return out
|
|
}
|
|
|
|
type liveVendor struct{ client *http.Client }
|
|
|
|
// LiveVendor is the vendor over the network.
|
|
func LiveVendor() Vendor { return liveVendor{client: &http.Client{Timeout: 30 * time.Second}} }
|
|
|
|
func (v liveVendor) Refresh(ctx context.Context, g FullGrant) Refreshed {
|
|
form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {g.RefreshToken}, "client_id": {clientID}}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint(), strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return Refreshed{Reason: err.Error()}
|
|
}
|
|
req.Header.Set("content-type", "application/x-www-form-urlencoded")
|
|
resp, err := v.client.Do(req)
|
|
if err != nil {
|
|
return Refreshed{Reason: "the token endpoint did not answer: " + err.Error()}
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if resp.StatusCode/100 != 2 {
|
|
// The body, never only the status: a malformed request and a revoked grant both answer 400.
|
|
reason := string(body)
|
|
if len(reason) > 400 {
|
|
reason = reason[:400]
|
|
}
|
|
return Refreshed{Status: resp.StatusCode, Reason: reason}
|
|
}
|
|
var r tokenResponse
|
|
if err := json.Unmarshal(body, &r); err != nil {
|
|
return Refreshed{Status: resp.StatusCode, Reason: "the vendor's answer is not JSON"}
|
|
}
|
|
return NextGrant(g, r, time.Now().UnixMilli())
|
|
}
|
|
|
|
// Usage is the vendor's usage answer whole; a failed answer is an error naming its status and reason, the body, never
|
|
// only the status (design 39 bus-and-switch §24).
|
|
func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]any, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageEndpoint(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("authorization", "Bearer "+accessToken)
|
|
resp, err := v.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the usage endpoint did not answer: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if resp.StatusCode/100 != 2 {
|
|
reason := string(body)
|
|
if len(reason) > 400 {
|
|
reason = reason[:400]
|
|
}
|
|
return nil, fmt.Errorf("the usage endpoint answered %d: %s", resp.StatusCode, reason)
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
return nil, fmt.Errorf("the usage endpoint answered %d with what is not JSON", resp.StatusCode)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// FlattenUsage reads the session, weekly and sonnet windows, each with its reset time (§24).
|
|
func FlattenUsage(u map[string]any) UsageReading {
|
|
window := func(k string) (*float64, string) {
|
|
w, ok := u[k].(map[string]any)
|
|
if !ok {
|
|
return nil, ""
|
|
}
|
|
pct, ok := w["utilization"].(float64)
|
|
resets, _ := w["resets_at"].(string)
|
|
if !ok {
|
|
return nil, resets
|
|
}
|
|
return &pct, resets
|
|
}
|
|
s, sResets := window("five_hour")
|
|
w, wResets := window("seven_day")
|
|
so, soResets := window("seven_day_sonnet")
|
|
return UsageReading{SessionPct: s, SessionResetsAt: sResets, WeeklyPct: w, WeeklyResetsAt: wResets, SonnetPct: so,
|
|
SonnetResetsAt: soResets}
|
|
}
|