@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.
Files changed (117) hide show
  1. package/README.md +353 -0
  2. package/npm/bin/kagent-serve.js +6 -0
  3. package/npm/bin/kagent.js +6 -0
  4. package/npm/lib/App.js +524 -0
  5. package/npm/lib/app-state.js +224 -0
  6. package/npm/lib/approval-choice.js +25 -0
  7. package/npm/lib/commands.js +59 -0
  8. package/npm/lib/editor.js +188 -0
  9. package/npm/lib/ink-runner.js +41 -0
  10. package/npm/lib/kagent-home.js +39 -0
  11. package/npm/lib/launcher.js +221 -0
  12. package/npm/lib/protocol.js +33 -0
  13. package/npm/lib/provider-setup.js +139 -0
  14. package/npm/lib/python-runner.js +892 -0
  15. package/npm/lib/runtime-client.js +390 -0
  16. package/npm/lib/terminal-input.js +127 -0
  17. package/npm/lib/terminal-text.js +19 -0
  18. package/npm/lib/terminal-width.js +14 -0
  19. package/npm/lib/transcript.js +227 -0
  20. package/npm/lib/ui-components.js +247 -0
  21. package/npm/lib/update-manager.js +334 -0
  22. package/package.json +39 -0
  23. package/pyproject.toml +55 -0
  24. package/src/kagent/__init__.py +90 -0
  25. package/src/kagent/cli/__init__.py +5 -0
  26. package/src/kagent/cli/__main__.py +6 -0
  27. package/src/kagent/cli/commands.py +112 -0
  28. package/src/kagent/cli/conversation.py +127 -0
  29. package/src/kagent/cli/interactive.py +841 -0
  30. package/src/kagent/cli/main.py +685 -0
  31. package/src/kagent/cli/memory.py +460 -0
  32. package/src/kagent/cli/pending_approval.py +169 -0
  33. package/src/kagent/cli/provider.py +27 -0
  34. package/src/kagent/cli/session_commands.py +401 -0
  35. package/src/kagent/cli/stdio_runtime.py +784 -0
  36. package/src/kagent/cli/trace.py +67 -0
  37. package/src/kagent/cli/ui.py +931 -0
  38. package/src/kagent/core/__init__.py +0 -0
  39. package/src/kagent/core/agent.py +296 -0
  40. package/src/kagent/core/faults.py +11 -0
  41. package/src/kagent/core/invariants.py +47 -0
  42. package/src/kagent/core/normalization.py +81 -0
  43. package/src/kagent/core/planning.py +48 -0
  44. package/src/kagent/core/state.py +73 -0
  45. package/src/kagent/core/summary.py +64 -0
  46. package/src/kagent/core/tools.py +196 -0
  47. package/src/kagent/core/trace.py +57 -0
  48. package/src/kagent/eval/__init__.py +11 -0
  49. package/src/kagent/eval/cases.py +229 -0
  50. package/src/kagent/eval/evaluator.py +216 -0
  51. package/src/kagent/integrations/__init__.py +3 -0
  52. package/src/kagent/integrations/audit.py +95 -0
  53. package/src/kagent/integrations/backends.py +131 -0
  54. package/src/kagent/integrations/memory.py +301 -0
  55. package/src/kagent/ops/__init__.py +0 -0
  56. package/src/kagent/ops/batch.py +156 -0
  57. package/src/kagent/ops/doctor.py +255 -0
  58. package/src/kagent/ops/metrics.py +214 -0
  59. package/src/kagent/ops/release_evidence.py +877 -0
  60. package/src/kagent/ops/release_manifest.py +142 -0
  61. package/src/kagent/ops/trace_replay.py +285 -0
  62. package/src/kagent/providers/__init__.py +7 -0
  63. package/src/kagent/providers/embeddings.py +187 -0
  64. package/src/kagent/providers/llm.py +770 -0
  65. package/src/kagent/py.typed +1 -0
  66. package/src/kagent/runtime/__init__.py +28 -0
  67. package/src/kagent/runtime/action_graph.py +543 -0
  68. package/src/kagent/runtime/agent.py +2089 -0
  69. package/src/kagent/runtime/approval.py +64 -0
  70. package/src/kagent/runtime/cancellation.py +64 -0
  71. package/src/kagent/runtime/checkpoint_state.py +146 -0
  72. package/src/kagent/runtime/checkpoint_storage.py +270 -0
  73. package/src/kagent/runtime/context.py +65 -0
  74. package/src/kagent/runtime/file_transaction.py +195 -0
  75. package/src/kagent/runtime/hooks.py +74 -0
  76. package/src/kagent/runtime/metadata.py +116 -0
  77. package/src/kagent/runtime/patch_checkpoints.py +385 -0
  78. package/src/kagent/runtime/policy.py +50 -0
  79. package/src/kagent/runtime/presentation.py +205 -0
  80. package/src/kagent/runtime/redaction.py +130 -0
  81. package/src/kagent/runtime/sandbox.py +331 -0
  82. package/src/kagent/runtime/skills.py +66 -0
  83. package/src/kagent/runtime/steering.py +56 -0
  84. package/src/kagent/runtime/steps.py +255 -0
  85. package/src/kagent/runtime/task_state.py +40 -0
  86. package/src/kagent/runtime/tools.py +3532 -0
  87. package/src/kagent/runtime/types.py +240 -0
  88. package/src/kagent/runtime/workspace.py +597 -0
  89. package/src/kagent/service/__init__.py +26 -0
  90. package/src/kagent/service/__main__.py +6 -0
  91. package/src/kagent/service/active_runs.py +178 -0
  92. package/src/kagent/service/cli.py +737 -0
  93. package/src/kagent/service/contract.py +2571 -0
  94. package/src/kagent/service/errors.py +71 -0
  95. package/src/kagent/service/idempotency.py +584 -0
  96. package/src/kagent/service/router.py +884 -0
  97. package/src/kagent/service/run.py +150 -0
  98. package/src/kagent/service/runtime.py +1915 -0
  99. package/src/kagent/service/runtime_approval.py +20 -0
  100. package/src/kagent/service/runtime_cancel.py +192 -0
  101. package/src/kagent/service/runtime_lifecycle.py +229 -0
  102. package/src/kagent/service/runtime_metadata.py +21 -0
  103. package/src/kagent/service/runtime_policy.py +115 -0
  104. package/src/kagent/service/runtime_recovery.py +573 -0
  105. package/src/kagent/service/runtime_resume.py +382 -0
  106. package/src/kagent/service/runtime_resume_claim.py +116 -0
  107. package/src/kagent/service/runtime_run.py +350 -0
  108. package/src/kagent/service/runtime_status.py +2007 -0
  109. package/src/kagent/service/safety.py +139 -0
  110. package/src/kagent/service/server.py +114 -0
  111. package/src/kagent/service/status.py +233 -0
  112. package/src/kagent/service/trace_store.py +322 -0
  113. package/src/kagent/service/transport.py +24 -0
  114. package/src/kagent/utils/__init__.py +0 -0
  115. package/src/kagent/utils/config_validation.py +21 -0
  116. package/src/kagent/utils/json_output.py +23 -0
  117. 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)