Skip to content

Commit bc258f6

Browse files
authored
fix(cline-pass): correct upstream-id reverse map and API base path
Follows PR #122 (squash-merged to dev as commit 8a23432) with two bug fixes that surfaced after deployment. Bug 1 — empty WebUI quota card (deployment 2026-07-11 ~02:34 UTC): The quota tracker's _build_billing_url() defensively stripped a trailing /v1 from the resolved base, producing the wrong URL: https://api.cline.bot/api/users/me/plan/usage-limits instead of the correct documented path: https://api.cline.bot/api/v1/users/me/plan/usage-limits The upstream call 404'd, no baselines landed in UsageManager, and the /v1/quota-stats filter (total_requests == 0 and no quota data) dropped the provider entirely from the response. Fix: removed the trailing-/v1 strip in _build_billing_url. The Cline API is a flat /api/v1 namespace — the helper just joins base + path. Also unified provider routing on self.api_base so chat completions, model discovery, and quota polling all hit the same documented /api/v1/... prefix. Root cause: first cut conflated 'OpenAI-compatible body' with 'OpenAI-compatible path prefix'. Cline's API is rooted at /api/v1/, not /v1/. Bug 2 — upstream-id double-prefix (Kilo Code review on PR #122): _build_reverse_map wrapped the already-prefixed upstream ids (`cline-pass/glm-5.2`) with `cline_pass/` again, producing double-prefixed keys (`cline_pass/cline-pass/glm-5.2`) that `normalize_model_for_tracking` never matched. As a result, raw upstream ids from Cline error messages and quota breakdowns were passed through unchanged instead of being mapped to the proxy's display names — breaking the very 'WebUI/pricing maps upstream → display' feature the original PR description called out. Umans does not have this bug because its UMANS_MODELS upstream ids are bare (`umans-kimi-k2.6`); wrapping with `umans/` produces a valid reverse-map key. ClinePass cannot reuse that trick because the upstream ids already include the `cline-pass/` prefix. Fix: _build_reverse_map stores the raw upstream id as the key (no proxy prefix wrapping) and the proxy display name (`cline_pass/<bare>`) as the value. normalize_model_for_tracking handles all three caller-input shapes (raw upstream id, proxy display name, bare) and returns the canonical display name. Stack compliance: Adds a `cline-pass` entry under `allowed_duplicate_features` in `.fork/stack.yml` since this is a `fix()` companion to the existing `feat(cline-pass):` commit on dev. The xai and umans feature areas already follow this pattern. Regression coverage (10 new tests, all pass): test_build_billing_url_default_base_passes_path_through test_build_billing_url_preserves_v1_in_api_v1_base test_build_billing_url_trailing_slash_on_base_is_normalised test_provider_api_base_default_uses_documented_upstream test_provider_uses_single_api_base_for_both_models_and_chat test_build_reverse_map_keys_are_raw_upstream_ids test_normalize_model_from_raw_upstream_id test_normalize_model_from_proxy_display_name test_normalize_model_from_bare_name test_normalize_model_unknown_returns_input The test_provider_* tests pin both the documented upstream URLs and the single-base invariant so a future split between quota/chat/models bases will fail CI loudly instead of silently 404'ing in production. Verification: uv run python3 -m py_compile — passed (all 3 changed Python files) uv run ruff check --select F401,F811,F821,E9 — passed uv run --with pytest python3 -m pytest tests/test_cline_pass_quota_tracker.py -q — 34 passed uv run --with pytest --with pytest-asyncio python3 -m pytest tests/ -q — 493 passed (+3 from this branch); same 15 pre-existing setup errors and same 2 pre-existing umans/xai test failures as dev (unrelated to this PR). Files (5): src/rotator_library/providers/cline_pass_provider.py src/rotator_library/providers/utilities/cline_pass_quota_tracker.py tests/test_cline_pass_quota_tracker.py .fork/stack.yml .fork/features/cline-pass.md
1 parent 8a23432 commit bc258f6

