@openlucaskaka/kagent 0.1.7
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 +353 -0
- package/npm/bin/kagent-serve.js +6 -0
- package/npm/bin/kagent.js +6 -0
- package/npm/lib/App.js +524 -0
- package/npm/lib/app-state.js +224 -0
- package/npm/lib/approval-choice.js +25 -0
- package/npm/lib/commands.js +59 -0
- package/npm/lib/editor.js +188 -0
- package/npm/lib/ink-runner.js +41 -0
- package/npm/lib/kagent-home.js +39 -0
- package/npm/lib/launcher.js +221 -0
- package/npm/lib/protocol.js +33 -0
- package/npm/lib/provider-setup.js +139 -0
- package/npm/lib/python-runner.js +892 -0
- package/npm/lib/runtime-client.js +390 -0
- package/npm/lib/terminal-input.js +127 -0
- package/npm/lib/terminal-text.js +19 -0
- package/npm/lib/terminal-width.js +14 -0
- package/npm/lib/transcript.js +227 -0
- package/npm/lib/ui-components.js +247 -0
- package/npm/lib/update-manager.js +334 -0
- package/package.json +39 -0
- package/pyproject.toml +55 -0
- package/src/kagent/__init__.py +90 -0
- package/src/kagent/cli/__init__.py +5 -0
- package/src/kagent/cli/__main__.py +6 -0
- package/src/kagent/cli/commands.py +112 -0
- package/src/kagent/cli/conversation.py +127 -0
- package/src/kagent/cli/interactive.py +841 -0
- package/src/kagent/cli/main.py +685 -0
- package/src/kagent/cli/memory.py +460 -0
- package/src/kagent/cli/pending_approval.py +169 -0
- package/src/kagent/cli/provider.py +27 -0
- package/src/kagent/cli/session_commands.py +401 -0
- package/src/kagent/cli/stdio_runtime.py +784 -0
- package/src/kagent/cli/trace.py +67 -0
- package/src/kagent/cli/ui.py +931 -0
- package/src/kagent/core/__init__.py +0 -0
- package/src/kagent/core/agent.py +296 -0
- package/src/kagent/core/faults.py +11 -0
- package/src/kagent/core/invariants.py +47 -0
- package/src/kagent/core/normalization.py +81 -0
- package/src/kagent/core/planning.py +48 -0
- package/src/kagent/core/state.py +73 -0
- package/src/kagent/core/summary.py +64 -0
- package/src/kagent/core/tools.py +196 -0
- package/src/kagent/core/trace.py +57 -0
- package/src/kagent/eval/__init__.py +11 -0
- package/src/kagent/eval/cases.py +229 -0
- package/src/kagent/eval/evaluator.py +216 -0
- package/src/kagent/integrations/__init__.py +3 -0
- package/src/kagent/integrations/audit.py +95 -0
- package/src/kagent/integrations/backends.py +131 -0
- package/src/kagent/integrations/memory.py +301 -0
- package/src/kagent/ops/__init__.py +0 -0
- package/src/kagent/ops/batch.py +156 -0
- package/src/kagent/ops/doctor.py +255 -0
- package/src/kagent/ops/metrics.py +214 -0
- package/src/kagent/ops/release_evidence.py +877 -0
- package/src/kagent/ops/release_manifest.py +142 -0
- package/src/kagent/ops/trace_replay.py +285 -0
- package/src/kagent/providers/__init__.py +7 -0
- package/src/kagent/providers/embeddings.py +187 -0
- package/src/kagent/providers/llm.py +770 -0
- package/src/kagent/py.typed +1 -0
- package/src/kagent/runtime/__init__.py +28 -0
- package/src/kagent/runtime/action_graph.py +543 -0
- package/src/kagent/runtime/agent.py +2089 -0
- package/src/kagent/runtime/approval.py +64 -0
- package/src/kagent/runtime/cancellation.py +64 -0
- package/src/kagent/runtime/checkpoint_state.py +146 -0
- package/src/kagent/runtime/checkpoint_storage.py +270 -0
- package/src/kagent/runtime/context.py +65 -0
- package/src/kagent/runtime/file_transaction.py +195 -0
- package/src/kagent/runtime/hooks.py +74 -0
- package/src/kagent/runtime/metadata.py +116 -0
- package/src/kagent/runtime/patch_checkpoints.py +385 -0
- package/src/kagent/runtime/policy.py +50 -0
- package/src/kagent/runtime/presentation.py +205 -0
- package/src/kagent/runtime/redaction.py +130 -0
- package/src/kagent/runtime/sandbox.py +331 -0
- package/src/kagent/runtime/skills.py +66 -0
- package/src/kagent/runtime/steering.py +56 -0
- package/src/kagent/runtime/steps.py +255 -0
- package/src/kagent/runtime/task_state.py +40 -0
- package/src/kagent/runtime/tools.py +3532 -0
- package/src/kagent/runtime/types.py +240 -0
- package/src/kagent/runtime/workspace.py +597 -0
- package/src/kagent/service/__init__.py +26 -0
- package/src/kagent/service/__main__.py +6 -0
- package/src/kagent/service/active_runs.py +178 -0
- package/src/kagent/service/cli.py +737 -0
- package/src/kagent/service/contract.py +2571 -0
- package/src/kagent/service/errors.py +71 -0
- package/src/kagent/service/idempotency.py +584 -0
- package/src/kagent/service/router.py +884 -0
- package/src/kagent/service/run.py +150 -0
- package/src/kagent/service/runtime.py +1915 -0
- package/src/kagent/service/runtime_approval.py +20 -0
- package/src/kagent/service/runtime_cancel.py +192 -0
- package/src/kagent/service/runtime_lifecycle.py +229 -0
- package/src/kagent/service/runtime_metadata.py +21 -0
- package/src/kagent/service/runtime_policy.py +115 -0
- package/src/kagent/service/runtime_recovery.py +573 -0
- package/src/kagent/service/runtime_resume.py +382 -0
- package/src/kagent/service/runtime_resume_claim.py +116 -0
- package/src/kagent/service/runtime_run.py +350 -0
- package/src/kagent/service/runtime_status.py +2007 -0
- package/src/kagent/service/safety.py +139 -0
- package/src/kagent/service/server.py +114 -0
- package/src/kagent/service/status.py +233 -0
- package/src/kagent/service/trace_store.py +322 -0
- package/src/kagent/service/transport.py +24 -0
- package/src/kagent/utils/__init__.py +0 -0
- package/src/kagent/utils/config_validation.py +21 -0
- package/src/kagent/utils/json_output.py +23 -0
- package/src/kagent/utils/paths.py +473 -0
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from os import environ
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional
|
|
15
|
+
|
|
16
|
+
from kagent.runtime.redaction import REDACTED_VALUE, redact_runtime_text
|
|
17
|
+
from kagent.utils.paths import kagent_config_dir, migrate_legacy_kagent_state
|
|
18
|
+
|
|
19
|
+
DEFAULT_LLM_MODEL = "qwen3.5-122b-a10b"
|
|
20
|
+
PROVIDER_CONFIG_SCHEMA_VERSION = "1"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProviderKind(str, Enum):
|
|
24
|
+
OPENAI_COMPATIBLE = "openai_compatible"
|
|
25
|
+
DEEPSEEK = "deepseek"
|
|
26
|
+
QWEN_OPENAI_COMPATIBLE = "qwen_openai_compatible"
|
|
27
|
+
OLLAMA_OPENAI_COMPATIBLE = "ollama_openai_compatible"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class LLMProviderConfig:
|
|
32
|
+
provider: ProviderKind = ProviderKind.OPENAI_COMPATIBLE
|
|
33
|
+
base_url: str = ""
|
|
34
|
+
api_key: str = ""
|
|
35
|
+
model: str = ""
|
|
36
|
+
timeout_seconds: float = 30.0
|
|
37
|
+
max_retries: int = 2
|
|
38
|
+
retry_backoff_seconds: float = 0.25
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "LLMProviderConfig":
|
|
42
|
+
source = env if env is not None else environ
|
|
43
|
+
base_url = source.get("KAGENT_LLM_BASE_URL", cls.base_url)
|
|
44
|
+
model = source.get("KAGENT_LLM_MODEL", cls.model)
|
|
45
|
+
return cls(
|
|
46
|
+
provider=_provider_from_env(source, base_url=base_url, model=model),
|
|
47
|
+
base_url=base_url,
|
|
48
|
+
api_key=source.get("KAGENT_LLM_API_KEY", cls.api_key),
|
|
49
|
+
model=model,
|
|
50
|
+
timeout_seconds=_env_float(
|
|
51
|
+
source,
|
|
52
|
+
"KAGENT_LLM_TIMEOUT_SECONDS",
|
|
53
|
+
cls.timeout_seconds,
|
|
54
|
+
),
|
|
55
|
+
max_retries=_env_int(
|
|
56
|
+
source,
|
|
57
|
+
"KAGENT_LLM_MAX_RETRIES",
|
|
58
|
+
cls.max_retries,
|
|
59
|
+
),
|
|
60
|
+
retry_backoff_seconds=_env_float(
|
|
61
|
+
source,
|
|
62
|
+
"KAGENT_LLM_RETRY_BACKOFF_SECONDS",
|
|
63
|
+
cls.retry_backoff_seconds,
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_sources(
|
|
69
|
+
cls,
|
|
70
|
+
env: Optional[Mapping[str, str]] = None,
|
|
71
|
+
config_path: str = "",
|
|
72
|
+
) -> "LLMProviderConfig":
|
|
73
|
+
source = env if env is not None else environ
|
|
74
|
+
file_config = load_provider_config(
|
|
75
|
+
config_path or default_provider_config_path(source)
|
|
76
|
+
)
|
|
77
|
+
merged = {
|
|
78
|
+
"KAGENT_LLM_PROVIDER": file_config.provider.value,
|
|
79
|
+
"KAGENT_LLM_BASE_URL": file_config.base_url,
|
|
80
|
+
"KAGENT_LLM_API_KEY": file_config.api_key,
|
|
81
|
+
"KAGENT_LLM_MODEL": file_config.model,
|
|
82
|
+
"KAGENT_LLM_TIMEOUT_SECONDS": str(file_config.timeout_seconds),
|
|
83
|
+
"KAGENT_LLM_MAX_RETRIES": str(file_config.max_retries),
|
|
84
|
+
"KAGENT_LLM_RETRY_BACKOFF_SECONDS": str(
|
|
85
|
+
file_config.retry_backoff_seconds
|
|
86
|
+
),
|
|
87
|
+
}
|
|
88
|
+
for key, value in source.items():
|
|
89
|
+
if key.startswith("KAGENT_LLM_") and value != "":
|
|
90
|
+
merged[key] = value
|
|
91
|
+
provider_overridden = bool(source.get("KAGENT_LLM_PROVIDER", "").strip())
|
|
92
|
+
endpoint_overridden = bool(
|
|
93
|
+
source.get("KAGENT_LLM_BASE_URL", "").strip()
|
|
94
|
+
or source.get("KAGENT_LLM_MODEL", "").strip()
|
|
95
|
+
)
|
|
96
|
+
if endpoint_overridden and not provider_overridden:
|
|
97
|
+
merged["KAGENT_LLM_PROVIDER"] = ""
|
|
98
|
+
return cls.from_env(merged)
|
|
99
|
+
|
|
100
|
+
def redacted_snapshot(self) -> Dict[str, str]:
|
|
101
|
+
provider = self.provider.value if self.base_url and self.model else "unconfigured"
|
|
102
|
+
display_name = (
|
|
103
|
+
provider_display_name(self.provider)
|
|
104
|
+
if self.base_url and self.model
|
|
105
|
+
else "Unconfigured"
|
|
106
|
+
)
|
|
107
|
+
base_url_configured = bool(self.base_url)
|
|
108
|
+
return {
|
|
109
|
+
"llm_provider": provider,
|
|
110
|
+
"llm_provider_display_name": display_name,
|
|
111
|
+
"llm_base_url": "configured" if base_url_configured else "",
|
|
112
|
+
"llm_base_url_configured": str(base_url_configured).lower(),
|
|
113
|
+
"llm_model": self.model,
|
|
114
|
+
"llm_api_key_configured": str(bool(self.api_key)).lower(),
|
|
115
|
+
"llm_timeout_seconds": str(self.timeout_seconds),
|
|
116
|
+
"llm_max_retries": str(self.max_retries),
|
|
117
|
+
"llm_retry_backoff_seconds": str(self.retry_backoff_seconds),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
def __post_init__(self) -> None:
|
|
121
|
+
object.__setattr__(self, "provider", normalize_provider_kind(self.provider))
|
|
122
|
+
if self.timeout_seconds <= 0:
|
|
123
|
+
raise ValueError("timeout_seconds must be positive")
|
|
124
|
+
if self.max_retries < 0:
|
|
125
|
+
raise ValueError("max_retries must be non-negative")
|
|
126
|
+
if self.retry_backoff_seconds < 0:
|
|
127
|
+
raise ValueError("retry_backoff_seconds must be non-negative")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def default_provider_config_path(env: Optional[Mapping[str, str]] = None) -> str:
|
|
131
|
+
source = env if env is not None else environ
|
|
132
|
+
if source.get("KAGENT_LLM_CONFIG_PATH"):
|
|
133
|
+
return source["KAGENT_LLM_CONFIG_PATH"]
|
|
134
|
+
migrate_legacy_kagent_state(source)
|
|
135
|
+
return str(kagent_config_dir(source) / "provider.json")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def load_provider_config(path: str = "") -> LLMProviderConfig:
|
|
139
|
+
config_path = Path(path or default_provider_config_path())
|
|
140
|
+
if not config_path.exists():
|
|
141
|
+
return LLMProviderConfig()
|
|
142
|
+
_validate_provider_config_path_for_read(config_path)
|
|
143
|
+
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
|
144
|
+
if not isinstance(payload, dict):
|
|
145
|
+
raise ValueError("provider config must be a JSON object")
|
|
146
|
+
if str(payload.get("schema_version", "")) != PROVIDER_CONFIG_SCHEMA_VERSION:
|
|
147
|
+
raise ValueError("provider config schema_version is unsupported")
|
|
148
|
+
provider = normalize_provider_kind(str(payload.get("provider", "openai_compatible")))
|
|
149
|
+
return LLMProviderConfig(
|
|
150
|
+
provider=provider,
|
|
151
|
+
base_url=str(payload.get("base_url", "")),
|
|
152
|
+
api_key=str(payload.get("api_key", "")),
|
|
153
|
+
model=str(payload.get("model", "")),
|
|
154
|
+
timeout_seconds=float(
|
|
155
|
+
payload.get("timeout_seconds", LLMProviderConfig.timeout_seconds)
|
|
156
|
+
),
|
|
157
|
+
max_retries=int(payload.get("max_retries", LLMProviderConfig.max_retries)),
|
|
158
|
+
retry_backoff_seconds=float(
|
|
159
|
+
payload.get(
|
|
160
|
+
"retry_backoff_seconds",
|
|
161
|
+
LLMProviderConfig.retry_backoff_seconds,
|
|
162
|
+
)
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def save_provider_config(config: LLMProviderConfig, path: str = "") -> str:
|
|
168
|
+
config_path = Path(path or default_provider_config_path())
|
|
169
|
+
_validate_provider_config_path_for_write(config_path)
|
|
170
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
os.chmod(config_path.parent, 0o700)
|
|
172
|
+
payload = {
|
|
173
|
+
"schema_version": PROVIDER_CONFIG_SCHEMA_VERSION,
|
|
174
|
+
"provider": config.provider.value,
|
|
175
|
+
"base_url": config.base_url,
|
|
176
|
+
"api_key": config.api_key,
|
|
177
|
+
"model": config.model,
|
|
178
|
+
"timeout_seconds": config.timeout_seconds,
|
|
179
|
+
"max_retries": config.max_retries,
|
|
180
|
+
"retry_backoff_seconds": config.retry_backoff_seconds,
|
|
181
|
+
}
|
|
182
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
183
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
184
|
+
flags |= os.O_NOFOLLOW
|
|
185
|
+
fd = os.open(config_path, flags, 0o600)
|
|
186
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
187
|
+
json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
|
|
188
|
+
handle.write("\n")
|
|
189
|
+
os.chmod(config_path, 0o600)
|
|
190
|
+
return str(config_path)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def normalize_provider_kind(value: object) -> ProviderKind:
|
|
194
|
+
if isinstance(value, ProviderKind):
|
|
195
|
+
return value
|
|
196
|
+
normalized = str(value or "").strip().lower().replace("-", "_")
|
|
197
|
+
aliases = {
|
|
198
|
+
"": ProviderKind.OPENAI_COMPATIBLE,
|
|
199
|
+
"openai": ProviderKind.OPENAI_COMPATIBLE,
|
|
200
|
+
"openai_compatible": ProviderKind.OPENAI_COMPATIBLE,
|
|
201
|
+
"openai-compatible": ProviderKind.OPENAI_COMPATIBLE,
|
|
202
|
+
"deepseek": ProviderKind.DEEPSEEK,
|
|
203
|
+
"qwen": ProviderKind.QWEN_OPENAI_COMPATIBLE,
|
|
204
|
+
"dashscope": ProviderKind.QWEN_OPENAI_COMPATIBLE,
|
|
205
|
+
"qwen_openai_compatible": ProviderKind.QWEN_OPENAI_COMPATIBLE,
|
|
206
|
+
"qwen-compatible": ProviderKind.QWEN_OPENAI_COMPATIBLE,
|
|
207
|
+
"ollama": ProviderKind.OLLAMA_OPENAI_COMPATIBLE,
|
|
208
|
+
"ollama_openai_compatible": ProviderKind.OLLAMA_OPENAI_COMPATIBLE,
|
|
209
|
+
}
|
|
210
|
+
if normalized in aliases:
|
|
211
|
+
return aliases[normalized]
|
|
212
|
+
try:
|
|
213
|
+
return ProviderKind(normalized)
|
|
214
|
+
except ValueError as exc:
|
|
215
|
+
raise ValueError(f"unsupported llm provider: {value}") from exc
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def detect_provider_kind(base_url: str, model: str = "") -> ProviderKind:
|
|
219
|
+
haystack = f"{base_url} {model}".strip().lower()
|
|
220
|
+
if not haystack:
|
|
221
|
+
return ProviderKind.OPENAI_COMPATIBLE
|
|
222
|
+
if "deepseek" in haystack:
|
|
223
|
+
return ProviderKind.DEEPSEEK
|
|
224
|
+
if any(marker in haystack for marker in ("dashscope", "aliyuncs", "qwen")):
|
|
225
|
+
return ProviderKind.QWEN_OPENAI_COMPATIBLE
|
|
226
|
+
if any(marker in haystack for marker in ("localhost:11434", "127.0.0.1:11434", "ollama")):
|
|
227
|
+
return ProviderKind.OLLAMA_OPENAI_COMPATIBLE
|
|
228
|
+
return ProviderKind.OPENAI_COMPATIBLE
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def provider_display_name(provider: object) -> str:
|
|
232
|
+
try:
|
|
233
|
+
kind = normalize_provider_kind(provider)
|
|
234
|
+
except ValueError:
|
|
235
|
+
kind = ProviderKind.OPENAI_COMPATIBLE
|
|
236
|
+
names = {
|
|
237
|
+
ProviderKind.OPENAI_COMPATIBLE: "OpenAI-compatible",
|
|
238
|
+
ProviderKind.DEEPSEEK: "DeepSeek",
|
|
239
|
+
ProviderKind.QWEN_OPENAI_COMPATIBLE: "Qwen",
|
|
240
|
+
ProviderKind.OLLAMA_OPENAI_COMPATIBLE: "Ollama",
|
|
241
|
+
}
|
|
242
|
+
return names[kind]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def provider_setup_options(
|
|
246
|
+
default_model: str = DEFAULT_LLM_MODEL,
|
|
247
|
+
) -> List[Dict[str, object]]:
|
|
248
|
+
return [
|
|
249
|
+
{
|
|
250
|
+
"provider": ProviderKind.QWEN_OPENAI_COMPATIBLE,
|
|
251
|
+
"label": "Qwen / DashScope",
|
|
252
|
+
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
253
|
+
"model": default_model,
|
|
254
|
+
"api_key_required": True,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"provider": ProviderKind.DEEPSEEK,
|
|
258
|
+
"label": "DeepSeek",
|
|
259
|
+
"base_url": "https://api.deepseek.com/v1",
|
|
260
|
+
"model": "deepseek-chat",
|
|
261
|
+
"api_key_required": True,
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
"provider": ProviderKind.OLLAMA_OPENAI_COMPATIBLE,
|
|
265
|
+
"label": "Ollama local",
|
|
266
|
+
"base_url": "http://localhost:11434/v1",
|
|
267
|
+
"model": "llama3",
|
|
268
|
+
"api_key_required": False,
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"provider": ProviderKind.OPENAI_COMPATIBLE,
|
|
272
|
+
"label": "OpenAI-compatible / custom",
|
|
273
|
+
"base_url": "",
|
|
274
|
+
"model": default_model,
|
|
275
|
+
"api_key_required": False,
|
|
276
|
+
},
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def missing_provider_config_fields(config: LLMProviderConfig) -> List[str]:
|
|
281
|
+
missing = []
|
|
282
|
+
if not config.base_url.strip():
|
|
283
|
+
missing.append("KAGENT_LLM_BASE_URL")
|
|
284
|
+
if not config.model.strip():
|
|
285
|
+
missing.append("KAGENT_LLM_MODEL")
|
|
286
|
+
if config.provider in {
|
|
287
|
+
ProviderKind.QWEN_OPENAI_COMPATIBLE,
|
|
288
|
+
ProviderKind.DEEPSEEK,
|
|
289
|
+
} and not config.api_key.strip():
|
|
290
|
+
missing.append("KAGENT_LLM_API_KEY")
|
|
291
|
+
return missing
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def validate_provider_setup_config(config: LLMProviderConfig) -> None:
|
|
295
|
+
if not config.base_url.strip():
|
|
296
|
+
raise ValueError("base_url is required")
|
|
297
|
+
endpoint = urllib.parse.urlsplit(config.base_url)
|
|
298
|
+
if endpoint.scheme not in {"http", "https"} or not endpoint.netloc:
|
|
299
|
+
raise ValueError("base_url must be an absolute http or https URL")
|
|
300
|
+
if not config.model.strip():
|
|
301
|
+
raise ValueError("model is required")
|
|
302
|
+
if config.provider in {
|
|
303
|
+
ProviderKind.QWEN_OPENAI_COMPATIBLE,
|
|
304
|
+
ProviderKind.DEEPSEEK,
|
|
305
|
+
} and not config.api_key.strip():
|
|
306
|
+
raise ValueError("api_key is required for this provider")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _provider_from_env(
|
|
310
|
+
env: Mapping[str, str],
|
|
311
|
+
*,
|
|
312
|
+
base_url: str,
|
|
313
|
+
model: str,
|
|
314
|
+
) -> ProviderKind:
|
|
315
|
+
explicit = env.get("KAGENT_LLM_PROVIDER", "")
|
|
316
|
+
if explicit.strip():
|
|
317
|
+
return normalize_provider_kind(explicit)
|
|
318
|
+
return detect_provider_kind(base_url, model)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _validate_provider_config_path_for_read(path: Path) -> None:
|
|
322
|
+
_reject_symlink_path(path)
|
|
323
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
324
|
+
if mode != 0o600:
|
|
325
|
+
raise ValueError("provider config file must be owner-only")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _validate_provider_config_path_for_write(path: Path) -> None:
|
|
329
|
+
_reject_symlink_path(path)
|
|
330
|
+
if path.parent.exists():
|
|
331
|
+
_reject_symlink_path(path.parent)
|
|
332
|
+
os.chmod(path.parent, 0o700)
|
|
333
|
+
if path.exists():
|
|
334
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
335
|
+
if mode != 0o600:
|
|
336
|
+
raise ValueError("provider config file must be owner-only")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _reject_symlink_path(path: Path) -> None:
|
|
340
|
+
current = Path(path.anchor or ".")
|
|
341
|
+
parts = path.parts[1:] if path.is_absolute() else path.parts
|
|
342
|
+
for part in parts:
|
|
343
|
+
current = current / part
|
|
344
|
+
if current.is_symlink():
|
|
345
|
+
if current.parent == Path(current.anchor) and current.lstat().st_uid == 0:
|
|
346
|
+
continue
|
|
347
|
+
raise ValueError("provider config path must not contain symlinks")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class FakeLLMProvider:
|
|
351
|
+
def __init__(self, response_text: str, stream_chunks: Optional[List[str]] = None) -> None:
|
|
352
|
+
self.response_text = response_text
|
|
353
|
+
self.stream_chunks = list(stream_chunks) if stream_chunks is not None else [response_text]
|
|
354
|
+
self.calls: List[Dict[str, str]] = []
|
|
355
|
+
self.stream_calls: List[Dict[str, str]] = []
|
|
356
|
+
|
|
357
|
+
def complete(self, system: str, user: str) -> str:
|
|
358
|
+
self.calls.append({"system": system, "user": user})
|
|
359
|
+
return self.response_text
|
|
360
|
+
|
|
361
|
+
def stream_complete(self, system: str, user: str) -> Iterator[str]:
|
|
362
|
+
self.stream_calls.append({"system": system, "user": user})
|
|
363
|
+
yield from self.stream_chunks
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class SequentialFakeLLMProvider:
|
|
367
|
+
def __init__(self, response_texts: List[str]) -> None:
|
|
368
|
+
if not response_texts:
|
|
369
|
+
raise ValueError("response_texts must be non-empty")
|
|
370
|
+
self.response_texts = list(response_texts)
|
|
371
|
+
self.calls: List[Dict[str, str]] = []
|
|
372
|
+
|
|
373
|
+
def complete(self, system: str, user: str) -> str:
|
|
374
|
+
self.calls.append({"system": system, "user": user})
|
|
375
|
+
if len(self.calls) <= len(self.response_texts):
|
|
376
|
+
return self.response_texts[len(self.calls) - 1]
|
|
377
|
+
return self.response_texts[-1]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class OpenAICompatibleProvider:
|
|
381
|
+
def __init__(
|
|
382
|
+
self,
|
|
383
|
+
config: LLMProviderConfig,
|
|
384
|
+
*,
|
|
385
|
+
urlopen: Callable[..., Any] = urllib.request.urlopen,
|
|
386
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
387
|
+
) -> None:
|
|
388
|
+
if not config.base_url:
|
|
389
|
+
raise ValueError("base_url is required")
|
|
390
|
+
if not config.model:
|
|
391
|
+
raise ValueError("model is required")
|
|
392
|
+
self.config = config
|
|
393
|
+
self._urlopen = urlopen
|
|
394
|
+
self._sleep = sleep
|
|
395
|
+
self._last_request_diagnostics: Dict[str, str] = {}
|
|
396
|
+
|
|
397
|
+
def complete(self, system: str, user: str) -> str:
|
|
398
|
+
request = self._chat_completion_request(system, user, stream=False)
|
|
399
|
+
body = self._request_json_with_retries(request)
|
|
400
|
+
try:
|
|
401
|
+
return str(body["choices"][0]["message"]["content"])
|
|
402
|
+
except (KeyError, IndexError, TypeError) as exc:
|
|
403
|
+
self._mark_request_diagnostics_failed("response_error")
|
|
404
|
+
raise RuntimeError("llm provider response missing message content") from exc
|
|
405
|
+
|
|
406
|
+
def stream_complete(self, system: str, user: str) -> Iterator[str]:
|
|
407
|
+
request = self._chat_completion_request(system, user, stream=True)
|
|
408
|
+
yield from self._request_stream_with_retries(request)
|
|
409
|
+
|
|
410
|
+
def request_diagnostics(self) -> Dict[str, str]:
|
|
411
|
+
return dict(self._last_request_diagnostics)
|
|
412
|
+
|
|
413
|
+
def _chat_completion_request(
|
|
414
|
+
self,
|
|
415
|
+
system: str,
|
|
416
|
+
user: str,
|
|
417
|
+
*,
|
|
418
|
+
stream: bool,
|
|
419
|
+
) -> urllib.request.Request:
|
|
420
|
+
endpoint = self.config.base_url.rstrip("/") + "/chat/completions"
|
|
421
|
+
payload = {
|
|
422
|
+
"model": self.config.model,
|
|
423
|
+
"messages": [
|
|
424
|
+
{"role": "system", "content": system},
|
|
425
|
+
{"role": "user", "content": user},
|
|
426
|
+
],
|
|
427
|
+
"temperature": 0,
|
|
428
|
+
}
|
|
429
|
+
if stream:
|
|
430
|
+
payload["stream"] = True
|
|
431
|
+
headers = {
|
|
432
|
+
"Content-Type": "application/json",
|
|
433
|
+
}
|
|
434
|
+
if self.config.api_key:
|
|
435
|
+
headers["Authorization"] = f"Bearer {self.config.api_key}"
|
|
436
|
+
return urllib.request.Request(
|
|
437
|
+
endpoint,
|
|
438
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
439
|
+
headers=headers,
|
|
440
|
+
method="POST",
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
def _request_json_with_retries(self, request: urllib.request.Request) -> Dict[str, Any]:
|
|
444
|
+
max_attempts = self.config.max_retries + 1
|
|
445
|
+
started = time.perf_counter()
|
|
446
|
+
retry_count = 0
|
|
447
|
+
for attempt in range(max_attempts):
|
|
448
|
+
try:
|
|
449
|
+
with self._urlopen(
|
|
450
|
+
request,
|
|
451
|
+
timeout=self.config.timeout_seconds,
|
|
452
|
+
) as response:
|
|
453
|
+
body = json.loads(response.read().decode("utf-8"))
|
|
454
|
+
self._set_request_diagnostics(
|
|
455
|
+
started_at=started,
|
|
456
|
+
attempt_count=attempt + 1,
|
|
457
|
+
retry_count=retry_count,
|
|
458
|
+
stream=False,
|
|
459
|
+
status="ok",
|
|
460
|
+
)
|
|
461
|
+
return body
|
|
462
|
+
except urllib.error.HTTPError as exc:
|
|
463
|
+
body = _read_http_error_body(exc)
|
|
464
|
+
if attempt >= max_attempts - 1 or not _is_retryable_provider_error(
|
|
465
|
+
exc,
|
|
466
|
+
body,
|
|
467
|
+
):
|
|
468
|
+
self._set_request_diagnostics(
|
|
469
|
+
started_at=started,
|
|
470
|
+
attempt_count=attempt + 1,
|
|
471
|
+
retry_count=retry_count,
|
|
472
|
+
stream=False,
|
|
473
|
+
status="failed",
|
|
474
|
+
error_type=_provider_error_type(exc),
|
|
475
|
+
http_status=str(exc.code),
|
|
476
|
+
retryable_reason=_provider_retryable_reason(exc, body),
|
|
477
|
+
)
|
|
478
|
+
raise RuntimeError(
|
|
479
|
+
_provider_failure_message(exc, self.config.api_key, body)
|
|
480
|
+
) from exc
|
|
481
|
+
retry_count += 1
|
|
482
|
+
retry_delay = _provider_retry_delay_seconds(exc, self.config)
|
|
483
|
+
if retry_delay:
|
|
484
|
+
self._sleep(retry_delay)
|
|
485
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
486
|
+
if attempt >= max_attempts - 1 or not _is_retryable_provider_error(exc):
|
|
487
|
+
self._set_request_diagnostics(
|
|
488
|
+
started_at=started,
|
|
489
|
+
attempt_count=attempt + 1,
|
|
490
|
+
retry_count=retry_count,
|
|
491
|
+
stream=False,
|
|
492
|
+
status="failed",
|
|
493
|
+
error_type=_provider_error_type(exc),
|
|
494
|
+
)
|
|
495
|
+
raise RuntimeError(
|
|
496
|
+
_provider_failure_message(exc, self.config.api_key)
|
|
497
|
+
) from exc
|
|
498
|
+
retry_count += 1
|
|
499
|
+
if self.config.retry_backoff_seconds:
|
|
500
|
+
self._sleep(self.config.retry_backoff_seconds)
|
|
501
|
+
self._set_request_diagnostics(
|
|
502
|
+
started_at=started,
|
|
503
|
+
attempt_count=max_attempts,
|
|
504
|
+
retry_count=retry_count,
|
|
505
|
+
stream=False,
|
|
506
|
+
status="failed",
|
|
507
|
+
error_type="exhausted",
|
|
508
|
+
)
|
|
509
|
+
raise RuntimeError("llm provider request failed")
|
|
510
|
+
|
|
511
|
+
def _request_stream_with_retries(
|
|
512
|
+
self,
|
|
513
|
+
request: urllib.request.Request,
|
|
514
|
+
) -> Iterator[str]:
|
|
515
|
+
max_attempts = self.config.max_retries + 1
|
|
516
|
+
started = time.perf_counter()
|
|
517
|
+
retry_count = 0
|
|
518
|
+
for attempt in range(max_attempts):
|
|
519
|
+
try:
|
|
520
|
+
with self._urlopen(
|
|
521
|
+
request,
|
|
522
|
+
timeout=self.config.timeout_seconds,
|
|
523
|
+
) as response:
|
|
524
|
+
try:
|
|
525
|
+
yield from _stream_openai_chat_completion_chunks(response)
|
|
526
|
+
except RuntimeError:
|
|
527
|
+
self._set_request_diagnostics(
|
|
528
|
+
started_at=started,
|
|
529
|
+
attempt_count=attempt + 1,
|
|
530
|
+
retry_count=retry_count,
|
|
531
|
+
stream=True,
|
|
532
|
+
status="failed",
|
|
533
|
+
error_type="response_error",
|
|
534
|
+
)
|
|
535
|
+
raise
|
|
536
|
+
self._set_request_diagnostics(
|
|
537
|
+
started_at=started,
|
|
538
|
+
attempt_count=attempt + 1,
|
|
539
|
+
retry_count=retry_count,
|
|
540
|
+
stream=True,
|
|
541
|
+
status="ok",
|
|
542
|
+
)
|
|
543
|
+
return
|
|
544
|
+
except urllib.error.HTTPError as exc:
|
|
545
|
+
body = _read_http_error_body(exc)
|
|
546
|
+
if attempt >= max_attempts - 1 or not _is_retryable_provider_error(
|
|
547
|
+
exc,
|
|
548
|
+
body,
|
|
549
|
+
):
|
|
550
|
+
self._set_request_diagnostics(
|
|
551
|
+
started_at=started,
|
|
552
|
+
attempt_count=attempt + 1,
|
|
553
|
+
retry_count=retry_count,
|
|
554
|
+
stream=True,
|
|
555
|
+
status="failed",
|
|
556
|
+
error_type=_provider_error_type(exc),
|
|
557
|
+
http_status=str(exc.code),
|
|
558
|
+
retryable_reason=_provider_retryable_reason(exc, body),
|
|
559
|
+
)
|
|
560
|
+
raise RuntimeError(
|
|
561
|
+
_provider_failure_message(exc, self.config.api_key, body)
|
|
562
|
+
) from exc
|
|
563
|
+
retry_count += 1
|
|
564
|
+
retry_delay = _provider_retry_delay_seconds(exc, self.config)
|
|
565
|
+
if retry_delay:
|
|
566
|
+
self._sleep(retry_delay)
|
|
567
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
568
|
+
if attempt >= max_attempts - 1 or not _is_retryable_provider_error(exc):
|
|
569
|
+
self._set_request_diagnostics(
|
|
570
|
+
started_at=started,
|
|
571
|
+
attempt_count=attempt + 1,
|
|
572
|
+
retry_count=retry_count,
|
|
573
|
+
stream=True,
|
|
574
|
+
status="failed",
|
|
575
|
+
error_type=_provider_error_type(exc),
|
|
576
|
+
)
|
|
577
|
+
raise RuntimeError(
|
|
578
|
+
_provider_failure_message(exc, self.config.api_key)
|
|
579
|
+
) from exc
|
|
580
|
+
retry_count += 1
|
|
581
|
+
if self.config.retry_backoff_seconds:
|
|
582
|
+
self._sleep(self.config.retry_backoff_seconds)
|
|
583
|
+
self._set_request_diagnostics(
|
|
584
|
+
started_at=started,
|
|
585
|
+
attempt_count=max_attempts,
|
|
586
|
+
retry_count=retry_count,
|
|
587
|
+
stream=True,
|
|
588
|
+
status="failed",
|
|
589
|
+
error_type="exhausted",
|
|
590
|
+
)
|
|
591
|
+
raise RuntimeError("llm provider request failed")
|
|
592
|
+
|
|
593
|
+
def _set_request_diagnostics(
|
|
594
|
+
self,
|
|
595
|
+
*,
|
|
596
|
+
started_at: float,
|
|
597
|
+
attempt_count: int,
|
|
598
|
+
retry_count: int,
|
|
599
|
+
stream: bool,
|
|
600
|
+
status: str,
|
|
601
|
+
error_type: str = "",
|
|
602
|
+
http_status: str = "",
|
|
603
|
+
retryable_reason: str = "",
|
|
604
|
+
) -> None:
|
|
605
|
+
diagnostics = {
|
|
606
|
+
"attempt_count": str(max(0, attempt_count)),
|
|
607
|
+
"retry_count": str(max(0, retry_count)),
|
|
608
|
+
"status": status,
|
|
609
|
+
"stream": str(stream).lower(),
|
|
610
|
+
"duration_seconds": f"{time.perf_counter() - started_at:.4f}",
|
|
611
|
+
}
|
|
612
|
+
if error_type:
|
|
613
|
+
diagnostics["error_type"] = error_type
|
|
614
|
+
if http_status:
|
|
615
|
+
diagnostics["http_status"] = http_status
|
|
616
|
+
if retryable_reason:
|
|
617
|
+
diagnostics["retryable_reason"] = retryable_reason
|
|
618
|
+
self._last_request_diagnostics = diagnostics
|
|
619
|
+
|
|
620
|
+
def _mark_request_diagnostics_failed(self, error_type: str) -> None:
|
|
621
|
+
diagnostics = dict(self._last_request_diagnostics)
|
|
622
|
+
if not diagnostics:
|
|
623
|
+
return
|
|
624
|
+
diagnostics["status"] = "failed"
|
|
625
|
+
diagnostics["error_type"] = error_type
|
|
626
|
+
self._last_request_diagnostics = diagnostics
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def build_llm_provider(
|
|
630
|
+
config: LLMProviderConfig,
|
|
631
|
+
*,
|
|
632
|
+
urlopen: Callable[..., Any] = urllib.request.urlopen,
|
|
633
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
634
|
+
) -> OpenAICompatibleProvider:
|
|
635
|
+
# DeepSeek, Qwen, Ollama, and many company gateways expose the same
|
|
636
|
+
# /v1/chat/completions contract. Native protocol adapters can branch here.
|
|
637
|
+
normalize_provider_kind(config.provider)
|
|
638
|
+
return OpenAICompatibleProvider(config, urlopen=urlopen, sleep=sleep)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _stream_openai_chat_completion_chunks(response: Any) -> Iterator[str]:
|
|
642
|
+
while True:
|
|
643
|
+
raw_line = response.readline()
|
|
644
|
+
if raw_line == b"" or raw_line == "":
|
|
645
|
+
return
|
|
646
|
+
line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else str(raw_line)
|
|
647
|
+
line = line.strip()
|
|
648
|
+
if not line or not line.startswith("data:"):
|
|
649
|
+
continue
|
|
650
|
+
data = line.removeprefix("data:").strip()
|
|
651
|
+
if data == "[DONE]":
|
|
652
|
+
return
|
|
653
|
+
try:
|
|
654
|
+
payload = json.loads(data)
|
|
655
|
+
content = payload["choices"][0]["delta"].get("content", "")
|
|
656
|
+
except (json.JSONDecodeError, KeyError, IndexError, TypeError, AttributeError) as exc:
|
|
657
|
+
raise RuntimeError("llm provider stream chunk missing delta content") from exc
|
|
658
|
+
if content:
|
|
659
|
+
yield str(content)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _env_float(env: Mapping[str, str], name: str, default: float) -> float:
|
|
663
|
+
value = env.get(name)
|
|
664
|
+
if value in {None, ""}:
|
|
665
|
+
return default
|
|
666
|
+
try:
|
|
667
|
+
return float(value)
|
|
668
|
+
except ValueError as exc:
|
|
669
|
+
raise ValueError(f"{name} must be a number") from exc
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _env_int(env: Mapping[str, str], name: str, default: int) -> int:
|
|
673
|
+
value = env.get(name)
|
|
674
|
+
if value in {None, ""}:
|
|
675
|
+
return default
|
|
676
|
+
try:
|
|
677
|
+
return int(value)
|
|
678
|
+
except ValueError as exc:
|
|
679
|
+
raise ValueError(f"{name} must be an integer") from exc
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _is_retryable_provider_error(exc: BaseException, body: str = "") -> bool:
|
|
683
|
+
if isinstance(exc, urllib.error.HTTPError):
|
|
684
|
+
return (
|
|
685
|
+
exc.code == 429
|
|
686
|
+
or 500 <= exc.code <= 599
|
|
687
|
+
or _provider_retryable_reason(exc, body) == "model_unloaded"
|
|
688
|
+
)
|
|
689
|
+
return isinstance(exc, (urllib.error.URLError, TimeoutError))
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _provider_retryable_reason(exc: BaseException, body: str = "") -> str:
|
|
693
|
+
if (
|
|
694
|
+
isinstance(exc, urllib.error.HTTPError)
|
|
695
|
+
and exc.code == 400
|
|
696
|
+
and "model unloaded" in body.lower()
|
|
697
|
+
):
|
|
698
|
+
return "model_unloaded"
|
|
699
|
+
return ""
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _provider_retry_delay_seconds(
|
|
703
|
+
exc: BaseException,
|
|
704
|
+
config: LLMProviderConfig,
|
|
705
|
+
) -> float:
|
|
706
|
+
if isinstance(exc, urllib.error.HTTPError):
|
|
707
|
+
retry_after = _numeric_retry_after_seconds(exc)
|
|
708
|
+
if retry_after is not None:
|
|
709
|
+
return retry_after
|
|
710
|
+
return config.retry_backoff_seconds
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _provider_error_type(exc: BaseException) -> str:
|
|
714
|
+
if isinstance(exc, urllib.error.HTTPError):
|
|
715
|
+
return "http_error"
|
|
716
|
+
if isinstance(exc, urllib.error.URLError):
|
|
717
|
+
return "url_error"
|
|
718
|
+
if isinstance(exc, TimeoutError):
|
|
719
|
+
return "timeout"
|
|
720
|
+
return "provider_error"
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _numeric_retry_after_seconds(exc: urllib.error.HTTPError) -> float | None:
|
|
724
|
+
retry_after = exc.headers.get("Retry-After") if exc.headers else None
|
|
725
|
+
if retry_after is None:
|
|
726
|
+
return None
|
|
727
|
+
try:
|
|
728
|
+
seconds = float(str(retry_after).strip())
|
|
729
|
+
except ValueError:
|
|
730
|
+
return None
|
|
731
|
+
if seconds < 0:
|
|
732
|
+
return None
|
|
733
|
+
return seconds
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _provider_failure_message(
|
|
737
|
+
exc: BaseException,
|
|
738
|
+
api_key: str,
|
|
739
|
+
body: str = "",
|
|
740
|
+
) -> str:
|
|
741
|
+
message = "llm provider request failed"
|
|
742
|
+
if isinstance(exc, urllib.error.HTTPError):
|
|
743
|
+
redacted_body = _redact_provider_text(body, api_key)
|
|
744
|
+
if redacted_body:
|
|
745
|
+
return f"{message}: http_status={exc.code} body={redacted_body}"
|
|
746
|
+
return f"{message}: http_status={exc.code} reason={exc.reason}"
|
|
747
|
+
if isinstance(exc, urllib.error.URLError):
|
|
748
|
+
reason = _redact_provider_text(str(exc.reason), api_key)
|
|
749
|
+
if reason:
|
|
750
|
+
return f"{message}: reason={reason}"
|
|
751
|
+
if isinstance(exc, TimeoutError):
|
|
752
|
+
return f"{message}: reason=timeout"
|
|
753
|
+
return message
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _read_http_error_body(exc: urllib.error.HTTPError) -> str:
|
|
757
|
+
try:
|
|
758
|
+
body = exc.read()
|
|
759
|
+
except OSError:
|
|
760
|
+
return ""
|
|
761
|
+
if not body:
|
|
762
|
+
return ""
|
|
763
|
+
return body.decode("utf-8", errors="replace")[:500]
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _redact_provider_text(text: str, api_key: str) -> str:
|
|
767
|
+
redacted = text
|
|
768
|
+
if api_key:
|
|
769
|
+
redacted = redacted.replace(api_key, REDACTED_VALUE)
|
|
770
|
+
return redact_runtime_text(redacted)
|