create-windy 0.2.19 → 0.2.21
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.
- package/README.md +5 -1
- package/dist/cli.js +392 -160
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/README.md +1 -0
- package/template/apps/agent-server/Dockerfile +20 -0
- package/template/apps/agent-server/README.md +46 -0
- package/template/apps/agent-server/pyproject.toml +44 -0
- package/template/apps/agent-server/src/southwind_agent_server/__init__.py +5 -0
- package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +39 -0
- package/template/apps/agent-server/src/southwind_agent_server/app.py +263 -0
- package/template/apps/agent-server/src/southwind_agent_server/config.py +141 -0
- package/template/apps/agent-server/src/southwind_agent_server/contracts.py +99 -0
- package/template/apps/agent-server/src/southwind_agent_server/deterministic.py +52 -0
- package/template/apps/agent-server/src/southwind_agent_server/engine.py +85 -0
- package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +197 -0
- package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +246 -0
- package/template/apps/agent-server/src/southwind_agent_server/service.py +180 -0
- package/template/apps/agent-server/src/southwind_agent_server/sqlite_store.py +317 -0
- package/template/apps/agent-server/src/southwind_agent_server/sse.py +41 -0
- package/template/apps/agent-server/src/southwind_agent_server/store.py +144 -0
- package/template/apps/agent-server/src/southwind_agent_server/tool_host.py +80 -0
- package/template/apps/agent-server/tests/test_api.py +207 -0
- package/template/apps/agent-server/tests/test_config.py +56 -0
- package/template/apps/agent-server/tests/test_openai_provider.py +224 -0
- package/template/apps/agent-server/tests/test_sqlite_store.py +93 -0
- package/template/apps/agent-server/uv.lock +925 -0
- package/template/apps/server/src/agent/execution-grants.ts +89 -0
- package/template/apps/server/src/agent/remote-runtime.ts +128 -0
- package/template/apps/server/src/agent/run-contracts.ts +43 -0
- package/template/apps/server/src/agent/run-facade.test.ts +257 -0
- package/template/apps/server/src/agent/run-facade.ts +190 -0
- package/template/apps/server/src/agent/run-routes.ts +222 -0
- package/template/apps/server/src/agent/runtime-bootstrap.ts +53 -0
- package/template/apps/server/src/agent/tool-host.test.ts +242 -0
- package/template/apps/server/src/agent/tool-host.ts +153 -0
- package/template/apps/server/src/application-services.ts +2 -2
- package/template/apps/server/src/audit/search-summary.ts +15 -8
- package/template/apps/server/src/example-modules.ts +58 -70
- package/template/apps/server/src/foundation.ts +8 -4
- package/template/apps/server/src/guards.test.ts +13 -0
- package/template/apps/server/src/guards.ts +12 -2
- package/template/apps/server/src/index.ts +48 -95
- package/template/apps/server/src/installed-business-modules.ts +23 -4
- package/template/apps/server/src/module-bootstrap.ts +83 -0
- package/template/apps/server/src/module-composition.test.ts +54 -1
- package/template/apps/server/src/module-composition.ts +58 -0
- package/template/apps/server/src/module-host/initialize-contracts.ts +67 -0
- package/template/apps/server/src/module-host/initialize.test.ts +192 -0
- package/template/apps/server/src/module-host/initialize.ts +328 -0
- package/template/apps/server/src/module-host/request-context.test.ts +66 -0
- package/template/apps/server/src/module-host/request-context.ts +156 -0
- package/template/apps/server/src/module-host/role-presets.test.ts +94 -0
- package/template/apps/server/src/module-host/role-presets.ts +76 -0
- package/template/apps/server/src/module-host/route-installer.test.ts +317 -0
- package/template/apps/server/src/module-host/route-installer.ts +97 -0
- package/template/apps/server/src/module-host/route-registration.test.ts +160 -0
- package/template/apps/server/src/module-host/search-providers.ts +31 -0
- package/template/apps/server/src/module-host/storage-port.test.ts +165 -0
- package/template/apps/server/src/module-host/storage-port.ts +59 -0
- package/template/apps/server/src/module-host/task-definitions.ts +41 -0
- package/template/apps/server/src/module-host/test-fixtures.ts +26 -0
- package/template/apps/server/src/module-host/tool-handlers.ts +25 -0
- package/template/apps/server/src/module-host.test.ts +202 -58
- package/template/apps/server/src/module-host.ts +35 -113
- package/template/apps/server/src/module-runtime-validation.ts +40 -0
- package/template/apps/server/src/route-guards.test.ts +112 -1
- package/template/apps/server/src/runtime-feature.ts +66 -31
- package/template/apps/server/src/runtime.test.ts +1 -0
- package/template/apps/server/src/runtime.ts +3 -1
- package/template/apps/server/src/system/built-in-roles.ts +1 -1
- package/template/apps/server/src/work-order/foundation-modules.test.ts +30 -10
- package/template/apps/server/src/work-order/routes.test.ts +30 -18
- package/template/apps/server/src/work-order/routes.ts +104 -135
- package/template/apps/server/src/work-order/search-api.test.ts +14 -3
- package/template/apps/server/src/work-order/search-provider.test.ts +23 -15
- package/template/apps/server/src/work-order/search-provider.ts +17 -12
- package/template/apps/server/src/work-order/task.test.ts +16 -11
- package/template/apps/web/Dockerfile +4 -1
- package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +4 -1
- package/template/apps/web/src/layout/GlobalWatermark.vue +2 -0
- package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +4 -1
- package/template/docker-compose.yml +25 -0
- package/template/docs/architecture/ai-runtime.md +744 -0
- package/template/docs/architecture/object-storage.md +12 -0
- package/template/docs/platform/agent-runtime.md +128 -0
- package/template/docs/platform/agent-tools.md +8 -1
- package/template/package.json +1 -0
- package/template/packages/config/index.test.ts +100 -0
- package/template/packages/config/index.ts +1 -0
- package/template/packages/config/package.json +2 -1
- package/template/packages/config/src/ai-openai-compatible.ts +131 -0
- package/template/packages/config/src/platform.ts +28 -1
- package/template/packages/database/package.json +1 -1
- package/template/packages/database/src/runner.ts +7 -9
- package/template/packages/example-work-order/index.ts +1 -0
- package/template/packages/example-work-order/package.json +2 -0
- package/template/packages/example-work-order/src/server-module.ts +61 -0
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/jobs/src/types.ts +7 -1
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/catalog-validation.test.ts +212 -0
- package/template/packages/modules/src/catalog-validation.ts +19 -2
- package/template/packages/modules/src/migration-bundle-validation.test.ts +191 -0
- package/template/packages/server-sdk/index.ts +53 -0
- package/template/packages/server-sdk/package.json +18 -3
- package/template/packages/server-sdk/src/actor.ts +15 -0
- package/template/packages/server-sdk/src/ai-operation-registration.ts +31 -0
- package/template/packages/server-sdk/src/audit-port.ts +22 -0
- package/template/packages/server-sdk/src/jobs-port.ts +24 -0
- package/template/packages/server-sdk/src/module-host.ts +41 -1
- package/template/packages/server-sdk/src/namespace.test.ts +26 -0
- package/template/packages/server-sdk/src/namespace.ts +13 -0
- package/template/packages/server-sdk/src/request-context.ts +21 -0
- package/template/packages/server-sdk/src/role-preset.ts +18 -0
- package/template/packages/server-sdk/src/route-definition.ts +26 -0
- package/template/packages/server-sdk/src/search-provider-port.ts +48 -0
- package/template/packages/server-sdk/src/storage-port.ts +25 -0
- package/template/packages/server-sdk/src/task-registration.ts +18 -0
- package/template/packages/server-sdk/src/tool-host-port.ts +22 -0
- package/template/packages/server-sdk/src/tool-registration.ts +29 -0
- package/template/packages/shared/index.ts +1 -0
- package/template/packages/shared/package.json +1 -1
- package/template/packages/shared/src/license-catalog.test.ts +73 -1
- package/template/packages/shared/src/license-catalog.ts +95 -0
- package/template/packages/{database/src/bundle-validation.ts → shared/src/migration-bundle-validation.ts} +2 -1
- package/template/apps/server/src/work-order/task.ts +0 -30
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
|
|
6
|
+
from .engine import (
|
|
7
|
+
EngineCompleted,
|
|
8
|
+
EngineRunRequest,
|
|
9
|
+
EngineSignal,
|
|
10
|
+
OutputDelta,
|
|
11
|
+
UsageReported,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DeterministicEngineAdapter:
|
|
16
|
+
"""Deterministic test adapter. It never calls a model or remote service."""
|
|
17
|
+
|
|
18
|
+
operations = frozenset({"test.echo", "test.wait", "test.fail"})
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._cancellations: dict[str, asyncio.Event] = {}
|
|
22
|
+
|
|
23
|
+
def supports(self, operation_key: str) -> bool:
|
|
24
|
+
return operation_key in self.operations
|
|
25
|
+
|
|
26
|
+
async def execute(
|
|
27
|
+
self,
|
|
28
|
+
request: EngineRunRequest,
|
|
29
|
+
) -> AsyncIterator[EngineSignal]:
|
|
30
|
+
cancellation = self._cancellations.setdefault(request.run_id, asyncio.Event())
|
|
31
|
+
try:
|
|
32
|
+
if request.operation_key == "test.fail":
|
|
33
|
+
raise RuntimeError("upstream-secret-body")
|
|
34
|
+
if request.operation_key == "test.wait":
|
|
35
|
+
await cancellation.wait()
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
text = "deterministic"
|
|
39
|
+
if isinstance(request.input, dict):
|
|
40
|
+
input_text = request.input.get("text")
|
|
41
|
+
if isinstance(input_text, str):
|
|
42
|
+
text = input_text
|
|
43
|
+
yield OutputDelta(text)
|
|
44
|
+
yield UsageReported({"source": "unknown"})
|
|
45
|
+
yield EngineCompleted(request.input)
|
|
46
|
+
finally:
|
|
47
|
+
self._cancellations.pop(request.run_id, None)
|
|
48
|
+
|
|
49
|
+
async def cancel(self, run_id: str) -> None:
|
|
50
|
+
cancellation = self._cancellations.get(run_id)
|
|
51
|
+
if cancellation is not None:
|
|
52
|
+
cancellation.set()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from .contracts import AgentErrorCode, JsonValue, RuntimeLimits, RuntimeToolDefinition
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class EngineRunRequest:
|
|
12
|
+
run_id: str
|
|
13
|
+
operation_key: str
|
|
14
|
+
input: JsonValue
|
|
15
|
+
instructions: str = ""
|
|
16
|
+
user_prompt: str = ""
|
|
17
|
+
allowed_tools: tuple[RuntimeToolDefinition, ...] = ()
|
|
18
|
+
limits: RuntimeLimits = field(default_factory=RuntimeLimits)
|
|
19
|
+
execution_grant: str = ""
|
|
20
|
+
tool_host_url: str = ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class OutputDelta:
|
|
25
|
+
text: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ToolRequested:
|
|
30
|
+
tool_call_id: str
|
|
31
|
+
tool_key: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ApprovalRequired:
|
|
36
|
+
work_item_id: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ToolCompleted:
|
|
41
|
+
tool_call_id: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class UsageReported:
|
|
46
|
+
usage: dict[str, JsonValue]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class EngineCompleted:
|
|
51
|
+
output: JsonValue
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
type EngineSignal = (
|
|
55
|
+
OutputDelta
|
|
56
|
+
| ToolRequested
|
|
57
|
+
| ApprovalRequired
|
|
58
|
+
| ToolCompleted
|
|
59
|
+
| UsageReported
|
|
60
|
+
| EngineCompleted
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SafeEngineError(Exception):
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
code: AgentErrorCode,
|
|
68
|
+
message: str,
|
|
69
|
+
*,
|
|
70
|
+
retryable: bool,
|
|
71
|
+
) -> None:
|
|
72
|
+
super().__init__(message)
|
|
73
|
+
self.code = code
|
|
74
|
+
self.safe_message = message
|
|
75
|
+
self.retryable = retryable
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AgentEngineAdapter(Protocol):
|
|
79
|
+
"""Internal engine seam. HTTP DTO and ADK types must not cross it."""
|
|
80
|
+
|
|
81
|
+
def supports(self, operation_key: str) -> bool: ...
|
|
82
|
+
|
|
83
|
+
def execute(self, request: EngineRunRequest) -> AsyncIterator[EngineSignal]: ...
|
|
84
|
+
|
|
85
|
+
async def cancel(self, run_id: str) -> None: ...
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
|
|
7
|
+
from .contracts import JsonValue
|
|
8
|
+
from .engine import (
|
|
9
|
+
AgentEngineAdapter,
|
|
10
|
+
EngineCompleted,
|
|
11
|
+
EngineRunRequest,
|
|
12
|
+
EngineSignal,
|
|
13
|
+
OutputDelta,
|
|
14
|
+
SafeEngineError,
|
|
15
|
+
ToolCompleted,
|
|
16
|
+
ToolRequested,
|
|
17
|
+
UsageReported,
|
|
18
|
+
)
|
|
19
|
+
from .openai_provider import OpenAiCompatibleProvider, ProviderToolCall
|
|
20
|
+
from .tool_host import ToolHostClient, ToolInvocation
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OpenAiCompatibleEngineAdapter(AgentEngineAdapter):
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
provider: OpenAiCompatibleProvider,
|
|
27
|
+
tool_host: ToolHostClient,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._provider = provider
|
|
30
|
+
self._tool_host = tool_host
|
|
31
|
+
self._cancellations: dict[str, asyncio.Event] = {}
|
|
32
|
+
|
|
33
|
+
def supports(self, operation_key: str) -> bool:
|
|
34
|
+
return bool(operation_key.strip())
|
|
35
|
+
|
|
36
|
+
async def execute(
|
|
37
|
+
self,
|
|
38
|
+
request: EngineRunRequest,
|
|
39
|
+
) -> AsyncIterator[EngineSignal]:
|
|
40
|
+
validate_runtime_request(request)
|
|
41
|
+
cancellation = self._cancellations.setdefault(
|
|
42
|
+
request.run_id,
|
|
43
|
+
asyncio.Event(),
|
|
44
|
+
)
|
|
45
|
+
allowed = {tool.key for tool in request.allowed_tools}
|
|
46
|
+
messages: list[dict[str, JsonValue]] = [
|
|
47
|
+
{"role": "system", "content": request.instructions},
|
|
48
|
+
{"role": "user", "content": request.user_prompt},
|
|
49
|
+
]
|
|
50
|
+
output_bytes = 0
|
|
51
|
+
try:
|
|
52
|
+
for _round in range(self._provider.config.max_tool_rounds):
|
|
53
|
+
cancelled(cancellation)
|
|
54
|
+
turn = await self._provider.complete_turn(
|
|
55
|
+
messages,
|
|
56
|
+
request.allowed_tools,
|
|
57
|
+
)
|
|
58
|
+
if turn.tool_calls:
|
|
59
|
+
messages.append(assistant_tool_message(turn.tool_calls))
|
|
60
|
+
async for signal in self._execute_tools(
|
|
61
|
+
request,
|
|
62
|
+
turn.tool_calls,
|
|
63
|
+
allowed,
|
|
64
|
+
messages,
|
|
65
|
+
cancellation,
|
|
66
|
+
):
|
|
67
|
+
yield signal
|
|
68
|
+
continue
|
|
69
|
+
for delta in turn.text_deltas:
|
|
70
|
+
output_bytes += len(delta.encode())
|
|
71
|
+
if output_bytes > request.limits.max_output_bytes:
|
|
72
|
+
raise SafeEngineError(
|
|
73
|
+
"BUDGET_EXCEEDED",
|
|
74
|
+
"Agent 输出超过 Operation 上限",
|
|
75
|
+
retryable=False,
|
|
76
|
+
)
|
|
77
|
+
yield OutputDelta(delta)
|
|
78
|
+
yield UsageReported(turn.usage or {"source": "unknown"})
|
|
79
|
+
yield EngineCompleted({"text": turn.text})
|
|
80
|
+
return
|
|
81
|
+
raise SafeEngineError(
|
|
82
|
+
"BUDGET_EXCEEDED",
|
|
83
|
+
"Agent Tool 调用轮次超过上限",
|
|
84
|
+
retryable=False,
|
|
85
|
+
)
|
|
86
|
+
finally:
|
|
87
|
+
self._cancellations.pop(request.run_id, None)
|
|
88
|
+
|
|
89
|
+
async def _execute_tools(
|
|
90
|
+
self,
|
|
91
|
+
request: EngineRunRequest,
|
|
92
|
+
calls: list[ProviderToolCall],
|
|
93
|
+
allowed: set[str],
|
|
94
|
+
messages: list[dict[str, JsonValue]],
|
|
95
|
+
cancellation: asyncio.Event,
|
|
96
|
+
) -> AsyncIterator[EngineSignal]:
|
|
97
|
+
for call in calls:
|
|
98
|
+
cancelled(cancellation)
|
|
99
|
+
if not call.call_id or not call.name or call.name not in allowed:
|
|
100
|
+
raise SafeEngineError(
|
|
101
|
+
"TOOL_DENIED",
|
|
102
|
+
"模型请求了未授权 Tool",
|
|
103
|
+
retryable=False,
|
|
104
|
+
)
|
|
105
|
+
arguments = parse_arguments(call.arguments)
|
|
106
|
+
yield ToolRequested(call.call_id, call.name)
|
|
107
|
+
result = await self._tool_host.execute(
|
|
108
|
+
request.tool_host_url,
|
|
109
|
+
ToolInvocation(
|
|
110
|
+
request.run_id,
|
|
111
|
+
call.call_id,
|
|
112
|
+
call.name,
|
|
113
|
+
arguments,
|
|
114
|
+
request.execution_grant,
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
yield ToolCompleted(call.call_id)
|
|
118
|
+
messages.append(
|
|
119
|
+
{
|
|
120
|
+
"role": "tool",
|
|
121
|
+
"tool_call_id": call.call_id,
|
|
122
|
+
"content": json.dumps(
|
|
123
|
+
result,
|
|
124
|
+
ensure_ascii=False,
|
|
125
|
+
separators=(",", ":"),
|
|
126
|
+
),
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def cancel(self, run_id: str) -> None:
|
|
131
|
+
event = self._cancellations.get(run_id)
|
|
132
|
+
if event is not None:
|
|
133
|
+
event.set()
|
|
134
|
+
|
|
135
|
+
async def health(self) -> None:
|
|
136
|
+
await self._provider.health()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def validate_runtime_request(request: EngineRunRequest) -> None:
|
|
140
|
+
if (
|
|
141
|
+
not request.instructions.strip()
|
|
142
|
+
or not request.user_prompt.strip()
|
|
143
|
+
or not request.execution_grant.strip()
|
|
144
|
+
or not request.tool_host_url.strip()
|
|
145
|
+
):
|
|
146
|
+
raise SafeEngineError(
|
|
147
|
+
"INVALID_INPUT",
|
|
148
|
+
"Agent Operation 运行计划不完整",
|
|
149
|
+
retryable=False,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cancelled(event: asyncio.Event) -> None:
|
|
154
|
+
if event.is_set():
|
|
155
|
+
raise SafeEngineError(
|
|
156
|
+
"RUN_CANCELLED",
|
|
157
|
+
"Agent Run 已取消",
|
|
158
|
+
retryable=False,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def parse_arguments(raw: str) -> dict[str, JsonValue]:
|
|
163
|
+
try:
|
|
164
|
+
value = json.loads(raw or "{}")
|
|
165
|
+
except json.JSONDecodeError:
|
|
166
|
+
raise SafeEngineError(
|
|
167
|
+
"TOOL_DENIED",
|
|
168
|
+
"Agent Tool 参数无效",
|
|
169
|
+
retryable=False,
|
|
170
|
+
) from None
|
|
171
|
+
if not isinstance(value, dict):
|
|
172
|
+
raise SafeEngineError(
|
|
173
|
+
"TOOL_DENIED",
|
|
174
|
+
"Agent Tool 参数必须是对象",
|
|
175
|
+
retryable=False,
|
|
176
|
+
)
|
|
177
|
+
return value
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def assistant_tool_message(
|
|
181
|
+
calls: list[ProviderToolCall],
|
|
182
|
+
) -> dict[str, JsonValue]:
|
|
183
|
+
return {
|
|
184
|
+
"role": "assistant",
|
|
185
|
+
"content": None,
|
|
186
|
+
"tool_calls": [
|
|
187
|
+
{
|
|
188
|
+
"id": call.call_id,
|
|
189
|
+
"type": "function",
|
|
190
|
+
"function": {
|
|
191
|
+
"name": call.name,
|
|
192
|
+
"arguments": call.arguments,
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
for call in calls
|
|
196
|
+
],
|
|
197
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .config import (
|
|
10
|
+
EnvironmentSecretResolver,
|
|
11
|
+
OpenAiCompatibleConfig,
|
|
12
|
+
)
|
|
13
|
+
from .contracts import AgentErrorCode, JsonValue, RuntimeToolDefinition
|
|
14
|
+
from .engine import SafeEngineError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ProviderToolCall:
|
|
19
|
+
call_id: str = ""
|
|
20
|
+
name: str = ""
|
|
21
|
+
arguments: str = ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ProviderTurn:
|
|
26
|
+
text_deltas: list[str] = field(default_factory=list)
|
|
27
|
+
tool_calls: list[ProviderToolCall] = field(default_factory=list)
|
|
28
|
+
usage: dict[str, JsonValue] | None = None
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def text(self) -> str:
|
|
32
|
+
return "".join(self.text_deltas)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OpenAiCompatibleProvider:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
config: OpenAiCompatibleConfig,
|
|
39
|
+
resolver: EnvironmentSecretResolver,
|
|
40
|
+
*,
|
|
41
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.config = config
|
|
44
|
+
self._resolver = resolver
|
|
45
|
+
self._transport = transport
|
|
46
|
+
|
|
47
|
+
async def health(self) -> None:
|
|
48
|
+
async with self._client() as client:
|
|
49
|
+
try:
|
|
50
|
+
response = await client.get("/models")
|
|
51
|
+
await self._bounded_body(response)
|
|
52
|
+
except httpx.HTTPError as error:
|
|
53
|
+
raise provider_failure(error) from None
|
|
54
|
+
if not response.is_success:
|
|
55
|
+
raise provider_status_failure(response.status_code)
|
|
56
|
+
|
|
57
|
+
async def complete_turn(
|
|
58
|
+
self,
|
|
59
|
+
messages: list[dict[str, JsonValue]],
|
|
60
|
+
tools: tuple[RuntimeToolDefinition, ...],
|
|
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]
|
|
70
|
+
turn = ProviderTurn()
|
|
71
|
+
async with self._client() as client:
|
|
72
|
+
try:
|
|
73
|
+
async with client.stream(
|
|
74
|
+
"POST",
|
|
75
|
+
"/chat/completions",
|
|
76
|
+
json=payload,
|
|
77
|
+
) as response:
|
|
78
|
+
if not response.is_success:
|
|
79
|
+
await self._bounded_stream(response)
|
|
80
|
+
raise provider_status_failure(response.status_code)
|
|
81
|
+
await self._consume_stream(response, turn)
|
|
82
|
+
except SafeEngineError:
|
|
83
|
+
raise
|
|
84
|
+
except httpx.HTTPError as error:
|
|
85
|
+
raise provider_failure(error) from None
|
|
86
|
+
return turn
|
|
87
|
+
|
|
88
|
+
def _client(self) -> httpx.AsyncClient:
|
|
89
|
+
api_key = (
|
|
90
|
+
self._resolver.resolve(self.config.api_key_ref)
|
|
91
|
+
if self.config.api_key_ref
|
|
92
|
+
else None
|
|
93
|
+
)
|
|
94
|
+
headers = {"accept": "text/event-stream"}
|
|
95
|
+
if api_key:
|
|
96
|
+
headers["authorization"] = f"Bearer {api_key}"
|
|
97
|
+
timeout = httpx.Timeout(
|
|
98
|
+
self.config.request_timeout_seconds,
|
|
99
|
+
connect=self.config.connect_timeout_seconds,
|
|
100
|
+
)
|
|
101
|
+
return httpx.AsyncClient(
|
|
102
|
+
base_url=f"{self.config.base_url}/",
|
|
103
|
+
headers=headers,
|
|
104
|
+
timeout=timeout,
|
|
105
|
+
transport=self._transport,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
async def _consume_stream(
|
|
109
|
+
self,
|
|
110
|
+
response: httpx.Response,
|
|
111
|
+
turn: ProviderTurn,
|
|
112
|
+
) -> None:
|
|
113
|
+
consumed = 0
|
|
114
|
+
async for line in response.aiter_lines():
|
|
115
|
+
consumed += len(line.encode()) + 1
|
|
116
|
+
if consumed > self.config.max_response_bytes:
|
|
117
|
+
raise SafeEngineError(
|
|
118
|
+
"PROVIDER_UNAVAILABLE",
|
|
119
|
+
"模型响应超过安全上限",
|
|
120
|
+
retryable=False,
|
|
121
|
+
)
|
|
122
|
+
if not line.startswith("data:"):
|
|
123
|
+
continue
|
|
124
|
+
data = line.removeprefix("data:").strip()
|
|
125
|
+
if not data or data == "[DONE]":
|
|
126
|
+
continue
|
|
127
|
+
try:
|
|
128
|
+
chunk = json.loads(data)
|
|
129
|
+
consume_chunk(chunk, turn)
|
|
130
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
131
|
+
raise SafeEngineError(
|
|
132
|
+
"PROVIDER_UNAVAILABLE",
|
|
133
|
+
"模型返回协议无效",
|
|
134
|
+
retryable=True,
|
|
135
|
+
) from None
|
|
136
|
+
|
|
137
|
+
async def _bounded_stream(self, response: httpx.Response) -> bytes:
|
|
138
|
+
body = bytearray()
|
|
139
|
+
async for chunk in response.aiter_bytes():
|
|
140
|
+
body.extend(chunk)
|
|
141
|
+
if len(body) > self.config.max_response_bytes:
|
|
142
|
+
break
|
|
143
|
+
return bytes(body)
|
|
144
|
+
|
|
145
|
+
async def _bounded_body(self, response: httpx.Response) -> bytes:
|
|
146
|
+
body = await response.aread()
|
|
147
|
+
if len(body) > self.config.max_response_bytes:
|
|
148
|
+
raise SafeEngineError(
|
|
149
|
+
"PROVIDER_UNAVAILABLE",
|
|
150
|
+
"模型响应超过安全上限",
|
|
151
|
+
retryable=False,
|
|
152
|
+
)
|
|
153
|
+
return body
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def consume_chunk(value: object, turn: ProviderTurn) -> None:
|
|
157
|
+
if not isinstance(value, dict):
|
|
158
|
+
raise TypeError
|
|
159
|
+
usage = value.get("usage")
|
|
160
|
+
if isinstance(usage, dict):
|
|
161
|
+
turn.usage = usage_value(usage)
|
|
162
|
+
choices = value.get("choices")
|
|
163
|
+
if not isinstance(choices, list) or not choices:
|
|
164
|
+
return
|
|
165
|
+
choice = choices[0]
|
|
166
|
+
if not isinstance(choice, dict):
|
|
167
|
+
raise TypeError
|
|
168
|
+
delta = choice.get("delta")
|
|
169
|
+
if not isinstance(delta, dict):
|
|
170
|
+
return
|
|
171
|
+
content = delta.get("content")
|
|
172
|
+
if isinstance(content, str) and content:
|
|
173
|
+
turn.text_deltas.append(content)
|
|
174
|
+
raw_calls = delta.get("tool_calls")
|
|
175
|
+
if not isinstance(raw_calls, list):
|
|
176
|
+
return
|
|
177
|
+
for raw in raw_calls:
|
|
178
|
+
if not isinstance(raw, dict):
|
|
179
|
+
raise TypeError
|
|
180
|
+
index = raw.get("index")
|
|
181
|
+
if not isinstance(index, int) or index < 0:
|
|
182
|
+
raise TypeError
|
|
183
|
+
while len(turn.tool_calls) <= index:
|
|
184
|
+
turn.tool_calls.append(ProviderToolCall())
|
|
185
|
+
call = turn.tool_calls[index]
|
|
186
|
+
call_id = raw.get("id")
|
|
187
|
+
if isinstance(call_id, str):
|
|
188
|
+
call.call_id += call_id
|
|
189
|
+
function = raw.get("function")
|
|
190
|
+
if isinstance(function, dict):
|
|
191
|
+
name = function.get("name")
|
|
192
|
+
arguments = function.get("arguments")
|
|
193
|
+
if isinstance(name, str):
|
|
194
|
+
call.name += name
|
|
195
|
+
if isinstance(arguments, str):
|
|
196
|
+
call.arguments += arguments
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def tool_schema(tool: RuntimeToolDefinition) -> JsonValue:
|
|
200
|
+
return {
|
|
201
|
+
"type": "function",
|
|
202
|
+
"function": {
|
|
203
|
+
"name": tool.key,
|
|
204
|
+
"description": tool.description,
|
|
205
|
+
"parameters": {
|
|
206
|
+
"type": "object",
|
|
207
|
+
"additionalProperties": True,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def usage_value(raw: dict[object, object]) -> dict[str, JsonValue]:
|
|
214
|
+
values = {
|
|
215
|
+
"inputTokens": raw.get("prompt_tokens"),
|
|
216
|
+
"outputTokens": raw.get("completion_tokens"),
|
|
217
|
+
"totalTokens": raw.get("total_tokens"),
|
|
218
|
+
}
|
|
219
|
+
measurements = {
|
|
220
|
+
key: value
|
|
221
|
+
for key, value in values.items()
|
|
222
|
+
if isinstance(value, int) and value >= 0
|
|
223
|
+
}
|
|
224
|
+
return (
|
|
225
|
+
{"source": "provider-reported", **measurements}
|
|
226
|
+
if measurements
|
|
227
|
+
else {"source": "unknown"}
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def provider_failure(error: httpx.HTTPError) -> SafeEngineError:
|
|
232
|
+
del error
|
|
233
|
+
return SafeEngineError(
|
|
234
|
+
"PROVIDER_UNAVAILABLE",
|
|
235
|
+
"模型服务暂不可用",
|
|
236
|
+
retryable=True,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def provider_status_failure(status: int) -> SafeEngineError:
|
|
241
|
+
code: AgentErrorCode = "RATE_LIMITED" if status == 429 else "PROVIDER_UNAVAILABLE"
|
|
242
|
+
return SafeEngineError(
|
|
243
|
+
code,
|
|
244
|
+
"模型服务限流" if status == 429 else "模型服务暂不可用",
|
|
245
|
+
retryable=status == 429 or status >= 500,
|
|
246
|
+
)
|