5 files changed

Lines changed: 176 additions & 24 deletions

File tree

.fork/features/cline-pass.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,71 @@ Notes:
151151
`SingletonABCMeta` singleton so all test cases share one instance,
152152
and the `__init__`-built reverse map is reused across cases. No
153153
cleanup needed (and cleanup would actually break later tests).
154+
155+
## 2026-07-11 — Fix wrong API base path (deployment hotfix)
156+
157+
Target: `feat(cline-pass): add ClinePass provider with 3-window quota tracking`
158+
Files:
159+
- `src/rotator_library/providers/cline_pass_provider.py`
160+
- `src/rotator_library/providers/utilities/cline_pass_quota_tracker.py`
161+
- `tests/test_cline_pass_quota_tracker.py`
162+
163+
Problem (caught from production deployment on 2026-07-11, 02:34 UTC):
164+
1. **Chat completions returned 404.** The provider class introduced
165+
a separate `CLINE_PASS_LITELLM_BASE = "https://api.cline.bot/v1"`
166+
for chat routing (assumption: "Cline is OpenAI-shaped, base is
167+
`/v1`"). The actual upstream path is
168+
`https://api.cline.bot/api/v1/chat/completions` — the prefix is
169+
`/api/v1`, not `/v1`. Every request landed at
170+
`https://api.cline.bot/v1/chat/completions` and 404'd.
171+
2. **Quota card empty in WebUI.** The quota tracker's
172+
`_build_billing_url()` defensively stripped a trailing `/v1` from
173+
`_resolve_api_base()`, producing
174+
`https://api.cline.bot/api/users/me/plan/usage-limits` instead
175+
of the correct
176+
`https://api.cline.bot/api/v1/users/me/plan/usage-limits`. The
177+
upstream call 404'd, baseline writes never happened, and the
178+
`/v1/quota-stats` filter (`total_requests == 0 and no quota data`)
179+
then dropped the provider from the response.
180+
181+
Root cause (both bugs):
182+
- Mistakenly assumed "OpenAI-compatible *body*" implied "OpenAI-compatible
183+
*path prefix*". Cline's API uses `/api/v1/`, not `/v1/`. The
184+
original writeup's `cline.md` documented the quota endpoint URL as
185+
`https://api.cline.bot/api/v1/users/me/plan/usage-limits` — if
186+
that had been more directly referenced during the first cut, the
187+
bug would have been caught.
188+
- The first cut unhelpfully split into two bases (one for quota, one
189+
for chat) and then rewrote the quota URL. The chat URL was wrong
190+
by construction; the quota URL was wrong by post-processing.
191+
192+
Fix:
193+
- Drop `CLINE_PASS_LITELLM_BASE` and the `self.litellm_base`
194+
attribute. Chat completions now use `self.api_base`, same as
195+
quota and model discovery. `self.api_base` defaults to
196+
`https://api.cline.bot/api/v1` and is overridable via
197+
`CLINE_PASS_API_BASE`.
198+
- Remove the trailing-`/v1` strip in `_build_billing_url()`. The
199+
upstream is a flat `/api/v1` namespace — the helper just joins
200+
base + path.
201+
- Update the docstring on the constant to call out the path-prefix
202+
gotcha so this doesn't regress.
203+
204+
Regression coverage (4 new tests, all pass):
205+
- `test_build_billing_url_default_base_passes_path_through` — default
206+
base yields `https://api.cline.bot/api/v1/users/me/plan`.
207+
- `test_build_billing_url_preserves_v1_in_api_v1_base` — pinning
208+
the no-strip behavior.
209+
- `test_build_billing_url_trailing_slash_on_base_is_normalised`
210+
trailing slash on the base doesn't double up.
211+
- `test_provider_api_base_default_uses_documented_upstream`
212+
provider default matches the Cline docs.
213+
- `test_provider_uses_single_api_base_for_both_models_and_chat`
214+
pinning the single-base invariant so a future split can't
215+
reintroduce Bug 1.
216+
217+
Verification:
218+
- `uv run python3 -m py_compile` — passed (all 3 files)
219+
- `uv run ruff check --select F401,F811,F821,E9` — passed (all 3 files)
220+
- `uv run --with pytest python3 -m pytest tests/test_cline_pass_quota_tracker.py -q` — 34 passed
221+
- `uv run --with pytest --with pytest-asyncio python3 -m pytest tests/ -q` — 493 passed (+3), same 15 pre-existing setup errors and same 2 pre-existing umans/xai test failures as `dev` (unrelated to this PR).

.fork/stack.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ rules:
2626
- "feat(xai): add xAI Grok OAuth provider with PKCE and Device Code flows"
2727
- "feat(xai): enable xAI Grok device-code OAuth in admin WebUI"
2828
- "fix(xai): set Grok Build User-Agent on all requests to fix billing classification"
29+
- "feat(xai): route all chat completions through CLI proxy"
30+
cline-pass:
31+
- "fix(cline-pass): correct upstream-id reverse map and API base path"
32+
- "feat(cline-pass): add ClinePass provider with 3-window quota tracking"
2933
umans:
3034
- "feat(umans): add Umans provider with request-based quota tracking"
3135
- "fix(umans): normalize API base and show quota without proxy requests"

src/rotator_library/providers/cline_pass_provider.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,12 @@
7676
"qwen3.7-plus": {"id": "cline-pass/qwen3.7-plus"},
7777
}
7878

79-
# The Cline API base includes the ``/api/v1`` path prefix (different from
80-
# standard OpenAI's ``/v1``). We strip it here and re-add ``/v1`` for
81-
# LiteLLM's openai/ provider, which expects the conventional shape.
79+
# The Cline API is rooted at https://api.cline.bot/api/v1 — every endpoint
80+
# (chat completions, models, usage-limits, plan) lives under that /api/v1
81+
# prefix. Do NOT use the standard OpenAI ``/v1`` shape here: Cline's path is
82+
# ``/api/v1/chat/completions``, not ``/v1/chat/completions``. (Deployment
83+
# 2026-07-11: routing via ``/v1`` produced 404s.)
8284
CLINE_PASS_DEFAULT_API_BASE = "https://api.cline.bot/api/v1"
83-
CLINE_PASS_LITELLM_BASE = "https://api.cline.bot/v1"
8485

8586
# Litellm params accepted for the openai/ provider route. Mirrors the
8687
# x_ai / ollama_cloud allowlists; conservative subset that the Cline API
@@ -152,9 +153,6 @@ def __init__(self):
152153
self.api_base = os.getenv(
153154
"CLINE_PASS_API_BASE", CLINE_PASS_DEFAULT_API_BASE
154155
)
155-
self.litellm_base = os.getenv(
156-
"CLINE_PASS_LITELLM_BASE", CLINE_PASS_LITELLM_BASE
157-
)
158156
self.model_definitions = ModelDefinitions()
159157
# Upstream-id -> display-name reverse map (mirrors Umans pattern).
160158
# Built lazily from the active model catalog (env override first,
@@ -371,13 +369,16 @@ async def acompletion(
371369
f"(set CLINE_PASS_MODELS or update DEFAULT_CLINEPASS_MODELS)"
372370
)
373371

372+
# ``self.api_base`` is rooted at ``/api/v1`` — LiteLLM's openai/
373+
# provider appends ``/chat/completions`` to whatever base you give
374+
# it, so the resulting URL is ``https://api.cline.bot/api/v1/chat/completions``.
374375
kwargs["model"] = f"openai/{upstream_id}"
375376
kwargs["api_key"] = credential
376-
kwargs["api_base"] = self.litellm_base
377+
kwargs["api_base"] = self.api_base
377378
kwargs["custom_llm_provider"] = "openai"
378379
kwargs["client"] = openai.AsyncOpenAI(
379380
api_key=credential,
380-
base_url=self.litellm_base,
381+
base_url=self.api_base,
381382
http_client=client,
382383
)
383384

