Files
ai-agent/backend/schemas.py
Gabriel Vidal 14aca8ea09 feat(models): browse and price every OpenRouter model from APPE's catalogue
The pi.dev harness could only be pointed at two hand-written qwen entries, and a
pi session was costed at one flat qwen rate whatever it actually ran on.

- `backend/openrouter.py` pulls APPE's published catalogue
  (appe.dev.gabvdl.xyz/api/models/openrouter.json — a daily models.dev sync) and
  serves it at `GET /api/models/openrouter`: ~340 models with $/Mtok in / out /
  cache-read, context window, parameter size and capability tags. Cached 6h
  in-process, with a `/data` disk copy so a restart with no network still lists
  models, and warmed at startup so nothing waits on it.
- `conversations.rates_for()` prices a turn from that catalogue — per model,
  cache-read included (OpenRouter's cache price is per-model policy, not
  Anthropic's flat 10%). Unknown ids keep the old estimate; pi's own `costUSD`
  still wins over any of it.
- The composer's model select gains `Browse OpenRouter…` on the pi harness: a
  FuzzyList of the whole catalogue, each row stating the three rates that bill an
  agent run plus size and context, with capability filters. Cheapest first.
- Any vendor-prefixed id now resolves to the pi harness frontend-side
  (`isOpenRouterId`), so an arbitrary pick survives a resume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 23:48:52 +02:00

1536 lines
50 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Pydantic response models — the OpenAPI schema for the read endpoints.
These are **documentation-only**: they are attached to the routes via
``responses={200: {"model": …}}`` (see ``main.py``), never as ``response_model``.
FastAPI therefore serves them in ``/openapi.json`` but does **not** validate or
filter the actual returned dicts through them — so a model that drifts from the
real payload can only produce a wrong TypeScript type (caught by ``tsc``), never
a silent runtime regression.
They mirror ``frontend/src/types.ts`` field-for-field; fields are intentionally
**camelCase** (matching the JSON the handlers already emit) so Orval generates
property names identical to the hand-written types they replace. Class names
match the TS interface names so the generated model names line up 1:1.
"""
from __future__ import annotations
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict
FileKind = Literal["claude-md", "skill-md", "markdown", "script", "config", "other"]
class Schema(BaseModel):
# ``model`` is a legit field name on several of these; opt out of Pydantic's
# protected ``model_`` namespace so it doesn't warn.
model_config = ConfigDict(protected_namespaces=())
# ── files / bundle ────────────────────────────────────────────────────────────
class FileEntry(Schema):
path: str
name: str
ext: str
kind: FileKind
isMarkdown: bool
bytes: int
words: int
chars: int
tokens: int
cost: float
estimated: bool
editable: bool
content: Optional[str] = None
class Pricing(Schema):
model: str
inputPerMTok: float
class Totals(Schema):
files: int
mdFiles: int
words: int
chars: int
tokens: int
cost: float
estimated: int
class Bundle(Schema):
workspace: str
totals: Totals
files: list[FileEntry]
pricing: Pricing
# ── dashboard ─────────────────────────────────────────────────────────────────
class SkillStat(Schema):
name: str
count: int
lastUsed: Optional[str] = None
contextTokens: Optional[int] = None
contextCost: Optional[float] = None
estSpend: Optional[float] = None
class DashboardTotals(Schema):
files: int
tokens: int
cost: float
estimated: int
skills: int
invocations: int
skillSpend: float
class TopFile(Schema):
path: str
tokens: int
cost: float
estimated: bool
class Dashboard(Schema):
pricing: Pricing
totals: DashboardTotals
skills: list[SkillStat]
topFiles: list[TopFile]
# ── activity dashboard ────────────────────────────────────────────────────────
class ActivityDay(Schema):
"""One local calendar day of agent activity (the heatmap's cell)."""
date: str # YYYY-MM-DD, in the caller's timezone
conversations: int # top-level runs started this day (heatmap intensity)
subagents: int # sidechain runs started this day
agents: int # conversations + subagents
activeBins: int # distinct 5-min slots that saw work (× 5 = minutes)
tokens: int
cost: float
class ActivityModel(Schema):
"""Everything one model did, across the whole archive."""
model: str
runs: int # agent runs whose busiest model was this one
conversations: int
subagents: int
activeBins: int # distinct 5-min slots this model worked in
tokens: int
cost: float
class ActivityTotals(Schema):
agents: int
conversations: int
subagents: int
activeBins: int
tokens: int
cost: float
days: int
firstDay: Optional[str] = None
lastDay: Optional[str] = None
class ActivityResponse(Schema):
"""The activity dashboard's whole payload — see ``backend/activity.py``.
``binsByDay`` maps a local ``YYYY-MM-DD`` to that day's occupied histogram
cells as positional ``[slot, modelIndex, agents]`` triples: ``slot`` is the
5-minute slot of the local day (0…``binsPerDay``-1) and ``modelIndex``
indexes ``models``. Empty slots are simply absent.
"""
binMinutes: int
binsPerDay: int
tzOffset: int # the minutes-east-of-UTC the rollup was built for
days: list[ActivityDay]
models: list[ActivityModel]
binsByDay: dict[str, list[list[int]]]
totals: ActivityTotals
# ── skills catalog ────────────────────────────────────────────────────────────
class SkillSummary(Schema):
"""One skill card: what it is on disk + what its use has cost."""
name: str # the invocation name (its dir name)
title: str # frontmatter `name:`, falls back to `name`
description: str
# None for a built-in/plugin skill: nothing of it lives in this repo, so
# there is no file to link into the editor.
dir: Optional[str] = None
path: Optional[str] = None
source: str # "repo" | <project dir-name> | "builtin"
sourceKind: str # "repo" | "project" | "builtin"
files: int
updatedAt: Optional[str] = None
count: int # total invocations across all transcripts
lastUsed: Optional[str] = None
conversations: int # distinct conversations that invoked it
contextTokens: Optional[int] = None
contextCost: Optional[float] = None
estSpend: Optional[float] = None # contextCost × count
sessionCost: float # total spend of the conversations using it
sessionTokens: int
class SkillsTotals(Schema):
skills: int
used: int
invocations: int
contextCost: float
estSpend: float
class SkillsResponse(Schema):
skills: list[SkillSummary]
totals: SkillsTotals
# ── conversations ─────────────────────────────────────────────────────────────
class ToolUsage(Schema):
tokens: int
cost: float
output: int
class ConvNotification(Schema):
title: Optional[str] = None
body: Optional[str] = None
type: Optional[str] = None
url: Optional[str] = None
at: Optional[str] = None
# The French line the desk phone read aloud (notify-done --spoken), kept so
# the UI can replay the push's sound. Absent on pushes that weren't spoken.
spoken: Optional[str] = None
class WorktreeInfo(Schema):
name: str
dir: Optional[str] = None
createdAt: Optional[str] = None
removedAt: Optional[str] = None
class ForkedFrom(Schema):
"""Where a forked conversation branched off: the source conversation and
the user-message ordinal (``mi``) whose edit created the fork."""
id: str # source conversation id (<slug>/<sid>.jsonl)
sessionId: Optional[str] = None # source session id
mi: int # message ordinal the fork cut before
class CronRef(Schema):
"""The cron job that spawned a conversation (see cron.py) — rendered as a
tag on the card/detail linking back to Settings → Cron."""
id: str
name: Optional[str] = None
class AgentRunRef(Schema):
"""The agent definition a conversation was launched from by the agent
page's "Run now" — the manual sibling of :class:`CronRef`, rendered as a
tag linking back to ``/agents/<name>``."""
name: str
at: Optional[str] = None
class ConvMeta(Schema):
state: str
# Title the session chose for itself (`conv-meta title "…"`); the backend
# already prefers it over the parsed one on every card, so the UI reads
# `title` — this is here for the raw-sidecar endpoint.
title: Optional[str] = None
projects: list[str]
services: list[str]
worktrees: Optional[list[WorktreeInfo]] = None
committed: Optional[str] = None
pushed: Optional[str] = None
merged: Optional[str] = None
deployed: Optional[str] = None
notified: Optional[str] = None
notifications: list[ConvNotification]
archived: bool = False
# Agent harness the session runs on: "claude" | "pi" (None = pre-field).
harness: Optional[str] = None
# Whether the session runs with thinking on. False ⇒ it was spawned (or last
# resumed) with thinking disabled. None = pre-field, i.e. on.
thinking: Optional[bool] = None
# The claude `--effort` level the session was spawned (or last resumed) with:
# "low" | "medium" | "high" | "xhigh" | "max". Claude-only, and None when the
# run never picked one (the CLI's own default) — pi runs never set it.
effort: Optional[str] = None
# Set when this conversation was forked off another one (edit & resubmit).
forkedFrom: Optional[ForkedFrom] = None
# Set when a cron job spawned this conversation.
cron: Optional[CronRef] = None
# Set when the agent page's "Run now" spawned this conversation.
agentRun: Optional[AgentRunRef] = None
# End-of-conversation CTAs already run on this session, {ctaId: iso} —
# a record of which buttons were pressed (ctas.py), rendered as a pill on
# the CTA linking to Settings → CTAs.
ctas: Optional[dict[str, str]] = None
# The conversation's published summary, written by the Complete CTA.
summary: Optional["ConvSummary"] = None
class ConvSummary(Schema):
"""A conversation's published summary: the filled-in scaffold markdown (see
scaffold.py) plus when it was published."""
markdown: str
at: Optional[str] = None
class Usage(Schema):
input: int
output: int
cacheRead: int
cacheWriteTokens: int
cacheWriteUnits: int
class SubagentRef(Schema):
id: str
agentType: Optional[str] = None
description: Optional[str] = None
title: Optional[str] = None
model: Optional[str] = None
tokens: Optional[int] = None
cost: Optional[float] = None
messages: Optional[int] = None
# Lifecycle of the subagent itself: ``running`` while the parent's Task call
# is still open (no tool_result yet), else ``finished``.
state: Optional[str] = None
startedAt: Optional[str] = None
endedAt: Optional[str] = None
class SubagentListItem(Schema):
"""A flat subagent (sidechain) ref with its parent conversation id — the
graph view's node source (the top-level conversation list hides these)."""
id: str
parentId: str
agentType: Optional[str] = None
description: Optional[str] = None
title: Optional[str] = None
tokens: Optional[int] = None
cost: Optional[float] = None
state: Optional[str] = None
class SubagentsResponse(Schema):
subagents: list[SubagentListItem]
class RunningAgent(Schema):
"""A Task/Agent call that hasn't returned — i.e. a subagent still running."""
toolUseId: str
agentType: Optional[str] = None
description: Optional[str] = None
startedAt: Optional[str] = None
class ParentConvRef(Schema):
id: str
title: Optional[str] = None
toolUseId: Optional[str] = None
agentType: Optional[str] = None
description: Optional[str] = None
class Task(Schema):
"""One task from the conversation's task list (TaskCreate/TaskUpdate, or the
older TodoWrite). ``status`` is one of pending / in_progress / completed /
cancelled."""
id: str
subject: str
status: str
class ConversationSummary(Schema):
id: str
sessionId: Optional[str] = None
project: Optional[str] = None
cwd: Optional[str] = None
gitBranch: Optional[str] = None
model: str
# Every model that produced a turn, busiest first (a session can switch
# models mid-thread). Rendered as the conversation's model tags.
models: Optional[list[str]] = None
# Every `--effort` level the turns actually ran at, busiest first — the read
# side of the composer's effort select, mined from the transcript. Empty for
# pi runs and for transcripts predating the CLI flag.
efforts: Optional[list[str]] = None
title: str
userTurns: int
assistantTurns: int
messages: int
startedAt: Optional[str] = None
endedAt: Optional[str] = None
tokens: int
cost: float
usage: Optional[Usage] = None
byTool: Optional[dict[str, ToolUsage]] = None
# Second-level breakdown of each activity bucket: bucket -> sub-label ->
# usage. bash by program, read/edit by file extension, msg by prompt/replies,
# skill as "skills read", other tool buckets by tool name. Powers the
# click-to-drill-down on the conversations dashboard chart.
byToolSub: Optional[dict[str, dict[str, ToolUsage]]] = None
subagentCount: Optional[int] = None
subagentTokens: Optional[int] = None
subagentCost: Optional[float] = None
# Subagents still in flight (their Task call never came back). The list card
# shows one "running" chip per entry while the conversation itself is running.
runningAgents: Optional[list[RunningAgent]] = None
# A deploy command this conversation issued hasn't returned yet. The list
# card shows a "Deploying…" chip while the conversation itself is running,
# same gating as `runningAgents`.
deploying: Optional[bool] = None
# Current context size: the last assistant API call's full prompt+reply
# (input + cache read/write + output) — what the next request replays.
# `contextModel` is the model that made that call, so the UI can pick the
# right context-window size (see ModelInfo.contextWindow).
contextTokens: Optional[int] = None
contextModel: Optional[str] = None
# The session's first API call's input side, split in two: the injected
# context (system prompt, CLAUDE.md, memories, other <system-reminder>
# blocks) vs the user's actual first message. The API reports only the
# combined total, so the message share is estimated from its length
# (~4 chars/token) and the remainder is attributed to context.
firstContextTokens: Optional[int] = None
firstMessageTokens: Optional[int] = None
tasks: Optional[list[Task]] = None
# What the conversation *used*, busiest first — the tool-side counterpart of
# `meta.projects`/`meta.services`, and what the tag registry derives its
# `skill:`/`agent:` tags from. Skills are the `Skill(...)` calls mined from
# the transcript (a spawned subagent's calls count as the parent's, since
# the child is never listed on its own); agents are the run list the agents
# catalog counts, so a card can't disagree with the agent page.
skillsUsed: Optional[list[str]] = None
agentsUsed: Optional[list[str]] = None
meta: Optional[ConvMeta] = None
class ThreadItem(Schema):
role: Literal["user", "assistant", "tool"]
# "turn" is a content-less carrier: an API call that rendered nothing (a
# redacted-thinking turn) but was still billed. It exists so the cost chart
# sees every priced turn; the viewer never draws it.
kind: Literal[
"text", "thinking", "tool_use", "tool_result", "interrupted", "paused",
"turn"]
name: Optional[str] = None
input: Optional[Any] = None
text: Optional[str] = None
isError: Optional[bool] = None
result: Optional[str] = None
out: Optional[int] = None
turnTokens: Optional[int] = None
turnCost: Optional[float] = None
# The cached-prompt share of this turn: tokens replayed from the prompt cache
# and what they cost (10% of the input rate). The rest of `turnTokens` /
# `turnCost` is fresh input, cache writes and output.
turnCacheTokens: Optional[int] = None
turnCacheCost: Optional[float] = None
# The activity bucket this turn's cost was charged to in the `byTool`
# breakdown (msg / read / bash / …), so the cost chart colours and totals a
# turn exactly as "cost by activity" does.
turnBucket: Optional[str] = None
# On the first API call only: the share booked to the "context" bucket
# (injected system prompt, CLAUDE.md, memories), split off from this turn's
# own activity just as the breakdown does.
turnContextCost: Optional[float] = None
turnContextTokens: Optional[int] = None
# Only on the user's *first* message: how much of the session's first API
# call was injected context (system prompt, CLAUDE.md, memories, …) vs the
# message's own text (length-estimated split of the API-reported total).
firstContextTokens: Optional[int] = None
firstMessageTokens: Optional[int] = None
mi: Optional[int] = None
ts: Optional[str] = None
toolUseId: Optional[str] = None
subagent: Optional[SubagentRef] = None
# On a Task/Agent card: whether the agent it spawned is still running. Set
# even before the subagent's transcript exists, so a just-spawned agent's
# card reads "running" straight away.
agentRunning: Optional[bool] = None
# Wall-clock duration of this step: for a tool call, how long it ran (call →
# its result); for a message/thinking block, the assistant API call's latency
# (previous transcript record → this one).
durationMs: Optional[int] = None
# The model that produced this assistant step.
model: Optional[str] = None
# Size of a tool call's result payload (before clipping).
resultChars: Optional[int] = None
resultLines: Optional[int] = None
# On a `Skill` tool card: the injected SKILL.md body (shown collapsible in the
# card) and a repo-relative path to it (a link into the editor), so a skill
# invocation renders as one self-contained action instead of a huge "You" bubble.
skillBody: Optional[str] = None
skillPath: Optional[str] = None
# On a deploy tool call (npm run deploy / deploy*.sh / zipgo deploy / the
# deploy-html skill): the URL it reported, scraped from the raw — unclipped —
# output. Drives the deploy preview widget and the "open the deploy" gizmo.
deployedUrl: Optional[str] = None
class TurnUsage(Usage):
model: Optional[str] = None
class MemorySummary(Schema):
slug: str
name: str
description: str
type: Optional[str] = None
originSessionId: Optional[str] = None
projects: list[str]
services: list[str]
links: list[str]
words: int
bytes: int
updatedAt: Optional[str] = None
class ArtefactItem(Schema):
"""One generated artefact (screenshot, render, export) found under a
project/service's ``.ai/artefacts/<date>/<sessionId>/`` folder."""
kind: str # "project" | "service"
slug: str
name: str
date: str # the YYYY-MM-DD folder it landed in
path: str # <date>/<sessionId>/<file>, for /api/artefact
url: str # ready-to-use /api/artefact serve URL
bytes: int
mtime: Optional[str] = None
video: Optional[bool] = None
class ConversationDetail(ConversationSummary):
usage: Usage
thread: list[ThreadItem]
turns: list[TurnUsage]
memoriesRead: Optional[list[MemorySummary]] = None
memoriesCreated: Optional[list[MemorySummary]] = None
subagents: Optional[list[SubagentRef]] = None
parentConversation: Optional[ParentConvRef] = None
# Plans (data/plans/*.md) whose planning session is this conversation — the
# `plan` skill stamps its sessionId/conversationIds, so we join them back.
plans: Optional[list[PlanSummary]] = None
# Generated artefacts collected from the conversation's projects/services
# (.ai/artefacts/<date>/<sessionId>/), oldest first.
artefacts: Optional[list[ArtefactItem]] = None
class ConversationsResponse(Schema):
conversations: list[ConversationSummary]
# Total conversations matching the filter (pre-pagination).
count: Optional[int] = None
# Cursor for the next page (`?before=`); None when this page ends the list.
nextBefore: Optional[str] = None
pricing: Pricing
# ── notification feed (light source for the history + "new" badges) ──────────
class NotifConversation(Schema):
"""A conversation that recorded notify-done pushes, feed-relevant bits only."""
id: str
title: str
endedAt: Optional[str] = None
projects: list[str]
services: list[str]
notifications: list[ConvNotification]
class NotificationsResponse(Schema):
conversations: list[NotifConversation]
count: Optional[int] = None
class FeedNotification(Schema):
"""A flat notification feed item (the server-side mirror of the frontend's
FeedNotif), as returned by ``/api/notifications/unread``."""
id: str
convId: str
convTitle: str
title: str
body: str
type: str
url: str
at: Optional[str] = None
projects: list[str]
services: list[str]
kind: Optional[str] = None
spoken: Optional[str] = None
class UnreadNotificationsResponse(Schema):
notifications: list[FeedNotification]
count: int # items returned (after the limit)
total: int # total unread (before the limit)
# ── first-class notifications: webhooks + notify/ask log (notify.py) ─────────
class Webhook(Schema):
id: str
name: str
url: str
events: list[Literal["notify", "ask"]]
enabled: bool
class WebhooksResponse(Schema):
webhooks: list[Webhook]
class DeliveryStatus(Schema):
"""One webhook's delivery outcome for a forwarded notification."""
webhook: str
ok: bool
status: int
class NotifyResult(Schema):
ok: bool
id: str
delivered: list[DeliveryStatus]
class AskRecord(Schema):
"""An ask (choice question) with its lifecycle state."""
id: str
kind: Literal["ask"]
title: str
body: str
type: str
url: str
at: str
sessionId: str
delivered: list[DeliveryStatus]
options: list[str]
status: Literal["pending", "answered", "expired"]
answer: Optional[str] = None
answerIndex: Optional[int] = None
answeredAt: Optional[str] = None
expiresAt: Optional[str] = None
class NotifyLogEntry(Schema):
"""One recorded notification (kind "notify") or ask (kind "ask")."""
id: str
kind: Literal["notify", "ask"]
title: str
body: str
type: str
url: str
at: str
sessionId: str
delivered: list[DeliveryStatus]
# The line the desk phone read aloud, kept so the UI can replay the sound.
spoken: Optional[str] = None
# ask-only lifecycle fields (absent on plain pushes)
options: Optional[list[str]] = None
status: Optional[Literal["pending", "answered", "expired"]] = None
answer: Optional[str] = None
answerIndex: Optional[int] = None
answeredAt: Optional[str] = None
expiresAt: Optional[str] = None
class NotifyLogResponse(Schema):
notifications: list[NotifyLogEntry]
count: Optional[int] = None
# ── forms: rich structured questions answered in the PWA (forms.py) ──────────
class FormFieldOption(Schema):
value: str
label: str
class FormField(Schema):
"""One field of a form spec (see forms.FIELD_TYPES)."""
key: str
label: str
type: Literal["text", "textarea", "number", "select", "multiselect",
"radio", "checkbox", "slider", "file", "date"]
required: bool
placeholder: Optional[str] = None
help: Optional[str] = None
accept: Optional[str] = None # file: input accept filter
options: Optional[list[FormFieldOption]] = None
min: Optional[float] = None
max: Optional[float] = None
step: Optional[float] = None
multiple: Optional[bool] = None # file: allow several
default: Optional[Any] = None
class FormRecord(Schema):
"""A form with its lifecycle state; ``answers`` keyed by field key."""
id: str
title: str
description: str
type: str
url: str
sessionId: str
at: str
fields: list[FormField]
status: Literal["pending", "submitted", "cancelled"]
answers: Optional[dict[str, Any]] = None
submittedAt: Optional[str] = None
cancelledAt: Optional[str] = None
class FormsResponse(Schema):
forms: list[FormRecord]
count: Optional[int] = None
# ── full-text search index ────────────────────────────────────────────────────
class SearchMsg(Schema):
i: int
role: Literal["user", "assistant"]
text: str
class SearchConv(Schema):
id: str
title: str
project: Optional[str] = None
model: str
endedAt: Optional[str] = None
msgs: list[SearchMsg]
class SearchIndexResponse(Schema):
conversations: list[SearchConv]
count: Optional[int] = None
# ── memories ──────────────────────────────────────────────────────────────────
class MemoryConvRef(Schema):
id: str
sessionId: Optional[str] = None
title: Optional[str] = None
model: Optional[str] = None
endedAt: Optional[str] = None
cost: Optional[float] = None
tokens: Optional[int] = None
meta: Optional[ConvMeta] = None
class MemoryDetail(MemorySummary):
content: str
createdIn: list[MemoryConvRef]
readIn: list[MemoryConvRef]
class MemoriesResponse(Schema):
memories: list[MemorySummary]
# ── plans (data/plans/, written by the `plan` skill) ──────────────────────────
class PlanSummary(Schema):
slug: str
title: str
status: str
created: Optional[str] = None
updatedAt: Optional[str] = None
sessionId: Optional[str] = None
conversationIds: list[str]
projects: list[str]
services: list[str]
estCost: Optional[float] = None
estTimeMinutes: Optional[int] = None
estTokens: Optional[int] = None
files: int
steps: int
questions: int = 0
class PlanFile(Schema):
file: str
change: str
class PlanStep(Schema):
title: str
detail: str
class PlanActual(Schema):
cost: float
tokens: int
durationMs: Optional[int] = None
class PlanConvRef(Schema):
id: str
sessionId: Optional[str] = None
title: Optional[str] = None
model: Optional[str] = None
startedAt: Optional[str] = None
endedAt: Optional[str] = None
cost: Optional[float] = None
tokens: Optional[int] = None
durationMs: Optional[int] = None
meta: Optional[ConvMeta] = None
class PlanQuestionForm(Schema):
"""The plan's open questions, as an ask-form field spec (the ``## Questions``
section's fenced JSON). Answered client-side on the plan page — answers live
in the browser and feed the "implement this plan" prompt."""
title: str
description: str
fields: list[FormField]
class PlanDetail(PlanSummary):
content: str
fileList: list[PlanFile]
stepList: list[PlanStep]
conversations: list[PlanConvRef]
actual: Optional[PlanActual] = None
questionForm: Optional[PlanQuestionForm] = None
class PlansResponse(Schema):
plans: list[PlanSummary]
# ── projects ──────────────────────────────────────────────────────────────────
class ProjectCostSummary(Schema):
cost: float
tokens: int
conversations: int
class TokenTypeSpend(Schema):
tokens: int
cost: float
class ProjectByType(Schema):
input: TokenTypeSpend
output: TokenTypeSpend
cacheRead: TokenTypeSpend
cacheWrite: TokenTypeSpend
class ProjectCostDetail(ProjectCostSummary):
meanCost: float
medianCost: float
meanTokens: float
medianTokens: float
byType: ProjectByType
loc: int
meanCostPerLoc: float
# A directory's GOAL.md: the checklist rollup that tags a card, and — on a
# detail response — the markdown itself. Absent file ⇒ the field is None.
class GoalSummary(Schema):
done: int
total: int
# One checklist item on a detail page's Goal checklist widget: the raw markdown
# after its checkbox (rendered), a plain single-line summary (the Work prompt +
# tooltip), and whether it's ticked.
class GoalChecklistItem(Schema):
text: str
plain: str
checked: bool
# A goal-keeper's in-flight claim under "## Being worked on" — an item some agent
# is on right now. Surfaced on the detail (so the checklist can flag a claimed
# item) and on the board. (Defined here so GoalDetail can reference it.)
class GoalClaim(Schema):
text: str
since: Optional[str] = None # ISO — parsed off a trailing "@<iso>"
stale: bool # older than the goal-keeper's 5h cadence
class GoalDetail(GoalSummary):
content: str
items: list[GoalChecklistItem]
claims: list[GoalClaim]
# ── the goals board (/api/goals) ─────────────────────────────────────────────
# One card per project/service that keeps a GOAL.md: its progress, the version
# milestones off its "## Horizons", the themed wishlist groups, the goal-keeper
# claims in "## Being worked on", and how many agents are on it right now.
class GoalMilestone(Schema):
title: str
version: Optional[str] = None # "0.3" — absent on an unversioned horizon
current: bool # the horizon marked "(now)"
done: int
total: int
hasTasks: bool # False ⇒ prose horizon, don't render "0/0"
class GoalTheme(Schema):
title: str
done: int
total: int
class RunningConversation(Schema):
id: str
title: str
startedAt: Optional[str] = None
class GoalBoardEntry(GoalSummary):
kind: Literal["project", "service"]
dir: str
name: str
description: str
url: Optional[str] = None
updatedAt: Optional[str] = None
milestones: list[GoalMilestone]
themes: list[GoalTheme]
claims: list[GoalClaim]
# The goal's checklist, so a card can expand into its individual items (and
# put a Work button on each) without a round-trip to the detail page. Capped
# server-side — see `goal.BOARD_ITEMS` — since the board renders many goals.
items: list[GoalChecklistItem]
itemsTruncated: int # items beyond the cap, for a "+N more" hint
agents: int # sessions running on this unit right now
runningConversations: list[RunningConversation]
conversations: int
lastConversationAt: Optional[str] = None
class GoalsResponse(Schema):
goals: list[GoalBoardEntry]
class ProjectSummary(Schema):
dir: str
name: str
description: str
version: Optional[str] = None
url: Optional[str] = None
stack: list[str]
keywords: list[str]
hasPackageJson: bool
goal: Optional[GoalSummary] = None
updatedAt: Optional[str] = None
costs: Optional[ProjectCostSummary] = None
class IndexMeta(Schema):
title: Optional[str] = None
description: Optional[str] = None
ogImage: Optional[str] = None
ogUrl: Optional[str] = None
themeColor: Optional[str] = None
class OgImage(Schema):
kind: Literal["url", "asset"]
src: str
class KeyFile(Schema):
name: str
bytes: int
class ProjectCommit(Schema):
hash: str
date: str
subject: str
class ProjectDetail(ProjectSummary):
readme: Optional[str] = None
claudeMd: Optional[str] = None
goal: Optional[GoalDetail] = None
indexMeta: Optional[IndexMeta] = None
ogImage: Optional[OgImage] = None
scripts: list[str]
keyFiles: list[KeyFile]
commits: list[ProjectCommit]
costs: Optional[ProjectCostDetail] = None
class ProjectsResponse(Schema):
projects: list[ProjectSummary]
class ProjectEnvVar(Schema):
key: str
# Secrets never travel to the UI: value is None for them, hasValue says
# whether a non-empty value is set on disk.
value: Optional[str] = None
secret: bool
hasValue: bool
class ProjectEnvResponse(Schema):
slug: str
exists: bool
vars: list[ProjectEnvVar]
# ── templates ─────────────────────────────────────────────────────────────────
class TemplateSummary(Schema):
name: str
description: str
stack: list[str]
fileCount: int
sizeBytes: int
hasReadme: bool
class TemplateTreeNode(Schema):
name: str
path: str
type: Literal["dir", "file"]
size: Optional[int] = None
text: Optional[bool] = None
children: Optional[list["TemplateTreeNode"]] = None
class TemplateDetail(Schema):
name: str
description: str
stack: list[str]
fileCount: int
sizeBytes: int
readme: Optional[str] = None
scripts: dict[str, str]
dependencies: list[str]
tree: list[TemplateTreeNode]
class TemplatesResponse(Schema):
templates: list[TemplateSummary]
# ── services catalog ──────────────────────────────────────────────────────────
class ServiceSummary(Schema):
dir: str
name: str
description: str
icon: Optional[str] = None
group: Optional[str] = None
urls: list[str]
containers: list[str]
containerCount: int
images: list[str]
profiles: list[str]
auth: Optional[Literal["auth", "public", "api", "mixed"]] = None
hasCompose: bool
hasTraefik: bool
goal: Optional[GoalSummary] = None
updatedAt: Optional[str] = None
class ServiceVolume(Schema):
source: Optional[str] = None
target: Optional[str] = None
mode: Optional[str] = None
readOnly: bool
class ServiceContainer(Schema):
key: str
containerName: str
image: Optional[str] = None
build: Optional[str] = None
profiles: list[str]
restart: Optional[str] = None
networkMode: Optional[str] = None
networks: list[str]
ports: list[str]
dependsOn: list[str]
envKeys: list[str]
envFiles: list[str]
volumes: list[ServiceVolume]
hasHealthcheck: bool
composeFile: str
class ServiceRouter(Schema):
name: str
kind: Literal["http", "tcp"]
rule: str
hosts: list[str]
url: Optional[str] = None
entryPoints: list[str]
service: Optional[str] = None
middlewares: list[str]
tls: bool
priority: Optional[int] = None
class TraefikService(Schema):
name: str
servers: list[str]
class ServiceTreeNode(Schema):
name: str
path: str
type: Literal["dir", "file"]
size: Optional[int] = None
text: Optional[bool] = None
secret: Optional[bool] = None
children: Optional[list["ServiceTreeNode"]] = None
class HomepageMeta(Schema):
label: str
description: Optional[str] = None
icon: Optional[str] = None
href: Optional[str] = None
group: Optional[str] = None
class ServiceDetail(ServiceSummary):
containersDetail: list[ServiceContainer]
composeFiles: list[str]
topNetworks: list[str]
routers: list[ServiceRouter]
traefikServices: list[TraefikService]
middlewares: list[str]
envKeys: list[str]
homepage: Optional[HomepageMeta] = None
readme: Optional[str] = None
claudeMd: Optional[str] = None
goal: Optional[GoalDetail] = None
keyFiles: list[KeyFile]
commits: list[ProjectCommit]
tree: list[ServiceTreeNode]
class ServicesResponse(Schema):
services: list[ServiceSummary]
# ── small mutation / misc envelopes ───────────────────────────────────────────
class UiStateValue(Schema):
value: Optional[str] = None
class OkResponse(Schema):
ok: bool
class ModelInfo(Schema):
"""One pickable model (a model tag in the composer)."""
id: str # the exact `--model` argument
displayName: str
family: str # opus | sonnet | haiku | fable | qwen
version: str # "4.8", "5", … (within the family)
alias: Optional[str] = None # CLI shorthand, only on a family's newest
latest: bool = False
# Which agent harness runs sessions on this model: "claude" (the claude
# CLI) or "pi" (the pi.dev runner). Picking the chip picks the harness.
harness: Optional[str] = None
# pi models only: "openrouter" (hosted) or "evox2" (local LM Studio).
provider: Optional[str] = None
# Optional short chip label ("qwen 3.6 local"); the frontend derives one
# from the id when absent.
label: Optional[str] = None
# Context-window size in tokens (`max_input_tokens` from the Anthropic
# Models API, with a static per-family fallback). Drives the conversation
# page's context-fill gizmo.
contextWindow: Optional[int] = None
class ModelsResponse(Schema):
models: list[ModelInfo]
default: str # model a run uses when nothing is picked
class OpenRouterModel(Schema):
"""One OpenRouter model a pi.dev session can be spawned on.
The catalogue behind it is APPE's (models.dev, synced daily) — see
`backend/openrouter.py`. Prices are USD per **million** tokens.
"""
id: str # the `--model` argument, e.g. "openai/gpt-oss-20b"
name: str # display name ("GPT OSS 20B")
vendor: str # the id's first segment ("openai")
description: str = ""
inputCost: float
outputCost: float
# Cache-read price. None = the model has no prompt cache (a cache read is
# then billed as a fresh read), which is a different fact from a free cache.
cacheCost: Optional[float] = None
contextWindow: Optional[int] = None
# Parameters in billions, mined from the id/name by APPE. None for models
# that publish no size (most closed ones).
modelSize: Optional[float] = None
tags: list[str] = [] # vision | reasoning | tools | opensource | …
tier: str = "" # small | medium | big (blended-price heuristic)
license: str = ""
speedTps: Optional[float] = None # median output tokens/sec
class OpenRouterModelsResponse(Schema):
models: list[OpenRouterModel] # cheapest first (blended 3:1 in:out)
source: str # the APPE URL the catalogue came from
generatedAt: str = "" # when APPE generated it (ISO 8601)
class SpawnResult(Schema):
sessionId: str
pid: Optional[int] = None
class InterruptResult(Schema):
sessionId: str
ok: bool
class UploadedFile(Schema):
name: str
size: int
contentType: str
# Repo-relative path the composer injects into the prompt (read by the session).
repoPath: str
# Backend serve route the thread viewer renders the file from.
url: str
class UploadResult(Schema):
files: list[UploadedFile]
class ConversationMetaResponse(Schema):
meta: dict[str, ConvMeta]
class MetaUpdateResult(Schema):
id: str
meta: ConvMeta
class ArchiveOldResult(Schema):
archived: int # how many conversations were newly archived
cutoff: str # UTC start-of-yesterday used as the age threshold
# ── diff view (commit + conversation diffs) ───────────────────────────────────
class CommitRef(Schema):
sha: str
short: str
date: str
author: str
subject: str
additions: int
deletions: int
files: int
repo: str # repo token: project:<slug> | service:<slug> | super:_
class ConversationCommits(Schema):
id: str
commits: list[CommitRef]
repos: list[str]
filesChanged: int
additions: int
deletions: int
class DiffLine(Schema):
type: Literal["add", "del", "ctx"]
oldNo: Optional[int] = None
newNo: Optional[int] = None
text: str
class DiffHunk(Schema):
header: str
oldStart: int
oldLines: int
newStart: int
newLines: int
lines: list[DiffLine]
class FileDiff(Schema):
path: str
oldPath: Optional[str] = None
status: Literal["added", "deleted", "modified", "renamed"]
additions: int
deletions: int
binary: bool
truncated: bool
hunks: list[DiffHunk]
repo: Optional[str] = None
# Worktree directory this file was read from (worktree diffs only) — the
# blob route needs it to serve the right copy of an image.
wt: Optional[str] = None
class WorktreeRef(Schema):
"""A live git worktree a conversation's diff was read from."""
dir: str
branch: Optional[str] = None
repo: str
class DiffResult(Schema):
title: str
subtitle: Optional[str] = None
files: list[FileDiff]
additions: int
deletions: int
commits: Optional[list[CommitRef]] = None
approx: Optional[bool] = None
# "worktree" when the changes were read with a real `git diff` from a live
# worktree; absent/"transcript" when replayed from the transcript's edits.
source: Optional[Literal["worktree", "transcript"]] = None
worktrees: Optional[list[WorktreeRef]] = None
# ── cron jobs ────────────────────────────────────────────────────────────────
class CronRun(Schema):
"""One recorded firing of a cron job — the history mapping's entry enriched
with the conversation id the session landed on (once its transcript
synced), so the UI can link straight to it."""
at: str
sessionId: str
conversationId: Optional[str] = None
class CronRunConfig(Schema):
"""How a job's firings are launched, read out of the prompt file's YAML
frontmatter (``cron.run_config``). Every field is optional: None means the
key isn't set and the spawn path's own default applies."""
# "claude" (the claude CLI) or "pi" (the pi.dev runner).
harness: Optional[str] = None
# A `--model` value: a family alias ("sonnet") or a full model id.
model: Optional[str] = None
# `--effort` level (low|medium|high|xhigh|max); "" = explicitly no flag.
effort: Optional[str] = None
# False ⇒ run with extended thinking off; None ⇒ the CLI's own default.
thinking: Optional[bool] = None
class CronJob(Schema):
id: str
name: str
# 5-field cron expression (min hour dom mon dow), container-local time.
schedule: str
# Markdown prompt file under .claude/agents/ — its content is the prompt
# each firing spawns a session with (leading YAML frontmatter stripped),
# and its frontmatter is what `runConfig` is read from.
promptFile: str
enabled: bool
# Legacy per-job model, kept as the fallback when the frontmatter sets none.
model: Optional[str] = None
# The resolved run config from the prompt file's frontmatter.
runConfig: Optional[CronRunConfig] = None
createdAt: Optional[str] = None
lastStatus: Optional[str] = None
# Next fire time (local ISO minute); None when disabled or never.
nextRun: Optional[str] = None
# The stored history: {ISO timestamp: sessionId}.
history: dict[str, str]
# `history` enriched + sorted newest-first for rendering.
runs: list[CronRun]
class CronJobsResponse(Schema):
jobs: list[CronJob]
class CronPromptFile(Schema):
path: str
content: str
exists: bool
# ── agents catalog ───────────────────────────────────────────────────────────
# Sits after the cron models because an agent's detail carries the cron job
# scheduled on its definition file.
class AgentSummary(Schema):
"""One agent card: the definition on disk + what its runs actually cost.
The skills catalog can only *estimate* spend (context size × invocations);
an agent run is a whole conversation, so `cost`/`tokens` here are measured.
"""
name: str # the invocation name (frontmatter `name:`)
title: str
description: str
# None for a built-in agent type (`Explore`, `general-purpose`, …): nothing
# of it lives in this repo, so there is no file to link into the editor.
path: Optional[str] = None
dir: Optional[str] = None
source: str # "repo" | <project dir-name> | "builtin"
sourceKind: str # "repo" | "project" | "builtin"
tools: list[str] # frontmatter `tools:`; empty ⇒ inherits all
bytes: int
updatedAt: Optional[str] = None
# Run config declared in the frontmatter, verbatim (see agents.RUN_KEYS).
harness: Optional[str] = None
model: Optional[str] = None
effort: Optional[str] = None
thinking: Optional[str] = None
runs: int # total recorded runs, both origins
taskRuns: int # …spawned as a subagent (Task tool)
sessionRuns: int # …spawned as a session (cron / Run now)
lastRun: Optional[str] = None
conversations: int # distinct conversations it ran in
cost: float # real spend across every run
tokens: int
avgCost: float
contextTokens: Optional[int] = None # cost of loading the definition once
contextCost: Optional[float] = None
class AgentsTotals(Schema):
agents: int
used: int
runs: int
cost: float
tokens: int
class AgentsResponse(Schema):
agents: list[AgentSummary]
totals: AgentsTotals
class AgentRun(Schema):
"""One recorded run of an agent."""
agent: str
origin: str # "task" | "cron" | "manual"
at: Optional[str] = None
cost: float
tokens: int
model: Optional[str] = None
title: Optional[str] = None
# The conversation to open: for a subagent run this is its *parent* (where
# the Task card is); `subConversationId` is the child transcript itself.
conversationId: Optional[str] = None
subConversationId: Optional[str] = None
class AgentDay(Schema):
"""One day of the detail page's run sparkline."""
date: str
runs: int
cost: float
class AgentDetail(Schema):
agent: AgentSummary
content: str # the definition file verbatim ("" if none)
runs: list[AgentRun] # most recent first
daily: list[AgentDay]
cron: Optional[CronJob] = None # the job scheduled on this definition
# False when cron would reject this definition as a prompt file (nested or
# project-local) — the UI explains instead of offering a button that 400s.
canSchedule: bool
# ── conversation CTAs ────────────────────────────────────────────────────────
class CtaRun(Schema):
"""One background firing of a CTA (long-press), newest first."""
at: str
sessionId: str
class Cta(Schema):
"""A conversation CTA: a prompt file offered as a button under the last
message of a finished conversation (see ctas.py)."""
id: str
name: str
# Button text. Falls back to `name` when unset.
label: str = ""
# Tooltip in the thread, description on the composer tag.
description: str = ""
# A lucide icon name, resolved client-side.
icon: str = ""
# Markdown prompt file under .claude/agents/cta/ — its content (leading
# YAML frontmatter stripped) is what the button sends.
promptFile: str
enabled: bool
# Button order, low → left.
order: int = 0
# The prompt body itself, so a click can fill the resume box (and a tag can
# fold the prompt into a typed message) without a second round-trip.
prompt: str = ""
promptExists: bool = True
# The resolved run config from the prompt file's frontmatter. Unset keys
# mean "keep whatever the session already ran with".
runConfig: Optional[CronRunConfig] = None
createdAt: Optional[str] = None
lastStatus: Optional[str] = None
# Background firings, newest first (the stored map is {ISO: sessionId}).
history: list[CtaRun]
class CtasResponse(Schema):
ctas: list[Cta]
class CtaPromptFile(Schema):
path: str
content: str
exists: bool
class ClaudeHookEntry(Schema):
"""One native Claude Code hook from the workspace's .claude/settings.json,
flattened to a single command."""
# PreToolUse / UserPromptSubmit / Stop / …
event: str
# The tool/name matcher, when the event supports one.
matcher: Optional[str] = None
type: str = "command"
command: str
class ClaudeHooksResponse(Schema):
"""The repo's native hooks — read-only: editing .claude/settings.json from
the container would be host-level code execution."""
path: str
exists: bool
entries: list[ClaudeHookEntry]
# ── deploy status ────────────────────────────────────────────────────────────
class DeployStep(Schema):
"""One phase of the blue-green deploy, with its rendered dot state."""
n: int
label: str
# pending (grey) · active (loading) · done (green) · failed (red)
state: Literal["pending", "active", "done", "failed"]
class DeployStatus(Schema):
"""Snapshot of the in-flight ai-agent deploy (``deploy.sh``), or ``null``.
Written by the host-side deploy script into the data dir and polled by the
backend, which pings a ``deploy`` SSE event on change. Drives the sticky
in-app deploy banner (worktree label + a dot-per-phase timeline)."""
# Worktree / branch label the deploy was kicked off from.
label: Optional[str] = None
status: Literal["running", "done", "failed"]
phaseNum: int
totalPhases: int
# Human description of the current phase.
phase: Optional[str] = None
steps: list[DeployStep]
startedAt: Optional[int] = None
updatedAt: Optional[int] = None
# The conversation that triggered the deploy, for the expanded panel's link.
sessionId: Optional[str] = None
convUrl: Optional[str] = None
convTitle: Optional[str] = None