620 lines
20 KiB
Python
620 lines
20 KiB
Python
"""Contracts: billing / subscription / usage envelopes, delegation controls, handoff, message
|
|
reactions, pet generation and ``project.facts`` (handlers in ``tui_gateway/methods_session.py``).
|
|
|
|
Billing routes are FAIL-OPEN: a logged-out / unreachable portal answers an ``ok`` result whose
|
|
``error`` carries the typed code (``_serialize_billing_error``) — never a JSON-RPC error — so the
|
|
client always resolves and branches on the envelope. ``BillingEnvelope`` is that shared shape.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pydantic import Field
|
|
|
|
from .base import JsonValue, Params, Result, WireEnum
|
|
from .common import MessageReaction, OpenModel, ProfileParams, SessionParams
|
|
from .registry import method
|
|
|
|
# ── billing envelope ──────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class BillingEnvelope(Result):
|
|
"""``tui_gateway/billing_view.py::_serialize_billing_error`` on failure (``ok`` false + typed
|
|
``error``); success routes add their own fields. ``payload`` is the raw portal body (error
|
|
extras such as ``remainingUsd``, or the NAS success body on pending-change routes)."""
|
|
|
|
ok: bool
|
|
error: str | None = None
|
|
message: str | None = None
|
|
portal_url: str | None = None
|
|
retry_after: int | float | None = None
|
|
payload: dict[str, JsonValue] | None = None
|
|
actor: str | None = None
|
|
code: str | None = None
|
|
recovery: str | None = None
|
|
|
|
|
|
# ── usage.bars (shared two-bar dollar model) ──────────────────────────────────────────────────
|
|
|
|
|
|
class UsageBarKind(WireEnum):
|
|
plan = "plan"
|
|
topup = "topup"
|
|
|
|
|
|
class UsageBar(Result):
|
|
"""``_serialize_usage_bar``: one bar, magnitudes pre-formatted; ``pct_used`` only for ``plan``."""
|
|
|
|
kind: UsageBarKind
|
|
remaining_display: str
|
|
total_display: str
|
|
spent_display: str
|
|
pct_used: int | None = None
|
|
fill_fraction: float
|
|
|
|
|
|
class UsageModel(Result):
|
|
"""``_serialize_usage_model`` — also embedded as ``usage`` in the billing / subscription states,
|
|
where the fail-open form is a bare ``{available: false}`` (no ``ok``)."""
|
|
|
|
ok: bool | None = None
|
|
available: bool
|
|
status: str | None = None
|
|
plan_name: str | None = None
|
|
renews_at: str | None = None
|
|
renews_display: str | None = None
|
|
subscription_remaining_display: str | None = None
|
|
topup_remaining_display: str | None = None
|
|
total_spendable_display: str | None = None
|
|
has_topup: bool | None = None
|
|
plan_bar: UsageBar | None = None
|
|
topup_bar: UsageBar | None = None
|
|
|
|
|
|
method("usage.bars", params=ProfileParams, result=UsageModel,
|
|
doc="Two-bar dollar usage view shared by /usage, /topup and /subscription; fail-open to unavailable.")
|
|
|
|
|
|
# ── billing.state ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class BillingCardInfo(Result):
|
|
brand: str
|
|
last4: str
|
|
masked: str
|
|
display: str | None = None
|
|
resolved_via: str | None = None
|
|
|
|
|
|
class PaymentMethodKind(WireEnum):
|
|
card = "card"
|
|
link = "link"
|
|
unknown = "unknown"
|
|
|
|
|
|
class BillingPaymentMethod(Result):
|
|
"""``_serialize_payment_method``: each kind emits only its own fields (a ``card`` never carries
|
|
``email``; ``unknown`` carries what the server called it in ``raw_kind``)."""
|
|
|
|
kind: PaymentMethodKind
|
|
brand: str | None = None
|
|
last4: str | None = None
|
|
wallet: str | None = None
|
|
email: str | None = None
|
|
raw_kind: str | None = None
|
|
resolved_via: str | None = None
|
|
|
|
|
|
class BillingMonthlyCap(Result):
|
|
limit_usd: str | None = None
|
|
limit_display: str
|
|
spent_this_month_usd: str | None = None
|
|
spent_display: str
|
|
is_default_ceiling: bool
|
|
|
|
|
|
class AutoReloadCardKind(WireEnum):
|
|
canonical = "canonical"
|
|
distinct = "distinct"
|
|
none = "none"
|
|
|
|
|
|
class BillingAutoReloadCard(Result):
|
|
"""Only ``distinct`` carries the payment-method identity."""
|
|
|
|
kind: AutoReloadCardKind
|
|
payment_method_id: str | None = None
|
|
brand: str | None = None
|
|
last4: str | None = None
|
|
|
|
|
|
class BillingAutoReload(Result):
|
|
enabled: bool
|
|
threshold_usd: str | None = None
|
|
threshold_display: str
|
|
reload_to_usd: str | None = None
|
|
reload_to_display: str
|
|
card: BillingAutoReloadCard | None = None
|
|
|
|
|
|
class BillingStateResult(Result):
|
|
"""``_serialize_billing_state`` (money as strings); the ``except`` fallback emits only
|
|
``ok / logged_in / free_tier / error``, so everything else is optional."""
|
|
|
|
ok: bool
|
|
logged_in: bool
|
|
free_tier: bool = False
|
|
free_tier_model: str | None = None
|
|
org_name: str | None = None
|
|
org_slug: str | None = None
|
|
role: str | None = None
|
|
is_admin: bool | None = None
|
|
can_change_plan: bool | None = None
|
|
can_charge: bool | None = None
|
|
balance_usd: str | None = None
|
|
balance_display: str | None = None
|
|
cli_billing_enabled: bool | None = None
|
|
charge_presets: list[str] | None = None
|
|
charge_presets_display: list[str] | None = None
|
|
min_usd: str | None = None
|
|
max_usd: str | None = None
|
|
card: BillingCardInfo | None = None
|
|
payment_method: BillingPaymentMethod | None = None
|
|
monthly_cap: BillingMonthlyCap | None = None
|
|
auto_reload: BillingAutoReload | None = None
|
|
portal_url: str | None = None
|
|
error: str | None = None
|
|
usage: UsageModel | None = None
|
|
|
|
|
|
method("billing.state", params=ProfileParams, result=BillingStateResult,
|
|
doc="Read-only billing view (no scope); the Nous free tier is answered locally without a portal call.")
|
|
|
|
|
|
# ── subscription.state / preview / change / resume / upgrade ──────────────────────────────────
|
|
|
|
|
|
class SubscriptionContext(WireEnum):
|
|
personal = "personal"
|
|
team = "team"
|
|
|
|
|
|
class CurrentSubscription(Result):
|
|
tier_id: str | None = None
|
|
tier_name: str | None = None
|
|
monthly_credits: str | None = None
|
|
credits_remaining: str | None = None
|
|
cycle_ends_at: str | None = None
|
|
pending_downgrade_tier_name: str | None = None
|
|
pending_downgrade_at: str | None = None
|
|
pending_downgrade_display: str | None = None
|
|
cancel_at_period_end: bool
|
|
cancellation_effective_at: str | None = None
|
|
cancellation_effective_display: str | None = None
|
|
|
|
|
|
class SubscriptionTierOption(Result):
|
|
tier_id: str
|
|
name: str
|
|
tier_order: int
|
|
dollars_per_month_display: str
|
|
monthly_credits: str | None = None
|
|
is_current: bool
|
|
is_enabled: bool
|
|
|
|
|
|
class SubscriptionStateResult(Result):
|
|
"""``_serialize_subscription_state``; the view's fallback emits only ``ok / logged_in / error``."""
|
|
|
|
ok: bool
|
|
logged_in: bool
|
|
is_admin: bool | None = None
|
|
can_change_plan: bool | None = None
|
|
org_name: str | None = None
|
|
org_id: str | None = None
|
|
role: str | None = None
|
|
context: SubscriptionContext | None = None
|
|
current: CurrentSubscription | None = None
|
|
tiers: list[SubscriptionTierOption] | None = None
|
|
portal_url: str | None = None
|
|
error: str | None = None
|
|
usage: UsageModel | None = None
|
|
|
|
|
|
method("subscription.state", params=ProfileParams, result=SubscriptionStateResult,
|
|
doc="Current plan, tier catalog and usage for the picker; fail-open when logged out.")
|
|
|
|
|
|
class SubscriptionPreviewParams(ProfileParams):
|
|
subscription_type_id: str | None = None
|
|
|
|
|
|
class SubscriptionChangeEffect(WireEnum):
|
|
charge_now = "charge_now"
|
|
scheduled = "scheduled"
|
|
no_op = "no_op"
|
|
blocked = "blocked"
|
|
|
|
|
|
class SubscriptionPreviewResult(BillingEnvelope):
|
|
"""``_serialize_subscription_preview`` on success; ``effect`` drives the confirm copy."""
|
|
|
|
effect: SubscriptionChangeEffect | None = None
|
|
reason: str | None = None
|
|
current_tier_id: str | None = None
|
|
current_tier_name: str | None = None
|
|
target_tier_id: str | None = None
|
|
target_tier_name: str | None = None
|
|
monthly_credits_delta: str | None = None
|
|
amount_due_now_cents: int | None = None
|
|
effective_at: str | None = None
|
|
|
|
|
|
method("subscription.preview", params=SubscriptionPreviewParams, result=SubscriptionPreviewResult,
|
|
doc="Chargeless quote of what a plan change would do (billing:manage).")
|
|
|
|
|
|
class SubscriptionChangeParams(ProfileParams):
|
|
"""Either a target tier (downgrade / same-price change) or ``cancel`` (period-end cancellation)."""
|
|
|
|
subscription_type_id: str | None = None
|
|
cancel: bool = False
|
|
|
|
|
|
class BillingPendingChangeResult(BillingEnvelope):
|
|
"""``_billing_pending_change``: ``message`` + the raw NAS body in ``payload`` on success."""
|
|
|
|
|
|
method("subscription.change", params=SubscriptionChangeParams, result=BillingPendingChangeResult,
|
|
doc="Schedule a downgrade / same-price change or a period-end cancellation.")
|
|
method("subscription.resume", params=ProfileParams, result=BillingPendingChangeResult,
|
|
doc="Clear a scheduled downgrade / cancellation (re-enables recurring spend).")
|
|
|
|
|
|
class SubscriptionUpgradeParams(ProfileParams):
|
|
subscription_type_id: str | None = None
|
|
idempotency_key: str | None = None
|
|
|
|
|
|
class SubscriptionUpgradeResult(BillingEnvelope):
|
|
"""The money route: ``status`` separates a completed upgrade from an SCA / decline that must
|
|
finish in the portal at ``recovery_url``; ``idempotency_key`` is echoed (also on error) so a
|
|
retry reuses it."""
|
|
|
|
status: str | None = None
|
|
target_tier_name: str | None = None
|
|
recovery_url: str | None = None
|
|
reason: str | None = None
|
|
idempotency_key: str | None = None
|
|
|
|
|
|
method("subscription.upgrade", params=SubscriptionUpgradeParams, result=SubscriptionUpgradeResult,
|
|
doc="Prorate, charge and flip the plan (billing:manage, idempotent).")
|
|
|
|
|
|
# ── billing.charge / charge_status / auto_reload / step_up ───────────────────────────────────
|
|
|
|
|
|
class BillingChargeParams(ProfileParams):
|
|
amount_usd: float | str | None = None
|
|
idempotency_key: str | None = None
|
|
|
|
|
|
class BillingChargeResult(BillingEnvelope):
|
|
"""``202 {chargeId}`` — money is not confirmed yet; poll ``billing.charge_status``."""
|
|
|
|
charge_id: str | None = None
|
|
idempotency_key: str | None = None
|
|
|
|
|
|
method("billing.charge", params=BillingChargeParams, result=BillingChargeResult,
|
|
doc="Start a one-off top-up charge (billing:manage, idempotent).")
|
|
|
|
|
|
class BillingChargeStatusParams(ProfileParams):
|
|
charge_id: str | None = None
|
|
|
|
|
|
class BillingChargeStatusResult(BillingEnvelope):
|
|
"""Single status read (pending | settled | failed); the caller drives the poll cadence."""
|
|
|
|
status: str | None = None
|
|
amount_usd: str | float | None = None
|
|
settled_at: str | None = None
|
|
reason: str | None = None
|
|
|
|
|
|
method("billing.charge_status", params=BillingChargeStatusParams, result=BillingChargeStatusResult,
|
|
doc="Poll one charge by id.")
|
|
|
|
|
|
class BillingAutoReloadParams(ProfileParams):
|
|
enabled: bool = False
|
|
threshold: float | str | None = None
|
|
top_up_amount: float | str | None = None
|
|
|
|
|
|
class BillingMutationResult(BillingEnvelope):
|
|
"""A write with no success payload beyond ``ok``."""
|
|
|
|
|
|
method("billing.auto_reload", params=BillingAutoReloadParams, result=BillingMutationResult,
|
|
doc="Enable/disable auto top-up with its threshold and reload amount (billing:manage).")
|
|
|
|
|
|
class BillingStepUpParams(ProfileParams):
|
|
session_id: str | None = None
|
|
|
|
|
|
class BillingStepUpResult(BillingEnvelope):
|
|
"""``granted`` false when the server downscopes (also on every error envelope)."""
|
|
|
|
granted: bool | None = None
|
|
|
|
|
|
method("billing.step_up", params=BillingStepUpParams, result=BillingStepUpResult,
|
|
doc="Run the billing:manage device flow; the URL/code arrive via billing.step_up.verification.")
|
|
|
|
|
|
# ── delegation / subagent.steer ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
class ActiveSubagent(OpenModel):
|
|
"""One live child from ``tools/delegate_tool_registry.py::list_active_subagents`` (the record
|
|
is extended by the child runner — ``missed_steer`` etc. — so it stays open)."""
|
|
|
|
subagent_id: str
|
|
parent_id: str | None = None
|
|
depth: int | None = None
|
|
goal: str | None = None
|
|
delegation_id: str | None = None
|
|
model: str | None = None
|
|
started_at: float | None = None
|
|
status: str | None = None
|
|
tool_count: int | None = None
|
|
owner_agent_session_id: str | None = None
|
|
|
|
|
|
class DelegationStatusResult(Result):
|
|
active: list[ActiveSubagent]
|
|
paused: bool
|
|
max_spawn_depth: int
|
|
max_concurrent_children: int
|
|
|
|
|
|
method("delegation.status", params=ProfileParams, result=DelegationStatusResult,
|
|
doc="Running subagent tree plus the spawn pause flag and limits.")
|
|
|
|
|
|
class DelegationPauseParams(ProfileParams):
|
|
paused: bool = True
|
|
|
|
|
|
class DelegationPauseResult(Result):
|
|
paused: bool
|
|
|
|
|
|
method("delegation.pause", params=DelegationPauseParams, result=DelegationPauseResult,
|
|
doc="Block/unblock NEW spawns globally (active children keep running); returns the new state.")
|
|
|
|
|
|
class SubagentSteerParams(SessionParams):
|
|
subagent_id: str
|
|
text: str
|
|
|
|
|
|
class SteerStatus(WireEnum):
|
|
queued = "queued"
|
|
rejected = "rejected"
|
|
|
|
|
|
class SubagentSteerResult(Result):
|
|
"""``queued`` is not ``delivered``: a child past its final tool batch surfaces ``missed_steer``."""
|
|
|
|
status: SteerStatus
|
|
subagent_id: str
|
|
text: str
|
|
|
|
|
|
method("subagent.steer", params=SubagentSteerParams, result=SubagentSteerResult,
|
|
doc="Queue steering text into a live delegated child owned by this session.")
|
|
|
|
|
|
# ── handoff ───────────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class HandoffRequestParams(SessionParams):
|
|
platform: str
|
|
|
|
|
|
class HandoffRequestResult(Result):
|
|
queued: bool
|
|
session_key: str
|
|
platform: str
|
|
home_name: str
|
|
|
|
|
|
method("handoff.request", params=HandoffRequestParams, result=HandoffRequestResult,
|
|
doc="Queue a handoff to a messaging platform's home channel; the gateway watcher claims it.")
|
|
|
|
|
|
class HandoffStateResult(Result):
|
|
"""``state`` is pending | running | completed | failed, or '' when nothing was requested."""
|
|
|
|
state: str
|
|
platform: str
|
|
error: str
|
|
|
|
|
|
method("handoff.state", params=SessionParams, result=HandoffStateResult,
|
|
doc="Poll the handoff row for this session.")
|
|
|
|
|
|
class HandoffFailParams(SessionParams):
|
|
error: str | None = None
|
|
|
|
|
|
class HandoffFailResult(Result):
|
|
"""``failed`` false when the watcher already claimed the row; ``state`` is what it is now."""
|
|
|
|
failed: bool
|
|
state: str
|
|
|
|
|
|
method("handoff.fail", params=HandoffFailParams, result=HandoffFailResult,
|
|
doc="Fail a not-yet-claimed handoff (client poll timeout); CAS against the watcher.")
|
|
|
|
|
|
# ── message.react ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class ReactionAuthor(WireEnum):
|
|
user = "user"
|
|
agent = "agent"
|
|
|
|
|
|
class MessageReactParams(SessionParams):
|
|
"""``row_id`` is ``messages.id``; a not-yet-persisted live message names ``newest_role`` instead.
|
|
``emoji`` null clears; the same emoji again retracts."""
|
|
|
|
row_id: int | None = None
|
|
newest_role: str | None = None
|
|
emoji: str | None = None
|
|
author: ReactionAuthor | None = None
|
|
|
|
|
|
class MessageReactResult(Result):
|
|
row_id: int
|
|
reactions: list[MessageReaction]
|
|
|
|
|
|
method("message.react", params=MessageReactParams, result=MessageReactResult,
|
|
doc="Set/clear one author's emoji reaction on a message; returns the row's full reaction list.")
|
|
|
|
|
|
# ── pets: generate / hatch / cancel / status ──────────────────────────────────────────────────
|
|
|
|
|
|
class PetCancelParams(ProfileParams):
|
|
token: str | None = None
|
|
|
|
|
|
class PetCancelResult(Result):
|
|
ok: bool
|
|
|
|
|
|
method("pet.cancel", params=PetCancelParams, result=PetCancelResult,
|
|
doc="Stop an in-flight pet generate/hatch by token (idempotent).")
|
|
|
|
|
|
class PetGenProvider(Result):
|
|
"""``agent/pet/generate/imagegen.py::list_sprite_providers`` row."""
|
|
|
|
name: str
|
|
label: str
|
|
default: bool
|
|
|
|
|
|
class PetGenerateStatusResult(Result):
|
|
available: bool
|
|
providers: list[PetGenProvider]
|
|
|
|
|
|
method("pet.generate.status", params=ProfileParams, result=PetGenerateStatusResult,
|
|
doc="Whether pet generation is possible (a reference-capable image backend) and which providers.")
|
|
|
|
|
|
class PetGenerateParams(ProfileParams):
|
|
"""``prompt`` or a ``referenceImage`` data URL is required (the handler answers 4004 without one)."""
|
|
|
|
prompt: str | None = None
|
|
referenceImage: str | None = None # noqa: N815 - wire key
|
|
count: int | None = None
|
|
style: str | None = None
|
|
provider: str | None = None
|
|
|
|
|
|
class PetDraft(Result):
|
|
index: int
|
|
dataUri: str # noqa: N815 - wire key
|
|
|
|
|
|
class PetGenerateResult(Result):
|
|
ok: bool
|
|
token: str
|
|
drafts: list[PetDraft]
|
|
|
|
|
|
method("pet.generate", params=PetGenerateParams, result=PetGenerateResult,
|
|
doc="Candidate base looks for a new pet (draft step); drafts also stream via pet.generate.progress.")
|
|
|
|
|
|
class PetHatchParams(ProfileParams):
|
|
token: str
|
|
name: str
|
|
cancelToken: str | None = None # noqa: N815 - wire key
|
|
index: int | None = None
|
|
description: str | None = None
|
|
prompt: str | None = None
|
|
style: str | None = None
|
|
provider: str | None = None
|
|
|
|
|
|
# TODO(common): ``tui_gateway/server.py::_pet_sprite_payload`` is one shape for ``pet.info`` and
|
|
# ``pet.hatch``; consolidate with the pets contract module. Every field optional: ``pet.hatch``
|
|
# emits ``{}`` when the installed pet cannot be reloaded.
|
|
class PetSpritePayload(Result):
|
|
slug: str | None = None
|
|
displayName: str | None = None # noqa: N815 - wire key
|
|
mime: str | None = None
|
|
spritesheetBase64: str | None = None # noqa: N815 - wire key
|
|
spritesheetRevision: str | None = None # noqa: N815 - wire key
|
|
frameW: int | None = None # noqa: N815 - wire key
|
|
frameH: int | None = None # noqa: N815 - wire key
|
|
framesPerState: int | None = None # noqa: N815 - wire key
|
|
framesByState: dict[str, int] | None = None # noqa: N815 - wire key
|
|
framesByRow: dict[str, int] | None = None # noqa: N815 - wire key
|
|
loopMs: int | None = None # noqa: N815 - wire key
|
|
scale: float | None = None
|
|
stateRows: list[str] | None = None # noqa: N815 - wire key
|
|
|
|
|
|
class PetHatchResult(Result):
|
|
"""The hatched pet is installed but NOT active (``pet.select`` adopts, ``pet.remove`` discards)."""
|
|
|
|
ok: bool
|
|
slug: str
|
|
displayName: str # noqa: N815 - wire key
|
|
warnings: list[JsonValue] = Field(default_factory=list)
|
|
pet: PetSpritePayload
|
|
|
|
|
|
method("pet.hatch", params=PetHatchParams, result=PetHatchResult,
|
|
doc="Turn a base draft into a full spritesheet pet; progress streams via pet.hatch.progress.")
|
|
|
|
|
|
# ── project.facts ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class ProjectFactsParams(ProfileParams):
|
|
cwd: str | None = None
|
|
|
|
|
|
class ProjectFacts(Result):
|
|
"""``agent/coding_context.py::project_facts_for`` — the system prompt's coding-context detection."""
|
|
|
|
root: str
|
|
manifests: list[str]
|
|
packageManagers: list[str] # noqa: N815 - wire key
|
|
verifyCommands: list[str] # noqa: N815 - wire key
|
|
contextFiles: list[str] # noqa: N815 - wire key
|
|
|
|
|
|
class ProjectFactsResult(Result):
|
|
"""``facts`` null outside a workspace (or when detection failed)."""
|
|
|
|
facts: ProjectFacts | None = None
|
|
|
|
|
|
method("project.facts", params=ProjectFactsParams, result=ProjectFactsResult,
|
|
doc="Structured project facts for a cwd so UIs don't re-sniff the workspace.")
|