Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,010 changes: 1,005 additions & 1,005 deletions Pipfile.lock

Large diffs are not rendered by default.

151 changes: 149 additions & 2 deletions docs/open-api-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: The Agent's user-facing API
description: The user-facing parts of The Agent's API service (excluding system-level endpoints, chat completion, maintenance endpoints, etc.)
version: 5.4.0
version: 5.5.0
license:
name: MIT
url: https://opensource.org/licenses/MIT
Expand Down Expand Up @@ -376,6 +376,49 @@ paths:
security:
- bearerAuth: []

/user/{resource_id}/transfers:
post:
summary: Transfer credits to another user
description: Transfers credits from the authenticated user to another user identified by platform handle
operationId: transferCredits
tags: [CreditTransfers]
parameters:
- name: resource_id
in: path
required: true
schema:
type: string
description: Sender's user ID in hexadecimal format (UUID without hyphens)
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreditTransferPayload"
responses:
"200":
description: Credits transferred successfully
content:
application/json:
schema:
$ref: "#/components/schemas/StatusResponse"
"400":
$ref: "#/components/responses/ValidationError"
"401":
$ref: "#/components/responses/UnauthorizedError"
"404":
$ref: "#/components/responses/NotFoundError"
"429":
description: Insufficient credits
content:
application/json:
schema:
$ref: "#/components/schemas/ServiceErrorResponse"
"500":
$ref: "#/components/responses/ServerError"
security:
- bearerAuth: []

/user/{user_id}/usage:
get:
summary: Get user's usage records
Expand Down Expand Up @@ -432,6 +475,20 @@ paths:
type: boolean
default: false
description: Include usage records from all users sponsored by current user
- name: include_transfers
in: query
required: false
schema:
type: boolean
default: true
description: Include credit transfer records in results
- name: only_transfers
in: query
required: false
schema:
type: boolean
default: false
description: Return only credit transfer records (takes precedence over include_transfers)
- name: tool_id
in: query
required: false
Expand Down Expand Up @@ -513,6 +570,20 @@ paths:
type: boolean
default: false
description: Include stats from all users sponsored by current user
- name: include_transfers
in: query
required: false
schema:
type: boolean
default: true
description: Include credit transfer records in stats
- name: only_transfers
in: query
required: false
schema:
type: boolean
default: false
description: Return stats for only credit transfer records (takes precedence over include_transfers)
- name: tool_id
in: query
required: false
Expand Down Expand Up @@ -1227,6 +1298,32 @@ components:
- platform_handle
- platform

CreditTransferPayload:
type: object
properties:
platform:
type: string
nullable: false
enum: [telegram, whatsapp]
description: Platform type for the recipient handle resolution
platform_handle:
type: string
nullable: false
description: Platform-specific handle of the recipient (e.g., Telegram username, WhatsApp phone number)
amount:
type: number
format: float
nullable: false
description: Amount of credits to transfer (minimum 1.0)
note:
type: string
nullable: true
description: Optional note or reason for the transfer
required:
- platform
- platform_handle
- amount

ChatInfo:
type: object
properties:
Expand Down Expand Up @@ -1498,7 +1595,7 @@ components:
tool_purpose:
type: string
nullable: false
enum: [chat, reasoning, copywriting, vision, hearing, images_gen, images_edit, search, embedding, api_fiat_exchange, api_crypto_exchange, api_twitter, deprecated]
enum: [chat, reasoning, copywriting, vision, hearing, images_gen, images_edit, search, embedding, api_fiat_exchange, api_crypto_exchange, api_twitter, credit_transfer, deprecated]
description: The purpose/type of tool usage
timestamp:
type: string
Expand Down Expand Up @@ -1568,6 +1665,18 @@ components:
items:
type: string
description: Sizes of input images processed
counterpart_id:
type: string
nullable: true
description: The other party's user ID in hexadecimal format (only present for credit transfer records)
note:
type: string
nullable: true
description: Optional note attached to the record (only present for credit transfer records)
participant_details:
$ref: "#/components/schemas/ParticipantDetails"
nullable: true
description: Snapshot of participant identities at record creation time
required:
- user_id
- payer_id
Expand All @@ -1583,6 +1692,44 @@ components:
- maintenance_fee_credits
- total_cost_credits

