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

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

def account_keys( self, *, limit: int = 50, offset: int = 0) -> ctfy.sdk._helpers.PagedList[ctfy.server.models.AccountKeyRow]:
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.

def catalog(self) -> list[ctfy.server.models.LlmProviderChoice]:
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.

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

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

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