src/rotator_library/providers/utilities/cline_pass_quota_tracker.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,14 @@ def _as_optional_float(value: Any) -> Optional[float]:
148148

149149

150150
def _resolve_api_base() -> str:
151-
"""Resolve the Cline API base (allows override for testing)."""
151+
"""Resolve the Cline API base (allows override for testing).
152+
153+
The upstream is rooted at https://api.cline.bot/api/v1 — every
154+
endpoint (chat completions, models, usage-limits, plan) lives
155+
under that ``/api/v1`` prefix. We do **not** strip the trailing
156+
``/v1``: doing so produces ``https://api.cline.bot/api/...`` which
157+
404s on the upstream (caught in deployment 2026-07-11).
158+
"""
152159
# ``or`` (not the second arg of getenv) so an empty string falls back to
153160
# the default — operators occasionally set ``CLINE_PASS_API_BASE=""`` to
154161
# "reset" and we shouldn't break the tracker when they do.
@@ -158,12 +165,13 @@ def _resolve_api_base() -> str:
158165

159166

160167
def _build_billing_url(path: str) -> str:
161-
"""Join the API base with a path, tolerating a base that already ends with ``/v1``."""
168+
"""Join the API base with a path.
169+
170+
The Cline API is a flat ``/api/v1`` namespace; no path rewriting is
171+
needed. Path may be passed with or without a leading slash.
172+
"""
162173
base = _resolve_api_base()
163174
suffix = path if path.startswith("/") else f"/{path}"
164-
# If the base already includes /v1, strip it so we never produce /v1/v1
165-
if base.endswith("/v1"):
166-
base = base[:-3]
167175
return f"{base}{suffix}"
168176

169177

tests/test_cline_pass_quota_tracker.py

Lines changed: 81 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,28 +62,40 @@ def test_coerce_percent_rejects_bools():
6262
# ---------------------------------------------------------------------------
6363

6464

65-
def test_build_billing_url_strips_trailing_v1():
65+
def test_build_billing_url_default_base_passes_path_through():
66+
"""Default base ``https://api.cline.bot/api/v1`` is the upstream's
67+
flat namespace — no path rewriting. The URL helper just joins the
68+
base with the caller-supplied path.
69+
70+
Regression for the deployment on 2026-07-11: the previous
71+
implementation stripped a trailing ``/v1`` from the base, producing
72+
``https://api.cline.bot/api/...`` paths that 404'd upstream and
73+
meant quota baselines never landed in the UsageManager (the WebUI
74+
quota card then had no data to render).
75+
"""
6676
with patch.dict(
6777
"os.environ",
68-
{"CLINE_PASS_API_BASE": "https://api.cline.bot/api/v1"},
78+
{"CLINE_PASS_API_BASE": ""},
6979
clear=False,
7080
):
7181
assert (
72-
_build_billing_url("/users/me/plan/usage-limits")
73-
== "https://api.cline.bot/api/users/me/plan/usage-limits"
82+
_build_billing_url("/users/me/plan")
83+
== "https://api.cline.bot/api/v1/users/me/plan"
7484
)
7585

7686