ParticipantInfo:
type: object
properties:
user_id:
type: string
description: User ID in hexadecimal format
full_name:
type: string
nullable: true
description: User's full name at record creation time
platform:
type: string
nullable: true
description: Platform name (e.g., "Telegram", "WhatsApp")
handle:
type: string
nullable: true
description: Platform-specific handle (username or phone number)
required:
- user_id

ParticipantDetails:
type: object
required:
- payer
- owner
properties:
payer:
$ref: "#/components/schemas/ParticipantInfo"
description: Who was billed for this usage
owner:
$ref: "#/components/schemas/ParticipantInfo"
description: Who ran the tool (same as payer for regular and transfer usage; differs for sponsored usage)
counterpart:
$ref: "#/components/schemas/ParticipantInfo"
nullable: true
description: The other party in a credit transfer (null for non-transfer records)

UsageAggregatesResponse:
type: object
properties:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "the-agent"
version = "5.4.0"
version = "5.5.0"

[tool.setuptools]
package-dir = {"" = "src"}
Expand Down
18 changes: 18 additions & 0 deletions src/api/model/credit_transfer_payload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from pydantic import BaseModel, Field, field_validator


class CreditTransferPayload(BaseModel):
platform: str = Field(min_length = 1)
platform_handle: str = Field(min_length = 1)
amount: float = Field(gt = 0)
note: str | None = None

@field_validator("platform", "platform_handle", mode = "before")
@classmethod
def strip_whitespace(cls, v: str) -> str:
return v.strip()

@field_validator("platform_handle", mode = "after")
@classmethod
def strip_handle_prefixes(cls, v: str) -> str:
return v.lstrip("@").lstrip("+").lstrip("#")
11 changes: 8 additions & 3 deletions src/api/model/sponsorship_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@


class SponsorshipPayload(BaseModel):
platform: str = Field(min_length = 1)
platform_handle: str = Field(min_length = 1)
platform: str

@field_validator("platform_handle", mode = "before")
@field_validator("platform", "platform_handle", mode = "before")
@classmethod
def strip_handle(cls, v: str) -> str:
def strip_whitespace(cls, v: str) -> str:
return v.strip()

@field_validator("platform_handle", mode = "after")
@classmethod
def strip_handle_prefixes(cls, v: str) -> str:
return v.lstrip("@").lstrip("+").lstrip("#")
32 changes: 32 additions & 0 deletions src/api/transfers_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from api.model.credit_transfer_payload import CreditTransferPayload
from db.model.chat_config import ChatConfigDB
from di.di import DI
from util import log
from util.error_codes import INVALID_PLATFORM
from util.errors import ValidationError


class TransfersController:

__di: DI

def __init__(self, di: DI):
self.__di = di

def transfer_credits(self, sender_user_id_hex: str, payload: CreditTransferPayload) -> None:
user = self.__di.authorization_service.authorize_for_user(self.__di.invoker, sender_user_id_hex)
log.d(f"Transferring {payload.amount} credits to {payload.platform}/'@{payload.platform_handle}'")

chat_type = ChatConfigDB.ChatType.lookup(payload.platform)
if not chat_type:
raise ValidationError(f"Unsupported platform: {payload.platform}", INVALID_PLATFORM)

self.__di.credit_transfer_service.transfer_credits(
sender_id = user.id,
recipient_handle = payload.platform_handle,
chat_type = chat_type,
amount = payload.amount,
note = payload.note,
)

