abelworkflow 1.0.0 → 1.1.0

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.
@@ -25,6 +25,8 @@ import {
25
25
  import { defaultPaths, maskSecret, pathToLabel } from "../paths.mjs";
26
26
  import { normalizeOpenAiBaseUrl } from "./pi.mjs";
27
27
 
28
+ const CODEX_ENV_KEY = "OPENAI_API_KEY";
29
+
28
30
  const publishedCodexDeveloperInstructionHashes = new Set([
29
31
  "957adb227dd3fc298985acca38db4c22b8e018d387dab91f1ea8287db35385db"
30
32
  ]);
@@ -249,13 +251,6 @@ async function deployBundledCodexAgents(paths = defaultPaths, previousManagedFil
249
251
  return result;
250
252
  }
251
253
 
252
- function getDefaultCodexEnvKey(providerId) {
253
- if (providerId === "openai") return "OPENAI_API_KEY";
254
- if (providerId === "abelworkflow") return "ABELWORKFLOW_API_KEY";
255
- const digest = createHash("sha256").update(providerId).digest("hex").toUpperCase();
256
- return `ABELWORKFLOW_PROVIDER_${digest}_API_KEY`;
257
- }
258
-
259
254
  function getCodexProviderSectionName(providerId) {
260
255
  return `model_providers.${formatTomlKeySegment(providerId)}`;
261
256
  }
@@ -275,8 +270,7 @@ function assertCodexAuthKeyAvailable(auth, envKey) {
275
270
  function resolveExistingCodexApiConfig(content, auth = {}) {
276
271
  const providerId = readTopLevelTomlString(content, "model_provider") || "abelworkflow";
277
272
  const provider = parseTomlSection(content, getCodexProviderSectionName(providerId));
278
- const configuredEnvKey = provider.temp_env_key || "";
279
- const envKey = configuredEnvKey || getDefaultCodexEnvKey(providerId);
273
+ const envKey = CODEX_ENV_KEY;
280
274
  assertCodexAuthKeyAvailable(auth, envKey);
281
275
  const apiKey = typeof auth[envKey] === "string" ? auth[envKey] : "";
282
276
 
@@ -331,7 +325,7 @@ async function configureCodexApi(paths = defaultPaths, promptApi, ownership = {}
331
325
  const shouldDeploySubagents = await confirmOrCancel({ message: "是否部署 Codex subagents 配置?", initialValue: true });
332
326
  let managedCodexAgentFiles = { ...(ownership.managedCodexAgentFiles ?? {}) };
333
327
 
334
- const envKey = existing.envKey || "OPENAI_API_KEY";
328
+ const envKey = CODEX_ENV_KEY;
335
329
  const currentContent = await pathExists(paths.codexConfigPath) ? await readFile(paths.codexConfigPath, "utf8") : "";
336
330
  const templateContent = await loadBundledCodexConfigTemplate(paths);
337
331
  const content = buildCodexConfigContent(currentContent, {
@@ -340,8 +334,7 @@ async function configureCodexApi(paths = defaultPaths, promptApi, ownership = {}
340
334
  includeSubagentDefaults: shouldDeploySubagents,
341
335
  providerId,
342
336
  providerName,
343
- baseUrl,
344
- envKey
337
+ baseUrl
345
338
  });
346
339
 
347
340
  const auth = mergeCodexAuthData(
@@ -382,8 +375,7 @@ function buildCodexConfigContent(currentContent, {
382
375
  includeSubagentDefaults = true,
383
376
  providerId,
384
377
  providerName,
385
- baseUrl,
386
- envKey
378
+ baseUrl
387
379
  }) {
388
380
  const effectiveTemplateContent = includeSubagentDefaults
389
381
  ? templateContent
@@ -403,7 +395,7 @@ function buildCodexConfigContent(currentContent, {
403
395
  name: providerName,
404
396
  base_url: baseUrl,
405
397
  wire_api: "responses",
406
- temp_env_key: envKey,
398
+ temp_env_key: CODEX_ENV_KEY,
407
399
  requires_openai_auth: true,
408
400
  supports_websockets: true
409
401
  });
@@ -56,6 +56,15 @@ async function configureGrokSearchEnv(paths, ensureSkillPresent = async () => {}
56
56
  initialValue: Boolean(existing.TAVILY_API_KEY)
57
57
  });
58
58
 
59
+ const tavilyUrl = useTavily
60
+ ? await p.text({
61
+ message: "Tavily API URL",
62
+ initialValue: existing.TAVILY_API_URL || "https://api.tavily.com",
63
+ validate: required()
64
+ })
65
+ : null;
66
+ if (useTavily) assertNotCancelled(tavilyUrl);
67
+
59
68
  const tavilyKey = useTavily
60
69
  ? await p.password(passwordPromptOptions(
61
70
  "Tavily API Key",
@@ -70,6 +79,7 @@ async function configureGrokSearchEnv(paths, ensureSkillPresent = async () => {}
70
79
  GROK_API_URL: baseUrl,
71
80
  GROK_API_KEY: finalApiKey,
72
81
  GROK_MODEL: model,
82
+ TAVILY_API_URL: tavilyUrl,
73
83
  TAVILY_API_KEY: finalTavilyKey,
74
84
  TAVILY_ENABLED: useTavily ? "true" : null
75
85
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abelworkflow",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Install AbelWorkflow into ~/.agents and create Claude/Codex symlinks.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -216,7 +216,17 @@ def _emit_tavily_warning(message: str) -> None:
216
216
  RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
217
217
 
218
218
 
219
+ class StreamEmbeddedError(Exception):
220
+ """SSE 流内嵌错误事件(HTTP 200 + data: {"error": ...})。"""
221
+
222
+
223
+ class EmptyStreamError(Exception):
224
+ """流式响应解析完成但内容为空。"""
225
+
226
+
219
227
  def _is_retryable_exception(exc) -> bool:
228
+ if isinstance(exc, (StreamEmbeddedError, EmptyStreamError)):
229
+ return True
220
230
  if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError, httpx.ConnectError, httpx.RemoteProtocolError)):
221
231
  return True
222
232
  if isinstance(exc, httpx.HTTPStatusError):
@@ -501,6 +511,7 @@ def _normalize_tavily_base_url(raw: str) -> str:
501
511
 
502
512
  _http_client: Optional[httpx.AsyncClient] = None
503
513
  _DEFAULT_TIMEOUT = httpx.Timeout(connect=6.0, read=60.0, write=10.0, pool=None)
514
+ _NON_STREAM_TIMEOUT = httpx.Timeout(connect=6.0, read=10.0, write=10.0, pool=None)
504
515
 
505
516
 
506
517
  async def get_http_client() -> httpx.AsyncClient:
@@ -586,16 +597,23 @@ class GrokSearchProvider:
586
597
  return await self._execute(payload)
587
598
 
588
599
  async def _execute(self, payload: dict) -> str:
589
- """执行请求:先尝试非流式,失败时回退到流式。"""
600
+ """执行请求:流式优先,失败时回退到非流式(10s 超时 fail-fast)。"""
590
601
  try:
602
+ return await self._execute_stream(payload)
603
+ except httpx.HTTPStatusError as e:
604
+ if e.response.status_code not in RETRYABLE_STATUS_CODES:
605
+ raise
606
+ if config.debug_enabled:
607
+ print(f"[DEBUG] 流式失败: {e},回退到非流式", file=sys.stderr)
591
608
  return await self._execute_non_stream(payload)
592
- except (httpx.HTTPStatusError, json.JSONDecodeError) as e:
609
+ except (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError,
610
+ StreamEmbeddedError, EmptyStreamError, json.JSONDecodeError) as e:
593
611
  if config.debug_enabled:
594
- print(f"[DEBUG] 非流式失败: {e},回退到流式", file=sys.stderr)
595
- return await self._execute_stream(payload)
612
+ print(f"[DEBUG] 流式失败: {e},回退到非流式", file=sys.stderr)
613
+ return await self._execute_non_stream(payload)
596
614
 
597
615
  async def _execute_non_stream(self, payload: dict) -> str:
598
- """非流式请求(首选,对短响应更快)。"""
616
+ """非流式请求(流式失败后的回退方案,10s read 超时 fail-fast)。"""
599
617
  payload_copy = {**payload, "stream": False}
600
618
  client = await get_http_client()
601
619
 
@@ -610,6 +628,7 @@ class GrokSearchProvider:
610
628
  f"{self.api_url}/chat/completions",
611
629
  headers=self._headers,
612
630
  json=payload_copy,
631
+ timeout=_NON_STREAM_TIMEOUT,
613
632
  )
614
633
  response.raise_for_status()
615
634
  data = response.json()
@@ -619,7 +638,7 @@ class GrokSearchProvider:
619
638
  return ""
620
639
 
621
640
  async def _execute_stream(self, payload: dict) -> str:
622
- """流式请求(大响应的回退方案)。"""
641
+ """流式请求(首选,chunk 保活避免网关超时)。"""
623
642
  payload_copy = {**payload, "stream": True}
624
643
  client = await get_http_client()
625
644
 
@@ -655,24 +674,30 @@ class GrokSearchProvider:
655
674
  try:
656
675
  json_str = line[5:].lstrip()
657
676
  data = json.loads(json_str)
658
- choices = data.get("choices", [])
659
- if choices:
660
- delta = choices[0].get("delta", {})
661
- if "content" in delta:
662
- content += delta["content"]
663
677
  except (json.JSONDecodeError, IndexError):
664
678
  continue
679
+ if isinstance(data, dict) and "error" in data:
680
+ raise StreamEmbeddedError(json.dumps(data["error"], ensure_ascii=False)[:300])
681
+ choices = data.get("choices", [])
682
+ if choices:
683
+ delta = choices[0].get("delta", {})
684
+ if "content" in delta:
685
+ content += delta["content"]
665
686
 
666
687
  if not content and full_body_buffer:
667
688
  try:
668
689
  full_text = "".join(full_body_buffer)
669
690
  data = json.loads(full_text)
691
+ if isinstance(data, dict) and "error" in data:
692
+ raise StreamEmbeddedError(json.dumps(data["error"], ensure_ascii=False)[:300])
670
693
  if "choices" in data and data["choices"]:
671
694
  message = data["choices"][0].get("message", {})
672
695
  content = message.get("content", "")
673
696
  except json.JSONDecodeError:
674
697
  pass
675
698
 
699
+ if not content.strip():
700
+ raise EmptyStreamError("流式响应内容为空")
676
701
  return content
677
702
 
678
703
 
@@ -935,8 +960,9 @@ async def cmd_web_search(args):
935
960
  except ValueError as e:
936
961
  print(json.dumps({"error": str(e)}, ensure_ascii=False), file=sys.stderr)
937
962
  sys.exit(1)
938
- except httpx.HTTPStatusError as e:
939
- print(json.dumps({"error": f"API错误: {e.response.status_code}"}, ensure_ascii=False), file=sys.stderr)
963
+ except httpx.HTTPError as e:
964
+ detail = str(e.response.status_code) if isinstance(e, httpx.HTTPStatusError) else (str(e) or type(e).__name__)
965
+ print(json.dumps({"error": f"API错误: {detail}"}, ensure_ascii=False), file=sys.stderr)
940
966
  sys.exit(1)
941
967
 
942
968
 
@@ -974,8 +1000,9 @@ async def cmd_web_fetch(args):
974
1000
  except ValueError as e:
975
1001
  print(f"错误: {e}", file=sys.stderr)
976
1002
  sys.exit(1)
977
- except httpx.HTTPStatusError as e:
978
- print(f"API错误: {e.response.status_code}", file=sys.stderr)
1003
+ except httpx.HTTPError as e:
1004
+ detail = str(e.response.status_code) if isinstance(e, httpx.HTTPStatusError) else (str(e) or type(e).__name__)
1005
+ print(f"API错误: {detail}", file=sys.stderr)
979
1006
  sys.exit(1)
980
1007
 
981
1008
  if not result and tavily_error and not use_grok_fallback: