ctfy.sdk.admin_resources.llm_providers
client.admin.llm_providers — the upstream credentials a deployment spends.
1"""``client.admin.llm_providers`` — the upstream credentials a deployment spends.""" 2 3from __future__ import annotations 4 5from ctfy.sdk._helpers import _raise_for_status 6from ctfy.sdk.base import BaseHttpClient 7from ctfy.server.models import LlmProviderInfo, LlmProviderPage, LlmProviderProbe 8 9 10class AdminLlmProvidersResource: 11 """Add, list and remove the gateway's upstream provider keys. 12 13 ⚠️ **Super-admin only.** Resetting a budget spends the deployment's 14 money once and is platform-admin; this *installs a credential* that 15 spends it continuously, which is a fact about the deployment rather 16 than about one event. 17 18 ⚠️ **Nothing here can read a secret back.** The column is sealed 19 precisely so it cannot be, and :class:`LlmProviderInfo` carries a 20 last-four ``hint`` instead — enough to tell two keys apart when 21 rotating one, which is the whole reason an operator needs to 22 distinguish them. 23 """ 24 25 def __init__(self, http: BaseHttpClient) -> None: 26 self._http = http 27 28 def list(self) -> LlmProviderPage: 29 """Every configured provider, redacted. 30 31 ⚠️ ``gateway_configured`` rides the envelope because an empty 32 list is ambiguous: "no providers added" and "this platform 33 pushes to no gateway at all" render identically and call for 34 opposite actions. Same shape as ``webhook_configured`` on the 35 email-suppression list. 36 Wraps ``GET /admin/llm-providers``.""" 37 resp = self._http.request("GET", "/admin/llm-providers") 38 _raise_for_status(resp) 39 return LlmProviderPage.model_validate(resp.json()) 40 41 def add( 42 self, 43 key_id: str, 44 api_key: str, 45 *, 46 flavor: str, 47 base_url: str = "", 48 model: str = "", 49 ) -> LlmProviderInfo: 50 """Store a provider and push the new fleet to the gateway. 51 52 ``flavor`` is required and never defaulted: an unflavoured key is 53 selectable by *either* provider route, collects a 401 from the 54 wrong one, and is retired as a bad credential — so a guess takes 55 working keys out of the fleet. 56 57 ``base_url`` and ``model`` are per **key** rather than per 58 dialect, because DeepSeek and OpenAI both speak ``openai`` and 59 live at different addresses, and a model name only means 60 anything inside one provider's namespace. 61 62 Re-using an existing ``key_id`` **replaces** it — that is what 63 rotation is, and delete-then-add would leave a window in which 64 every challenge on that fleet is refused. 65 Wraps ``POST /admin/llm-providers``.""" 66 resp = self._http.request( 67 "POST", 68 "/admin/llm-providers", 69 json={ 70 "key_id": key_id, 71 "api_key": api_key, 72 "flavor": flavor, 73 "base_url": base_url, 74 "model": model, 75 }, 76 ) 77 _raise_for_status(resp) 78 return LlmProviderInfo.model_validate(resp.json()) 79 80 def probe(self, key_id: str) -> LlmProviderProbe: 81 """Spend one token on this credential and report what happened. 82 83 The only other way to learn whether a stored row *works* is to 84 launch an ``AGENTBENCH/`` challenge and read the banner its app 85 prints — which needs docker, a competition and a registration, 86 and is unavailable before an event. 87 88 ⚠️ **Read ``code``, never just ``ok``.** A rejected key, an 89 address nothing answers at, a model outside the provider's 90 namespace and an account out of credit are four different fields 91 to go and fix. ⚠️ And check ``stub``: the offline backend answers 92 *ok*, which is the one green result that means nothing. 93 Wraps ``POST /admin/llm-providers/{key_id}/probe``.""" 94 resp = self._http.request("POST", f"/admin/llm-providers/{key_id}/probe") 95 _raise_for_status(resp) 96 return LlmProviderProbe.model_validate(resp.json()) 97 98 def delete(self, key_id: str) -> None: 99 """Remove a provider and stop it being spent. 100 101 The gateway hears immediately rather than on the next sync tick: 102 a removed credential that keeps spending for another interval is 103 exactly the window revocation exists to close. 104 Wraps ``DELETE /admin/llm-providers/{key_id}``.""" 105 resp = self._http.request("DELETE", f"/admin/llm-providers/{key_id}") 106 _raise_for_status(resp)
11class AdminLlmProvidersResource: 12 """Add, list and remove the gateway's upstream provider keys. 13 14 ⚠️ **Super-admin only.** Resetting a budget spends the deployment's 15 money once and is platform-admin; this *installs a credential* that 16 spends it continuously, which is a fact about the deployment rather 17 than about one event. 18 19 ⚠️ **Nothing here can read a secret back.** The column is sealed 20 precisely so it cannot be, and :class:`LlmProviderInfo` carries a 21 last-four ``hint`` instead — enough to tell two keys apart when 22 rotating one, which is the whole reason an operator needs to 23 distinguish them. 24 """ 25 26 def __init__(self, http: BaseHttpClient) -> None: 27 self._http = http 28 29 def list(self) -> LlmProviderPage: 30 """Every configured provider, redacted. 31 32 ⚠️ ``gateway_configured`` rides the envelope because an empty 33 list is ambiguous: "no providers added" and "this platform 34 pushes to no gateway at all" render identically and call for 35 opposite actions. Same shape as ``webhook_configured`` on the 36 email-suppression list. 37 Wraps ``GET /admin/llm-providers``.""" 38 resp = self._http.request("GET", "/admin/llm-providers") 39 _raise_for_status(resp) 40 return LlmProviderPage.model_validate(resp.json()) 41 42 def add( 43 self, 44 key_id: str, 45 api_key: str, 46 *, 47 flavor: str, 48 base_url: str = "", 49 model: str = "", 50 ) -> LlmProviderInfo: 51 """Store a provider and push the new fleet to the gateway. 52 53 ``flavor`` is required and never defaulted: an unflavoured key is 54 selectable by *either* provider route, collects a 401 from the 55 wrong one, and is retired as a bad credential — so a guess takes 56 working keys out of the fleet. 57 58 ``base_url`` and ``model`` are per **key** rather than per 59 dialect, because DeepSeek and OpenAI both speak ``openai`` and 60 live at different addresses, and a model name only means 61 anything inside one provider's namespace. 62 63 Re-using an existing ``key_id`` **replaces** it — that is what 64 rotation is, and delete-then-add would leave a window in which 65 every challenge on that fleet is refused. 66 Wraps ``POST /admin/llm-providers``.""" 67 resp = self._http.request( 68 "POST", 69 "/admin/llm-providers", 70 json={ 71 "key_id": key_id, 72 "api_key": api_key, 73 "flavor": flavor, 74 "base_url": base_url, 75 "model": model, 76 }, 77 ) 78 _raise_for_status(resp) 79 return LlmProviderInfo.model_validate(resp.json()) 80 81 def probe(self, key_id: str) -> LlmProviderProbe: 82 """Spend one token on this credential and report what happened. 83 84 The only other way to learn whether a stored row *works* is to 85 launch an ``AGENTBENCH/`` challenge and read the banner its app 86 prints — which needs docker, a competition and a registration, 87 and is unavailable before an event. 88 89 ⚠️ **Read ``code``, never just ``ok``.** A rejected key, an 90 address nothing answers at, a model outside the provider's 91 namespace and an account out of credit are four different fields 92 to go and fix. ⚠️ And check ``stub``: the offline backend answers 93 *ok*, which is the one green result that means nothing. 94 Wraps ``POST /admin/llm-providers/{key_id}/probe``.""" 95 resp = self._http.request("POST", f"/admin/llm-providers/{key_id}/probe") 96 _raise_for_status(resp) 97 return LlmProviderProbe.model_validate(resp.json()) 98 99 def delete(self, key_id: str) -> None: 100 """Remove a provider and stop it being spent. 101 102 The gateway hears immediately rather than on the next sync tick: 103 a removed credential that keeps spending for another interval is 104 exactly the window revocation exists to close. 105 Wraps ``DELETE /admin/llm-providers/{key_id}``.""" 106 resp = self._http.request("DELETE", f"/admin/llm-providers/{key_id}") 107 _raise_for_status(resp)
Add, list and remove the gateway's upstream provider keys.
⚠️ Super-admin only. Resetting a budget spends the deployment's money once and is platform-admin; this installs a credential that spends it continuously, which is a fact about the deployment rather than about one event.
⚠️ Nothing here can read a secret back. The column is sealed
precisely so it cannot be, and LlmProviderInfo carries a
last-four hint instead — enough to tell two keys apart when
rotating one, which is the whole reason an operator needs to
distinguish them.
29 def list(self) -> LlmProviderPage: 30 """Every configured provider, redacted. 31 32 ⚠️ ``gateway_configured`` rides the envelope because an empty 33 list is ambiguous: "no providers added" and "this platform 34 pushes to no gateway at all" render identically and call for 35 opposite actions. Same shape as ``webhook_configured`` on the 36 email-suppression list. 37 Wraps ``GET /admin/llm-providers``.""" 38 resp = self._http.request("GET", "/admin/llm-providers") 39 _raise_for_status(resp) 40 return LlmProviderPage.model_validate(resp.json())
Every configured provider, redacted.
⚠️ gateway_configured rides the envelope because an empty
list is ambiguous: "no providers added" and "this platform
pushes to no gateway at all" render identically and call for
opposite actions. Same shape as webhook_configured on the
email-suppression list.
Wraps GET /admin/llm-providers.
42 def add( 43 self, 44 key_id: str, 45 api_key: str, 46 *, 47 flavor: str, 48 base_url: str = "", 49 model: str = "", 50 ) -> LlmProviderInfo: 51 """Store a provider and push the new fleet to the gateway. 52 53 ``flavor`` is required and never defaulted: an unflavoured key is 54 selectable by *either* provider route, collects a 401 from the 55 wrong one, and is retired as a bad credential — so a guess takes 56 working keys out of the fleet. 57 58 ``base_url`` and ``model`` are per **key** rather than per 59 dialect, because DeepSeek and OpenAI both speak ``openai`` and 60 live at different addresses, and a model name only means 61 anything inside one provider's namespace. 62 63 Re-using an existing ``key_id`` **replaces** it — that is what 64 rotation is, and delete-then-add would leave a window in which 65 every challenge on that fleet is refused. 66 Wraps ``POST /admin/llm-providers``.""" 67 resp = self._http.request( 68 "POST", 69 "/admin/llm-providers", 70 json={ 71 "key_id": key_id, 72 "api_key": api_key, 73 "flavor": flavor, 74 "base_url": base_url, 75 "model": model, 76 }, 77 ) 78 _raise_for_status(resp) 79 return LlmProviderInfo.model_validate(resp.json())
Store a provider and push the new fleet to the gateway.
flavor is required and never defaulted: an unflavoured key is
selectable by either provider route, collects a 401 from the
wrong one, and is retired as a bad credential — so a guess takes
working keys out of the fleet.
base_url and model are per key rather than per
dialect, because DeepSeek and OpenAI both speak openai and
live at different addresses, and a model name only means
anything inside one provider's namespace.
Re-using an existing key_id replaces it — that is what
rotation is, and delete-then-add would leave a window in which
every challenge on that fleet is refused.
Wraps POST /admin/llm-providers.
81 def probe(self, key_id: str) -> LlmProviderProbe: 82 """Spend one token on this credential and report what happened. 83 84 The only other way to learn whether a stored row *works* is to 85 launch an ``AGENTBENCH/`` challenge and read the banner its app 86 prints — which needs docker, a competition and a registration, 87 and is unavailable before an event. 88 89 ⚠️ **Read ``code``, never just ``ok``.** A rejected key, an 90 address nothing answers at, a model outside the provider's 91 namespace and an account out of credit are four different fields 92 to go and fix. ⚠️ And check ``stub``: the offline backend answers 93 *ok*, which is the one green result that means nothing. 94 Wraps ``POST /admin/llm-providers/{key_id}/probe``.""" 95 resp = self._http.request("POST", f"/admin/llm-providers/{key_id}/probe") 96 _raise_for_status(resp) 97 return LlmProviderProbe.model_validate(resp.json())
Spend one token on this credential and report what happened.
The only other way to learn whether a stored row works is to
launch an AGENTBENCH/ challenge and read the banner its app
prints — which needs docker, a competition and a registration,
and is unavailable before an event.
⚠️ Read code, never just ok. A rejected key, an
address nothing answers at, a model outside the provider's
namespace and an account out of credit are four different fields
to go and fix. ⚠️ And check stub: the offline backend answers
ok, which is the one green result that means nothing.
Wraps POST /admin/llm-providers/{key_id}/probe.
99 def delete(self, key_id: str) -> None: 100 """Remove a provider and stop it being spent. 101 102 The gateway hears immediately rather than on the next sync tick: 103 a removed credential that keeps spending for another interval is 104 exactly the window revocation exists to close. 105 Wraps ``DELETE /admin/llm-providers/{key_id}``.""" 106 resp = self._http.request("DELETE", f"/admin/llm-providers/{key_id}") 107 _raise_for_status(resp)
Remove a provider and stop it being spent.
The gateway hears immediately rather than on the next sync tick:
a removed credential that keeps spending for another interval is
exactly the window revocation exists to close.
Wraps DELETE /admin/llm-providers/{key_id}.