create-windy 0.2.33 → 0.2.34

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.
Files changed (69) hide show
  1. package/dist/cli.js +7 -1
  2. package/package.json +1 -1
  3. package/template/.agents/skills/windy-business-module/SKILL.md +10 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/AGENTS.md +4 -0
  6. package/template/apps/agent-server/README.md +10 -0
  7. package/template/apps/agent-server/src/southwind_agent_server/app.py +62 -1
  8. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +35 -0
  9. package/template/apps/agent-server/src/southwind_agent_server/engine.py +2 -0
  10. package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +31 -3
  11. package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +22 -9
  12. package/template/apps/agent-server/src/southwind_agent_server/provider_audit.py +144 -0
  13. package/template/apps/agent-server/src/southwind_agent_server/provider_governance.py +227 -0
  14. package/template/apps/agent-server/src/southwind_agent_server/service.py +1 -0
  15. package/template/apps/agent-server/tests/test_api.py +16 -1
  16. package/template/apps/agent-server/tests/test_openai_provider.py +79 -0
  17. package/template/apps/agent-server/tests/test_provider_governance.py +215 -0
  18. package/template/apps/server/src/agent/provider-input-governance.test.ts +82 -0
  19. package/template/apps/server/src/agent/provider-input-governance.ts +140 -0
  20. package/template/apps/server/src/agent/remote-runtime.test.ts +22 -0
  21. package/template/apps/server/src/agent/remote-runtime.ts +1 -0
  22. package/template/apps/server/src/agent/run-contracts.ts +2 -0
  23. package/template/apps/server/src/agent/run-facade.test.ts +52 -2
  24. package/template/apps/server/src/agent/run-facade.ts +36 -2
  25. package/template/apps/server/src/index.ts +28 -45
  26. package/template/apps/server/src/module-host/initialize-contracts.ts +6 -0
  27. package/template/apps/server/src/module-host/initialize.ts +30 -44
  28. package/template/apps/server/src/module-host/request-context.ts +23 -17
  29. package/template/apps/server/src/module-host/resource-file-policies.test.ts +197 -0
  30. package/template/apps/server/src/module-host/resource-file-policies.ts +108 -0
  31. package/template/apps/server/src/module-host/route-declaration.ts +45 -0
  32. package/template/apps/server/src/module-host/route-installer.ts +27 -3
  33. package/template/apps/server/src/module-host/storage-port.test.ts +170 -1
  34. package/template/apps/server/src/module-host/storage-port.ts +134 -1
  35. package/template/apps/server/src/module-host/storage-scope.ts +96 -0
  36. package/template/apps/server/src/module-host/tool-handlers.test.ts +305 -0
  37. package/template/apps/server/src/module-host/tool-handlers.ts +38 -4
  38. package/template/apps/server/src/module-host/user-directory-composition.test.ts +126 -0
  39. package/template/apps/server/src/module-host/user-directory-port.test.ts +238 -0
  40. package/template/apps/server/src/module-host/user-directory-port.ts +143 -0
  41. package/template/apps/server/src/module-host.ts +4 -0
  42. package/template/apps/server/src/runtime-development.ts +1 -0
  43. package/template/apps/server/src/system/built-in-roles.ts +1 -0
  44. package/template/docker-compose.yml +2 -0
  45. package/template/docs/architecture/ai-egress.md +49 -7
  46. package/template/docs/architecture/ai-runtime.md +20 -1
  47. package/template/docs/architecture/data-scope-query-enforcement.md +4 -0
  48. package/template/docs/architecture/object-storage.md +22 -2
  49. package/template/docs/development/custom-login-page.md +3 -3
  50. package/template/docs/platform/agent-runtime.md +44 -1
  51. package/template/docs/platform/business-user-directory.md +82 -0
  52. package/template/packages/modules/package.json +1 -1
  53. package/template/packages/modules/src/ai-operation-composition.test.ts +68 -0
  54. package/template/packages/modules/src/ai-operation-composition.ts +115 -0
  55. package/template/packages/modules/src/manifest.ts +26 -0
  56. package/template/packages/modules/src/provider-data-policy-composition.test.ts +74 -0
  57. package/template/packages/modules/src/system-audit-actions.ts +5 -0
  58. package/template/packages/modules/src/system-features.ts +5 -0
  59. package/template/packages/modules/src/system-module-catalog.test.ts +16 -0
  60. package/template/packages/modules/src/system-module-catalog.ts +26 -3
  61. package/template/packages/modules/src/system-permissions.ts +2 -0
  62. package/template/packages/server-sdk/index.ts +11 -0
  63. package/template/packages/server-sdk/package.json +3 -2
  64. package/template/packages/server-sdk/src/ai-operation-registration.ts +2 -1
  65. package/template/packages/server-sdk/src/module-host.ts +8 -0
  66. package/template/packages/server-sdk/src/request-context.ts +2 -0
  67. package/template/packages/server-sdk/src/storage-port.ts +66 -0
  68. package/template/packages/server-sdk/src/tool-registration.ts +6 -0
  69. package/template/packages/server-sdk/src/user-directory-port.ts +42 -0
