xai-review 0.18.0__py3-none-any.whl → 0.20.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.

Potentially problematic release.


This version of xai-review might be problematic. Click here for more details.

Files changed (27) hide show
  1. ai_review/clients/github/pr/client.py +13 -7
  2. ai_review/clients/github/pr/schema/pull_request.py +6 -6
  3. ai_review/clients/gitlab/mr/client.py +29 -20
  4. ai_review/clients/gitlab/mr/schema/changes.py +5 -5
  5. ai_review/clients/gitlab/mr/schema/discussions.py +1 -4
  6. ai_review/clients/gitlab/mr/schema/notes.py +19 -0
  7. ai_review/services/llm/factory.py +1 -1
  8. ai_review/services/prompt/adapter.py +15 -15
  9. ai_review/services/prompt/schema.py +18 -18
  10. ai_review/services/review/service.py +45 -42
  11. ai_review/services/vcs/factory.py +1 -1
  12. ai_review/services/vcs/github/client.py +52 -34
  13. ai_review/services/vcs/gitlab/client.py +62 -44
  14. ai_review/services/vcs/types.py +38 -29
  15. ai_review/tests/suites/services/cost/__init__.py +0 -0
  16. ai_review/tests/suites/services/cost/test_schema.py +124 -0
  17. ai_review/tests/suites/services/cost/test_service.py +99 -0
  18. ai_review/tests/suites/services/prompt/test_adapter.py +59 -0
  19. ai_review/tests/suites/services/prompt/test_schema.py +18 -18
  20. ai_review/tests/suites/services/prompt/test_service.py +13 -11
  21. {xai_review-0.18.0.dist-info → xai_review-0.20.0.dist-info}/METADATA +24 -9
  22. {xai_review-0.18.0.dist-info → xai_review-0.20.0.dist-info}/RECORD +26 -22
  23. ai_review/clients/gitlab/mr/schema/comments.py +0 -19
  24. {xai_review-0.18.0.dist-info → xai_review-0.20.0.dist-info}/WHEEL +0 -0
  25. {xai_review-0.18.0.dist-info → xai_review-0.20.0.dist-info}/entry_points.txt +0 -0
  26. {xai_review-0.18.0.dist-info → xai_review-0.20.0.dist-info}/licenses/LICENSE +0 -0
  27. {xai_review-0.18.0.dist-info → xai_review-0.20.0.dist-info}/top_level.txt +0 -0
@@ -1,13 +1,13 @@
1
1
  from ai_review.clients.github.client import get_github_http_client
2
+ from ai_review.clients.github.pr.schema.comments import GitHubCreateReviewCommentRequestSchema
2
3
  from ai_review.config import settings
3
4
  from ai_review.libs.logger import get_logger
4
5
  from ai_review.services.vcs.types import (
5
6
  VCSClient,
6
- MRNoteSchema,
7
- MRUserSchema,
8
- MRInfoSchema,
9
- MRCommentSchema,
10
- MRDiscussionSchema,
7
+ UserSchema,
8
+ BranchRefSchema,
9
+ ReviewInfoSchema,
10
+ ReviewCommentSchema,
11
11
  )
12
12
 
13
13
  logger = get_logger("GITHUB_VCS_CLIENT")
@@ -20,7 +20,7 @@ class GitHubVCSClient(VCSClient):
20
20
  self.repo = settings.vcs.pipeline.repo
21
21
  self.pull_number = settings.vcs.pipeline.pull_number
22
22
 
23
- async def get_mr_info(self) -> MRInfoSchema:
23
+ async def get_review_info(self) -> ReviewInfoSchema:
24
24
  try:
25
25
  pr = await self.http_client.pr.get_pull_request(
26
26
  owner=self.owner, repo=self.repo, pull_number=self.pull_number
@@ -33,35 +33,44 @@ class GitHubVCSClient(VCSClient):
33
33
  f"Fetched PR info for {self.owner}/{self.repo}#{self.pull_number}"
34
34
  )
35
35
 
