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 5# ⚠️ ``list`` names this class's own method inside the class body, so a 6# bare ``list[...]`` annotation resolves to it. Same reason 7# ``resources/activities.py`` imports this. 8import builtins 9 10from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status 11from ctfy.sdk.base import BaseHttpClient 12from ctfy.server.models import ( 13 AccountKeyRow, 14 LlmProviderChoice, 15 LlmProviderInfo, 16 LlmProviderPage, 17 LlmProviderProbe, 18) 19 20 21class AdminLlmProvidersResource: 22 """Add, list and remove the gateway's upstream provider keys. 23 24 ⚠️ **Super-admin only.** Resetting a budget spends the deployment's 25 money once and is platform-admin; this *installs a credential* that 26 spends it continuously, which is a fact about the deployment rather 27 than about one event. 28 29 ⚠️ **Nothing here can read a secret back.** The column is sealed 30 precisely so it cannot be, and :class:`LlmProviderInfo` carries a 31 last-four ``hint`` instead — enough to tell two keys apart when 32 rotating one, which is the whole reason an operator needs to 33 distinguish them. 34 """ 35 36 def __init__(self, http: BaseHttpClient) -> None: 37 self._http = http 38 39 def list(self) -> LlmProviderPage: 40 """Every configured provider, redacted. 41 42 ⚠️ ``gateway_configured`` rides the envelope because an empty 43 list is ambiguous: "no providers added" and "this platform 44 pushes to no gateway at all" render identically and call for 45 opposite actions. Same shape as ``webhook_configured`` on the 46 email-suppression list. 47 Wraps ``GET /admin/llm-providers``.""" 48 resp = self._http.request("GET", "/admin/llm-providers") 49 _raise_for_status(resp) 50 return LlmProviderPage.model_validate(resp.json()) 51 52 def account_keys(self, *, limit: int = 50, offset: int = 0) -> PagedList[AccountKeyRow]: 53 """The credentials **accounts hold themselves**, beside the fleet. 54 55 A bring-your-own run spends its *owner's* key and never this 56 deployment's, so a healthy fleet says nothing about whether those 57 runs can reach a provider at all — which is how a run once 58 finished ``succeeded`` having relayed zero calls while 59 ``list()`` reported green at the same upstream. 60 61 ⚠️ **No secret, no fingerprint and no Test.** A probe spends 62 somebody else's upstream account, so what comes back is the 63 stored verdict of the *owner's* last press, with its instant: a 64 code with no date reads as current for ever. 65 66 Wraps ``GET /admin/llm-providers/account-keys``.""" 67 resp = self._http.request( 68 "GET", "/admin/llm-providers/account-keys", params={"limit": limit, "offset": offset} 69 ) 70 _raise_for_status(resp) 71 # ⚠️ Through `_extract_items`, never `[Model(**r) for r in json]`: 72 # the body is a page envelope, and iterating a dict yields its 73 # **keys** — the shape that made seven list methods raise against 74 # every real response when those routes grew a window. 75 return _extract_items(resp.json(), AccountKeyRow) 76 77 def catalog(self) -> builtins.list[LlmProviderChoice]: 78 """The upstreams to pick from — each one a dialect *and* an address. 79 80 ⚠️ **A provider is both halves.** DeepSeek, Moonshot and OpenAI 81 all speak ``openai``, and several vendors serve ``anthropic`` at 82 a second route, so choosing them separately leaves a wrong 83 combination reachable — and a key stored against one is a 84 working credential nothing will ever look up. 85 86 ⚠️ **Not the same list as** ``GET /eval/llm-providers``. That one 87 is narrowed by ``byok_llm_disabled_providers``, the SSRF boundary on an 88 address a *user* chose; this is the whole catalog, because 89 ``add`` accepts any address an operator names. 90 Wraps ``GET /admin/llm-providers/catalog``.""" 91 resp = self._http.request("GET", "/admin/llm-providers/catalog") 92 _raise_for_status(resp) 93 return [LlmProviderChoice.model_validate(row) for row in resp.json()] 94 95 def add( 96 self, 97 key_id: str, 98 api_key: str, 99 *, 100 flavor: str, 101 base_url: str = "", 102 ) -> LlmProviderInfo: 103 """Store a provider and push the new fleet to the gateway. 104 105 ``flavor`` is required and never defaulted: an unflavoured key is 106 selectable by *either* provider route, collects a 401 from the 107 wrong one, and is retired as a bad credential — so a guess takes 108 working keys out of the fleet. 109 110 ``base_url`` is per **key** rather than per dialect, because 111 DeepSeek and OpenAI both speak ``openai`` and live at different 112 addresses. 113 114 ⚠️ **There is no ``model`` here any more.** A credential used to 115 carry one and the gateway preferred it over the model the 116 platform had chosen, so a run could evaluate one model and record 117 another. What a key decides is *where* a request goes; what to 118 ask for is the deployment's ``challenge_llm_model`` setting, or 119 an eval model registry row. 120 121 Re-using an existing ``key_id`` **replaces** it — that is what 122 rotation is, and delete-then-add would leave a window in which 123 every challenge on that fleet is refused. 124 Wraps ``POST /admin/llm-providers``.""" 125 resp = self._http.request( 126 "POST", 127 "/admin/llm-providers", 128 json={ 129 "key_id": key_id, 130 "api_key": api_key, 131 "flavor": flavor, 132 "base_url": base_url, 133 }, 134 ) 135 _raise_for_status(resp) 136 return LlmProviderInfo.model_validate(resp.json()) 137 138 def probe(self, key_id: str) -> LlmProviderProbe: 139 """Spend one token on this credential and report what happened. 140 141 The only other way to learn whether a stored row *works* is to 142 launch an ``AGENTBENCH/`` challenge and read the banner its app 143 prints — which needs docker, a competition and a registration, 144 and is unavailable before an event. 145 146 ⚠️ **Read ``code``, never just ``ok``.** A rejected key, an 147 address nothing answers at, a model outside the provider's 148 namespace and an account out of credit are four different fields 149 to go and fix. ⚠️ And check ``stub``: the offline backend answers 150 *ok*, which is the one green result that means nothing. 151 Wraps ``POST /admin/llm-providers/{key_id}/probe``.""" 152 resp = self._http.request("POST", f"/admin/llm-providers/{key_id}/probe") 153 _raise_for_status(resp) 154 return LlmProviderProbe.model_validate(resp.json()) 155 156 def delete(self, key_id: str) -> None: 157 """Remove a provider and stop it being spent. 158 159 The gateway hears immediately rather than on the next sync tick: 160 a removed credential that keeps spending for another interval is 161 exactly the window revocation exists to close. 162 Wraps ``DELETE /admin/llm-providers/{key_id}``.""" 163 resp = self._http.request("DELETE", f"/admin/llm-providers/{key_id}") 164 _raise_for_status(resp)
22class AdminLlmProvidersResource: 23 """Add, list and remove the gateway's upstream provider keys. 24 25 ⚠️ **Super-admin only.** Resetting a budget spends the deployment's 26 money once and is platform-admin; this *installs a credential* that 27 spends it continuously, which is a fact about the deployment rather 28 than about one event. 29 30 ⚠️ **Nothing here can read a secret back.** The column is sealed 31 precisely so it cannot be, and :class:`LlmProviderInfo` carries a 32 last-four ``hint`` instead — enough to tell two keys apart when 33 rotating one, which is the whole reason an operator needs to 34 distinguish them. 35 """ 36 37 def __init__(self, http: BaseHttpClient) -> None: 38 self._http = http 39 40 def list(self) -> LlmProviderPage: 41 """Every configured provider, redacted. 42 43 ⚠️ ``gateway_configured`` rides the envelope because an empty 44 list is ambiguous: "no providers added" and "this platform 45 pushes to no gateway at all" render identically and call for 46 opposite actions. Same shape as ``webhook_configured`` on the 47 email-suppression list. 48 Wraps ``GET /admin/llm-providers``.""" 49 resp = self._http.request("GET", "/admin/llm-providers") 50 _raise_for_status(resp) 51 return LlmProviderPage.model_validate(resp.json()) 52 53 def account_keys(self, *, limit: int = 50, offset: int = 0) -> PagedList[AccountKeyRow]: 54 """The credentials **accounts hold themselves**, beside the fleet. 55 56 A bring-your-own run spends its *owner's* key and never this 57 deployment's, so a healthy fleet says nothing about whether those 58 runs can reach a provider at all — which is how a run once 59 finished ``succeeded`` having relayed zero calls while 60 ``list()`` reported green at the same upstream. 61 62 ⚠️ **No secret, no fingerprint and no Test.** A probe spends 63 somebody else's upstream account, so what comes back is the 64 stored verdict of the *owner's* last press, with its instant: a 65 code with no date reads as current for ever. 66 67 Wraps ``GET /admin/llm-providers/account-keys``.""" 68 resp = self._http.request( 69 "GET", "/admin/llm-providers/account-keys", params={"limit": limit, "offset": offset} 70 ) 71 _raise_for_status(resp) 72 # ⚠️ Through `_extract_items`, never `[Model(**r) for r in json]`: 73 # the body is a page envelope, and iterating a dict yields its 74 # **keys** — the shape that made seven list methods raise against 75 # every real response when those routes grew a window. 76 return _extract_items(resp.json(), AccountKeyRow) 77 78 def catalog(self) -> builtins.list[LlmProviderChoice]: 79 """The upstreams to pick from — each one a dialect *and* an address. 80 81 ⚠️ **A provider is both halves.** DeepSeek, Moonshot and OpenAI 82 all speak ``openai``, and several vendors serve ``anthropic`` at 83 a second route, so choosing them separately leaves a wrong 84 combination reachable — and a key stored against one is a 85 working credential nothing will ever look up. 86 87 ⚠️ **Not the same list as** ``GET /eval/llm-providers``. That one 88 is narrowed by ``byok_llm_disabled_providers``, the SSRF boundary on an 89 address a *user* chose; this is the whole catalog, because 90 ``add`` accepts any address an operator names. 91 Wraps ``GET /admin/llm-providers/catalog``.""" 92 resp = self._http.request("GET", "/admin/llm-providers/catalog") 93 _raise_for_status(resp) 94 return [LlmProviderChoice.model_validate(row) for row in resp.json()] 95 96 def add( 97 self, 98 key_id: str, 99 api_key: str, 100 *, 101 flavor: str, 102 base_url: str = "", 103 ) -> LlmProviderInfo: 104 """Store a provider and push the new fleet to the gateway. 105 106 ``flavor`` is required and never defaulted: an unflavoured key is 107 selectable by *either* provider route, collects a 401 from the 108 wrong one, and is retired as a bad credential — so a guess takes 109 working keys out of the fleet. 110 111 ``base_url`` is per **key** rather than per dialect, because 112 DeepSeek and OpenAI both speak ``openai`` and live at different 113 addresses. 114 115 ⚠️ **There is no ``model`` here any more.** A credential used to 116 carry one and the gateway preferred it over the model the 117 platform had chosen, so a run could evaluate one model and record 118 another. What a key decides is *where* a request goes; what to 119 ask for is the deployment's ``challenge_llm_model`` setting, or 120 an eval model registry row. 121 122 Re-using an existing ``key_id`` **replaces** it — that is what 123 rotation is, and delete-then-add would leave a window in which 124 every challenge on that fleet is refused. 125 Wraps ``POST /admin/llm-providers``.""" 126 resp = self._http.request( 127 "POST", 128 "/admin/llm-providers", 129 json={ 130 "key_id": key_id, 131 "api_key": api_key, 132 "flavor": flavor, 133 "base_url": base_url, 134 }, 135 ) 136 _raise_for_status(resp) 137 return LlmProviderInfo.model_validate(resp.json()) 138 139 def probe(self, key_id: str) -> LlmProviderProbe: 140 """Spend one token on this credential and report what happened. 141 142 The only other way to learn whether a stored row *works* is to 143 launch an ``AGENTBENCH/`` challenge and read the banner its app 144 prints — which needs docker, a competition and a registration, 145 and is unavailable before an event. 146 147 ⚠️ **Read ``code``, never just ``ok``.** A rejected key, an 148 address nothing answers at, a model outside the provider's 149 namespace and an account out of credit are four different fields 150 to go and fix. ⚠️ And check ``stub``: the offline backend answers 151 *ok*, which is the one green result that means nothing. 152 Wraps ``POST /admin/llm-providers/{key_id}/probe``.""" 153 resp = self._http.request("POST", f"/admin/llm-providers/{key_id}/probe") 154 _raise_for_status(resp) 155 return LlmProviderProbe.model_validate(resp.json()) 156 157 def delete(self, key_id: str) -> None: 158 """Remove a provider and stop it being spent. 159 160 The gateway hears immediately rather than on the next sync tick: 161 a removed credential that keeps spending for another interval is 162 exactly the window revocation exists to close. 163 Wraps ``DELETE /admin/llm-providers/{key_id}``.""" 164 resp = self._http.request("DELETE", f"/admin/llm-providers/{key_id}") 165 _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.
40 def list(self) -> LlmProviderPage: 41 """Every configured provider, redacted. 42 43 ⚠️ ``gateway_configured`` rides the envelope because an empty 44 list is ambiguous: "no providers added" and "this platform 45 pushes to no gateway at all" render identically and call for 46 opposite actions. Same shape as ``webhook_configured`` on the 47 email-suppression list. 48 Wraps ``GET /admin/llm-providers``.""" 49 resp = self._http.request("GET", "/admin/llm-providers") 50 _raise_for_status(resp) 51 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.
53 def account_keys(self, *, limit: int = 50, offset: int = 0) -> PagedList[AccountKeyRow]: 54 """The credentials **accounts hold themselves**, beside the fleet. 55 56 A bring-your-own run spends its *owner's* key and never this 57 deployment's, so a healthy fleet says nothing about whether those 58 runs can reach a provider at all — which is how a run once 59 finished ``succeeded`` having relayed zero calls while 60 ``list()`` reported green at the same upstream. 61 62 ⚠️ **No secret, no fingerprint and no Test.** A probe spends 63 somebody else's upstream account, so what comes back is the 64 stored verdict of the *owner's* last press, with its instant: a 65 code with no date reads as current for ever. 66 67 Wraps ``GET /admin/llm-providers/account-keys``.""" 68 resp = self._http.request( 69 "GET", "/admin/llm-providers/account-keys", params={"limit": limit, "offset": offset} 70 ) 71 _raise_for_status(resp) 72 # ⚠️ Through `_extract_items`, never `[Model(**r) for r in json]`: 73 # the body is a page envelope, and iterating a dict yields its 74 # **keys** — the shape that made seven list methods raise against 75 # every real response when those routes grew a window. 76 return _extract_items(resp.json(), AccountKeyRow)
The credentials accounts hold themselves, beside the fleet.
A bring-your-own run spends its owner's key and never this
deployment's, so a healthy fleet says nothing about whether those
runs can reach a provider at all — which is how a run once
finished succeeded having relayed zero calls while
list() reported green at the same upstream.
⚠️ No secret, no fingerprint and no Test. A probe spends somebody else's upstream account, so what comes back is the stored verdict of the owner's last press, with its instant: a code with no date reads as current for ever.
Wraps GET /admin/llm-providers/account-keys.
78 def catalog(self) -> builtins.list[LlmProviderChoice]: 79 """The upstreams to pick from — each one a dialect *and* an address. 80 81 ⚠️ **A provider is both halves.** DeepSeek, Moonshot and OpenAI 82 all speak ``openai``, and several vendors serve ``anthropic`` at 83 a second route, so choosing them separately leaves a wrong 84 combination reachable — and a key stored against one is a 85 working credential nothing will ever look up. 86 87 ⚠️ **Not the same list as** ``GET /eval/llm-providers``. That one 88 is narrowed by ``byok_llm_disabled_providers``, the SSRF boundary on an 89 address a *user* chose; this is the whole catalog, because 90 ``add`` accepts any address an operator names. 91 Wraps ``GET /admin/llm-providers/catalog``.""" 92 resp = self._http.request("GET", "/admin/llm-providers/catalog") 93 _raise_for_status(resp) 94 return [LlmProviderChoice.model_validate(row) for row in resp.json()]
The upstreams to pick from — each one a dialect and an address.
⚠️ A provider is both halves. DeepSeek, Moonshot and OpenAI
all speak openai, and several vendors serve anthropic at
a second route, so choosing them separately leaves a wrong
combination reachable — and a key stored against one is a
working credential nothing will ever look up.
⚠️ Not the same list as GET /eval/llm-providers. That one
is narrowed by byok_llm_disabled_providers, the SSRF boundary on an
address a user chose; this is the whole catalog, because
add accepts any address an operator names.
Wraps GET /admin/llm-providers/catalog.
96 def add( 97 self, 98 key_id: str, 99 api_key: str, 100 *, 101 flavor: str, 102 base_url: str = "", 103 ) -> LlmProviderInfo: 104 """Store a provider and push the new fleet to the gateway. 105 106 ``flavor`` is required and never defaulted: an unflavoured key is 107 selectable by *either* provider route, collects a 401 from the 108 wrong one, and is retired as a bad credential — so a guess takes 109 working keys out of the fleet. 110 111 ``base_url`` is per **key** rather than per dialect, because 112 DeepSeek and OpenAI both speak ``openai`` and live at different 113 addresses. 114 115 ⚠️ **There is no ``model`` here any more.** A credential used to 116 carry one and the gateway preferred it over the model the 117 platform had chosen, so a run could evaluate one model and record 118 another. What a key decides is *where* a request goes; what to 119 ask for is the deployment's ``challenge_llm_model`` setting, or 120 an eval model registry row. 121 122 Re-using an existing ``key_id`` **replaces** it — that is what 123 rotation is, and delete-then-add would leave a window in which 124 every challenge on that fleet is refused. 125 Wraps ``POST /admin/llm-providers``.""" 126 resp = self._http.request( 127 "POST", 128 "/admin/llm-providers", 129 json={ 130 "key_id": key_id, 131 "api_key": api_key, 132 "flavor": flavor, 133 "base_url": base_url, 134 }, 135 ) 136 _raise_for_status(resp) 137 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 is per key rather than per dialect, because
DeepSeek and OpenAI both speak openai and live at different
addresses.
⚠️ There is no model here any more. A credential used to
carry one and the gateway preferred it over the model the
platform had chosen, so a run could evaluate one model and record
another. What a key decides is where a request goes; what to
ask for is the deployment's challenge_llm_model setting, or
an eval model registry row.
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.
139 def probe(self, key_id: str) -> LlmProviderProbe: 140 """Spend one token on this credential and report what happened. 141 142 The only other way to learn whether a stored row *works* is to 143 launch an ``AGENTBENCH/`` challenge and read the banner its app 144 prints — which needs docker, a competition and a registration, 145 and is unavailable before an event. 146 147 ⚠️ **Read ``code``, never just ``ok``.** A rejected key, an 148 address nothing answers at, a model outside the provider's 149 namespace and an account out of credit are four different fields 150 to go and fix. ⚠️ And check ``stub``: the offline backend answers 151 *ok*, which is the one green result that means nothing. 152 Wraps ``POST /admin/llm-providers/{key_id}/probe``.""" 153 resp = self._http.request("POST", f"/admin/llm-providers/{key_id}/probe") 154 _raise_for_status(resp) 155 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.
157 def delete(self, key_id: str) -> None: 158 """Remove a provider and stop it being spent. 159 160 The gateway hears immediately rather than on the next sync tick: 161 a removed credential that keeps spending for another interval is 162 exactly the window revocation exists to close. 163 Wraps ``DELETE /admin/llm-providers/{key_id}``.""" 164 resp = self._http.request("DELETE", f"/admin/llm-providers/{key_id}") 165 _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}.