log.i(f"Transfer completed: {payload.amount} credits to '@{payload.platform_handle}'")
8 changes: 8 additions & 0 deletions src/api/usage_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ def fetch_usage_records(
end_date: datetime | None = None,
exclude_self: bool = False,
include_sponsored: bool = False,
include_transfers: bool = True,
only_transfers: bool = False,
tool_id: str | None = None,
purpose: str | None = None,
provider_id: str | None = None,
Expand All @@ -40,6 +42,8 @@ def fetch_usage_records(
end_date = end_date,
exclude_self = exclude_self,
include_sponsored = include_sponsored,
include_transfers = include_transfers,
only_transfers = only_transfers,
tool_id = tool_id,
purpose = purpose,
provider_id = provider_id,
Expand All @@ -52,6 +56,8 @@ def fetch_usage_aggregates(
end_date: datetime | None = None,
exclude_self: bool = False,
include_sponsored: bool = False,
include_transfers: bool = True,
only_transfers: bool = False,
tool_id: str | None = None,
purpose: str | None = None,
provider_id: str | None = None,
Expand All @@ -64,6 +70,8 @@ def fetch_usage_aggregates(
end_date = end_date,
exclude_self = exclude_self,
include_sponsored = include_sponsored,
include_transfers = include_transfers,
only_transfers = only_transfers,
tool_id = tool_id,
purpose = purpose,
provider_id = provider_id,
Expand Down
45 changes: 45 additions & 0 deletions src/db/alembic/versions/f81c0b60d07e_credit_transfers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""credit_transfers

Revision ID: f81c0b60d07e
Revises: 4a9e7ab95bf7
Create Date: 2026-04-04 16:30:26.656134

"""
from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = "f81c0b60d07e"
down_revision: Union[str, None] = "4a9e7ab95bf7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# leftover: encrypted_messages migration created the BYTEA column as nullable; enforce NOT NULL to match the model
op.alter_column("chat_messages", "text", existing_type = postgresql.BYTEA(), nullable = False)

# ### commands auto generated by Alembic - please adjust! ###
op.add_column("usage_records", sa.Column("counterpart_id", sa.UUID(), nullable = True))
op.add_column("usage_records", sa.Column("note", sa.String(), nullable = True))
op.add_column("usage_records", sa.Column("participant_details", sa.JSON(), nullable = True))
op.create_index(
"idx_usage_records_counterpart_timestamp", "usage_records",
["counterpart_id", sa.literal_column("timestamp DESC")], unique = False,
)
# ### end Alembic commands ###


def downgrade() -> None:
# leftover: encrypted_messages migration created the BYTEA column as nullable
op.drop_index("idx_usage_records_counterpart_timestamp", table_name = "usage_records")

# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("usage_records", "participant_details")
op.drop_column("usage_records", "note")
op.drop_column("usage_records", "counterpart_id")
op.alter_column("chat_messages", "text", existing_type = postgresql.BYTEA(), nullable = True)
# ### end Alembic commands ###
23 changes: 23 additions & 0 deletions src/db/crud/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,29 @@ def update_locked(self, user_id: UUID, update_fn: Callable[[UserDB], None]) -> U
self._db.refresh(user)
return user

def update_locked_pair(
self,
first_id: UUID,
second_id: UUID,
update_fn: Callable[[UserDB, UserDB], None],
) -> tuple[UserDB, UserDB]:
lock_order = sorted([first_id, second_id])
first = self._db.query(UserDB).filter(
UserDB.id == lock_order[0],
).with_for_update().first()
second = self._db.query(UserDB).filter(
UserDB.id == lock_order[1],
).with_for_update().first()
if first is None or second is None:
raise NotFoundError("User not found", USER_NOT_FOUND)
mapped_first = first if first.id == first_id else second
mapped_second = second if second.id == second_id else first
update_fn(mapped_first, mapped_second)
self._db.commit()
self._db.refresh(mapped_first)
self._db.refresh(mapped_second)
return mapped_first, mapped_second

def delete(self, user_id: UUID, commit: bool = True) -> UserDB | None:
user = self.get(user_id)
if user:
Expand Down
Loading
Loading