36
- return MRInfoSchema(
36
+ return ReviewInfoSchema(
37
+ id=pr.number,
37
38
  title=pr.title,
38
- author=MRUserSchema(
39
+ description=pr.body or "",
40
+ author=UserSchema(
41
+ id=pr.user.id,
39
42
  name=pr.user.login,
40
43
  username=pr.user.login,
41
44
  ),
42
- labels=[label.name for label in pr.labels],
45
+ labels=[label.name for label in pr.labels if label.name],
43
46
  base_sha=pr.base.sha,
44
47
  head_sha=pr.head.sha,
45
48
  assignees=[
46
- MRUserSchema(name=user.login, username=user.login)
49
+ UserSchema(id=user.id, name=user.login, username=user.login)
47
50
  for user in pr.assignees
48
51
  ],
49
52
  reviewers=[
50
- MRUserSchema(name=user.login, username=user.login)
53
+ UserSchema(id=user.id, name=user.login, username=user.login)
51
54
  for user in pr.requested_reviewers
52
55
  ],
53
- description=pr.body or "",
54
- source_branch=pr.head.ref,
55
- target_branch=pr.base.ref,
56
+ source_branch=BranchRefSchema(
57
+ ref=pr.head.ref,
58
+ sha=pr.head.sha,
59
+ ),
60
+ target_branch=BranchRefSchema(
61
+ ref=pr.base.ref,
62
+ sha=pr.base.sha,
63
+ ),
56
64
  changed_files=[file.filename for file in files.root],
57
65
  )
58
66
  except Exception as error:
59
67
  logger.exception(
60
68
  f"Failed to fetch PR info {self.owner}/{self.repo}#{self.pull_number}: {error}"
61
69
  )
62
- return MRInfoSchema()
70
+ return ReviewInfoSchema()
63
71
 
64
- async def get_comments(self) -> list[MRCommentSchema]:
72
+ # === GENERAL COMMENTS ===
73
+ async def get_general_comments(self) -> list[ReviewCommentSchema]:
65
74
  try:
66
75
  response = await self.http_client.pr.get_issue_comments(
67
76
  owner=self.owner,
@@ -69,17 +78,20 @@ class GitHubVCSClient(VCSClient):
69
78
  issue_number=self.pull_number,
70
79
  )
71
80
  logger.info(
72
- f"Fetched issue comments for {self.owner}/{self.repo}#{self.pull_number}"
81
+ f"Fetched general comments for {self.owner}/{self.repo}#{self.pull_number}"
73
82
  )
74
83
 
75
- return [MRCommentSchema(id=comment.id, body=comment.body) for comment in response.root]
84
+ return [
85
+ ReviewCommentSchema(id=comment.id, body=comment.body or "")
86
+ for comment in response.root
87
+ ]
76
88
  except Exception as error:
77
89
  logger.exception(
78
- f"Failed to fetch issue comments {self.owner}/{self.repo}#{self.pull_number}: {error}"
90
+ f"Failed to fetch general comments for {self.owner}/{self.repo}#{self.pull_number}: {error}"
79
91
  )
80
92
  return []
81
93
 
82
- async def get_discussions(self) -> list[MRDiscussionSchema]:
94
+ async def get_inline_comments(self) -> list[ReviewCommentSchema]:
83
95
  try:
84
96
  response = await self.http_client.pr.get_review_comments(
85
97
  owner=self.owner,
@@ -87,23 +99,25 @@ class GitHubVCSClient(VCSClient):
87
99
  pull_number=self.pull_number,
88
100
  )
89
101
  logger.info(
90
- f"Fetched review comments for {self.owner}/{self.repo}#{self.pull_number}"
102
+ f"Fetched inline comments for {self.owner}/{self.repo}#{self.pull_number}"
91
103
  )
92
104
 
93
105
  return [
94
- MRDiscussionSchema(
95
- id=str(comment.id),
96
- notes=[MRNoteSchema(id=comment.id, body=comment.body or "")]
106
+ ReviewCommentSchema(
107
+ id=comment.id,
108
+ body=comment.body or "",
109
+ file=comment.path,
110
+ line=comment.line,
97
111
  )
98
112
  for comment in response.root
99
113
  ]
100
114
  except Exception as error:
101
115
  logger.exception(
102
- f"Failed to fetch review comments {self.owner}/{self.repo}#{self.pull_number}: {error}"
116
+ f"Failed to fetch inline comments for {self.owner}/{self.repo}#{self.pull_number}: {error}"
103
117
  )
104
118
  return []
105
119
 
106
- async def create_comment(self, message: str) -> None:
120
+ async def create_general_comment(self, message: str) -> None:
107
121
  try:
108
122
  logger.info(
109
123
  f"Posting general comment to PR {self.owner}/{self.repo}#{self.pull_number}: {message}"
@@ -122,30 +136,34 @@ class GitHubVCSClient(VCSClient):
122
136
  f"Failed to create general comment in PR {self.owner}/{self.repo}#{self.pull_number}: {error}"
123
137
  )
124
138
 
125
- async def create_discussion(self, file: str, line: int, message: str) -> None:
139
+ async def create_inline_comment(self, file: str, line: int, message: str) -> None:
126
140
  try:
127
141
  logger.info(
128
- f"Posting inline comment in {self.owner}/{self.repo}#{self.pull_number} at {file}:{line}: {message}"
142
+ f"Posting inline comment in {self.owner}/{self.repo}#{self.pull_number} "
143
+ f"at {file}:{line}: {message}"
129
144
  )
130
145
 
131
146
  pr = await self.http_client.pr.get_pull_request(
132
147
  owner=self.owner, repo=self.repo, pull_number=self.pull_number
133
148
  )
134
- commit_id = pr.head.sha
135
149
 
150
+ request = GitHubCreateReviewCommentRequestSchema(
151
+ body=message,
152
+ path=file,
153
+ line=line,
154
+ commit_id=pr.head.sha
155
+ )
136
156
  await self.http_client.pr.create_review_comment(
137
157
  owner=self.owner,
138
158
  repo=self.repo,
139
159
  pull_number=self.pull_number,
140
- body=message,
141
- path=file,
142
- line=line,
143
- commit_id=commit_id,
160
+ request=request,
144
161
  )
145
162
  logger.info(
146
163
  f"Created inline comment in {self.owner}/{self.repo}#{self.pull_number} at {file}:{line}"
147
164
  )
148
165
  except Exception as error:
149
166
  logger.exception(
150
- f"Failed to create inline comment in {self.owner}/{self.repo}#{self.pull_number} at {file}:{line}: {error}"
167
+ f"Failed to create inline comment in {self.owner}/{self.repo}#{self.pull_number} "
168
+ f"at {file}:{line}: {error}"
151
169
  )
@@ -1,17 +1,16 @@
1
1
  from ai_review.clients.gitlab.client import get_gitlab_http_client
2
2
  from ai_review.clients.gitlab.mr.schema.discussions import (
3
3
  GitLabDiscussionPositionSchema,
4
- GitLabCreateMRDiscussionRequestSchema
4
+ GitLabCreateMRDiscussionRequestSchema,
5
5
  )
6
6
  from ai_review.config import settings
7
7
  from ai_review.libs.logger import get_logger
8
8
  from ai_review.services.vcs.types import (
9
9
  VCSClient,
10
- MRUserSchema,
11
- MRInfoSchema,
12
- MRNoteSchema,
13
- MRCommentSchema,
14
- MRDiscussionSchema,
10
+ UserSchema,
11
+ BranchRefSchema,
12
+ ReviewInfoSchema,
13
+ ReviewCommentSchema,
15
14
  )
16
15
 
17
16
  logger = get_logger("GITLAB_VCS_CLIENT")
@@ -23,104 +22,121 @@ class GitLabVCSClient(VCSClient):
23
22
  self.project_id = settings.vcs.pipeline.project_id
24
23
  self.merge_request_id = settings.vcs.pipeline.merge_request_id
25
24
 
26
- async def get_mr_info(self) -> MRInfoSchema:
25
+ async def get_review_info(self) -> ReviewInfoSchema:
27
26
  try:
28
27
  response = await self.http_client.mr.get_changes(
29
28
  project_id=self.project_id,
30
29
  merge_request_id=self.merge_request_id,
31
30
  )
32
- logger.info(f"Fetched MR info for project_id={self.project_id} merge_request_id={self.merge_request_id}")
31
+ logger.info(
32
+ f"Fetched MR info for project_id={self.project_id} merge_request_id={self.merge_request_id}"
33
+ )
33
34
 
34
- return MRInfoSchema(
35
+ return ReviewInfoSchema(
36
+ id=response.iid,
35
37
  title=response.title,
36
- author=MRUserSchema(
38
+ description=response.description,
39
+ author=UserSchema(
37
40
  name=response.author.name,
38
- username=response.author.username
41
+ username=response.author.username,
42
+ id=response.author.id,
39
43
  ),
40
44
  labels=response.labels,
41
45
  base_sha=response.diff_refs.base_sha,
42
46
  head_sha=response.diff_refs.head_sha,
43
47
  start_sha=response.diff_refs.start_sha,
44
48
  reviewers=[
45
- MRUserSchema(name=reviewer.name, username=reviewer.username)
46
- for reviewer in response.reviewers
49
+ UserSchema(id=user.id, name=user.name, username=user.username)
50
+ for user in response.reviewers
47
51
  ],
48
52
  assignees=[
49
- MRUserSchema(name=assignee.name, username=assignee.username)
50
- for assignee in response.assignees
53
+ UserSchema(id=user.id, name=user.name, username=user.username)
54
+ for user in response.assignees
55
+ ],
56
+ source_branch=BranchRefSchema(
57
+ ref=response.source_branch,
58
+ sha=response.diff_refs.head_sha,
59
+ ),
60
+ target_branch=BranchRefSchema(
61
+ ref=response.target_branch,
62
+ sha=response.diff_refs.base_sha,
63
+ ),
64
+ changed_files=[
65
+ change.new_path for change in response.changes if change.new_path
51
66
  ],
52
- description=response.description,
53
- source_branch=response.source_branch,
54
- target_branch=response.target_branch,
55
- changed_files=[change.new_path for change in response.changes if change.new_path],
56
67
  )
57
68
  except Exception as error:
58
69
  logger.exception(
59
- f"Failed to fetch MR info project_id={self.project_id} "
70
+ f"Failed to fetch MR info for project_id={self.project_id} "
60
71
  f"merge_request_id={self.merge_request_id}: {error}"
61
72
  )
62
- return MRInfoSchema()
73
+ return ReviewInfoSchema()
63
74
 
64
- async def get_comments(self) -> list[MRCommentSchema]:
75
+ async def get_general_comments(self) -> list[ReviewCommentSchema]:
65
76
  try:
66
- response = await self.http_client.mr.get_comments(
77
+ response = await self.http_client.mr.get_notes(
67
78
  project_id=self.project_id,
68
79
  merge_request_id=self.merge_request_id,
69
80
  )
70
81
  logger.info(
71
- f"Fetched comments for project_id={self.project_id} merge_request_id={self.merge_request_id}"
82
+ f"Fetched general comments for project_id={self.project_id} "
83
+ f"merge_request_id={self.merge_request_id}"
72
84
  )
73
85
 
74
- return [MRCommentSchema(id=comment.id, body=comment.body) for comment in response.root]
86
+ return [
87
+ ReviewCommentSchema(id=note.id, body=note.body or "")
88
+ for note in response.root
89
+ ]
75
90
  except Exception as error:
76
91
  logger.exception(
77
- f"Failed to fetch comments project_id={self.project_id} "
92
+ f"Failed to fetch general comments project_id={self.project_id} "
78
93
  f"merge_request_id={self.merge_request_id}: {error}"
79
94
  )
80
95
  return []
81
96
 
82
- async def get_discussions(self) -> list[MRDiscussionSchema]:
97
+ async def get_inline_comments(self) -> list[ReviewCommentSchema]:
83
98
  try:
84
99
  response = await self.http_client.mr.get_discussions(
85
100
  project_id=self.project_id,
86
101
  merge_request_id=self.merge_request_id,
87
102
  )
88
103
  logger.info(
89
- f"Fetched discussions for project_id={self.project_id} merge_request_id={self.merge_request_id}"
104
+ f"Fetched inline discussions for project_id={self.project_id} "
105
+ f"merge_request_id={self.merge_request_id}"
90
106
  )
91
107
 
92
108
  return [
93
- MRDiscussionSchema(
94
- id=discussion.id,
95
- notes=[MRNoteSchema(id=note.id, body=note.body or "") for note in discussion.notes],
96
- )
109
+ ReviewCommentSchema(id=note.id, body=note.body or "")
97
110
  for discussion in response.root
111
+ for note in discussion.notes
98
112
  ]
99
113
  except Exception as error:
100
114
  logger.exception(
101
- f"Failed to fetch discussions project_id={self.project_id} "
115
+ f"Failed to fetch inline discussions project_id={self.project_id} "
102
116
  f"merge_request_id={self.merge_request_id}: {error}"
103
117
  )
104
118
  return []
105
119
 
106
- async def create_comment(self, message: str) -> None:
120
+ async def create_general_comment(self, message: str) -> None:
107
121
  try:
108
122
  logger.info(
109
- f"Posting comment to merge_request_id={self.merge_request_id}: {message}",
123
+ f"Posting general comment to merge_request_id={self.merge_request_id}: {message}"
110
124
  )
111
- await self.http_client.mr.create_comment(
112
- comment=message,
125
+ await self.http_client.mr.create_note(
126
+ body=message,
113
127
  project_id=self.project_id,
114
128
  merge_request_id=self.merge_request_id,
115
129
  )
116
- logger.info(f"Created comment in {self.merge_request_id=}")
130
+ logger.info(f"Created general comment in merge_request_id={self.merge_request_id}")
117
131
  except Exception as error:
118
- logger.exception(f"Failed to create comment in merge_request_id={self.merge_request_id}: {error}")
132
+ logger.exception(
133
+ f"Failed to create general comment in merge_request_id={self.merge_request_id}: {error}"
134
+ )
119
135
 
120
- async def create_discussion(self, file: str, line: int, message: str) -> None:
136
+ async def create_inline_comment(self, file: str, line: int, message: str) -> None:
121
137
  try:
122
138
  logger.info(
123
- f"Posting discussion to merge_request_id={self.merge_request_id} at {file}:{line}: {message}"
139
+ f"Posting inline comment in merge_request_id={self.merge_request_id} at {file}:{line}: {message}"
124
140
  )
125
141
 
126
142
  response = await self.http_client.mr.get_changes(
@@ -137,16 +153,18 @@ class GitLabVCSClient(VCSClient):
137
153
  start_sha=response.diff_refs.start_sha,
138
154
  new_path=file,
139
155
  new_line=line,
140
- )
156
+ ),
141
157
  )
142
158
  await self.http_client.mr.create_discussion(
143
159
  request=request,
144
160
  project_id=self.project_id,
145
161
  merge_request_id=self.merge_request_id,
146
162
  )
147
- logger.info(f"Created discussion in merge_request_id={self.merge_request_id} at {file}:{line}")
163
+ logger.info(
164
+ f"Created inline comment in merge_request_id={self.merge_request_id} at {file}:{line}"
165
+ )
148
166
  except Exception as error:
149
167
  logger.exception(
150
- f"Failed to create discussion in merge_request_id={self.merge_request_id} "
168
+ f"Failed to create inline comment in merge_request_id={self.merge_request_id} "
151
169
  f"at {file}:{line}: {error}"
152
170
  )
@@ -3,53 +3,62 @@ from typing import Protocol
3
3
  from pydantic import BaseModel, Field
4
4
 
5
5
 
6
- class MRUserSchema(BaseModel):
6
+ class UserSchema(BaseModel):
7
+ id: str | int | None = None
7
8
  name: str = ""
8
9
  username: str = ""
9
10
 
10
11
 
11
- class MRInfoSchema(BaseModel):
12
+ class BranchRefSchema(BaseModel):
13
+ ref: str = ""
14
+ sha: str = ""
15
+
16
+
17
+ class ReviewInfoSchema(BaseModel):
18
+ id: str | int | None = None
12
19
  title: str = ""
13
- author: MRUserSchema = Field(default_factory=MRUserSchema)
20
+ description: str = ""
21
+ author: UserSchema = Field(default_factory=UserSchema)
14
22
  labels: list[str] = Field(default_factory=list)
23
+ assignees: list[UserSchema] = Field(default_factory=list)
24
+ reviewers: list[UserSchema] = Field(default_factory=list)
25
+ source_branch: BranchRefSchema = Field(default_factory=BranchRefSchema)
26
+ target_branch: BranchRefSchema = Field(default_factory=BranchRefSchema)
27
+ changed_files: list[str] = Field(default_factory=list)
15
28
  base_sha: str = ""
16
29
  head_sha: str = ""
17
- assignees: list[MRUserSchema] = Field(default_factory=list)
18
- reviewers: list[MRUserSchema] = Field(default_factory=list)
19
30
  start_sha: str = ""
20
- description: str = ""
21
- source_branch: str = ""
22
- target_branch: str = ""
23
- changed_files: list[str] = Field(default_factory=list)
24
31
 
25
32
 
26
- class MRNoteSchema(BaseModel):
27
- id: int | str
33
+ class ReviewCommentSchema(BaseModel):
34
+ id: str | int
28
35
  body: str
36
+ file: str | None = None
37
+ line: int | None = None
29
38
 
30
39
 
31
- class MRDiscussionSchema(BaseModel):
32
- id: str
33
- notes: list[MRNoteSchema]
34
-
35
-
36
- class MRCommentSchema(BaseModel):
37
- id: int | str
38
- body: str
40
+ class ReviewThreadSchema(BaseModel):
41
+ id: str | int
42
+ comments: list[ReviewCommentSchema]
39
43
 
40
44
 
41
45
  class VCSClient(Protocol):
42
- async def get_mr_info(self) -> MRInfoSchema:
43
- ...
46
+ """
47
+ Unified interface for version control system integrations (GitHub, GitLab, Bitbucket, etc.).
48
+ Designed for code review automation: fetching review info, comments, and posting feedback.
49
+ """
50
+
51
+ async def get_review_info(self) -> ReviewInfoSchema:
52
+ """Fetch general information about the current review (PR/MR)."""
44
53
 
45
- async def get_comments(self) -> list[MRCommentSchema]:
46
- ...
54
+ async def get_general_comments(self) -> list[ReviewCommentSchema]:
55
+ """Fetch all top-level (non-inline) comments."""
47
56
 
48
- async def get_discussions(self) -> list[MRDiscussionSchema]:
49
- ...
57
+ async def get_inline_comments(self) -> list[ReviewCommentSchema]:
58
+ """Fetch inline (file + line attached) comments."""
50
59
 
51
- async def create_comment(self, message: str) -> None:
52
- ...
60
+ async def create_general_comment(self, message: str) -> None:
61
+ """Post a top-level comment."""
53
62
 
54
- async def create_discussion(self, file: str, line: int, message: str) -> None:
55
- ...
63
+ async def create_inline_comment(self, file: str, line: int, message: str) -> None:
64
+ """Post a comment attached to a specific line in file."""
File without changes
@@ -0,0 +1,124 @@
1
+ import pytest
2
+
3
+ from ai_review.services.cost.schema import CostReportSchema
4
+
5
+
6
+ # ---------- tests: PERCENTAGE CALCULATIONS ----------
7
+
8
+ def test_percent_calculations() -> None:
9
+ """
10
+ Should correctly calculate prompt and completion percent based on total cost.
11
+ """
12
+ report = CostReportSchema(
13
+ model="gpt-4",
14
+ prompt_tokens=1000,
15
+ completion_tokens=500,
16
+ input_cost=0.02,
17
+ output_cost=0.03,
18
+ total_cost=0.05,
19
+ )
20
+
21
+ assert pytest.approx(report.prompt_percent, 0.1) == 40.0
22
+ assert pytest.approx(report.completion_percent, 0.1) == 60.0
23
+
24
+
25
+ def test_percent_zero_total_cost() -> None:
26
+ """
27
+ Should handle total_cost=0 without division errors and return 0%.
28
+ """
29
+ report = CostReportSchema(
30
+ model="gpt-4o-mini",
31
+ prompt_tokens=100,
32
+ completion_tokens=50,
33
+ input_cost=0.01,
34
+ output_cost=0.01,
35
+ total_cost=0.0,
36
+ )
37
+
38
+ assert report.prompt_percent == 0.0
39
+ assert report.completion_percent == 0.0
40
+
41
+
42
+ # ---------- tests: PRETTY LINES ----------
43
+
44
+ def test_pretty_prompt_line_format() -> None:
45
+ """
46
+ Should render a formatted line for prompt tokens and cost.
47
+ """
48
+ report = CostReportSchema(
49
+ model="gpt-4",
50
+ prompt_tokens=1234,
51
+ completion_tokens=0,
52
+ input_cost=0.012345,
53
+ output_cost=0.0,
54
+ total_cost=0.012345,
55
+ )
56
+ out = report.pretty_prompt_line
57
+
58
+ assert "- Prompt tokens:" in out
59
+ assert "1234" in out
60
+ assert "USD" in out
61
+ assert "(" in out
62
+ assert ")" in out
63
+
64
+
65
+ def test_pretty_completion_line_format() -> None:
66
+ """
67
+ Should render a formatted line for completion tokens and cost.
68
+ """
69
+ report = CostReportSchema(
70
+ model="gpt-4",
71
+ prompt_tokens=0,
72
+ completion_tokens=567,
73
+ input_cost=0.0,
74
+ output_cost=0.06789,
75
+ total_cost=0.06789,
76
+ )
77
+ out = report.pretty_completion_line
78
+
79
+ assert "- Completion tokens:" in out
80
+ assert "567" in out
81
+ assert "USD" in out
82
+
83
+
84
+ def test_pretty_total_line_format() -> None:
85
+ """
86
+ Should render a formatted total cost line.
87
+ """
88
+ report = CostReportSchema(
89
+ model="gpt-4",
90
+ prompt_tokens=0,
91
+ completion_tokens=0,
92
+ input_cost=0.0,
93
+ output_cost=0.0,
94
+ total_cost=1.234567,
95
+ )
96
+ out = report.pretty_total_line
97
+
98
+ assert "- Total:" in out
99
+ assert "USD" in out
100
+ assert "1.234567" in out
101
+
102
+
103
+ # ---------- tests: MULTILINE OUTPUT ----------
104
+
105
+ def test_pretty_multiline_output() -> None:
106
+ """
107
+ Should produce a full formatted cost report with all parts.
108
+ """
109
+ report = CostReportSchema(
110
+ model="gpt-4o-mini",
111
+ prompt_tokens=200,
112
+ completion_tokens=100,
113
+ input_cost=0.002,
114
+ output_cost=0.001,
115
+ total_cost=0.003,
116
+ )
117
+
118
+ out = report.pretty()
119
+
120
+ assert out.startswith("\n💰 Estimated Cost for `gpt-4o-mini`")
121
+ assert "- Prompt tokens:" in out
122
+ assert "- Completion tokens:" in out
123
+ assert "- Total:" in out
124
+ assert out.count("\n") >= 4