Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ All notable changes to scolta-python are documented here.
- **Auto-provisioned Amazee credentials stored without resolved model names no longer leave AI permanently broken (`src/scolta/ai/amazee/auto_provisioner.py`).** Provisioning persists credentials and resolves model names as two non-atomic steps (`AmazeeTrialProvisioner.provision()` stores the token+url, then calls `/model/info`). When the model-info call fails, `get_available_models()` swallows the error and returns `[]`, so the `on_models_resolved` gate never fires and no model name is persisted — but `ConfigStorage.load()` requires only token+url, so it reports the half-provisioned credentials as valid. `ensure_ai_available()` then short-circuited on stored credentials on every later request and never re-resolved, so the caller fell back to the dated config default (`claude-sonnet-4-5-20250929`) which the Amazee LiteLLM gateway rejects with HTTP 400 "Invalid model name" — failing AI silently with no self-recovery (outside `KeyExpiryRecovery`'s auth-only remit). `ensure_ai_available()` now accepts an optional `has_resolved_models` predicate: when stored credentials exist but the caller reports models are still unresolved, model resolution is re-attempted against the **already-stored key** (never a fresh trial, which would waste a server-limited allocation) and `on_models_resolved` fires with the result, so the incomplete-provision state self-heals on the next lazy-init pass. Without the predicate the historical no-op is unchanged. A regression test drives the full provision → failed-resolution → store → re-resolve sequence. (The dated-default fallback itself lives in the consuming adapter/demo client construction, which adopts the predicate when it re-vendors.)

### Added
- **`Referer: scolta-python` header on Amazee control-plane requests
(`src/scolta/ai/amazee/client.py`).** The `_post`/`_get` helpers that hit
`api.amazee.ai` now send `Referer: scolta-python` so the Amazee backend can
attribute control-plane traffic to this SDK. Port of @dan2k3k4's
tag1consulting/scolta-php#203 (issue tag1consulting/scolta-php#202) with the
package-specific value. The per-tenant LiteLLM calls are unchanged. Covered by
a test asserting the header on a POST and a GET.
- **CI now builds and validates the PyPI artifacts (`dist` job in
`ci.yml`).** Publishing is manual and nothing in CI built the sdist/wheel, so
packaging breakage or cruft was only found at `twine upload` time. The job
Expand Down
8 changes: 6 additions & 2 deletions src/scolta/ai/amazee/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ def validate_token(self, litellm_token: str, litellm_api_url: str) -> None:
# -- internal --

def _post(self, path: str, payload: dict, bearer: str | None = None) -> dict:
headers = {"Content-Type": "application/json", "Accept": "application/json"}
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Referer": "scolta-python",
}
if bearer is not None:
headers["Authorization"] = f"Bearer {bearer}"
try:
Expand All @@ -116,7 +120,7 @@ def _post(self, path: str, payload: dict, bearer: str | None = None) -> dict:
return self._decode(path, response)

def _get(self, path: str, bearer: str | None = None) -> dict:
headers = {"Accept": "application/json"}
headers = {"Accept": "application/json", "Referer": "scolta-python"}
if bearer is not None:
headers["Authorization"] = f"Bearer {bearer}"
try:
Expand Down
27 changes: 27 additions & 0 deletions tests/ai/amazee/test_amazee.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,33 @@ def test_list_regions():
assert regions[0]["id"] == "us"


def test_control_plane_requests_send_referer_header():
seen = {}

def handler(request: httpx.Request) -> httpx.Response:
seen[request.method] = request.headers.get("Referer")
if request.url.path == "/auth/generate-trial-access":
return httpx.Response(
200,
json={
"key": {
"litellm_token": "lt",
"litellm_api_url": "https://llm.amazee.ai",
"region": "eu",
}
},
)
if request.url.path == "/regions":
return httpx.Response(200, json={"regions": []})
return httpx.Response(404)

c = AmazeeClient(http_client=httpx.Client(transport=httpx.MockTransport(handler)))
c.provision_trial()
c.list_regions("sess-token")
assert seen["POST"] == "scolta-python"
assert seen["GET"] == "scolta-python"


def test_create_private_key():
c = _client(
{
Expand Down