Skip to content

Commit 633d7d7

Browse files
committed
make it work for direct providers
1 parent 2c605e2 commit 633d7d7

7 files changed

Lines changed: 81 additions & 4 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ LLM_API_KEY=sk-...
1818
# Optional. If omitted, the first model returned by {LLM_API_BASE}/v1/models
1919
# (or {LLM_API_BASE}/models when the base already ends in /v1) is used.
2020
# LLM_MODEL=gpt-4o
21+
# Optional. Org slug sent as X-HF-Bill-To. Required when using a Hugging Face
22+
# token whose Inference Providers permission is scoped to an org, not the user.
23+
# LLM_BILL_TO=huggingface
24+
# Optional. Max completion tokens requested from the LLM. Bump for reasoning
25+
# models (e.g. Kimi-K2) that spend tokens on reasoning before emitting JSON.
26+
# LLM_MAX_TOKENS=4096
2127

2228
# --- Review behaviour ---
2329
# Phrase that must appear in a PR/issue comment to trigger a review.

.github/workflows/ai-review.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,6 @@ jobs:
3737
llm_api_key: ${{ secrets.LLM_API_KEY }}
3838
llm_api_base: ${{ secrets.LLM_API_BASE }}
3939
llm_model: ${{ secrets.LLM_MODEL }}
40+
llm_bill_to: ${{ secrets.LLM_BILL_TO }}
41+
llm_max_tokens: ${{ secrets.LLM_MAX_TOKENS }}
42+
max_diff_chars: ${{ secrets.MAX_DIFF_CHARS }}

action.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ inputs:
1616
llm_model:
1717
description: 'Model identifier to pass in the chat-completion request. If omitted, the action auto-discovers the first model from {llm_api_base}/models.'
1818
required: false
19+
llm_bill_to:
20+
description: 'Optional org slug for routing inference billing (sent as the X-HF-Bill-To header). Required when using a Hugging Face token whose Inference Providers permission is scoped to an organization rather than the user.'
21+
required: false
22+
llm_max_tokens:
23+
description: 'Maximum completion tokens requested from the LLM. Bump for reasoning models (e.g. Kimi-K2) that spend tokens on reasoning before emitting JSON.'
24+
required: false
25+
default: '4096'
1926
mention_trigger:
2027
description: 'Phrase that must appear in a comment to trigger a review.'
2128
required: false
@@ -64,6 +71,8 @@ runs:
6471
LLM_API_BASE: ${{ inputs.llm_api_base }}
6572
LLM_API_KEY: ${{ inputs.llm_api_key }}
6673
LLM_MODEL: ${{ inputs.llm_model }}
74+
LLM_BILL_TO: ${{ inputs.llm_bill_to }}
75+
LLM_MAX_TOKENS: ${{ inputs.llm_max_tokens }}
6776
MENTION_TRIGGER: ${{ inputs.mention_trigger }}
6877
REVIEW_EVENT: ${{ inputs.review_event }}
6978
MAX_DIFF_CHARS: ${{ inputs.max_diff_chars }}

config.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
from typing import Optional
44

55

6+
def _int_env(name: str, default: int) -> int:
7+
"""Like int(os.environ[name]) with a default, but also treats an empty
8+
string as "use default" so unset GitHub Action secrets (which forward as
9+
"") don't blow up int parsing."""
10+
raw = (os.environ.get(name) or "").strip()
11+
return int(raw) if raw else default
12+
13+
614
def _load_private_key() -> Optional[str]:
715
inline = os.environ.get("GITHUB_PRIVATE_KEY")
816
if inline:
@@ -25,6 +33,8 @@ class Config:
2533
llm_api_base: str
2634
llm_api_key: str
2735
llm_model: Optional[str]
36+
llm_bill_to: Optional[str]
37+
llm_max_tokens: int
2838

