ADR-008: Phase 2 Architecture - Model Pricing & Navigation¶
Date: November 2, 2025
Status: Accepted
Context: Phase 2 implementation (Rich Tracing)
Deciders: Team + PO
Context¶
Phase 2 introduces several architectural decisions that will impact the entire SwisperStudio platform:
- Model pricing configuration - How to handle pricing for cost calculation
- Frontend navigation structure - How users navigate the application
- Cost calculation approach - Where and how costs are calculated
These decisions affect scalability, maintainability, and user experience.
Decision 1: Project-Level Model Pricing with Provider Granularity¶
Decision¶
Model pricing will be configured at project level with provider + model name granularity, stored in a dedicated model_pricing table.
Schema¶
class ModelPricing(SQLModel, table=True):
id: UUID
project_id: UUID | None # NULL for default pricing
hosting_provider: str # "openai", "anthropic", "azure", etc.
model_name: str # "gpt-4-turbo", "claude-3-opus", etc.
input_price_per_million: Decimal # USD per 1M tokens
output_price_per_million: Decimal # USD per 1M tokens
# Unique constraint: (project_id, hosting_provider, model_name)
Rationale¶
Why project-level: - Different projects may use different providers (OpenAI vs Azure vs self-hosted) - Different teams may have different enterprise agreements/pricing - Projects in different regions may have different costs - Allows per-project cost tracking and budgeting
Why provider + model granularity: - Same model name can exist across providers (e.g., "gpt-4-turbo" on OpenAI vs Azure) - Providers have different pricing structures - Fine-tuned models have custom pricing - Swisper allows per-node model configuration → each observation can use a different model/provider
Why database over configuration files: - Dynamic updates without deployment - Per-project configuration UI (Phase 4) - Historical pricing tracking - Easier to query and validate
Lookup Strategy¶
def get_model_pricing(project_id: UUID, provider: str, model: str):
# 1. Try project-specific pricing
pricing = query(project_id=project_id, provider=provider, model=model)
# 2. Fall back to default pricing (project_id=NULL)
if not pricing:
pricing = query(project_id=None, provider=provider, model=model)
# 3. Log warning if still not found
if not pricing:
logger.warning(f"No pricing for {provider}/{model} in project {project_id}")
return None
return pricing
Alternatives Considered¶
Alternative 1: Hardcoded pricing - ❌ Can't handle custom models or enterprise pricing - ❌ Requires code changes for price updates - ❌ No per-project customization
Alternative 2: Single global pricing table - ❌ Can't handle per-project pricing - ❌ Difficult to manage multi-tenant scenarios
Alternative 3: Model-only (no provider) - ❌ Ambiguity for same model on different providers - ❌ Can't distinguish OpenAI GPT-4 vs Azure GPT-4 pricing
Decision 2: Project Workspace with Sidebar Navigation¶
Decision¶
Implement a project workspace pattern with persistent sidebar navigation when user is within a project context.
Navigation Structure¶
Projects List (/projects)
↓ Click project card
Project Workspace (/projects/:projectId)
├─ Layout: Header + Sidebar + Content
├─ Sidebar Menu:
│ ├─ 📊 Overview
│ ├─ 🔍 Tracing
│ ├─ 📈 Analytics (grayed out for MVP)
│ ├─ 🌐 Graphs (grayed out for MVP)
│ └─ ⚙️ Configuration
└─ Routes:
├─ /projects/:projectId (Overview)
├─ /projects/:projectId/tracing (Trace List)
├─ /projects/:projectId/tracing/:traceId (Trace Detail)
├─ /projects/:projectId/analytics
├─ /projects/:projectId/graphs
└─ /projects/:projectId/config
Rationale¶
Why workspace pattern: - Context preservation - User always knows which project they're in - Consistent navigation - Sidebar persists across all project pages - Scalable - Easy to add new sections (Analytics, Graphs, etc.) - Familiar - Matches Swisper's agent sidebar pattern - Professional - Standard SaaS application pattern
Why sidebar navigation: - Always visible - No need to find menu - Quick switching - One click to any section - Visual hierarchy - Clear organization of features - Responsive - Can collapse on mobile
Why this menu structure: - Overview first - Landing page with quick stats - Tracing second - Primary feature for MVP - Analytics third - Natural progression from viewing to analyzing - Graphs fourth - Visualization is advanced feature - Config last - Settings typically at bottom
Implementation¶
Component Structure:
<ProjectLayout projectId={projectId}>
<ProjectHeader project={project} />
<ProjectSidebar activeRoute={currentRoute} />
<ProjectContent>
<Outlet /> {/* Nested routes render here */}
</ProjectContent>
</ProjectLayout>
Routing (React Router):
<Route path="/projects/:projectId" element={<ProjectLayout />}>
<Route index element={<ProjectOverview />} />
<Route path="tracing" element={<TracingPage />} />
<Route path="tracing/:traceId" element={<TraceDetailPage />} />
<Route path="config" element={<ConfigPage />} />
</Route>
Alternatives Considered¶
Alternative 1: Flat navigation (no sidebar) - ❌ No persistent context - ❌ Harder to navigate deep pages - ❌ Less professional appearance
Alternative 2: Top tab navigation - ❌ Limited space for menu items - ❌ Not mobile-friendly - ❌ Can't show hierarchical structure
Alternative 3: Dropdown menus - ❌ Hidden navigation (requires clicking) - ❌ Slower access to features - ❌ Less clear hierarchy
Decision 3: Observation-Level Cost Calculation with DB Lookups¶
Decision¶
Calculate costs per observation during trace ingestion, using database pricing lookups, and store calculated costs in the observations table.
Implementation¶
When: During observation creation/update
Where: Backend cost_calculation_service
How:
def create_observation(obs_data: ObservationCreate):
# 1. Create observation
observation = Observation(**obs_data)
# 2. If has LLM telemetry, calculate cost
if observation.prompt_tokens and observation.model:
cost = calculate_llm_cost(
project_id=observation.trace.project_id,
hosting_provider=extract_provider(observation.model),
model=observation.model,
prompt_tokens=observation.prompt_tokens,
completion_tokens=observation.completion_tokens
)
observation.calculated_input_cost = cost.input_cost
observation.calculated_output_cost = cost.output_cost
observation.calculated_total_cost = cost.total_cost
# 3. Save with costs
db.add(observation)
db.commit()
Rationale¶
Why per-observation: - Swisper uses different models per node (per-node configuration) - Trace can have observations with different models/providers - Accurate cost attribution per operation
Why during ingestion: - ✅ Costs calculated once, queried many times (performance) - ✅ Historical accuracy (prices may change later) - ✅ Simpler frontend (no client-side calculation) - ✅ Enables efficient filtering/sorting by cost
Why DB lookup: - ✅ Flexible pricing (no hardcoded values) - ✅ Per-project customization - ✅ Easy updates (no code deployment) - ✅ Audit trail (can track pricing changes)
Why store calculated costs: - ✅ Query performance (no calculation on read) - ✅ Historical accuracy (pricing at time of execution) - ✅ Enables cost-based filtering/sorting - ✅ Simplifies aggregation queries
Alternatives Considered¶
Alternative 1: Client-side calculation - ❌ Expensive on every page load - ❌ Inconsistent if pricing changes - ❌ Can't filter/sort by cost efficiently
Alternative 2: Calculate on-demand - ❌ Slow for large trace lists - ❌ Pricing changes affect historical data - ❌ More complex queries
Alternative 3: Trace-level cost only - ❌ Doesn't account for per-node models - ❌ Less detailed cost breakdown - ❌ Harder to identify expensive operations
Consequences¶
Positive¶
- Scalable pricing model - Handles multi-provider, multi-project scenarios
- Clear navigation - Users can easily find features
- Accurate costs - Per-observation calculation with historical accuracy
- Performance - Costs calculated once, queried efficiently
- Flexibility - Easy to add new providers/models/projects
- Professional UX - Standard SaaS navigation pattern
Negative¶
- Database complexity - Additional table and indexes
- Migration needed - Default pricing must be seeded
- Provider extraction - Need logic to extract provider from model string
- More frontend components - Sidebar, layout, overview page
Mitigations¶
- Seed defaults - Migration includes common model pricing
- Fallback logic - Graceful handling of missing pricing
- Provider patterns - Standard mapping (openai/gpt-4 → provider: "openai")
- Reuse Swisper patterns - Copy sidebar styling from Swisper
Implementation Notes¶
Phase 2 Scope¶
Must implement:
- ✅ ModelPricing table and migration
- ✅ Cost calculation service with DB lookups
- ✅ Project layout with sidebar
- ✅ Project overview page (simple MVP)
- ✅ Read-only config page (show pricing)
Deferred to Phase 4: - ❌ Model pricing CRUD UI (add/edit/delete pricing) - ❌ Pricing history tracking - ❌ Cost budget alerts - ❌ Analytics dashboard
Provider Extraction Logic¶
def extract_provider(model: str) -> str:
"""Extract hosting provider from model string."""
# Common patterns
if model.startswith("gpt-") or model.startswith("text-"):
return "openai"
elif model.startswith("claude-"):
return "anthropic"
elif "azure" in model.lower():
return "azure"
else:
# Default or extract from metadata
return "unknown"
References¶
- Phase 2 Analysis:
docs/analysis/phase2_rich_tracing_analysis.md - Phase 2 Sub-Plan:
docs/plans/phase2_detailed_subplan.md - Swisper State Structure:
reference/swisper/backend/app/api/services/agents/global_supervisor_state.py - Langfuse Observation Model:
reference/langfuse/packages/shared/src/observationsTable.ts - ADR-001: MUI v7 for frontend (navigation will use MUI components)
Status: Accepted and ready for implementation
Last Updated: November 2, 2025