package/dist/cli.js CHANGED
@@ -248,6 +248,7 @@ var optionalModuleManifests = [
248
248
  "apps/server/src/agent",
249
249
  "apps/server/src/mcp",
250
250
  "apps/server/src/module-host/tool-handlers.ts",
251
+ "apps/server/src/module-host/tool-handlers.test.ts",
251
252
  "packages/modules/src/system-agent-tools.ts"
252
253
  ],
253
254
  documentation: [
@@ -1582,6 +1583,8 @@ var agentComposeService = ` agent-server:
1582
1583
  environment:
1583
1584
  AGENT_SERVER_INTERNAL_TOKEN: \${AGENT_SERVER_INTERNAL_TOKEN:-}
1584
1585
  AGENT_RUN_STORE_PATH: /app/data/agent/runs.sqlite3
1586
+ AI_PROVIDER_AUDIT_STORE_PATH: /app/data/agent/provider-audit.sqlite3
1587
+ AI_PROVIDER_AUDIT_RETENTION_DAYS: \${AI_PROVIDER_AUDIT_RETENTION_DAYS:-90}
1585
1588
  QWEN_API_URL: \${QWEN_API_URL:-}
1586
1589
  QWEN_API_KEY: \${QWEN_API_KEY:-}
1587
1590
  QWEN_MODEL: \${QWEN_MODEL:-}
@@ -1614,6 +1617,7 @@ QWEN_API_URL=
1614
1617
  QWEN_API_KEY=
1615
1618
  QWEN_MODEL=
1616
1619
  AI_PROVIDER_HTTPS_HOST_ALLOWLIST=
1620
+ # AI_PROVIDER_AUDIT_RETENTION_DAYS=90
1617
1621
  # AI_PROVIDER_CONNECT_TIMEOUT_MS=5000
1618
1622
  # AI_PROVIDER_REQUEST_TIMEOUT_MS=60000
1619
1623
  # AI_PROVIDER_MAX_RESPONSE_BYTES=2097152
@@ -1934,6 +1938,7 @@ var templateDocumentationFiles = [
1934
1938
  "docs/platform/agent-runtime.md",
1935
1939
  "docs/platform/audit-reliability.md",
1936
1940
  "docs/platform/bulk-data-jobs.md",
1941
+ "docs/platform/business-user-directory.md",
1937
1942
  "docs/platform/data-access-control.md",
1938
1943
  "docs/platform/module-configuration.md",
1939
1944
  "docs/platform/navigation-registry.md",
@@ -4364,7 +4369,8 @@ var recipes = [
4364
4369
  { id: "starter-0.2.29-to-0.2.30", from: "0.2.29", to: "0.2.30" },
4365
4370
  { id: "starter-0.2.30-to-0.2.31", from: "0.2.30", to: "0.2.31" },
4366
4371
  { id: "starter-0.2.31-to-0.2.32", from: "0.2.31", to: "0.2.32" },
4367
- { id: "starter-0.2.32-to-0.2.33", from: "0.2.32", to: "0.2.33" }
4372
+ { id: "starter-0.2.32-to-0.2.33", from: "0.2.32", to: "0.2.33" },
4373
+ { id: "starter-0.2.33-to-0.2.34", from: "0.2.33", to: "0.2.34" }
4368
4374
  ];
4369
4375
  function resolveRecipeChain(sourceVersion, targetVersion) {
4370
4376
  if (sourceVersion === targetVersion)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-windy",
3
- "version": "0.2.33",
3
+ "version": "0.2.34",
4
4
  "description": "创建单组织、单租户、私有部署优先的 Windy 企业 Dashboard Starter",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,14 +18,23 @@ description: "Windy 业务模块、Manifest、Repository、Service、Agent Tool
18
18
  其它模块只能通过 Service、公开 Port 或模块注册机制协作。
19
19
  4. Server 通过 `InstallableServerModule` 和 `ModuleHost` 注册 Guarded Route、Task、
20
20
  Provider 或 Tool Handler。Handler 调用 Service,不直接访问其它模块 Repository。
21
+ 需要选择启用用户时使用 `ModuleRequestContext.userDirectory`;不要导入用户 Admin
22
+ Repository。业务 RolePreset 不能注册平台 Permission,部署管理员须显式向业务角色
23
+ 授予 `system.user-directory.read` 并配置数据范围。
21
24
  5. Tool 输入输出只使用 Manifest 已注册的顶层 object JSON Schema。Actor、Permission、
22
25
  Feature、License 和 Data Scope 只来自 `ModuleToolContext` 与平台 Runtime。
26
+ Tool 或 Route 需要读取同业务范围内其他用户上传的文件时,在 Host 注册精确
27
+ `purpose + resourceType` 的 `registerResourceFileAccessPolicies()`;Resolver 必须用
28
+ project-owned Repository 回查资源与 fileId 绑定及对象级授权,只返回布尔判定,
29
+ 再通过 `context.storage.openResourceFile()` 取得最小元数据与流。禁止注入
30
+ UploadRepository、BlobStore 或申请平台 manage Repository。
23
31
  6. 若项目包含 `system.agent`,`operation=query` 的 Tool 会由同一 Registry 自动进入
24
32
  MCP;不要编辑 MCP Adapter 或复制 Tool 定义。写操作在幂等、审批和结果追踪闭环前
25
33
  不通过 MCP 暴露。
26
34
  7. 补 Manifest/Composition、Repository/Service、Guard 拒绝和 Handler 契约测试。
27
35
  涉及 Tool 时还要覆盖 Schema 拒绝、数据范围与未授权发现/调用;涉及可选模块时覆盖
28
- Starter 的文件、依赖和数据库裁剪。
36
+ Starter 的文件、依赖和数据库裁剪。涉及资源文件时还要覆盖跨上传者成功、资源绑定/
37
+ purpose/Feature/Permission/Scope 任一拒绝、最小元数据与成功/拒绝审计。
29
38
  8. 更新长期文档,运行受影响测试、`bun run typecheck` 和 `bun run lint`,按一个完整
30
39
  小切片提交。
31
40
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "templateCommit": "395761d19b6dac6f4135547bedf996e3a53249a7",
4
- "generatorVersion": "0.2.33",
3
+ "templateCommit": "28f6b738276d5138a514a4ab374d60884b94e769",
4
+ "generatorVersion": "0.2.34",
5
5
  "modules": [
6
6
  {
7
7
  "name": "system",
@@ -44,6 +44,10 @@
44
44
  `apps/web/src/pages/auth/LoginPage.vue` 或 Router。业务页面复用
45
45
  `useLoginFlow`、`DevelopmentAuthWarning` 和平台品牌设置,保留安全
46
46
  `returnTo`、真实认证与开发认证提示。
47
+ - 业务 Agent Operation 开启 Provider 出网时,必须在 Manifest 同时声明最小
48
+ `providerDataPolicy.inputFields` JSON Pointer allowlist 与
49
+ `envelopeFields`;raw input 会在进入 `prepare` 前裁剪并执行 DLP。接入与升级
50
+ 诊断见 `docs/platform/agent-runtime.md`。
47
51
 
48
52
  ## 常用入口
49
53
 
@@ -32,6 +32,8 @@ export QWEN_API_KEY='<server-secret>'
32
32
  export QWEN_MODEL='qwen-plus'
33
33
  export AI_PROVIDER_HTTPS_HOST_ALLOWLIST='dashscope-intl.aliyuncs.com'
34
34
  export AGENT_RUN_STORE_PATH='.data/agent-runs.sqlite3'
35
+ export AI_PROVIDER_AUDIT_STORE_PATH='.data/provider-audit.sqlite3'
36
+ export AI_PROVIDER_AUDIT_RETENTION_DAYS='90'
35
37
  uv run uvicorn southwind_agent_server.app:app
36
38
  ```
37
39
 
@@ -46,3 +48,11 @@ readiness。缺少 URL/Model 时回退
46
48
  cursor;多副本部署仍必须替换为共享 Store。Execution Grant 与 Tool 授权由
47
49
  Southwind AI Server 签发和复核,Agent Server 不能自行扩大 Operation 的
48
50
  `allowedToolKeys`。
51
+
52
+ 每次实际 `/chat/completions` HTTP 调用前,Runtime 会对 Manifest 允许的输入 JSON
53
+ Pointer 与 Provider outbound envelope 字段执行 DLP,并将 deployment、operation、
54
+ run、attempt、分类集合、实际序列化请求字节数和 allow/redact/reject 决策写入独立
55
+ SQLite append-only 表。审计不保存 prompt、请求正文或 Secret;审计写入失败时禁止
56
+ 出网。记录到期后只由配置的 TTL 清理,清理计数可通过需内部 Token 的
57
+ `GET /health/provider-governance` 查看。SQLite 实现仅支持单副本;多副本部署必须替换
58
+ 为共享、并发安全的审计 Adapter。
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import hmac
4
4
  import os
5
5
  import uuid
6
- from typing import Annotated
6
+ from typing import Annotated, cast
7
7
 
8
8
  from fastapi import FastAPI, Header, Query, Request
9
9
  from fastapi.exceptions import RequestValidationError
@@ -23,6 +23,8 @@ from .contracts import (
23
23
  from .engine import AgentEngineAdapter
24
24
  from .openai_engine import OpenAiCompatibleEngineAdapter
25
25
  from .openai_provider import OpenAiCompatibleProvider
26
+ from .provider_audit import ProviderAuditStore
27
+ from .provider_governance import ProviderRequestGovernance
26
28
  from .service import (
27
29
  AgentRunService,
28
30
  IdempotencyConflictError,
@@ -71,6 +73,24 @@ def create_app(
71
73
  True,
72
74
  )
73
75
 
76
+ @application.get("/health/provider-governance", response_model=None)
77
+ async def provider_governance_health(
78
+ authorization: Annotated[str | None, Header()] = None,
79
+ ) -> dict[str, int | float | str | None] | JSONResponse:
80
+ if not authorized(authorization, internal_token):
81
+ return safe_error(403, "FORBIDDEN", "内部调用认证失败", False)
82
+ health = getattr(runtime_engine, "governance_health", None)
83
+ if not health:
84
+ return safe_error(
85
+ 503,
86
+ "RUNTIME_UNAVAILABLE",
87
+ "Provider 字段治理尚未配置",
88
+ True,
89
+ )
90
+ return provider_governance_snapshot(
91
+ cast(dict[str, int | float | str | None], health())
92
+ )
93
+
74
94
  @application.exception_handler(OperationNotFoundError)
75
95
  async def operation_not_found(
76
96
  _request: Request,
@@ -215,6 +235,12 @@ def default_engine() -> AgentEngineAdapter:
215
235
  EnvironmentSecretResolver(os.environ),
216
236
  ),
217
237
  ToolHostClient(internal_token),
238
+ ProviderRequestGovernance(
239
+ ProviderAuditStore(
240
+ required_environment("AI_PROVIDER_AUDIT_STORE_PATH"),
241
+ retention_days=provider_audit_retention_days(),
242
+ )
243
+ ),
218
244
  )
219
245
 
220
246
 
@@ -223,6 +249,41 @@ def default_store() -> RunStore:
223
249
  return SqliteRunStore(path) if path else InMemoryRunStore()
224
250
 
225
251
 
252
+ def required_environment(key: str) -> str:
253
+ value = os.environ.get(key, "").strip()
254
+ if not value:
255
+ raise ValueError(f"Provider 已配置但缺少 {key}")
256
+ return value
257
+
258
+
259
+ def provider_audit_retention_days() -> int:
260
+ raw = os.environ.get("AI_PROVIDER_AUDIT_RETENTION_DAYS", "90")
261
+ try:
262
+ value = int(raw)
263
+ except ValueError as error:
264
+ raise ValueError("AI_PROVIDER_AUDIT_RETENTION_DAYS 必须是整数") from error
265
+ if not 1 <= value <= 3650:
266
+ raise ValueError("AI_PROVIDER_AUDIT_RETENTION_DAYS 超出允许范围")
267
+ return value
268
+
269
+
270
+ def provider_governance_snapshot(
271
+ value: dict[str, int | float | str | None],
272
+ ) -> dict[str, int | float | str | None]:
273
+ allowed = (
274
+ "status",
275
+ "retentionDays",
276
+ "retainedRecords",
277
+ "rejectedAttempts",
278
+ "redactedAttempts",
279
+ "lastAttemptAt",
280
+ "lastPurgeAt",
281
+ "lastDeletedRecords",
282
+ "totalDeletedRecords",
283
+ )
284
+ return {key: value.get(key) for key in allowed}
285
+
286
+
226
287
  def authorized(provided: str | None, expected: str | None) -> bool:
227
288
  if expected is None:
228
289
  return True
@@ -74,6 +74,40 @@ class ProviderEgress(CanonicalModel):
74
74
  return self
75
75
 
76
76
 
77
+ type ProviderDataField = Literal[
78
+ "instructions",
79
+ "user-prompt",
80
+ "tool-definitions",
81
+ "provider-output",
82
+ "tool-results",
83
+ ]
84
+
85
+
86
+ class ProviderFieldPolicy(CanonicalModel):
87
+ field: ProviderDataField
88
+ classification: Literal["public", "internal", "sensitive"]
89
+ dlp_action: Literal["reject", "redact"]
90
+
91
+
92
+ class ProviderInputFieldPolicy(CanonicalModel):
93
+ json_pointer: str = Field(pattern=r"^/[^/]+(?:/[^/]+)*$")
94
+ classification: Literal["public", "internal", "sensitive"]
95
+ dlp_action: Literal["reject", "redact"]
96
+
97
+
98
+ class ProviderDataPolicy(CanonicalModel):
99
+ input_fields: list[ProviderInputFieldPolicy] = Field(default_factory=list)
100
+ envelope_fields: list[ProviderFieldPolicy] = Field(default_factory=list)
101
+
102
+ @model_validator(mode="after")
103
+ def validate_fields(self) -> Self:
104
+ names = [rule.field for rule in self.envelope_fields]
105
+ pointers = [rule.json_pointer for rule in self.input_fields]
106
+ if len(names) != len(set(names)) or len(pointers) != len(set(pointers)):
107
+ raise ValueError("Provider 字段发送规则重复")
108
+ return self
109
+
110
+
77
111
  class ToolNetworkPolicy(CanonicalModel):
78
112
  mode: Literal["forbidden"] = "forbidden"
79
113
 
@@ -86,6 +120,7 @@ class StartRunRequest(CanonicalModel):
86
120
  allowed_tools: list[RuntimeToolDefinition] = Field(default_factory=list)
87
121
  limits: RuntimeLimits = Field(default_factory=RuntimeLimits)
88
122
  provider_egress: ProviderEgress = Field(default_factory=ProviderEgress)
123
+ provider_data_policy: ProviderDataPolicy = Field(default_factory=ProviderDataPolicy)
89
124
  tool_network_policy: ToolNetworkPolicy = Field(default_factory=ToolNetworkPolicy)
90
125
  execution_grant: str = ""
91
126
  tool_host_url: str = ""
@@ -7,6 +7,7 @@ from typing import Protocol
7
7
  from .contracts import (
8
8
  AgentErrorCode,
9
9
  JsonValue,
10
+ ProviderDataPolicy,
10
11
  ProviderEgress,
11
12
  RuntimeLimits,
12
13
  RuntimeToolDefinition,
@@ -24,6 +25,7 @@ class EngineRunRequest:
24
25
  allowed_tools: tuple[RuntimeToolDefinition, ...] = ()
25
26
  limits: RuntimeLimits = field(default_factory=RuntimeLimits)
26
27
  provider_egress: ProviderEgress = field(default_factory=ProviderEgress)
28
+ provider_data_policy: ProviderDataPolicy = field(default_factory=ProviderDataPolicy)
27
29
  tool_network_policy: ToolNetworkPolicy = field(default_factory=ToolNetworkPolicy)
28
30
  execution_grant: str = ""
29
31
  tool_host_url: str = ""
@@ -17,6 +17,7 @@ from .engine import (
17
17
  UsageReported,
18
18
  )
19
19
  from .openai_provider import OpenAiCompatibleProvider, ProviderToolCall
20
+ from .provider_governance import ProviderRequestGovernance
20
21
  from .tool_host import ToolHostClient, ToolInvocation
21
22
 
22
23
 
@@ -25,9 +26,11 @@ class OpenAiCompatibleEngineAdapter(AgentEngineAdapter):
25
26
  self,
26
27
  provider: OpenAiCompatibleProvider,
27
28
  tool_host: ToolHostClient,
29
+ governance: ProviderRequestGovernance,
28
30
  ) -> None:
29
31
  self._provider = provider
30
32
  self._tool_host = tool_host
33
+ self._governance = governance
31
34
  self._cancellations: dict[str, asyncio.Event] = {}
32
35
 
33
36
  def supports(self, operation_key: str) -> bool:
@@ -49,11 +52,24 @@ class OpenAiCompatibleEngineAdapter(AgentEngineAdapter):
49
52
  ]
50
53
  output_bytes = 0
51
54
  try:
52
- for _round in range(self._provider.config.max_tool_rounds):
55
+ for attempt in range(1, self._provider.config.max_tool_rounds + 1):
53
56
  cancelled(cancellation)
57
+ governed = self._governance.authorize(
58
+ deployment_key=self._provider.config.deployment_key,
59
+ operation_key=request.operation_key,
60
+ run_id=request.run_id,
61
+ attempt=attempt,
62
+ messages=messages,
63
+ tools=request.allowed_tools,
64
+ policy=request.provider_data_policy,
65
+ input_value=request.input,
66
+ measure=lambda messages, tools: len(
67
+ self._provider.request_body(messages, tools)
68
+ ),
69
+ )
54
70
  turn = await self._provider.complete_turn(
55
- messages,
56
- request.allowed_tools,
71
+ governed.messages,
72
+ governed.tools,
57
73
  )
58
74
  if turn.tool_calls:
59
75
  messages.append(assistant_tool_message(turn.tool_calls))
@@ -135,6 +151,9 @@ class OpenAiCompatibleEngineAdapter(AgentEngineAdapter):
135
151
  async def health(self) -> None:
136
152
  await self._provider.health()
137
153
 
154
+ def governance_health(self) -> dict[str, int | float | str | None]:
155
+ return self._governance.health()
156
+
138
157
 
139
158
  def validate_runtime_request(
140
159
  request: EngineRunRequest,
@@ -166,6 +185,15 @@ def validate_runtime_request(
166
185
  "Agent Tool 网络策略不受支持",
167
186
  retryable=False,
168
187
  )
188
+ if (
189
+ not request.provider_data_policy.input_fields
190
+ or not request.provider_data_policy.envelope_fields
191
+ ):
192
+ raise SafeEngineError(
193
+ "FORBIDDEN",
194
+ "Agent Operation 缺少 Provider 字段治理策略",
195
+ retryable=False,
196
+ )
169
197
 
170
198
 
171
199
  def cancelled(event: asyncio.Event) -> None:
@@ -59,21 +59,15 @@ class OpenAiCompatibleProvider:
59
59
  messages: list[dict[str, JsonValue]],
60
60
  tools: tuple[RuntimeToolDefinition, ...],
61
61
  ) -> ProviderTurn:
62
- payload: dict[str, JsonValue] = {
63
- "model": self.config.model,
64
- "messages": cast(JsonValue, messages),
65
- "stream": True,
66
- "stream_options": {"include_usage": True},
67
- }
68
- if tools:
69
- payload["tools"] = [tool_schema(tool) for tool in tools]
62
+ body = self.request_body(messages, tools)
70
63
  turn = ProviderTurn()
71
64
  async with self._client() as client:
72
65
  try:
73
66
  async with client.stream(
74
67
  "POST",
75
68
  "/chat/completions",
76
- json=payload,
69
+ content=body,
70
+ headers={"content-type": "application/json"},
77
71
  ) as response:
78
72
  if not response.is_success:
79
73
  await self._bounded_stream(response)
@@ -85,6 +79,25 @@ class OpenAiCompatibleProvider:
85
79
  raise provider_failure(error) from None
86
80
  return turn
87
81
 
82
+ def request_body(
83
+ self,
84
+ messages: list[dict[str, JsonValue]],
85
+ tools: tuple[RuntimeToolDefinition, ...],
86
+ ) -> bytes:
87
+ payload: dict[str, JsonValue] = {
88
+ "model": self.config.model,
89
+ "messages": cast(JsonValue, messages),
90
+ "stream": True,
91
+ "stream_options": {"include_usage": True},
92
+ }
93
+ if tools:
94
+ payload["tools"] = [tool_schema(tool) for tool in tools]
95
+ return json.dumps(
96
+ payload,
97
+ ensure_ascii=False,
98
+ separators=(",", ":"),
99
+ ).encode()
100
+
88
101
  def _client(self) -> httpx.AsyncClient:
89
102
  api_key = (
90
103
  self._resolver.resolve(self.config.api_key_ref)
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ import threading
5
+ import time
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ProviderAttemptAudit:
13
+ deployment_key: str
14
+ operation_key: str
15
+ run_id: str
16
+ attempt: int
17
+ classifications: tuple[str, ...]
18
+ sent_bytes: int
19
+ decision: str
20
+
21
+
22
+ class ProviderAuditStore:
23
+ """Append-only Provider attempt metadata, except explicit TTL expiry."""
24
+
25
+ def __init__(self, path: str, *, retention_days: int) -> None:
26
+ if not path.strip():
27
+ raise ValueError("Provider 发送审计 Store 路径不能为空")
28
+ if not 1 <= retention_days <= 3650:
29
+ raise ValueError("Provider 发送审计留存天数超出范围")
30
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
31
+ self._database = sqlite3.connect(path, check_same_thread=False)
32
+ self._lock = threading.RLock()
33
+ self._retention_seconds = retention_days * 24 * 60 * 60
34
+ self.retention_days = retention_days
35
+ self._initialize()
36
+ self.purge_expired()
37
+
38
+ def append(self, audit: ProviderAttemptAudit) -> None:
39
+ with self._lock:
40
+ self.purge_expired()
41
+ try:
42
+ self._database.execute(
43
+ """
44
+ insert into provider_attempt_audit (
45
+ event_id, deployment_key, operation_key, run_id, attempt,
46
+ classifications, sent_bytes, decision, occurred_at
47
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
48
+ """,
49
+ (
50
+ f"pa_{uuid.uuid4().hex}",
51
+ audit.deployment_key,
52
+ audit.operation_key,
53
+ audit.run_id,
54
+ audit.attempt,
55
+ ",".join(audit.classifications),
56
+ audit.sent_bytes,
57
+ audit.decision,
58
+ time.time(),
59
+ ),
60
+ )
61
+ self._database.commit()
62
+ except Exception:
63
+ self._database.rollback()
64
+ raise
65
+
66
+ def purge_expired(self, *, now: float | None = None) -> int:
67
+ with self._lock:
68
+ cutoff = (now or time.time()) - self._retention_seconds
69
+ cursor = self._database.execute(
70
+ "delete from provider_attempt_audit where occurred_at < ?",
71
+ (cutoff,),
72
+ )
73
+ deleted = max(cursor.rowcount, 0)
74
+ self._database.execute(
75
+ """
76
+ update provider_audit_retention
77
+ set last_purge_at = ?, last_deleted = ?,
78
+ total_deleted = total_deleted + ?
79
+ where singleton = 1
80
+ """,
81
+ (now or time.time(), deleted, deleted),
82
+ )
83
+ self._database.commit()
84
+ return deleted
85
+
86
+ def health(self) -> dict[str, int | float | str | None]:
87
+ with self._lock:
88
+ return self._health()
89
+
90
+ def _health(self) -> dict[str, int | float | str | None]:
91
+ row = self._database.execute(
92
+ """
93
+ select count(*) as retained,
94
+ sum(case when decision = 'reject' then 1 else 0 end) as rejected,
95
+ sum(case when decision = 'redact' then 1 else 0 end) as redacted,
96
+ max(occurred_at) as last_attempt_at
97
+ from provider_attempt_audit
98
+ """
99
+ ).fetchone()
100
+ retention = self._database.execute(
101
+ "select * from provider_audit_retention where singleton = 1"
102
+ ).fetchone()
103
+ return {
104
+ "status": "healthy",
105
+ "retentionDays": self.retention_days,
106
+ "retainedRecords": int(row[0] or 0),
107
+ "rejectedAttempts": int(row[1] or 0),
108
+ "redactedAttempts": int(row[2] or 0),
109
+ "lastAttemptAt": row[3],
110
+ "lastPurgeAt": retention[1],
111
+ "lastDeletedRecords": int(retention[2]),
112
+ "totalDeletedRecords": int(retention[3]),
113
+ }
114
+
115
+ def close(self) -> None:
116
+ with self._lock:
117
+ self._database.close()
118
+
119
+ def _initialize(self) -> None:
120
+ self._database.executescript(
121
+ """
122
+ create table if not exists provider_attempt_audit (
123
+ event_id text primary key,
124
+ deployment_key text not null,
125
+ operation_key text not null,
126
+ run_id text not null,
127
+ attempt integer not null,
128
+ classifications text not null,
129
+ sent_bytes integer not null,
130
+ decision text not null check(decision in ('allow', 'redact', 'reject')),
131
+ occurred_at real not null,
132
+ unique(run_id, attempt)
133
+ );
134
+ create table if not exists provider_audit_retention (
135
+ singleton integer primary key check(singleton = 1),
136
+ last_purge_at real,
137
+ last_deleted integer not null,
138
+ total_deleted integer not null
139
+ );
140
+ insert or ignore into provider_audit_retention
141
+ values (1, null, 0, 0);
142
+ """
143
+ )
144
+ self._database.commit()