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)
class AdminLlmProvidersResource:
 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.

AdminLlmProvidersResource(http: ctfy.sdk.base.BaseHttpClient)
26    def __init__(self, http: BaseHttpClient) -> None:
27        self._http = http
def list(self) -> ctfy.server.models.LlmProviderPage:
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.

def add( self, key_id: str, api_key: str, *, flavor: str, base_url: str = '', model: str = '') -> ctfy.server.models.LlmProviderInfo:
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.

def probe(self, key_id: str) -> ctfy.server.models.LlmProviderProbe:
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.

def delete(self, key_id: str) -> None:
 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}.