imbi-plugin-openai 2.44.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- imbi_plugin_openai-2.44.0/.gitignore +30 -0
- imbi_plugin_openai-2.44.0/PKG-INFO +54 -0
- imbi_plugin_openai-2.44.0/README.md +37 -0
- imbi_plugin_openai-2.44.0/moon.yml +22 -0
- imbi_plugin_openai-2.44.0/pyproject.toml +44 -0
- imbi_plugin_openai-2.44.0/src/imbi/plugins/openai/__init__.py +8 -0
- imbi_plugin_openai-2.44.0/src/imbi/plugins/openai/plugin.py +21 -0
- imbi_plugin_openai-2.44.0/src/imbi/plugins/openai/py.typed +0 -0
- imbi_plugin_openai-2.44.0/src/imbi/plugins/openai/wif.py +83 -0
- imbi_plugin_openai-2.44.0/tests/__init__.py +0 -0
- imbi_plugin_openai-2.44.0/tests/test_wif.py +160 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
__pycache__
|
|
2
|
+
*.egg-info
|
|
3
|
+
*.pyc
|
|
4
|
+
*.env
|
|
5
|
+
.env
|
|
6
|
+
.env.test
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
docs/site/
|
|
10
|
+
helm/imbi/charts/
|
|
11
|
+
runtime/state/
|
|
12
|
+
.venv/
|
|
13
|
+
.coverage
|
|
14
|
+
.mypy_cache/
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.ruff_cache/
|
|
17
|
+
coverage.xml
|
|
18
|
+
site/
|
|
19
|
+
|
|
20
|
+
# moon
|
|
21
|
+
.moon/cache
|
|
22
|
+
.moon/docker
|
|
23
|
+
|
|
24
|
+
# design-sync
|
|
25
|
+
.design-sync/sb-reference/
|
|
26
|
+
.design-sync/learnings/
|
|
27
|
+
.design-sync/.cache/
|
|
28
|
+
.design-sync/node_modules
|
|
29
|
+
.ds-sync/
|
|
30
|
+
ds-bundle/
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: imbi-plugin-openai
|
|
3
|
+
Version: 2.44.0
|
|
4
|
+
Summary: OpenAI workload identity federation for Imbi
|
|
5
|
+
Author-email: "Gavin M. Roy" <gavinr@aweber.com>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Natural Language :: English
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Python: >=3.14
|
|
14
|
+
Requires-Dist: imbi-common==2.44.0
|
|
15
|
+
Requires-Dist: pydantic>=2
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# imbi-plugin-openai
|
|
19
|
+
|
|
20
|
+
OpenAI workload identity federation (WIF) for Imbi. The plugin adds:
|
|
21
|
+
|
|
22
|
+
- The OpenAI token exchanger. An OpenAI AI Provider can then use workload
|
|
23
|
+
identity instead of a stored key (Admin, AI Models).
|
|
24
|
+
- The `openai_wif` secret provider. A dynamic binding then mints a
|
|
25
|
+
short-lived OpenAI token for the agent of the task. The harness does
|
|
26
|
+
not use it yet: the Claude Agent SDK calls only the Anthropic API.
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
|
|
30
|
+
1. The organization selects an identity source (for example `aws_sts`) in
|
|
31
|
+
Admin, Secrets, Identity.
|
|
32
|
+
2. In the OpenAI dashboard, create a workload identity provider for the
|
|
33
|
+
issuer of that identity source. Set its audience to
|
|
34
|
+
`https://api.openai.com/v1`. Imbi always requests this audience.
|
|
35
|
+
See the [OpenAI guide](https://developers.openai.com/api/reference/workload-identity-federation).
|
|
36
|
+
3. Create a service account and a mapping. The mapping matches the role
|
|
37
|
+
ARN (`sub`) and the request tag `imbi_ai_provider` (an AI Provider) or
|
|
38
|
+
`imbi_agent` (an agent). AWS request tags are nested in the JWT, so
|
|
39
|
+
expose them with a CEL transformation, for example
|
|
40
|
+
`assertion["https://sts.amazonaws.com/"]["request_tags"]["imbi_ai_provider"]`.
|
|
41
|
+
4. Give Imbi the ids: on the AI Provider (Admin, AI Models, the key icon)
|
|
42
|
+
or in a dynamic `openai_wif` binding.
|
|
43
|
+
5. At use time, Imbi gets a new identity JWT that lasts 55 minutes, and
|
|
44
|
+
exchanges it at `https://auth.openai.com/oauth/token` (RFC 8693). The
|
|
45
|
+
token never outlives the JWT.
|
|
46
|
+
|
|
47
|
+
## Target
|
|
48
|
+
|
|
49
|
+
| Field | Required | Description |
|
|
50
|
+
| ---------------------- | -------- | ----------------------------------------- |
|
|
51
|
+
| `identity_provider_id` | yes | The OpenAI workload identity provider id. |
|
|
52
|
+
| `service_account_id` | yes | The OpenAI service account id. |
|
|
53
|
+
|
|
54
|
+
The provider takes no configuration.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# imbi-plugin-openai
|
|
2
|
+
|
|
3
|
+
OpenAI workload identity federation (WIF) for Imbi. The plugin adds:
|
|
4
|
+
|
|
5
|
+
- The OpenAI token exchanger. An OpenAI AI Provider can then use workload
|
|
6
|
+
identity instead of a stored key (Admin, AI Models).
|
|
7
|
+
- The `openai_wif` secret provider. A dynamic binding then mints a
|
|
8
|
+
short-lived OpenAI token for the agent of the task. The harness does
|
|
9
|
+
not use it yet: the Claude Agent SDK calls only the Anthropic API.
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
1. The organization selects an identity source (for example `aws_sts`) in
|
|
14
|
+
Admin, Secrets, Identity.
|
|
15
|
+
2. In the OpenAI dashboard, create a workload identity provider for the
|
|
16
|
+
issuer of that identity source. Set its audience to
|
|
17
|
+
`https://api.openai.com/v1`. Imbi always requests this audience.
|
|
18
|
+
See the [OpenAI guide](https://developers.openai.com/api/reference/workload-identity-federation).
|
|
19
|
+
3. Create a service account and a mapping. The mapping matches the role
|
|
20
|
+
ARN (`sub`) and the request tag `imbi_ai_provider` (an AI Provider) or
|
|
21
|
+
`imbi_agent` (an agent). AWS request tags are nested in the JWT, so
|
|
22
|
+
expose them with a CEL transformation, for example
|
|
23
|
+
`assertion["https://sts.amazonaws.com/"]["request_tags"]["imbi_ai_provider"]`.
|
|
24
|
+
4. Give Imbi the ids: on the AI Provider (Admin, AI Models, the key icon)
|
|
25
|
+
or in a dynamic `openai_wif` binding.
|
|
26
|
+
5. At use time, Imbi gets a new identity JWT that lasts 55 minutes, and
|
|
27
|
+
exchanges it at `https://auth.openai.com/oauth/token` (RFC 8693). The
|
|
28
|
+
token never outlives the JWT.
|
|
29
|
+
|
|
30
|
+
## Target
|
|
31
|
+
|
|
32
|
+
| Field | Required | Description |
|
|
33
|
+
| ---------------------- | -------- | ----------------------------------------- |
|
|
34
|
+
| `identity_provider_id` | yes | The OpenAI workload identity provider id. |
|
|
35
|
+
| `service_account_id` | yes | The OpenAI service account id. |
|
|
36
|
+
|
|
37
|
+
The provider takes no configuration.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json
|
|
2
|
+
layer: 'library'
|
|
3
|
+
stack: 'backend'
|
|
4
|
+
language: 'python'
|
|
5
|
+
tags:
|
|
6
|
+
- 'python'
|
|
7
|
+
|
|
8
|
+
project:
|
|
9
|
+
name: 'imbi-plugin-openai'
|
|
10
|
+
description: 'OpenAI workload identity plugin for Imbi'
|
|
11
|
+
|
|
12
|
+
dependsOn:
|
|
13
|
+
- 'common'
|
|
14
|
+
|
|
15
|
+
# Inherits lint / format / typecheck / test from .moon/tasks/python.yml. The
|
|
16
|
+
# shared test task points --cov-config at this member's pyproject, where
|
|
17
|
+
# [tool.coverage] scopes source + threshold to the member.
|
|
18
|
+
tasks:
|
|
19
|
+
test:
|
|
20
|
+
args:
|
|
21
|
+
- '--cov'
|
|
22
|
+
- '--cov-report=term-missing'
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "imbi-plugin-openai"
|
|
3
|
+
version = "2.44.0"
|
|
4
|
+
description = "OpenAI workload identity federation for Imbi"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
license = "BSD-3-Clause"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Gavin M. Roy", email = "gavinr@aweber.com" },
|
|
10
|
+
]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Natural Language :: English",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.14",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"imbi-common==2.44.0",
|
|
21
|
+
"pydantic>=2",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["hatchling"]
|
|
26
|
+
build-backend = "hatchling.build"
|
|
27
|
+
|
|
28
|
+
# Coverage for the per-member `moon run openai:test` run. Scoped to this
|
|
29
|
+
# member so the report/threshold reflect only imbi.plugins.openai; the aggregate
|
|
30
|
+
# `root:coverage` run uses the root pyproject config instead.
|
|
31
|
+
[tool.coverage.run]
|
|
32
|
+
branch = true
|
|
33
|
+
source_pkgs = ["imbi.plugins.openai"]
|
|
34
|
+
|
|
35
|
+
[tool.coverage.report]
|
|
36
|
+
exclude_also = ["typing.TYPE_CHECKING"]
|
|
37
|
+
fail_under = 85
|
|
38
|
+
show_missing = true
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/imbi"]
|
|
42
|
+
include = [
|
|
43
|
+
"src/imbi/plugins/openai/py.typed",
|
|
44
|
+
]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Imbi OpenAI plugin: workload identity federation."""
|
|
2
|
+
|
|
3
|
+
from imbi.plugins.openai.plugin import OpenAIPlugin
|
|
4
|
+
from imbi.plugins.openai.wif import OpenAIExchanger, OpenAIWif
|
|
5
|
+
|
|
6
|
+
PLUGIN = OpenAIPlugin
|
|
7
|
+
|
|
8
|
+
__all__ = ['PLUGIN', 'OpenAIExchanger', 'OpenAIPlugin', 'OpenAIWif']
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The OpenAI plugin manifest.
|
|
2
|
+
|
|
3
|
+
The plugin has no Integration capability. It adds the ``openai_wif``
|
|
4
|
+
secret provider and the OpenAI token exchanger (for AI Providers).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import imbi.common.plugins as plugins
|
|
8
|
+
from imbi.plugins.openai import wif
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenAIPlugin(plugins.Plugin):
|
|
12
|
+
manifest = plugins.PluginManifest(
|
|
13
|
+
slug='openai',
|
|
14
|
+
name='OpenAI',
|
|
15
|
+
description='OpenAI workload identity federation for model calls',
|
|
16
|
+
icon='si-openai',
|
|
17
|
+
auth_type='none',
|
|
18
|
+
capabilities=[],
|
|
19
|
+
secret_providers=[wif.OpenAIWif],
|
|
20
|
+
token_exchangers=[wif.OpenAIExchanger],
|
|
21
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""OpenAI workload identity federation (WIF plan 3.3, 4.3, 4.5).
|
|
2
|
+
|
|
3
|
+
- :class:`OpenAIExchanger` exchanges an identity JWT for an OpenAI
|
|
4
|
+
access token at a fixed endpoint (D8), with an RFC 8693 token
|
|
5
|
+
exchange.
|
|
6
|
+
- :class:`OpenAIWif` is the ``openai_wif`` secret provider. A dynamic
|
|
7
|
+
binding holds the exchanger target. Each resolution mints a new token
|
|
8
|
+
for the agent of the task through core (``vendor_token``).
|
|
9
|
+
|
|
10
|
+
The subject token and the access token are never logged.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import typing
|
|
14
|
+
|
|
15
|
+
import pydantic
|
|
16
|
+
|
|
17
|
+
from imbi.common.plugins import identity
|
|
18
|
+
|
|
19
|
+
#: Fixed in code (D8). No tenant setting can change it.
|
|
20
|
+
TOKEN_URL = 'https://auth.openai.com/oauth/token'
|
|
21
|
+
_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'
|
|
22
|
+
_SUBJECT_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:jwt'
|
|
23
|
+
|
|
24
|
+
_Id = typing.Annotated[
|
|
25
|
+
str,
|
|
26
|
+
pydantic.StringConstraints(
|
|
27
|
+
min_length=1, max_length=128, pattern=r'^[A-Za-z0-9_-]+$'
|
|
28
|
+
),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Target(pydantic.BaseModel):
|
|
33
|
+
"""The OpenAI ids of one workload identity provider and account."""
|
|
34
|
+
|
|
35
|
+
model_config = pydantic.ConfigDict(extra='forbid')
|
|
36
|
+
|
|
37
|
+
identity_provider_id: _Id
|
|
38
|
+
service_account_id: _Id
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class OpenAIExchanger(identity.TokenExchanger):
|
|
42
|
+
vendor = 'openai'
|
|
43
|
+
label = 'OpenAI'
|
|
44
|
+
target_help = (
|
|
45
|
+
f'{identity.target_keys(_Target)}, the ids from the OpenAI dashboard.'
|
|
46
|
+
)
|
|
47
|
+
#: The audience of the OpenAI workload identity provider. The
|
|
48
|
+
#: provider in the OpenAI dashboard must use this value.
|
|
49
|
+
audience = 'https://api.openai.com/v1'
|
|
50
|
+
#: An OpenAI token never outlives its subject token. 3300 seconds
|
|
51
|
+
#: keeps the ``aws_sts`` role session at 3600 seconds, the AWS
|
|
52
|
+
#: limit for role chaining (imbi-api on IRSA or EKS Pod Identity).
|
|
53
|
+
jwt_seconds = 3300
|
|
54
|
+
|
|
55
|
+
def validate_target(
|
|
56
|
+
self, target: dict[str, typing.Any]
|
|
57
|
+
) -> dict[str, typing.Any]:
|
|
58
|
+
return identity.validate_model(_Target, target)
|
|
59
|
+
|
|
60
|
+
async def exchange(
|
|
61
|
+
self, target: dict[str, typing.Any], identity_token: str
|
|
62
|
+
) -> identity.AccessToken:
|
|
63
|
+
return await identity.post_exchange(
|
|
64
|
+
'OpenAI',
|
|
65
|
+
TOKEN_URL,
|
|
66
|
+
{
|
|
67
|
+
'grant_type': _GRANT_TYPE,
|
|
68
|
+
'subject_token': identity_token,
|
|
69
|
+
'subject_token_type': _SUBJECT_TOKEN_TYPE,
|
|
70
|
+
**target,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
EXCHANGER = OpenAIExchanger()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class OpenAIWif(identity.WifSecretProvider):
|
|
79
|
+
"""``openai_wif``: an OpenAI token for the agent of the task."""
|
|
80
|
+
|
|
81
|
+
type = 'openai_wif'
|
|
82
|
+
label = 'OpenAI workload identity'
|
|
83
|
+
exchanger = EXCHANGER
|
|
File without changes
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Tests for the OpenAI exchanger and the ``openai_wif`` provider.
|
|
2
|
+
|
|
3
|
+
The token endpoint is an httpx mock transport: no call leaves the test.
|
|
4
|
+
The error mapping is tested with the Anthropic exchanger, which uses
|
|
5
|
+
the same :func:`identity.post_exchange`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
import functools
|
|
10
|
+
import json
|
|
11
|
+
import unittest
|
|
12
|
+
import uuid
|
|
13
|
+
from unittest import mock
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from imbi.common.plugins import identity, secrets
|
|
18
|
+
from imbi.plugins.openai import plugin, wif
|
|
19
|
+
|
|
20
|
+
TARGET = {'identity_provider_id': 'idp_1', 'service_account_id': 'sa_1'}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ExchangerTestCase(unittest.IsolatedAsyncioTestCase):
|
|
24
|
+
def setUp(self) -> None:
|
|
25
|
+
self.requests: list[httpx.Request] = []
|
|
26
|
+
self.reply = httpx.Response(
|
|
27
|
+
200,
|
|
28
|
+
json={
|
|
29
|
+
'access_token': 'openai-token',
|
|
30
|
+
'issued_token_type': (
|
|
31
|
+
'urn:ietf:params:oauth:token-type:access_token'
|
|
32
|
+
),
|
|
33
|
+
'token_type': 'Bearer',
|
|
34
|
+
'expires_in': 3600,
|
|
35
|
+
'expires_at': 1_800_000_000,
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
patcher = mock.patch.object(
|
|
39
|
+
identity.httpx,
|
|
40
|
+
'AsyncClient',
|
|
41
|
+
functools.partial(
|
|
42
|
+
httpx.AsyncClient, transport=httpx.MockTransport(self._handle)
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
patcher.start()
|
|
46
|
+
self.addCleanup(patcher.stop)
|
|
47
|
+
|
|
48
|
+
def _handle(self, request: httpx.Request) -> httpx.Response:
|
|
49
|
+
self.requests.append(request)
|
|
50
|
+
return self.reply
|
|
51
|
+
|
|
52
|
+
async def test_exchange(self) -> None:
|
|
53
|
+
before = datetime.datetime.now(datetime.UTC)
|
|
54
|
+
token = await wif.EXCHANGER.exchange(TARGET, 'eyJ.jwt')
|
|
55
|
+
self.assertEqual(token.token, 'openai-token')
|
|
56
|
+
self.assertIsNone(token.scope)
|
|
57
|
+
self.assertGreaterEqual(
|
|
58
|
+
token.expires_at, before + datetime.timedelta(seconds=3600)
|
|
59
|
+
)
|
|
60
|
+
request = self.requests[0]
|
|
61
|
+
self.assertEqual(str(request.url), wif.TOKEN_URL)
|
|
62
|
+
self.assertEqual(request.headers['content-type'], 'application/json')
|
|
63
|
+
self.assertEqual(
|
|
64
|
+
json.loads(request.content),
|
|
65
|
+
{
|
|
66
|
+
'grant_type': (
|
|
67
|
+
'urn:ietf:params:oauth:grant-type:token-exchange'
|
|
68
|
+
),
|
|
69
|
+
'subject_token': 'eyJ.jwt',
|
|
70
|
+
'subject_token_type': 'urn:ietf:params:oauth:token-type:jwt',
|
|
71
|
+
**TARGET,
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def test_refused(self) -> None:
|
|
76
|
+
self.reply = httpx.Response(400, json={'error': 'invalid_grant'})
|
|
77
|
+
with self.assertRaises(identity.IdentityError) as ctx:
|
|
78
|
+
await wif.EXCHANGER.exchange(TARGET, 'eyJ.jwt')
|
|
79
|
+
self.assertEqual(ctx.exception.reason, 'exchange_refused')
|
|
80
|
+
|
|
81
|
+
def test_target(self) -> None:
|
|
82
|
+
self.assertEqual(wif.EXCHANGER.validate_target(TARGET), TARGET)
|
|
83
|
+
for bad in (
|
|
84
|
+
{**TARGET, 'extra': 'x'},
|
|
85
|
+
{'identity_provider_id': 'idp_1'},
|
|
86
|
+
{**TARGET, 'service_account_id': '../x'},
|
|
87
|
+
):
|
|
88
|
+
with self.assertRaises(identity.InvalidConfiguration):
|
|
89
|
+
wif.EXCHANGER.validate_target(bad)
|
|
90
|
+
# The help lists the keys of the target model.
|
|
91
|
+
self.assertTrue(
|
|
92
|
+
wif.EXCHANGER.target_help.startswith(
|
|
93
|
+
json.dumps(dict.fromkeys(TARGET, '...'))
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def test_claims(self) -> None:
|
|
98
|
+
self.assertEqual(wif.EXCHANGER.audience, 'https://api.openai.com/v1')
|
|
99
|
+
self.assertEqual(wif.EXCHANGER.jwt_seconds, 3300)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ProviderTestCase(unittest.IsolatedAsyncioTestCase):
|
|
103
|
+
def setUp(self) -> None:
|
|
104
|
+
self.host = mock.Mock()
|
|
105
|
+
self.expires = datetime.datetime.now(datetime.UTC)
|
|
106
|
+
self.host.vendor_token = mock.AsyncMock(
|
|
107
|
+
return_value=identity.AccessToken('openai-token', self.expires)
|
|
108
|
+
)
|
|
109
|
+
self.provider = wif.OpenAIWif(self.host)
|
|
110
|
+
|
|
111
|
+
def _context(self, agent_id: str | None) -> secrets.ResolveContext:
|
|
112
|
+
return secrets.ResolveContext(
|
|
113
|
+
organization_id='org',
|
|
114
|
+
task_id=uuid.uuid4(),
|
|
115
|
+
project_id=None,
|
|
116
|
+
purpose='model_inference',
|
|
117
|
+
ttl_seconds=900,
|
|
118
|
+
credential_class='dynamic',
|
|
119
|
+
agent_id=agent_id,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def test_resolve_mints_for_the_agent(self) -> None:
|
|
123
|
+
material = await self.provider.resolve(
|
|
124
|
+
{}, TARGET, None, self._context('agent-1')
|
|
125
|
+
)
|
|
126
|
+
self.assertEqual(material.value, b'openai-token')
|
|
127
|
+
self.assertEqual(material.credential_expires_at, self.expires)
|
|
128
|
+
self.assertTrue(material.bearer)
|
|
129
|
+
self.host.vendor_token.assert_awaited_once_with(
|
|
130
|
+
'org', wif.EXCHANGER, TARGET, agent_id='agent-1'
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
async def test_resolve_refusals(self) -> None:
|
|
134
|
+
for field, agent_id, code in (
|
|
135
|
+
('api-key', 'agent-1', 'secret_field_not_allowed'),
|
|
136
|
+
(None, None, 'secret_access_denied'),
|
|
137
|
+
):
|
|
138
|
+
with self.subTest(code=code):
|
|
139
|
+
with self.assertRaises(secrets.BrokerError) as ctx:
|
|
140
|
+
await self.provider.resolve(
|
|
141
|
+
{}, TARGET, field, self._context(agent_id)
|
|
142
|
+
)
|
|
143
|
+
self.assertEqual(ctx.exception.code, code)
|
|
144
|
+
self.host.vendor_token.assert_not_awaited()
|
|
145
|
+
|
|
146
|
+
def test_config_and_locator(self) -> None:
|
|
147
|
+
self.assertEqual(self.provider.validate_config({}), {})
|
|
148
|
+
with self.assertRaises(secrets.InvalidConfiguration):
|
|
149
|
+
self.provider.validate_config({'api_key': 'x'})
|
|
150
|
+
self.assertEqual(
|
|
151
|
+
self.provider.validate_locator(TARGET, 'dynamic'), TARGET
|
|
152
|
+
)
|
|
153
|
+
with self.assertRaises(secrets.InvalidConfiguration):
|
|
154
|
+
self.provider.validate_locator(TARGET, 'static')
|
|
155
|
+
|
|
156
|
+
def test_manifest(self) -> None:
|
|
157
|
+
manifest = plugin.OpenAIPlugin.manifest
|
|
158
|
+
self.assertEqual(manifest.capabilities, [])
|
|
159
|
+
self.assertEqual(manifest.secret_providers, [wif.OpenAIWif])
|
|
160
|
+
self.assertEqual(manifest.token_exchangers, [wif.OpenAIExchanger])
|