codeshift 0.3.6__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. codeshift/__init__.py +2 -2
  2. codeshift/cli/__init__.py +1 -1
  3. codeshift/cli/commands/__init__.py +1 -1
  4. codeshift/cli/commands/auth.py +5 -5
  5. codeshift/cli/commands/scan.py +2 -5
  6. codeshift/cli/commands/upgrade.py +2 -7
  7. codeshift/cli/commands/upgrade_all.py +1 -1
  8. codeshift/cli/main.py +2 -2
  9. codeshift/migrator/llm_migrator.py +8 -12
  10. codeshift/utils/__init__.py +1 -1
  11. codeshift/utils/api_client.py +11 -11
  12. codeshift/utils/cache.py +1 -1
  13. {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/METADATA +2 -18
  14. {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/RECORD +18 -34
  15. {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/licenses/LICENSE +1 -1
  16. codeshift/api/__init__.py +0 -1
  17. codeshift/api/auth.py +0 -182
  18. codeshift/api/config.py +0 -73
  19. codeshift/api/database.py +0 -215
  20. codeshift/api/main.py +0 -103
  21. codeshift/api/models/__init__.py +0 -55
  22. codeshift/api/models/auth.py +0 -108
  23. codeshift/api/models/billing.py +0 -92
  24. codeshift/api/models/migrate.py +0 -42
  25. codeshift/api/models/usage.py +0 -116
  26. codeshift/api/routers/__init__.py +0 -5
  27. codeshift/api/routers/auth.py +0 -440
  28. codeshift/api/routers/billing.py +0 -395
  29. codeshift/api/routers/migrate.py +0 -304
  30. codeshift/api/routers/usage.py +0 -291
  31. codeshift/api/routers/webhooks.py +0 -289
  32. {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/WHEEL +0 -0
  33. {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/entry_points.txt +0 -0
  34. {codeshift-0.3.6.dist-info → codeshift-0.4.0.dist-info}/top_level.txt +0 -0
@@ -1,395 +0,0 @@
1
- """Billing router for the PyResolve API."""
2
-
3
- import stripe
4
- from fastapi import APIRouter, HTTPException, status
5
-
6
- from codeshift.api.auth import CurrentUser
7
- from codeshift.api.config import get_settings
8
- from codeshift.api.database import get_database
9
- from codeshift.api.models.billing import (
10
- BillingOverview,
11
- CheckoutSessionRequest,
12
- CheckoutSessionResponse,
13
- PaymentMethodInfo,
14
- PortalSessionResponse,
15
- SubscriptionInfo,
16
- TierInfo,
17
- )
18
-
19
- router = APIRouter()
20
-
21
- # Tier definitions
22
- TIERS: dict[str, TierInfo] = {
23
- "free": TierInfo(
24
- name="free",
25
- display_name="Free",
26
- price_monthly=0,
27
- files_per_month=100,
28
- llm_calls_per_month=50,
29
- features=[
30
- "100 file migrations/month",
31
- "50 LLM-assisted migrations/month",
32
- "5 supported libraries",
33
- "Community support",
34
- ],
35
- ),
36
- "pro": TierInfo(
37
- name="pro",
38
- display_name="Pro",
39
- price_monthly=1900, # $19.00
40
- files_per_month=1000,
41
- llm_calls_per_month=500,
42
- features=[
43
- "1,000 file migrations/month",
44
- "500 LLM-assisted migrations/month",
45
- "All supported libraries",
46
- "Priority support",
47
- "Custom knowledge bases",
48
- ],
49
- ),
50
- "unlimited": TierInfo(
51
- name="unlimited",
52
- display_name="Unlimited",
53
- price_monthly=4900, # $49.00
54
- files_per_month=999999999,
55
- llm_calls_per_month=999999999,
56
- features=[
57
- "Unlimited file migrations",
58
- "Unlimited LLM-assisted migrations",
59
- "All supported libraries",
60
- "Priority support",
61
- "Custom knowledge bases",
62
- "Usage analytics",
63
- ],
64
- ),
65
- "enterprise": TierInfo(
66
- name="enterprise",
67
- display_name="Enterprise",
68
- price_monthly=0, # Custom pricing
69
- files_per_month=999999999,
70
- llm_calls_per_month=999999999,
71
- features=[
72
- "Unlimited migrations",
73
- "Dedicated support",
74
- "Custom integrations",
75
- "SLA guarantees",
76
- "Self-hosted option",
77
- "SSO/SAML",
78
- ],
79
- ),
80
- }
81
-
82
-
83
- def get_stripe_client() -> stripe:
84
- """Get configured Stripe client."""
85
- settings = get_settings()
86
- stripe.api_key = settings.stripe_secret_key
87
- return stripe
88
-
89
-
90
- @router.get("/tiers", response_model=list[TierInfo])
91
- async def list_tiers() -> list[TierInfo]:
92
- """List all available pricing tiers."""
93
- return list(TIERS.values())
94
-
95
-
96
- @router.get("/tiers/{tier_name}", response_model=TierInfo)
97
- async def get_tier(tier_name: str) -> TierInfo:
98
- """Get details about a specific tier."""
99
- tier = TIERS.get(tier_name)
100
- if not tier:
101
- raise HTTPException(
102
- status_code=status.HTTP_404_NOT_FOUND,
103
- detail=f"Tier '{tier_name}' not found",
104
- )
105
- return tier
106
-
107
-
108
- @router.get("/subscription", response_model=SubscriptionInfo)
109
- async def get_subscription(user: CurrentUser) -> SubscriptionInfo:
110
- """Get the current user's subscription information."""
111
- db = get_database()
112
- profile = db.get_profile_by_id(user.user_id)
113
-
114
- if not profile:
115
- raise HTTPException(
116
- status_code=status.HTTP_404_NOT_FOUND,
117
- detail="User profile not found",
118
- )
119
-
120
- # If user has a Stripe subscription, fetch details
121
- subscription_id = profile.get("stripe_subscription_id")
122
- if subscription_id:
123
- try:
124
- stripe_client = get_stripe_client()
125
- subscription = stripe_client.Subscription.retrieve(subscription_id)
126
-
127
- return SubscriptionInfo(
128
- tier=profile.get("tier", "free"),
129
- status=subscription.status,
130
- stripe_subscription_id=subscription_id,
131
- current_period_start=subscription.current_period_start,
132
- current_period_end=subscription.current_period_end,
133
- cancel_at_period_end=subscription.cancel_at_period_end,
134
- )
135
- except stripe.error.StripeError:
136
- pass # Fall through to basic response
137
-
138
- return SubscriptionInfo(
139
- tier=profile.get("tier", "free"),
140
- status="active" if profile.get("tier", "free") != "free" else "free",
141
- )
142
-
143
-
144
- @router.get("/overview", response_model=BillingOverview)
145
- async def get_billing_overview(user: CurrentUser) -> BillingOverview:
146
- """Get complete billing overview for the current user."""
147
- db = get_database()
148
- profile = db.get_profile_by_id(user.user_id)
149
-
150
- if not profile:
151
- raise HTTPException(
152
- status_code=status.HTTP_404_NOT_FOUND,
153
- detail="User profile not found",
154
- )
155
-
156
- tier = profile.get("tier", "free")
157
- tier_info = TIERS.get(tier, TIERS["free"])
158
-
159
- subscription = SubscriptionInfo(
160
- tier=tier,
161
- status="active" if tier != "free" else "free",
162
- )
163
-
164
- payment_method: PaymentMethodInfo | None = None
165
-
166
- # Fetch Stripe details if available
167
- subscription_id = profile.get("stripe_subscription_id")
168
-
169
- if subscription_id:
170
- try:
171
- stripe_client = get_stripe_client()
172
- sub = stripe_client.Subscription.retrieve(subscription_id)
173
-
174
- subscription = SubscriptionInfo(
175
- tier=tier,
176
- status=sub.status,
177
- stripe_subscription_id=subscription_id,
178
- current_period_start=sub.current_period_start,
179
- current_period_end=sub.current_period_end,
180
- cancel_at_period_end=sub.cancel_at_period_end,
181
- )
182
-
183
- # Get payment method
184
- if sub.default_payment_method:
185
- pm = stripe_client.PaymentMethod.retrieve(sub.default_payment_method)
186
- if pm.type == "card" and pm.card:
187
- payment_method = PaymentMethodInfo(
188
- id=pm.id,
189
- type=pm.type,
190
- card_brand=pm.card.brand,
191
- card_last4=pm.card.last4,
192
- card_exp_month=pm.card.exp_month,
193
- card_exp_year=pm.card.exp_year,
194
- )
195
- except stripe.error.StripeError:
196
- pass
197
-
198
- return BillingOverview(
199
- subscription=subscription,
200
- tier_info=tier_info,
201
- payment_method=payment_method,
202
- )
203
-
204
-
205
- @router.post("/checkout", response_model=CheckoutSessionResponse)
206
- async def create_checkout_session(
207
- request: CheckoutSessionRequest,
208
- user: CurrentUser,
209
- ) -> CheckoutSessionResponse:
210
- """Create a Stripe checkout session for upgrading subscription."""
211
- settings = get_settings()
212
- stripe_client = get_stripe_client()
213
-
214
- # Get or create Stripe customer
215
- db = get_database()
216
- profile = db.get_profile_by_id(user.user_id)
217
-
218
- if not profile:
219
- raise HTTPException(
220
- status_code=status.HTTP_404_NOT_FOUND,
221
- detail="User profile not found",
222
- )
223
-
224
- customer_id = profile.get("stripe_customer_id")
225
-
226
- if not customer_id:
227
- # Create Stripe customer
228
- customer = stripe_client.Customer.create(
229
- email=profile["email"],
230
- metadata={
231
- "user_id": user.user_id,
232
- },
233
- )
234
- customer_id = customer.id
235
-
236
- # Update profile with customer ID
237
- db.update_profile(user.user_id, {"stripe_customer_id": customer_id})
238
-
239
- # Get the price ID for the requested tier
240
- if request.tier == "pro":
241
- price_id = settings.stripe_price_id_pro
242
- elif request.tier == "unlimited":
243
- price_id = settings.stripe_price_id_unlimited
244
- else:
245
- raise HTTPException(
246
- status_code=status.HTTP_400_BAD_REQUEST,
247
- detail=f"Invalid tier: {request.tier}",
248
- )
249
-
250
- if not price_id:
251
- raise HTTPException(
252
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
253
- detail="Stripe price not configured",
254
- )
255
-
256
- # Create checkout session
257
- success_url = request.success_url or f"{settings.codeshift_api_url}/billing/success"
258
- cancel_url = request.cancel_url or f"{settings.codeshift_api_url}/billing/cancel"
259
-
260
- try:
261
- session = stripe_client.checkout.Session.create(
262
- customer=customer_id,
263
- mode="subscription",
264
- line_items=[
265
- {
266
- "price": price_id,
267
- "quantity": 1,
268
- }
269
- ],
270
- success_url=success_url + "?session_id={CHECKOUT_SESSION_ID}",
271
- cancel_url=cancel_url,
272
- metadata={
273
- "user_id": user.user_id,
274
- "tier": request.tier,
275
- },
276
- )
277
- except stripe.error.StripeError as e:
278
- raise HTTPException(
279
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
280
- detail=f"Failed to create checkout session: {str(e)}",
281
- ) from e
282
-
283
- return CheckoutSessionResponse(
284
- checkout_url=session.url,
285
- session_id=session.id,
286
- )
287
-
288
-
289
- @router.get("/portal", response_model=PortalSessionResponse)
290
- async def create_portal_session(user: CurrentUser) -> PortalSessionResponse:
291
- """Create a Stripe billing portal session for managing subscription."""
292
- settings = get_settings()
293
- stripe_client = get_stripe_client()
294
-
295
- db = get_database()
296
- profile = db.get_profile_by_id(user.user_id)
297
-
298
- if not profile:
299
- raise HTTPException(
300
- status_code=status.HTTP_404_NOT_FOUND,
301
- detail="User profile not found",
302
- )
303
-
304
- customer_id = profile.get("stripe_customer_id")
305
-
306
- if not customer_id:
307
- raise HTTPException(
308
- status_code=status.HTTP_400_BAD_REQUEST,
309
- detail="No billing account found. Please subscribe first.",
310
- )
311
-
312
- try:
313
- session = stripe_client.billing_portal.Session.create(
314
- customer=customer_id,
315
- return_url=f"{settings.codeshift_api_url}/billing",
316
- )
317
- except stripe.error.StripeError as e:
318
- raise HTTPException(
319
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
320
- detail=f"Failed to create portal session: {str(e)}",
321
- ) from e
322
-
323
- return PortalSessionResponse(portal_url=session.url)
324
-
325
-
326
- @router.post("/cancel")
327
- async def cancel_subscription(user: CurrentUser) -> dict:
328
- """Cancel the current subscription at end of billing period."""
329
- stripe_client = get_stripe_client()
330
-
331
- db = get_database()
332
- profile = db.get_profile_by_id(user.user_id)
333
-
334
- if not profile:
335
- raise HTTPException(
336
- status_code=status.HTTP_404_NOT_FOUND,
337
- detail="User profile not found",
338
- )
339
-
340
- subscription_id = profile.get("stripe_subscription_id")
341
-
342
- if not subscription_id:
343
- raise HTTPException(
344
- status_code=status.HTTP_400_BAD_REQUEST,
345
- detail="No active subscription found",
346
- )
347
-
348
- try:
349
- stripe_client.Subscription.modify(
350
- subscription_id,
351
- cancel_at_period_end=True,
352
- )
353
- except stripe.error.StripeError as e:
354
- raise HTTPException(
355
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
356
- detail=f"Failed to cancel subscription: {str(e)}",
357
- ) from e
358
-
359
- return {"message": "Subscription will be canceled at end of billing period"}
360
-
361
-
362
- @router.post("/reactivate")
363
- async def reactivate_subscription(user: CurrentUser) -> dict:
364
- """Reactivate a subscription that was set to cancel."""
365
- stripe_client = get_stripe_client()
366
-
367
- db = get_database()
368
- profile = db.get_profile_by_id(user.user_id)
369
-
370
- if not profile:
371
- raise HTTPException(
372
- status_code=status.HTTP_404_NOT_FOUND,
373
- detail="User profile not found",
374
- )
375
-
376
- subscription_id = profile.get("stripe_subscription_id")
377
-
378
- if not subscription_id:
379
- raise HTTPException(
380
- status_code=status.HTTP_400_BAD_REQUEST,
381
- detail="No subscription found",
382
- )
383
-
384
- try:
385
- stripe_client.Subscription.modify(
386
- subscription_id,
387
- cancel_at_period_end=False,
388
- )
389
- except stripe.error.StripeError as e:
390
- raise HTTPException(
391
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
392
- detail=f"Failed to reactivate subscription: {str(e)}",
393
- ) from e
394
-
395
- return {"message": "Subscription reactivated"}
@@ -1,304 +0,0 @@
1
- """Migration router for LLM-powered code migrations.
2
-
3
- This router handles all LLM-powered migrations (Tier 2/3).
4
- The Anthropic API calls are made server-side, ensuring:
5
- 1. Users don't need their own API keys
6
- 2. Usage can be tracked and billed
7
- 3. The LLM prompts remain server-side
8
- """
9
-
10
- import re
11
- from typing import Annotated
12
-
13
- from anthropic import Anthropic
14
- from fastapi import APIRouter, Depends, HTTPException, status
15
-
16
- from codeshift.api.auth import AuthenticatedUser, require_tier
17
- from codeshift.api.config import get_settings
18
- from codeshift.api.database import get_database
19
- from codeshift.api.models.migrate import (
20
- ExplainChangeRequest,
21
- ExplainChangeResponse,
22
- MigrateCodeRequest,
23
- MigrateCodeResponse,
24
- )
25
-
26
- router = APIRouter(prefix="/migrate", tags=["migrate"])
27
-
28
-
29
- def get_anthropic_client(): # type: ignore[no-untyped-def]
30
- """Get the server-side Anthropic client."""
31
-
32
- settings = get_settings()
33
- if not settings.anthropic_api_key:
34
- raise HTTPException(
35
- status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
36
- detail="LLM service not configured",
37
- )
38
-
39
- return Anthropic(api_key=settings.anthropic_api_key)
40
-
41
-
42
- def check_llm_quota(user: AuthenticatedUser, quantity: int = 1) -> None:
43
- """Check if user has remaining LLM quota.
44
-
45
- Raises HTTPException if quota exceeded.
46
- """
47
- db = get_database()
48
- quota = db.get_user_quota(user.user_id)
49
-
50
- if not quota:
51
- raise HTTPException(
52
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
53
- detail="Could not retrieve quota information",
54
- )
55
-
56
- # Get tier limits
57
- tier_limits = {
58
- "free": 0, # Free tier cannot use LLM
59
- "pro": 100,
60
- "unlimited": 999999,
61
- "enterprise": 999999,
62
- }
63
-
64
- limit = tier_limits.get(user.tier, 0)
65
- current_usage = quota.get("llm_calls", 0)
66
-
67
- if current_usage + quantity > limit:
68
- raise HTTPException(
69
- status_code=status.HTTP_402_PAYMENT_REQUIRED,
70
- detail={
71
- "error": "LLM quota exceeded",
72
- "current_usage": current_usage,
73
- "limit": limit,
74
- "tier": user.tier,
75
- "upgrade_url": "https://codeshift.dev/pricing",
76
- },
77
- )
78
-
79
-
80
- def record_llm_usage(user: AuthenticatedUser, library: str, tokens_used: dict) -> None:
81
- """Record LLM usage for billing."""
82
- db = get_database()
83
- db.record_usage_event(
84
- user_id=user.user_id,
85
- event_type="llm_call",
86
- library=library,
87
- quantity=1,
88
- metadata={
89
- "input_tokens": tokens_used.get("input_tokens", 0),
90
- "output_tokens": tokens_used.get("output_tokens", 0),
91
- },
92
- )
93
-
94
-
95
- def extract_code_from_response(content: str) -> str:
96
- """Extract Python code from LLM response."""
97
- # Try to find code block
98
- code_block_pattern = r"```(?:python)?\n(.*?)```"
99
- matches = re.findall(code_block_pattern, content, re.DOTALL)
100
-
101
- if matches:
102
- # Return the longest code block (likely the full migration)
103
- longest_match: str = max(matches, key=len)
104
- return longest_match.strip()
105
-
106
- # No code block found, assume the entire response is code
107
- content = content.strip()
108
- if content.startswith("```"):
109
- content = content[3:]
110
- if content.endswith("```"):
111
- content = content[:-3]
112
-
113
- return content.strip()
114
-
115
-
116
- def validate_syntax(code: str) -> bool:
117
- """Quick syntax validation."""
118
- try:
119
- compile(code, "<string>", "exec")
120
- return True
121
- except SyntaxError:
122
- return False
123
-
124
-
125
- @router.post("/code", response_model=MigrateCodeResponse)
126
- async def migrate_code(
127
- request: MigrateCodeRequest,
128
- user: Annotated[AuthenticatedUser, Depends(require_tier("pro"))],
129
- ) -> MigrateCodeResponse:
130
- """Migrate code using LLM.
131
-
132
- This endpoint requires Pro tier or higher.
133
- The LLM call is made server-side using PyResolve's Anthropic API key.
134
- """
135
- # Check quota
136
- check_llm_quota(user)
137
-
138
- # Get Anthropic client
139
- client = get_anthropic_client()
140
-
141
- # Build the prompt
142
- system_prompt = f"""You are an expert Python developer specializing in code migrations.
143
- Your task is to migrate Python code from {request.library} v{request.from_version} to v{request.to_version}.
144
-
145
- Guidelines:
146
- 1. Only modify code that needs to change for the migration
147
- 2. Preserve all comments, formatting, and code style where possible
148
- 3. Add brief inline comments explaining non-obvious changes
149
- 4. If you're unsure about a change, add a TODO comment
150
- 5. Return ONLY the migrated code, no explanations before or after
151
-
152
- Important {request.library} migration changes to consider:
153
- - Config class -> model_config = ConfigDict(...)
154
- - @validator -> @field_validator with @classmethod
155
- - @root_validator -> @model_validator with @classmethod
156
- - .dict() -> .model_dump()
157
- - .json() -> .model_dump_json()
158
- - .schema() -> .model_json_schema()
159
- - .parse_obj() -> .model_validate()
160
- - .parse_raw() -> .model_validate_json()
161
- - .copy() -> .model_copy()
162
- - orm_mode -> from_attributes
163
- - Field(regex=...) -> Field(pattern=...)
164
- """
165
-
166
- user_prompt = f"""Migrate the following Python code from {request.library} v{request.from_version} to v{request.to_version}.
167
-
168
- {f"Context: {request.context}" if request.context else ""}
169
-
170
- Code to migrate:
171
- ```python
172
- {request.code}
173
- ```
174
-
175
- Return only the migrated Python code:"""
176
-
177
- try:
178
- response = client.messages.create(
179
- model="claude-sonnet-4-20250514",
180
- max_tokens=4096,
181
- temperature=0.0,
182
- system=system_prompt,
183
- messages=[{"role": "user", "content": user_prompt}],
184
- )
185
-
186
- # Extract content
187
- content = ""
188
- for block in response.content:
189
- if hasattr(block, "text"):
190
- content += block.text
191
-
192
- # Extract code from response
193
- migrated_code = extract_code_from_response(content)
194
-
195
- # Validate syntax
196
- if not validate_syntax(migrated_code):
197
- return MigrateCodeResponse(
198
- success=False,
199
- migrated_code=request.code,
200
- original_code=request.code,
201
- error="LLM output has syntax errors",
202
- usage={
203
- "input_tokens": response.usage.input_tokens,
204
- "output_tokens": response.usage.output_tokens,
205
- },
206
- )
207
-
208
- # Record usage
209
- record_llm_usage(
210
- user,
211
- request.library,
212
- {
213
- "input_tokens": response.usage.input_tokens,
214
- "output_tokens": response.usage.output_tokens,
215
- },
216
- )
217
-
218
- return MigrateCodeResponse(
219
- success=True,
220
- migrated_code=migrated_code,
221
- original_code=request.code,
222
- usage={
223
- "input_tokens": response.usage.input_tokens,
224
- "output_tokens": response.usage.output_tokens,
225
- },
226
- )
227
-
228
- except Exception as e:
229
- return MigrateCodeResponse(
230
- success=False,
231
- migrated_code=request.code,
232
- original_code=request.code,
233
- error=str(e),
234
- )
235
-
236
-
237
- @router.post("/explain", response_model=ExplainChangeResponse)
238
- async def explain_change(
239
- request: ExplainChangeRequest,
240
- user: Annotated[AuthenticatedUser, Depends(require_tier("pro"))],
241
- ) -> ExplainChangeResponse:
242
- """Explain a migration change using LLM.
243
-
244
- This endpoint requires Pro tier or higher.
245
- """
246
- # Check quota
247
- check_llm_quota(user)
248
-
249
- # Get Anthropic client
250
- client = get_anthropic_client()
251
-
252
- system_prompt = """You are an expert Python developer.
253
- Explain code changes clearly and concisely for other developers.
254
- Focus on the 'why' not just the 'what'."""
255
-
256
- user_prompt = f"""Explain the following {request.library} migration change:
257
-
258
- Original:
259
- ```python
260
- {request.original_code}
261
- ```
262
-
263
- Migrated:
264
- ```python
265
- {request.transformed_code}
266
- ```
267
-
268
- Provide a brief explanation (2-3 sentences) of what changed and why:"""
269
-
270
- try:
271
- response = client.messages.create(
272
- model="claude-sonnet-4-20250514",
273
- max_tokens=500,
274
- temperature=0.0,
275
- system=system_prompt,
276
- messages=[{"role": "user", "content": user_prompt}],
277
- )
278
-
279
- # Extract content
280
- content = ""
281
- for block in response.content:
282
- if hasattr(block, "text"):
283
- content += block.text
284
-
285
- # Record usage
286
- record_llm_usage(
287
- user,
288
- request.library,
289
- {
290
- "input_tokens": response.usage.input_tokens,
291
- "output_tokens": response.usage.output_tokens,
292
- },
293
- )
294
-
295
- return ExplainChangeResponse(
296
- success=True,
297
- explanation=content.strip(),
298
- )
299
-
300
- except Exception as e:
301
- return ExplainChangeResponse(
302
- success=False,
303
- error=str(e),
304
- )