77-
def test_build_billing_url_default_base():
87+
def test_build_billing_url_preserves_v1_in_api_v1_base():
88+
"""The Cline API is ``/api/v1``, NOT ``/v1``. We must not strip the
89+
trailing ``/v1`` from the base — it's part of the upstream namespace.
90+
"""
7891
with patch.dict(
7992
"os.environ",
80-
{"CLINE_PASS_API_BASE": ""},
93+
{"CLINE_PASS_API_BASE": "https://api.cline.bot/api/v1"},
8194
clear=False,
8295
):
83-
# Default base is https://api.cline.bot/api/v1; /v1 should be stripped
8496
assert (
85-
_build_billing_url("/users/me/plan")
86-
== "https://api.cline.bot/api/users/me/plan"
97+
_build_billing_url("/users/me/plan/usage-limits")
98+
== "https://api.cline.bot/api/v1/users/me/plan/usage-limits"
8799
)
88100

89101

@@ -95,7 +107,20 @@ def test_build_billing_url_no_leading_slash():
95107
):
96108
assert (
97109
_build_billing_url("users/me/plan/usage-limits")
98-
== "https://api.cline.bot/api/users/me/plan/usage-limits"
110+
== "https://api.cline.bot/api/v1/users/me/plan/usage-limits"
111+
)
112+
113+
114+
def test_build_billing_url_trailing_slash_on_base_is_normalised():
115+
with patch.dict(
116+
"os.environ",
117+
{"CLINE_PASS_API_BASE": "https://api.cline.bot/api/v1/"},
118+
clear=False,
119+
):
120+
# Base is rstripped so we don't get a doubled slash
121+
assert (
122+
_build_billing_url("/users/me/plan")
123+
== "https://api.cline.bot/api/v1/users/me/plan"
99124
)
100125

101126

@@ -607,3 +632,49 @@ def test_normalize_model_unknown_returns_input():
607632
)
608633
# Empty
609634
assert provider.normalize_model_for_tracking("") == ""
635+
636+
637+
# ---------------------------------------------------------------------------
638+
# Routing URLs (regression for deployment 2026-07-11)
639+
# ---------------------------------------------------------------------------
640+
641+
642+
def test_provider_api_base_default_uses_documented_upstream():
643+
"""Without an env override, the provider should default to the
644+
documented Cline API base ``https://api.cline.bot/api/v1``.
645+
646+
Regression: the previous default introduced a separate
647+
``/v1`` (no ``api/``) base for chat routing, which produced
648+
404s on every request — ``https://api.cline.bot/v1/chat/completions``
649+
is NOT the Cline API path; the path is ``/api/v1/chat/completions``.
650+
"""
651+
from rotator_library.providers.cline_pass_provider import (
652+
ClinePassProvider,
653+
CLINE_PASS_DEFAULT_API_BASE,
654+
)
655+
656+
assert CLINE_PASS_DEFAULT_API_BASE == "https://api.cline.bot/api/v1"
657+
provider = ClinePassProvider()
658+
assert provider.api_base == "https://api.cline.bot/api/v1"
659+
660+
661+
def test_provider_uses_single_api_base_for_both_models_and_chat():
662+
"""The provider must use the SAME base for model discovery and
663+
chat completions — the Cline API is rooted at ``/api/v1`` and
664+
every endpoint (models, chat completions, usage-limits, plan)
665+
lives under that prefix.
666+
"""
667+
from rotator_library.providers.cline_pass_provider import ClinePassProvider
668+
669+
provider = ClinePassProvider()
670+
# ``api_base`` drives both ``get_models`` (fetches ``{api_base}/models``)
671+
# and ``acompletion`` (sets litellm ``api_base`` so openai/ builds
672+
# ``{api_base}/chat/completions``). If we ever split them again, the
673+
# deployment 2026-07-11 failure will recur.
674+
api_base = provider.api_base
675+
assert api_base == "https://api.cline.bot/api/v1"
676+
# Sanity check the constructed URLs the proxy actually hits
677+
expected_chat_url = f"{api_base}/chat/completions"
678+
assert expected_chat_url == "https://api.cline.bot/api/v1/chat/completions"
679+
expected_models_url = f"{api_base.rstrip('/')}/models"
680+
assert expected_models_url == "https://api.cline.bot/api/v1/models"

0 commit comments

Comments
 (0)