2939
mention_trigger: str
3040
review_event: str
@@ -62,9 +72,11 @@ def from_env(cls, *, require_app: bool = True) -> "Config":
6272
llm_api_base=os.environ.get("LLM_API_BASE", "https://api.openai.com/v1").rstrip("/"),
6373
llm_api_key=os.environ["LLM_API_KEY"],
6474
llm_model=os.environ.get("LLM_MODEL") or None,
75+
llm_bill_to=os.environ.get("LLM_BILL_TO") or None,
76+
llm_max_tokens=_int_env("LLM_MAX_TOKENS", 4096),
6577
mention_trigger=os.environ.get("MENTION_TRIGGER", "@serge"),
6678
review_event=os.environ.get("REVIEW_EVENT", "COMMENT"),
67-
max_diff_chars=int(os.environ.get("MAX_DIFF_CHARS", "200000")),
79+
max_diff_chars=_int_env("MAX_DIFF_CHARS", 200000),
6880
review_rules_path=os.environ.get("REVIEW_RULES_PATH", ".ai/review-rules.md"),
6981
default_review_rules=os.environ.get(
7082
"DEFAULT_REVIEW_RULES",

llm_client.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,32 @@ class ChatCompletionClient:
1515
LM Studio, llama.cpp server, etc.
1616
"""
1717

18-
def __init__(self, api_base: str, api_key: str, model: Optional[str] = None):
18+
def __init__(
19+
self,
20+
api_base: str,
21+
api_key: str,
22+
model: Optional[str] = None,
23+
bill_to: Optional[str] = None,
24+
):
1925
self.api_base = api_base.rstrip("/")
2026
self.api_key = api_key
2127
self.model = model
28+
self.bill_to = bill_to or None
2229

2330
def _api_base_v1(self) -> str:
2431
if self.api_base.endswith("/v1"):
2532
return self.api_base
2633
return f"{self.api_base}/v1"
2734

2835
def _headers(self) -> dict[str, str]:
29-
return {
36+
headers = {
3037
"Authorization": f"Bearer {self.api_key}",
3138
"Content-Type": "application/json",
3239
}
40+
if self.bill_to:
41+
# HF Router: route inference billing to an org the token has access to.
42+
headers["X-HF-Bill-To"] = self.bill_to
43+
return headers
3344

3445
def _discover_model(self) -> str:
3546
models_url = f"{self._api_base_v1()}/models"

reviewer.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@ def run_review(cfg: Config, gh: GitHubClient, req: ReviewRequest) -> None:
150150

151151
review_rules = _load_review_rules(gh, req.owner, req.repo, pr, cfg)
152152

153-
llm = ChatCompletionClient(cfg.llm_api_base, cfg.llm_api_key, cfg.llm_model)
153+
llm = ChatCompletionClient(
154+
cfg.llm_api_base, cfg.llm_api_key, cfg.llm_model, bill_to=cfg.llm_bill_to
155+
)
154156
system_prompt = build_system_prompt(review_rules)
155157
user_prompt = build_user_prompt(
156158
repo_full_name=f"{req.owner}/{req.repo}",
@@ -169,6 +171,7 @@ def run_review(cfg: Config, gh: GitHubClient, req: ReviewRequest) -> None:
169171
{"role": "user", "content": user_prompt},
170172
],
171173
response_format={"type": "json_object"},
174+
max_tokens=cfg.llm_max_tokens,
172175
)
173176

174177
try:

tests/test_llm_client.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,39 @@ def test_complete_adds_v1_when_base_is_unversioned(self) -> None:
9696
"https://example.com/v1/chat/completions",
9797
)
9898

99+
def test_complete_sends_bill_to_header_when_configured(self) -> None:
100+
with patch("llm_client.requests.get") as mock_get, patch(
101+
"llm_client.requests.post"
102+
) as mock_post:
103+
mock_post.return_value = Mock(
104+
json=Mock(return_value={"choices": [{"message": {"content": "ok"}}]}),
105+
raise_for_status=Mock(),
106+
)
107+
108+
client = ChatCompletionClient(
109+
"https://example.com/v1", "token", "fixed-model", bill_to="my-org"
110+
)
111+
client.complete([{"role": "user", "content": "hi"}])
112+
113+
mock_get.assert_not_called()
114+
self.assertEqual(
115+
mock_post.call_args.kwargs["headers"]["X-HF-Bill-To"], "my-org"
116+
)
117+
118+
def test_complete_omits_bill_to_header_when_not_configured(self) -> None:
119+
with patch("llm_client.requests.get"), patch(
120+
"llm_client.requests.post"
121+
) as mock_post:
122+
mock_post.return_value = Mock(
123+
json=Mock(return_value={"choices": [{"message": {"content": "ok"}}]}),
124+
raise_for_status=Mock(),
125+
)
126+
127+
client = ChatCompletionClient("https://example.com/v1", "token", "fixed-model")
128+
client.complete([{"role": "user", "content": "hi"}])
129+
130+
self.assertNotIn("X-HF-Bill-To", mock_post.call_args.kwargs["headers"])
131+
99132
def test_complete_raises_when_discovery_returns_no_models(self) -> None:
100133
with patch("llm_client.requests.get") as mock_get, patch(
101134
"llm_client.requests.post"

0 commit comments

Comments
 (0)