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.
@@ -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
+ )
@@ -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])