ai-center-runtime 0.2.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,133 @@
1
+ Metadata-Version: 2.4
2
+ Name: ai-center-runtime
3
+ Version: 0.2.0
4
+ Summary: Relay-only model access SDK for AI Center applications (invocation binding, allowlist, stable errors)
5
+ Author: 01JohnMa
6
+ Project-URL: Homepage, https://github.com/01JohnMa/ai_center
7
+ Project-URL: Changelog, https://github.com/01JohnMa/ai_center/commits/main/packages/ai-center-runtime
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: openai==2.54.0
13
+ Requires-Dist: httpx==0.28.1
14
+ Provides-Extra: fastapi
15
+ Requires-Dist: fastapi>=0.115; extra == "fastapi"
16
+
17
+ # ai-center-runtime
18
+
19
+ Python 应用接入 AI Center 模型网关的最小 SDK。
20
+
21
+ **Relay-only 合同(ADR-0015,2026-09-29 修订)**:应用不直连 LiteLLM,也不手工拼装归因。
22
+ 部署时平台注入三元组环境变量,应用把模型请求发到平台 Relay,归因(spend logs
23
+ metadata)由平台在转发时注入,ModelCall/Token/费用由 AI Center 对账生成。
24
+
25
+ | 环境变量 | 含义 |
26
+ |---|---|
27
+ | `AI_CENTER_MODEL_GATEWAY_URL_TEMPLATE` | 含 `{invocation_id}` 占位符的 Relay 端点模板 |
28
+ | `AI_CENTER_RUNTIME_CREDENTIAL` | per-Deployment 运行时凭据(随部署轮换) |
29
+ | `AI_CENTER_MODEL_OPERATIONS` | 允许的 operation 白名单(逗号分隔;缺省一律拒绝) |
30
+
31
+ ## 安装
32
+
33
+ 从仓库根目录执行(当前无需发布 PyPI):
34
+
35
+ ```sh
36
+ uv pip install ./packages/ai-center-runtime
37
+ ```
38
+
39
+ ## 接入:异步 Job 中绑定 Invocation,再调用模型
40
+
41
+ ```python
42
+ from ai_center_runtime import (
43
+ PlatformModelClient, PlatformModelError,
44
+ bind_invocation, model_access_mode,
45
+ )
46
+
47
+ # 1) 部署受理时把平台 Invocation 绑定到当前任务上下文(如异步 Job)。
48
+ binding = bind_invocation(platform_invocation_id)
49
+ try:
50
+ if model_access_mode() != "relay":
51
+ raise RuntimeError("平台模型访问未配置 Relay;direct 仅限本地开发")
52
+
53
+ # 2) 客户端读取三元组环境变量;白名单外的 operation 在发请求前失败。
54
+ with PlatformModelClient() as models:
55
+ answer = models.chat_completion(
56
+ [{"role": "user", "content": "你好"}],
57
+ model="deepseek-flash",
58
+ json_mode=True,
59
+ )
60
+ print(answer["choices"][0]["message"]["content"])
61
+
62
+ vectors = models.text_embeddings(["一段文本"], model="text-embedding")
63
+ finally:
64
+ binding.restore()
65
+ ```
66
+
67
+ 要点:
68
+
69
+ - **FastAPI 一行接入**(可选依赖):同步接口不需要手写"读 header + bind +
70
+ try/finally restore"——用依赖注入器,请求进入自动绑定、响应后自动解绑:
71
+
72
+ ```python
73
+ from fastapi import Depends
74
+ from ai_center_runtime import model_invocation, PlatformModelClient
75
+
76
+ @app.post("/extract")
77
+ async def extract(req: ExtractRequest, _b=Depends(model_invocation())):
78
+ # 本 handler 内的 Relay 调用自动携带 Invocation
79
+ with PlatformModelClient() as models:
80
+ return models.chat_completion([...], model="deepseek-flash")
81
+ ```
82
+
83
+ 安装:`pip install 'ai-center-runtime[fastapi]'`。缺
84
+ `x-ai-center-invocation-id` 头时返回 422;`model_invocation(required=False)`
85
+ 放行无 Invocation 请求(调用点 fail-closed 不变)。
86
+
87
+ - **mode 三态,绝不回退**:`model_access_mode()` 返回 `relay` / `direct` /
88
+ `unconfigured`;`direct` 只有应用显式设置 `AI_CENTER_LLM_MODE=direct`(本地开发)
89
+ 时成立,平台从不注入它。
90
+ - **fail closed**:白名单缺失或 operation 越界抛 `MODEL_CAPABILITY_UNAVAILABLE`;
91
+ 未绑定 Invocation 抛 `MODEL_INVOCATION_MISSING`。构造
92
+ `PlatformModelClient()` 本身就是模式检查(未配置时构造即失败),无需再手写
93
+ mode 判断。
94
+ - **稳定错误**:`PlatformModelError.reason` ∈ `MODEL_CAPABILITY_UNAVAILABLE` /
95
+ `MODEL_INVOCATION_MISSING` / `MODEL_ACCESS_EXPIRED`(异步窗口过期)/
96
+ `MODEL_TIMEOUT` / `MODEL_UPSTREAM_ERROR`;`status` 携带 HTTP 状态。
97
+ - **logical_call_id**:默认按 `{invocation_id}:{scope}:{sha256[:24]}` 生成并经
98
+ `x-request-id` 透传;同一逻辑调用的重试应显式传同一个 `logical_call_id`。
99
+ - **OpenAI 兼容生态**:`relay_connection()` 返回 `{"base_url", "api_key"}`,
100
+ 可直接喂给 `openai.OpenAI(...)`、llama_index `OpenAI(...)` 等。
101
+
102
+ ## 旧合同(Deprecated)
103
+
104
+ `InvocationContext` / `ModelClient` 是直连 LiteLLM 时代的合同:平台已不再注入
105
+ `LITELLM_BASE_URL` / `LITELLM_API_KEY`,新应用禁止使用;它们仅为尚未迁移的
106
+ 历史应用保留,迁移路径是把 `InvocationContext.from_headers(...)` +
107
+ `ModelClient.from_runtime_context(...)` 替换为 `bind_invocation(...)` +
108
+ `PlatformModelClient(...)`。
109
+
110
+ ## 限制与证据边界
111
+
112
+ - 只支持 blocking JSON(chat.completions / embeddings);SSE 与
113
+ responses 端点的客户端封装暂未提供。同步调用用 `PlatformModelClient`,
114
+ 异步应用(FastAPI 路由、async Worker)用 `AsyncPlatformModelClient`,
115
+ 两者接口语义一致。
116
+ - 不自动重试、不跟随重定向;Provider 路由与重试由 LiteLLM owner 负责。
117
+ - 归因字段由平台 Relay 注入,SDK 与应用都不能覆盖;应用也不应伪造
118
+ `x-ai-center-*` header。
119
+ - SDK 不能在恶意代码面前充当沙箱;凭据模型限制、Relay 窗口与白名单、
120
+ Runtime 网络隔离负责服务端强制治理。
121
+
122
+ ## 验证
123
+
124
+ ```sh
125
+ PYTHONPATH=packages/ai-center-runtime/src uv run --project packages/ai-center-runtime \
126
+ python -m unittest discover -s packages/ai-center-runtime/tests
127
+ ```
128
+
129
+ 协议测试通过 httpx MockTransport 校验三元组解析、模板物化、白名单 fail-closed、
130
+ 错误映射与绑定隔离,不需要真实平台。真实链路探针见 `scripts/smoke_relay.py`。
131
+
132
+ 合同来源:[ADR 0013](../../docs/adr/0013-runtime-v2-unified-invocation-and-usage.md)、
133
+ [ADR 0015](../../docs/adr/0015-platform-model-gateway-relay.md)。
@@ -0,0 +1,117 @@
1
+ # ai-center-runtime
2
+
3
+ Python 应用接入 AI Center 模型网关的最小 SDK。
4
+
5
+ **Relay-only 合同(ADR-0015,2026-09-29 修订)**:应用不直连 LiteLLM,也不手工拼装归因。
6
+ 部署时平台注入三元组环境变量,应用把模型请求发到平台 Relay,归因(spend logs
7
+ metadata)由平台在转发时注入,ModelCall/Token/费用由 AI Center 对账生成。
8
+
9
+ | 环境变量 | 含义 |
10
+ |---|---|
11
+ | `AI_CENTER_MODEL_GATEWAY_URL_TEMPLATE` | 含 `{invocation_id}` 占位符的 Relay 端点模板 |
12
+ | `AI_CENTER_RUNTIME_CREDENTIAL` | per-Deployment 运行时凭据(随部署轮换) |
13
+ | `AI_CENTER_MODEL_OPERATIONS` | 允许的 operation 白名单(逗号分隔;缺省一律拒绝) |
14
+
15
+ ## 安装
16
+
17
+ 从仓库根目录执行(当前无需发布 PyPI):
18
+
19
+ ```sh
20
+ uv pip install ./packages/ai-center-runtime
21
+ ```
22
+
23
+ ## 接入:异步 Job 中绑定 Invocation,再调用模型
24
+
25
+ ```python
26
+ from ai_center_runtime import (
27
+ PlatformModelClient, PlatformModelError,
28
+ bind_invocation, model_access_mode,
29
+ )
30
+
31
+ # 1) 部署受理时把平台 Invocation 绑定到当前任务上下文(如异步 Job)。
32
+ binding = bind_invocation(platform_invocation_id)
33
+ try:
34
+ if model_access_mode() != "relay":
35
+ raise RuntimeError("平台模型访问未配置 Relay;direct 仅限本地开发")
36
+
37
+ # 2) 客户端读取三元组环境变量;白名单外的 operation 在发请求前失败。
38
+ with PlatformModelClient() as models:
39
+ answer = models.chat_completion(
40
+ [{"role": "user", "content": "你好"}],
41
+ model="deepseek-flash",
42
+ json_mode=True,
43
+ )
44
+ print(answer["choices"][0]["message"]["content"])
45
+
46
+ vectors = models.text_embeddings(["一段文本"], model="text-embedding")
47
+ finally:
48
+ binding.restore()
49
+ ```
50
+
51
+ 要点:
52
+
53
+ - **FastAPI 一行接入**(可选依赖):同步接口不需要手写"读 header + bind +
54
+ try/finally restore"——用依赖注入器,请求进入自动绑定、响应后自动解绑:
55
+
56
+ ```python
57
+ from fastapi import Depends
58
+ from ai_center_runtime import model_invocation, PlatformModelClient
59
+
60
+ @app.post("/extract")
61
+ async def extract(req: ExtractRequest, _b=Depends(model_invocation())):
62
+ # 本 handler 内的 Relay 调用自动携带 Invocation
63
+ with PlatformModelClient() as models:
64
+ return models.chat_completion([...], model="deepseek-flash")
65
+ ```
66
+
67
+ 安装:`pip install 'ai-center-runtime[fastapi]'`。缺
68
+ `x-ai-center-invocation-id` 头时返回 422;`model_invocation(required=False)`
69
+ 放行无 Invocation 请求(调用点 fail-closed 不变)。
70
+
71
+ - **mode 三态,绝不回退**:`model_access_mode()` 返回 `relay` / `direct` /
72
+ `unconfigured`;`direct` 只有应用显式设置 `AI_CENTER_LLM_MODE=direct`(本地开发)
73
+ 时成立,平台从不注入它。
74
+ - **fail closed**:白名单缺失或 operation 越界抛 `MODEL_CAPABILITY_UNAVAILABLE`;
75
+ 未绑定 Invocation 抛 `MODEL_INVOCATION_MISSING`。构造
76
+ `PlatformModelClient()` 本身就是模式检查(未配置时构造即失败),无需再手写
77
+ mode 判断。
78
+ - **稳定错误**:`PlatformModelError.reason` ∈ `MODEL_CAPABILITY_UNAVAILABLE` /
79
+ `MODEL_INVOCATION_MISSING` / `MODEL_ACCESS_EXPIRED`(异步窗口过期)/
80
+ `MODEL_TIMEOUT` / `MODEL_UPSTREAM_ERROR`;`status` 携带 HTTP 状态。
81
+ - **logical_call_id**:默认按 `{invocation_id}:{scope}:{sha256[:24]}` 生成并经
82
+ `x-request-id` 透传;同一逻辑调用的重试应显式传同一个 `logical_call_id`。
83
+ - **OpenAI 兼容生态**:`relay_connection()` 返回 `{"base_url", "api_key"}`,
84
+ 可直接喂给 `openai.OpenAI(...)`、llama_index `OpenAI(...)` 等。
85
+
86
+ ## 旧合同(Deprecated)
87
+
88
+ `InvocationContext` / `ModelClient` 是直连 LiteLLM 时代的合同:平台已不再注入
89
+ `LITELLM_BASE_URL` / `LITELLM_API_KEY`,新应用禁止使用;它们仅为尚未迁移的
90
+ 历史应用保留,迁移路径是把 `InvocationContext.from_headers(...)` +
91
+ `ModelClient.from_runtime_context(...)` 替换为 `bind_invocation(...)` +
92
+ `PlatformModelClient(...)`。
93
+
94
+ ## 限制与证据边界
95
+
96
+ - 只支持 blocking JSON(chat.completions / embeddings);SSE 与
97
+ responses 端点的客户端封装暂未提供。同步调用用 `PlatformModelClient`,
98
+ 异步应用(FastAPI 路由、async Worker)用 `AsyncPlatformModelClient`,
99
+ 两者接口语义一致。
100
+ - 不自动重试、不跟随重定向;Provider 路由与重试由 LiteLLM owner 负责。
101
+ - 归因字段由平台 Relay 注入,SDK 与应用都不能覆盖;应用也不应伪造
102
+ `x-ai-center-*` header。
103
+ - SDK 不能在恶意代码面前充当沙箱;凭据模型限制、Relay 窗口与白名单、
104
+ Runtime 网络隔离负责服务端强制治理。
105
+
106
+ ## 验证
107
+
108
+ ```sh
109
+ PYTHONPATH=packages/ai-center-runtime/src uv run --project packages/ai-center-runtime \
110
+ python -m unittest discover -s packages/ai-center-runtime/tests
111
+ ```
112
+
113
+ 协议测试通过 httpx MockTransport 校验三元组解析、模板物化、白名单 fail-closed、
114
+ 错误映射与绑定隔离,不需要真实平台。真实链路探针见 `scripts/smoke_relay.py`。
115
+
116
+ 合同来源:[ADR 0013](../../docs/adr/0013-runtime-v2-unified-invocation-and-usage.md)、
117
+ [ADR 0015](../../docs/adr/0015-platform-model-gateway-relay.md)。
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ai-center-runtime"
7
+ version = "0.2.0"
8
+ description = "Relay-only model access SDK for AI Center applications (invocation binding, allowlist, stable errors)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ dependencies = ["openai==2.54.0", "httpx==0.28.1"]
12
+ authors = [{ name = "01JohnMa" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Operating System :: OS Independent",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/01JohnMa/ai_center"
20
+ Changelog = "https://github.com/01JohnMa/ai_center/commits/main/packages/ai-center-runtime"
21
+
22
+ [project.optional-dependencies]
23
+ fastapi = ["fastapi>=0.115"]
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.ruff]
29
+ target-version = "py312"
30
+ line-length = 100
31
+
32
+ [tool.ruff.lint]
33
+ select = ["E4", "E7", "E9", "F", "I", "UP"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,284 @@
1
+ """AI Center Runtime SDK.
2
+
3
+ Relay-only 合同(ADR-0015 2026-09-29 修订)是应用接入平台模型访问的唯一
4
+ 方式:部署注入三元组环境变量,应用通过 :mod:`ai_center_runtime.relay` 的
5
+ ``bind_invocation`` + ``PlatformModelClient`` 调用模型,归因由平台 Relay 注入。
6
+
7
+ ``InvocationContext`` / ``ModelClient`` 是直连 LiteLLM 时代的旧合同,保留
8
+ 仅供历史应用迁移;新应用不要使用。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass
17
+ from types import SimpleNamespace
18
+ from urllib.parse import urlsplit
19
+
20
+ import httpx
21
+ from openai import OpenAI
22
+
23
+ from .fastapi_integration import INVOCATION_HEADER, model_invocation # 不在顶层导入 fastapi
24
+ from .relay import (
25
+ GATEWAY_URL_TEMPLATE_ENV,
26
+ LLM_MODE_ENV,
27
+ MODEL_OPERATIONS_ENV,
28
+ RUNTIME_CREDENTIAL_ENV,
29
+ AsyncPlatformModelClient,
30
+ PlatformModelClient,
31
+ PlatformModelError,
32
+ bind_invocation,
33
+ current_invocation_id,
34
+ model_access_mode,
35
+ restore_invocation,
36
+ )
37
+
38
+ __all__ = [
39
+ "GATEWAY_URL_TEMPLATE_ENV",
40
+ "INVOCATION_HEADER",
41
+ "LLM_MODE_ENV",
42
+ "MODEL_OPERATIONS_ENV",
43
+ "RUNTIME_CREDENTIAL_ENV",
44
+ "AsyncPlatformModelClient",
45
+ "InvocationContext",
46
+ "ModelAccessError",
47
+ "ModelClient",
48
+ "PlatformModelClient",
49
+ "PlatformModelError",
50
+ "bind_invocation",
51
+ "current_invocation_id",
52
+ "model_access_mode",
53
+ "model_invocation",
54
+ "restore_invocation",
55
+ ]
56
+
57
+ _OPERATIONS = frozenset({"chat.completions", "responses", "embeddings"})
58
+ _REFERENCE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,255}\Z")
59
+ _MODEL = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}\Z")
60
+ _TRACEPARENT = re.compile(r"00-(?!0{32}-)[0-9a-f]{32}-(?!0{16}-)[0-9a-f]{16}-[0-9a-f]{2}\Z")
61
+ _RESERVED = frozenset(
62
+ {
63
+ "schema_version",
64
+ "application_id",
65
+ "application_version_id",
66
+ "invocation_id",
67
+ "caller_id",
68
+ "trace_id",
69
+ "traceparent",
70
+ "operation",
71
+ "component",
72
+ "session_id",
73
+ "litellm_session_id",
74
+ "request_id",
75
+ "ai_center",
76
+ "spend_logs_metadata",
77
+ "api_key",
78
+ "base_url",
79
+ "litellm_params",
80
+ }
81
+ )
82
+
83
+
84
+ class ModelAccessError(ValueError):
85
+ """Invalid context/config/request. Raised before any model HTTP request."""
86
+
87
+
88
+ def _reference(value: object) -> bool:
89
+ return isinstance(value, str) and _REFERENCE.fullmatch(value) is not None
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class InvocationContext:
94
+ """Provided by the trusted Runtime ingress, never by application business input."""
95
+
96
+ invocation_id: str
97
+ application_id: str
98
+ application_version_id: str
99
+ caller_id: str
100
+ traceparent: str
101
+
102
+ def __post_init__(self):
103
+ if not all(
104
+ _reference(v)
105
+ for v in (
106
+ self.invocation_id,
107
+ self.application_id,
108
+ self.application_version_id,
109
+ self.caller_id,
110
+ )
111
+ ):
112
+ raise ModelAccessError("Invalid Gateway invocation identity")
113
+ if not isinstance(self.traceparent, str) or not _TRACEPARENT.fullmatch(self.traceparent):
114
+ raise ModelAccessError("A valid Gateway traceparent is required")
115
+
116
+ @property
117
+ def trace_id(self) -> str:
118
+ return self.traceparent.split("-")[1]
119
+
120
+ @classmethod
121
+ def from_headers(cls, headers: Mapping[str, str]) -> InvocationContext:
122
+ """Use only after ingress guarantees the request is from AI Center Gateway."""
123
+ normalized = {}
124
+ for name, value in headers.items():
125
+ name = name.lower()
126
+ if name in normalized:
127
+ raise ModelAccessError("Duplicate Gateway header")
128
+ normalized[name] = value
129
+ try:
130
+ return cls(
131
+ invocation_id=normalized["x-ai-center-invocation-id"],
132
+ application_id=normalized["x-ai-center-application-id"],
133
+ application_version_id=normalized["x-ai-center-application-version-id"],
134
+ caller_id=normalized["x-ai-center-caller-id"],
135
+ traceparent=normalized["traceparent"],
136
+ )
137
+ except KeyError:
138
+ raise ModelAccessError("Gateway invocation headers are incomplete") from None
139
+
140
+
141
+ class _Create:
142
+ def __init__(self, owner, operation, create):
143
+ self._owner, self._operation, self._create = owner, operation, create
144
+
145
+ def create(self, *, model: str, **kwargs):
146
+ return self._create(**self._owner._request(self._operation, model, kwargs))
147
+
148
+
149
+ class ModelClient:
150
+ """Blocking JSON only. Owns one OpenAI client for one immutable Invocation Context.
151
+
152
+ Deprecated: 直连 LiteLLM 的旧合同(平台不再注入 ``LITELLM_BASE_URL``)。
153
+ 新应用使用 :class:`ai_center_runtime.relay.PlatformModelClient`。
154
+ """
155
+
156
+ @classmethod
157
+ def from_runtime_context(
158
+ cls,
159
+ context: InvocationContext,
160
+ *,
161
+ base_url: str,
162
+ api_key: str,
163
+ timeout: float = 30,
164
+ transport: httpx.BaseTransport | None = None,
165
+ ) -> ModelClient:
166
+ return cls(
167
+ context,
168
+ base_url=base_url,
169
+ api_key=api_key,
170
+ timeout=timeout,
171
+ transport=transport,
172
+ )
173
+
174
+ def __init__(
175
+ self,
176
+ context: InvocationContext,
177
+ *,
178
+ base_url: str,
179
+ api_key: str,
180
+ timeout: float = 30,
181
+ transport: httpx.BaseTransport | None = None,
182
+ ):
183
+ if not isinstance(context, InvocationContext):
184
+ raise ModelAccessError("Trusted InvocationContext is required")
185
+ if os.environ.get("OPENAI_CUSTOM_HEADERS"):
186
+ raise ModelAccessError("Unset OPENAI_CUSTOM_HEADERS; Runtime headers are controlled")
187
+ if (
188
+ not isinstance(api_key, str)
189
+ or not api_key.strip()
190
+ or any(ord(c) < 33 or ord(c) > 126 for c in api_key)
191
+ ):
192
+ raise ModelAccessError("Runtime Integration Credential is required")
193
+ url = urlsplit(base_url)
194
+ if (
195
+ url.scheme not in {"http", "https"}
196
+ or not url.hostname
197
+ or url.username
198
+ or url.password
199
+ or url.query
200
+ or url.fragment
201
+ ):
202
+ raise ModelAccessError("An explicit LiteLLM base URL is required")
203
+ if not 0 < timeout <= 120:
204
+ raise ModelAccessError("Invalid model timeout")
205
+ self._context = context
206
+ self._model_request_count = 0
207
+ self._client = OpenAI(
208
+ organization="",
209
+ project="",
210
+ base_url=base_url.rstrip("/") + "/",
211
+ api_key=api_key,
212
+ max_retries=0,
213
+ timeout=timeout,
214
+ http_client=httpx.Client(
215
+ transport=transport,
216
+ timeout=timeout,
217
+ follow_redirects=False,
218
+ trust_env=False,
219
+ ),
220
+ )
221
+ self.chat = SimpleNamespace(
222
+ completions=_Create(self, "chat.completions", self._client.chat.completions.create)
223
+ )
224
+ self.responses = _Create(self, "responses", self._client.responses.create)
225
+ self.embeddings = _Create(self, "embeddings", self._client.embeddings.create)
226
+
227
+ def _request(self, operation: str, model: str, kwargs: dict) -> dict:
228
+ if not isinstance(model, str) or not _MODEL.fullmatch(model):
229
+ raise ModelAccessError("Model reference is invalid")
230
+ if _RESERVED.intersection(kwargs) or kwargs.get("stream") or kwargs.get("background"):
231
+ raise ModelAccessError("Platform fields and non-blocking calls are not allowed")
232
+ if kwargs.get("extra_query"):
233
+ raise ModelAccessError("Model URL query overrides are not allowed")
234
+ extra = dict(kwargs.pop("extra_body", None) or {})
235
+ if _RESERVED.union({"model", "metadata", "stream", "background"}).intersection(extra):
236
+ raise ModelAccessError("extra_body cannot override the model or platform fields")
237
+ metadata = dict(kwargs.pop("metadata", None) or {})
238
+ if _RESERVED.intersection(metadata):
239
+ raise ModelAccessError("Application metadata cannot contain platform fields")
240
+ headers = dict(kwargs.pop("extra_headers", None) or {})
241
+ for name in headers:
242
+ key = name.lower()
243
+ if key in {
244
+ "authorization",
245
+ "proxy-authorization",
246
+ "host",
247
+ "traceparent",
248
+ "tracestate",
249
+ "x-request-id",
250
+ "content-type",
251
+ "content-length",
252
+ } or key.startswith(("x-ai-center-", "x-litellm-", "openai-")):
253
+ raise ModelAccessError("Application headers cannot override platform fields")
254
+ ctx = self._context
255
+ self._model_request_count += 1
256
+ metadata["spend_logs_metadata"] = {
257
+ "ai_center": {
258
+ "schema_version": 2,
259
+ "application_id": ctx.application_id,
260
+ "application_version_id": ctx.application_version_id,
261
+ "invocation_id": ctx.invocation_id,
262
+ "caller_id": ctx.caller_id,
263
+ "trace_id": ctx.trace_id,
264
+ "operation": operation,
265
+ }
266
+ }
267
+ extra["metadata"] = metadata
268
+ headers.update({"traceparent": ctx.traceparent, "x-litellm-session-id": ctx.invocation_id})
269
+ return {**kwargs, "model": model, "extra_body": extra, "extra_headers": headers}
270
+
271
+ @property
272
+ def model_activity(self) -> str:
273
+ """Trusted Runtime completion fact; applications cannot set this value."""
274
+
275
+ return "attempted" if self._model_request_count else "none"
276
+
277
+ def close(self) -> None:
278
+ self._client.close()
279
+
280
+ def __enter__(self) -> ModelClient:
281
+ return self
282
+
283
+ def __exit__(self, *args):
284
+ self.close()
@@ -0,0 +1,69 @@
1
+ """FastAPI 集成:一行依赖替代手写的 Invocation 绑定/解绑仪式.
2
+
3
+ ``model_invocation()`` 返回一个 FastAPI 依赖:请求进入时从平台受信头读取
4
+ Invocation 并绑定到当前上下文,响应生成完毕后自动解绑;handler 内的
5
+ ``PlatformModelClient`` 调用因此自动携带正确的 Invocation。
6
+
7
+ fastapi 是可选依赖:本模块顶部不导入 fastapi,只有调用
8
+ :func:`model_invocation` 时才需要(未安装时抛带安装指引的 RuntimeError)。
9
+ """
10
+
11
+ from collections.abc import AsyncIterator
12
+
13
+ from .relay import InvocationBinding, bind_invocation
14
+
15
+ # 注意:本模块不能用 `from __future__ import annotations`——依赖函数的
16
+ # `request: Request` 注解需要运行时真实类型,FastAPI 按函数全局命名空间
17
+ # 解析字符串注解时拿不到闭包内的局部绑定。
18
+
19
+ INVOCATION_HEADER = "x-ai-center-invocation-id"
20
+
21
+ _LAZY_FASTAPI_HINT = (
22
+ "model_invocation() 需要 FastAPI 支持:pip install 'ai-center-runtime[fastapi]'"
23
+ )
24
+
25
+
26
+ def model_invocation(
27
+ *,
28
+ header: str = INVOCATION_HEADER,
29
+ required: bool = True,
30
+ ):
31
+ """FastAPI 依赖工厂:自动绑定平台 Invocation,响应后自动解绑.
32
+
33
+ 用法(替代手写读 header + bind + try/finally restore)::
34
+
35
+ from ai_center_runtime import model_invocation
36
+
37
+ @app.post("/extract")
38
+ async def extract(
39
+ req: ExtractRequest,
40
+ _binding=Depends(model_invocation()),
41
+ ):
42
+ # 依赖已绑定:本 handler 内的 Relay 调用自动携带 Invocation
43
+ ...
44
+
45
+ 返回裸依赖函数(配 ``Depends(...)`` 使用)。``required=True``(默认)
46
+ 时缺头直接 422;``required=False`` 允许无 Invocation 的请求通过
47
+ (未绑定时 Relay 调用会在调用点抛 ``MODEL_INVOCATION_MISSING``,
48
+ fail-closed 语义不变)。
49
+ """
50
+
51
+ try:
52
+ from fastapi import HTTPException, Request
53
+ except ImportError as exc: # pragma: no cover - 取决于环境是否安装 fastapi
54
+ raise RuntimeError(_LAZY_FASTAPI_HINT) from exc
55
+
56
+ async def dependency(request: Request) -> AsyncIterator[InvocationBinding]:
57
+ invocation_id = request.headers.get(header)
58
+ if not invocation_id and required:
59
+ raise HTTPException(
60
+ status_code=422,
61
+ detail=f"required header is missing: {header}",
62
+ )
63
+ binding = bind_invocation(invocation_id)
64
+ try:
65
+ yield binding
66
+ finally:
67
+ binding.restore()
68
+
69
+ return dependency