@oneciel-ai/ciel-runtime 0.2.22 → 0.2.23

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 (86) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +150 -57
  3. package/ciel-runtime-stop.cmd +1 -0
  4. package/ciel-runtime.cmd +1 -0
  5. package/ciel-runtimectl.cmd +1 -0
  6. package/ciel_runtime.py +56 -53
  7. package/ciel_runtime_support/anthropic_tool_turns.py +81 -0
  8. package/ciel_runtime_support/architecture.py +17 -0
  9. package/ciel_runtime_support/channel_injection.py +113 -3
  10. package/ciel_runtime_support/channel_message_prompt.py +15 -0
  11. package/ciel_runtime_support/channel_pending_injection.py +28 -4
  12. package/ciel_runtime_support/channel_pending_poll.py +2 -0
  13. package/ciel_runtime_support/channel_terminal_context.py +6 -0
  14. package/ciel_runtime_support/channel_terminal_dispatch.py +2 -0
  15. package/ciel_runtime_support/channel_terminal_proxy.py +8 -2
  16. package/ciel_runtime_support/channel_wake_claim_repository.py +7 -0
  17. package/ciel_runtime_support/channel_wake_context.py +7 -3
  18. package/ciel_runtime_support/cli_parser.py +6 -0
  19. package/ciel_runtime_support/codex_backend_context.py +4 -0
  20. package/ciel_runtime_support/codex_turn_recovery.py +75 -12
  21. package/ciel_runtime_support/config_migrations.py +102 -10
  22. package/ciel_runtime_support/config_repository.py +9 -0
  23. package/ciel_runtime_support/event_settings_cli.py +284 -2
  24. package/ciel_runtime_support/openai_chat_passthrough.py +2 -0
  25. package/ciel_runtime_support/protocols/openai_responses.py +22 -18
  26. package/ciel_runtime_support/provider_adapters.py +7 -0
  27. package/ciel_runtime_support/provider_policy.py +10 -1
  28. package/ciel_runtime_support/provider_request_builder.py +9 -2
  29. package/ciel_runtime_support/provider_responses_passthrough.py +4 -0
  30. package/ciel_runtime_support/providers/__init__.py +2 -0
  31. package/ciel_runtime_support/providers/alibaba.py +33 -10
  32. package/ciel_runtime_support/providers/constants.py +1 -0
  33. package/ciel_runtime_support/providers/opencode.py +25 -5
  34. package/ciel_runtime_support/providers/opencode_go.py +3 -3
  35. package/ciel_runtime_support/providers/openrouter.py +76 -3
  36. package/ciel_runtime_support/providers/tabitoken.py +130 -0
  37. package/ciel_runtime_support/remote_instructions.py +20 -6
  38. package/ciel_runtime_support/remote_memory.py +515 -55
  39. package/ciel_runtime_support/responses_input_compatibility.py +14 -0
  40. package/ciel_runtime_support/router_http.py +5 -0
  41. package/ciel_runtime_support/runtime_constants.py +1 -1
  42. package/ciel_runtime_support/runtime_launch.py +9 -2
  43. package/ciel_runtime_support/runtime_paths.py +2 -0
  44. package/ciel_runtime_support/tool_side_effect_dedupe.py +7 -4
  45. package/ciel_runtime_support/transcript_delta_delivery.py +333 -0
  46. package/ciel_runtime_support/usage_events.py +45 -3
  47. package/ciel_runtime_support/usage_service.py +871 -0
  48. package/ciel_runtime_support/windows_conpty.py +108 -2
  49. package/ciel_runtime_support/workspace_mcp.py +4 -0
  50. package/docs/CLI-Reference.md +11 -0
  51. package/docs/Configuration.md +7 -2
  52. package/docs/MCP-Channels.md +7 -3
  53. package/docs/Module-Map.md +4 -0
  54. package/docs/Providers.md +32 -0
  55. package/docs/Remote-Memory.md +29 -10
  56. package/docs/Test-Suite.md +1 -0
  57. package/docs/journal/2026/08/20/kevin-wake-orphan-tool-repair.md +161 -0
  58. package/docs/journal/2026/08/20/nightly-push-local-deployment.md +59 -2
  59. package/docs/journal/2026/08/20/wing-remote-memory-replication-verification.md +107 -0
  60. package/docs/journal/2026/08/21/alibaba-singapore-qwen38-official-alignment.md +64 -0
  61. package/docs/journal/2026/08/21/codex-visible-sse-wake-submit-regression.md +85 -0
  62. package/docs/journal/2026/08/21/external-sse-wake-only-change-analysis.md +131 -0
  63. package/docs/journal/2026/08/21/hyundai-invalid-replayed-tool-name-repair.okf +88 -0
  64. package/docs/journal/2026/08/21/local-stale-temporary-runtime-pin-repair.okf +118 -0
  65. package/docs/journal/2026/08/21/transcript-compaction-external-events.okf +131 -0
  66. package/docs/journal/2026/08/21/wing-ai-net-mcp-boundary-diagnosis.okf +129 -0
  67. package/docs/journal/2026/08/21/wing-cold-start-wake-submit-repair.okf +161 -0
  68. package/docs/journal/2026/08/21/wing-powershell-memory-runaway.okf +101 -0
  69. package/docs/journal/2026/08/21/wing-repeated-reasoning-tool-turn-repair.okf +112 -0
  70. package/docs/journal/2026/08/21/wing-repeated-tool-guard-finalization.okf +81 -0
  71. package/docs/journal/2026/08/21/wing-working-directory-memory-deployment.md +110 -0
  72. package/docs/journal/2026/08/21/wing-workspace-state-memory-prompt-injection.md +88 -0
  73. package/docs/journal/2026/08/22/alibaba-singapore-dual-endpoint-wire-alignment.okf +142 -0
  74. package/docs/journal/2026/08/22/delluhiold-claude-compaction-empty-tool-name.okf +168 -0
  75. package/docs/journal/2026/08/22/remote-memory-relative-prompt-paths.okf +79 -0
  76. package/docs/journal/2026/08/22/remote-memory-root-system-prompt.okf +121 -0
  77. package/docs/journal/2026/08/23/claude-web-chat-stateless-mcp-repair.okf +97 -0
  78. package/docs/journal/2026/08/23/cross-runtime-token-usage-interfaces-research.okf +378 -0
  79. package/docs/journal/2026/08/23/main-0.2.23-readme-release.okf +81 -0
  80. package/docs/journal/2026/08/23/mia-openrouter-ox-alpha-latency-diagnosis.okf +146 -0
  81. package/docs/journal/2026/08/23/openrouter-opencode-ox-alpha-support.okf +114 -0
  82. package/docs/journal/2026/08/23/tabitoken-provider-model-catalog.okf +97 -0
  83. package/docs/journal/2026/08/23/usage-stream-snapshot-implementation.okf +134 -0
  84. package/docs/usage-observability.md +141 -0
  85. package/install.ps1 +24 -3
  86. package/package.json +2 -1
@@ -13,6 +13,8 @@ from __future__ import annotations
13
13
 
14
14
  from dataclasses import dataclass
15
15
  from typing import Any, Callable
16
+ import os
17
+ import time
16
18
  import urllib.parse
17
19
  from pathlib import PurePosixPath
18
20
 
@@ -32,6 +34,39 @@ EXTERNAL_EVENT_KEYS = (
32
34
  "webhook_secret",
33
35
  "authorization",
34
36
  )
37
+ TRANSCRIPT_EVENT_KEYS = (
38
+ "enabled",
39
+ "url",
40
+ "authorization",
41
+ "timeout_seconds",
42
+ "poll_interval_ms",
43
+ "max_batch_bytes",
44
+ "start_mode",
45
+ )
46
+ TRANSCRIPT_EVENT_LIMITS = {
47
+ "timeout_seconds": (1, 30),
48
+ "poll_interval_ms": (100, 60_000),
49
+ "max_batch_bytes": (1_024, 16_777_216),
50
+ }
51
+ USAGE_EVENT_KEYS = (
52
+ "endpoint_id",
53
+ "enabled",
54
+ "url",
55
+ "authorization",
56
+ "api_key",
57
+ "timeout_seconds",
58
+ "poll_interval_seconds",
59
+ "start_mode",
60
+ "audit_interval_seconds",
61
+ "audit_emit_on_start",
62
+ "jsonl_enabled",
63
+ "backfill_paths",
64
+ )
65
+ USAGE_EVENT_LIMITS = {
66
+ "timeout_seconds": (1, 30),
67
+ "poll_interval_seconds": (1, 60),
68
+ "audit_interval_seconds": (1, 31_536_000),
69
+ }
35
70
  REMOTE_INSTRUCTION_URL_KEYS = (
36
71
  "claude_url",
37
72
  "codex_url",
@@ -63,7 +98,7 @@ REMOTE_MEMORY_LIMITS = {
63
98
  "max_total_bytes": (1_024, 134_217_728),
64
99
  "max_files": (1, 2_048),
65
100
  }
66
- _SECRET_KEYS = frozenset({"webhook_secret", "authorization"})
101
+ _SECRET_KEYS = frozenset({"webhook_secret", "authorization", "api_key"})
67
102
 
68
103
 
69
104
  class EventSettingsCliError(ValueError):
@@ -116,6 +151,7 @@ class EventSettingsCliPorts:
116
151
  sync_instructions: Callable[[], list[str]]
117
152
  sync_memories: Callable[[], list[str]]
118
153
  output: Callable[[str], None]
154
+ usage_keys: Callable[[], Any] = lambda: None
119
155
 
120
156
 
121
157
  @dataclass(frozen=True, slots=True)
@@ -186,6 +222,247 @@ class EventSettingsCli:
186
222
  service.save_receiver(self.receiver_id, body)
187
223
  self._confirm("external-events", changed)
188
224
 
225
+ # -- transcript events ------------------------------------------------
226
+
227
+ def _transcript_event_settings(self) -> dict[str, Any]:
228
+ value = self.ports.load_config().get("transcript_events")
229
+ return value if isinstance(value, dict) else {}
230
+
231
+ def transcript_event_values(self) -> dict[str, Any]:
232
+ current = self._transcript_event_settings()
233
+ return {
234
+ "enabled": bool(current.get("enabled", False)),
235
+ "url": str(current.get("url") or ""),
236
+ "authorization": "stored" if current.get("authorization") else "unset",
237
+ "timeout_seconds": current.get("timeout_seconds") or 5,
238
+ "poll_interval_ms": current.get("poll_interval_ms") or 1000,
239
+ "max_batch_bytes": current.get("max_batch_bytes") or 1_048_576,
240
+ "start_mode": str(current.get("start_mode") or "tail"),
241
+ }
242
+
243
+ def transcript_events(self, args: Any) -> None:
244
+ tokens = [str(value) for value in (getattr(args, "values", None) or [])]
245
+ if not tokens:
246
+ self._report("transcript-events", self.transcript_event_values())
247
+ return
248
+ current = dict(self._transcript_event_settings())
249
+ updates: dict[str, Any] = {}
250
+ changed: list[str] = []
251
+ for token in tokens:
252
+ key, value = split_assignment(token)
253
+ if key not in TRANSCRIPT_EVENT_KEYS:
254
+ raise EventSettingsCliError(
255
+ f"unsupported transcript event option: {key}; expected one of "
256
+ f"{', '.join(TRANSCRIPT_EVENT_KEYS)}"
257
+ )
258
+ if key == "enabled":
259
+ updates[key] = parse_flag(key, value)
260
+ elif key == "url":
261
+ updates[key] = _validated_url(key, value)
262
+ elif key in TRANSCRIPT_EVENT_LIMITS:
263
+ minimum, maximum = TRANSCRIPT_EVENT_LIMITS[key]
264
+ try:
265
+ parsed = int(value.strip())
266
+ except ValueError:
267
+ raise EventSettingsCliError(
268
+ f"{key} must be a whole number from {minimum} to {maximum}"
269
+ ) from None
270
+ if not minimum <= parsed <= maximum:
271
+ raise EventSettingsCliError(
272
+ f"{key} must be a whole number from {minimum} to {maximum}"
273
+ )
274
+ updates[key] = parsed
275
+ elif key == "start_mode":
276
+ mode = value.strip().lower()
277
+ if mode not in {"tail", "beginning"}:
278
+ raise EventSettingsCliError(
279
+ "start_mode must be tail or beginning"
280
+ )
281
+ updates[key] = mode
282
+ else:
283
+ updates[key] = value
284
+ changed.append(key)
285
+ resulting = {**current, **updates}
286
+ if resulting.get("enabled") and not str(resulting.get("url") or "").strip():
287
+ raise EventSettingsCliError(
288
+ "transcript-events requires url when enabled=true"
289
+ )
290
+ config = self.ports.load_config()
291
+ stored = config.get("transcript_events")
292
+ if not isinstance(stored, dict):
293
+ stored = {}
294
+ config["transcript_events"] = stored
295
+ stored.update(updates)
296
+ self.ports.save_config(config)
297
+ self._confirm("transcript-events", changed)
298
+
299
+ # -- usage events -----------------------------------------------------
300
+
301
+ def _usage_settings(self) -> dict[str, Any]:
302
+ value = self.ports.load_config().get("usage")
303
+ return value if isinstance(value, dict) else {}
304
+
305
+ def _usage_endpoint(self, endpoint_id: str) -> dict[str, Any]:
306
+ endpoints = self._usage_settings().get("push_endpoints")
307
+ for endpoint in endpoints if isinstance(endpoints, list) else []:
308
+ if isinstance(endpoint, dict) and str(endpoint.get("id") or "") == endpoint_id:
309
+ return dict(endpoint)
310
+ return {"id": endpoint_id, "enabled": False}
311
+
312
+ def usage_event_values(self, endpoint_id: str = "default") -> dict[str, Any]:
313
+ current = self._usage_endpoint(endpoint_id)
314
+ settings = self._usage_settings()
315
+ return {
316
+ "endpoint_id": endpoint_id,
317
+ "enabled": bool(current.get("enabled", False)),
318
+ "url": str(current.get("url") or ""),
319
+ "authorization": "stored" if current.get("authorization") else "unset",
320
+ "api_key": "stored" if current.get("api_key") else "unset",
321
+ "timeout_seconds": current.get("timeout_seconds") or 5,
322
+ "poll_interval_seconds": current.get("poll_interval_seconds") or 1,
323
+ "start_mode": str(current.get("start_mode") or "tail"),
324
+ "audit_interval_seconds": current.get("audit_interval_seconds") or 86400,
325
+ "audit_emit_on_start": bool(current.get("audit_emit_on_start", True)),
326
+ "jsonl_enabled": bool(settings.get("jsonl_enabled", True)),
327
+ "backfill_paths": os.pathsep.join(str(path) for path in (settings.get("backfill_paths") or [])),
328
+ }
329
+
330
+ def usage_events(self, args: Any) -> None:
331
+ tokens = [str(value) for value in (getattr(args, "values", None) or [])]
332
+ endpoint_id = "default"
333
+ for token in tokens:
334
+ key, value = split_assignment(token)
335
+ if key == "endpoint_id":
336
+ endpoint_id = value.strip() or "default"
337
+ if not tokens or all(split_assignment(token)[0] == "endpoint_id" for token in tokens):
338
+ self._report("usage-events", self.usage_event_values(endpoint_id))
339
+ return
340
+ current = self._usage_endpoint(endpoint_id)
341
+ updates: dict[str, Any] = {"id": endpoint_id}
342
+ changed: list[str] = []
343
+ backfill_paths: list[str] | None = None
344
+ jsonl_enabled: bool | None = None
345
+ for token in tokens:
346
+ key, value = split_assignment(token)
347
+ if key not in USAGE_EVENT_KEYS:
348
+ raise EventSettingsCliError(
349
+ f"unsupported usage event option: {key}; expected one of {', '.join(USAGE_EVENT_KEYS)}"
350
+ )
351
+ if key == "endpoint_id":
352
+ continue
353
+ if key in {"enabled", "audit_emit_on_start"}:
354
+ updates[key] = parse_flag(key, value)
355
+ elif key == "jsonl_enabled":
356
+ jsonl_enabled = parse_flag(key, value)
357
+ elif key == "url":
358
+ updates[key] = _validated_url(key, value)
359
+ elif key in USAGE_EVENT_LIMITS:
360
+ minimum, maximum = USAGE_EVENT_LIMITS[key]
361
+ try:
362
+ parsed = int(value.strip())
363
+ except ValueError:
364
+ raise EventSettingsCliError(
365
+ f"{key} must be a whole number from {minimum} to {maximum}"
366
+ ) from None
367
+ if not minimum <= parsed <= maximum:
368
+ raise EventSettingsCliError(
369
+ f"{key} must be a whole number from {minimum} to {maximum}"
370
+ )
371
+ updates[key] = parsed
372
+ elif key == "start_mode":
373
+ mode = value.strip().lower()
374
+ if mode not in {"tail", "beginning"}:
375
+ raise EventSettingsCliError("start_mode must be tail or beginning")
376
+ updates[key] = mode
377
+ elif key == "backfill_paths":
378
+ backfill_paths = [path.strip() for path in value.split(os.pathsep) if path.strip()]
379
+ else:
380
+ updates[key] = value
381
+ changed.append(key)
382
+ resulting = {**current, **updates}
383
+ if resulting.get("enabled") and not str(resulting.get("url") or "").strip():
384
+ raise EventSettingsCliError("usage-events requires url when enabled=true")
385
+ config = self.ports.load_config()
386
+ settings = config.get("usage")
387
+ if not isinstance(settings, dict):
388
+ settings = {}
389
+ config["usage"] = settings
390
+ endpoints = settings.get("push_endpoints")
391
+ stored_endpoints = [dict(item) for item in endpoints if isinstance(item, dict)] if isinstance(endpoints, list) else []
392
+ stored_endpoints = [item for item in stored_endpoints if str(item.get("id") or "") != endpoint_id]
393
+ stored_endpoints.append(resulting)
394
+ settings["push_endpoints"] = stored_endpoints
395
+ if backfill_paths is not None:
396
+ settings["backfill_paths"] = backfill_paths
397
+ if jsonl_enabled is not None:
398
+ settings["jsonl_enabled"] = jsonl_enabled
399
+ self.ports.save_config(config)
400
+ self._confirm("usage-events", changed)
401
+
402
+ def usage_api_key(self, args: Any) -> None:
403
+ tokens = [str(value) for value in (getattr(args, "values", None) or [])]
404
+ repository = self.ports.usage_keys()
405
+ if repository is None:
406
+ raise EventSettingsCliError("usage API key repository is unavailable")
407
+ action = "list"
408
+ assignments: dict[str, str] = {}
409
+ for index, token in enumerate(tokens):
410
+ key, value = split_assignment(token, bare_keys=("list", "issue", "revoke"))
411
+ if index == 0 and key in {"list", "issue", "revoke"}:
412
+ action = key
413
+ else:
414
+ assignments[key] = value
415
+ if action == "list":
416
+ rows = repository.list()
417
+ self.ports.output("usage-api-key:")
418
+ for row in rows:
419
+ self.ports.output(
420
+ f" {row['key_id']} name={row['name']} scopes={','.join(row['scopes'])} "
421
+ f"expires_at={row['expires_at']} revoked_at={row['revoked_at']}"
422
+ )
423
+ if not rows:
424
+ self.ports.output(" none")
425
+ return
426
+ if action == "revoke":
427
+ key_id = assignments.get("key_id", "").strip()
428
+ if not key_id:
429
+ raise EventSettingsCliError("usage-api-key revoke requires key_id=<id>")
430
+ self.ports.output(f"usage-api-key revoked: {key_id} ok={repository.revoke(key_id)}")
431
+ return
432
+ allowed = {"name", "scopes", "ttl_seconds", "expires_at", "api_key", "key_id"}
433
+ unknown = sorted(set(assignments) - allowed)
434
+ if unknown:
435
+ raise EventSettingsCliError(f"unsupported usage API key option: {unknown[0]}")
436
+ scopes = []
437
+ for scope in assignments.get("scopes", "read,stream").split(","):
438
+ normalized = scope.strip().lower()
439
+ if normalized in {"read", "usage:read"}:
440
+ scopes.append("usage:read")
441
+ elif normalized in {"stream", "usage:stream"}:
442
+ scopes.append("usage:stream")
443
+ elif normalized:
444
+ raise EventSettingsCliError(f"unsupported usage API key scope: {scope}")
445
+ try:
446
+ ttl = int(assignments.get("ttl_seconds", "0") or 0)
447
+ except ValueError:
448
+ raise EventSettingsCliError("ttl_seconds must be a non-negative whole number") from None
449
+ if ttl < 0:
450
+ raise EventSettingsCliError("ttl_seconds must be a non-negative whole number")
451
+ try:
452
+ expires_at = float(assignments.get("expires_at", "0") or 0)
453
+ except ValueError:
454
+ raise EventSettingsCliError("expires_at must be a non-negative epoch timestamp") from None
455
+ if expires_at < 0:
456
+ raise EventSettingsCliError("expires_at must be a non-negative epoch timestamp")
457
+ result = repository.issue(
458
+ assignments.get("name", "usage-consumer"), scopes,
459
+ expires_at or (time.time() + ttl if ttl else 0),
460
+ secret=assignments.get("api_key", ""), key_id=assignments.get("key_id", ""),
461
+ )
462
+ self.ports.output(f"usage-api-key issued: key_id={result['key_id']}")
463
+ self.ports.output(f" api_key={result['api_key']}")
464
+ self.ports.output(f" scopes={','.join(result['scopes'])}")
465
+
189
466
  # -- remote instructions -----------------------------------------------
190
467
 
191
468
  def _remote_settings(self) -> dict[str, Any]:
@@ -377,11 +654,14 @@ def _guarded(handler: Callable[[Any], None]) -> Callable[[Any], None]:
377
654
 
378
655
 
379
656
  def handlers(ports: EventSettingsCliPorts) -> tuple[Callable[[Any], None], ...]:
380
- """Return external-event, remote-instruction, and remote-memory handlers."""
657
+ """Return event, usage, instruction, and memory handlers."""
381
658
 
382
659
  controller = EventSettingsCli(ports)
383
660
  return (
384
661
  _guarded(controller.external_events),
662
+ _guarded(controller.transcript_events),
663
+ _guarded(controller.usage_events),
664
+ _guarded(controller.usage_api_key),
385
665
  _guarded(controller.remote_instructions),
386
666
  _guarded(controller.remote_memory),
387
667
  )
@@ -389,6 +669,8 @@ def handlers(ports: EventSettingsCliPorts) -> tuple[Callable[[Any], None], ...]:
389
669
 
390
670
  __all__ = [
391
671
  "EXTERNAL_EVENT_KEYS",
672
+ "TRANSCRIPT_EVENT_KEYS",
673
+ "USAGE_EVENT_KEYS",
392
674
  "REMOTE_MEMORY_KEYS",
393
675
  "REMOTE_INSTRUCTION_KEYS",
394
676
  "REMOTE_INSTRUCTION_URL_KEYS",
@@ -18,6 +18,7 @@ class OpenAIChatPassthroughPorts:
18
18
  urlopen: Callable[..., Any]
19
19
  timeout_seconds: Callable[[dict[str, Any]], float]
20
20
  copy_response_headers: Callable[[Any, Any], None]
21
+ finalize_body: Callable[[dict[str, Any]], dict[str, Any]] = lambda body: body
21
22
 
22
23
 
23
24
  class OpenAIChatPassthrough:
@@ -40,6 +41,7 @@ class OpenAIChatPassthrough:
40
41
  upstream_body = dict(
41
42
  self._ports.normalize_request(provider, config, upstream_body)
42
43
  )
44
+ upstream_body = self._ports.finalize_body(upstream_body)
43
45
  url = self._ports.join_url(
44
46
  self._ports.upstream_base(provider, config),
45
47
  "/v1/chat/completions",
@@ -175,6 +175,7 @@ def openai_responses_to_anthropic_messages(body: dict[str, Any], fallback_model:
175
175
  raw_input = []
176
176
  saw_conversation_item = False
177
177
  pending_reasoning = ""
178
+ pending_tool_role = ""
178
179
  for item in raw_input:
179
180
  if not isinstance(item, dict):
180
181
  continue
@@ -200,30 +201,33 @@ def openai_responses_to_anthropic_messages(body: dict[str, Any], fallback_model:
200
201
  ),
201
202
  }
202
203
  )
203
- messages.append(
204
- {
205
- "role": "assistant",
206
- "content": content,
207
- }
208
- )
204
+ if pending_tool_role == "assistant" and messages:
205
+ messages[-1]["content"].extend(content)
206
+ else:
207
+ messages.append(
208
+ {
209
+ "role": "assistant",
210
+ "content": content,
211
+ }
212
+ )
213
+ pending_tool_role = "assistant"
209
214
  saw_conversation_item = True
210
215
  continue
211
216
  if item_type in {"function_call_output", "custom_tool_call_output"}:
212
217
  call_id = str(item.get("call_id") or item.get("id") or "call_tool")
213
- messages.append(
214
- {
215
- "role": "user",
216
- "content": [
217
- {
218
- "type": "tool_result",
219
- "tool_use_id": call_id,
220
- "content": _content_text(item.get("output")),
221
- }
222
- ],
223
- }
224
- )
218
+ result = {
219
+ "type": "tool_result",
220
+ "tool_use_id": call_id,
221
+ "content": _content_text(item.get("output")),
222
+ }
223
+ if pending_tool_role == "user" and messages:
224
+ messages[-1]["content"].append(result)
225
+ else:
226
+ messages.append({"role": "user", "content": [result]})
227
+ pending_tool_role = "user"
225
228
  saw_conversation_item = True
226
229
  continue
230
+ pending_tool_role = ""
227
231
  role = str(item.get("role") or "user").strip().lower()
228
232
  blocks = _content_blocks(item.get("content", item.get("text", "")))
229
233
  if not blocks:
@@ -19,6 +19,7 @@ from .providers.constants import PROVIDER_DEFAULT_BASE_URLS, ZAI_MODEL_FALLBACK_
19
19
  from .providers.native import AgyProviderAdapter, CodexProviderAdapter
20
20
  from .providers.ollama import OllamaCloudProviderAdapter, OllamaProviderAdapter
21
21
  from .providers.openrouter import OpenRouterProviderAdapter
22
+ from .providers.tabitoken import TabitokenProviderAdapter
22
23
  from .providers.lm_studio import LMStudioProviderAdapter
23
24
  from .providers.nim import SelfHostedNimProviderAdapter
24
25
  from .providers.nvidia import NvidiaHostedProviderAdapter
@@ -64,6 +65,12 @@ PROVIDER_DESCRIPTORS = ProviderDescriptorRegistry(
64
65
  ProviderDescriptor("nvidia-hosted", "Nvidia Hosted", NvidiaHostedProviderAdapter),
65
66
  ProviderDescriptor("self-hosted-nim", "Self Hosted NIM", SelfHostedNimProviderAdapter),
66
67
  ProviderDescriptor("openrouter", "OpenRouter", OpenRouterProviderAdapter),
68
+ ProviderDescriptor(
69
+ "tabitoken",
70
+ "TaBiAI (Tabitoken.com)",
71
+ TabitokenProviderAdapter,
72
+ aliases=("tabi", "tabiai", "tabi-token"),
73
+ ),
67
74
  ProviderDescriptor("xai", "xAI", XaiProviderAdapter, aliases=("grok",)),
68
75
  ProviderDescriptor("fireworks", "Fireworks.ai", FireworksProviderAdapter),
69
76
  ProviderDescriptor(
@@ -72,6 +72,7 @@ class ProviderRequestServices:
72
72
  normalize_tool_choice_for_provider: Callable[..., Any]
73
73
  provider_wire_profile: Callable[..., Any]
74
74
  sanitize_assistant_pseudo_tool_text_history: Callable[..., Any]
75
+ sanitize_invalid_anthropic_tool_history: Callable[..., Any]
75
76
 
76
77
 
77
78
  def normalize_provider_request(provider: str, pcfg: dict[str, Any], body: dict[str, Any],
@@ -87,10 +88,18 @@ def normalize_provider_request(provider: str, pcfg: dict[str, Any], body: dict[s
87
88
  normalize_tool_choice_for_provider = services.normalize_tool_choice_for_provider
88
89
  provider_wire_profile = services.provider_wire_profile
89
90
  sanitize_assistant_pseudo_tool_text_history = services.sanitize_assistant_pseudo_tool_text_history
91
+ sanitize_invalid_anthropic_tool_history = services.sanitize_invalid_anthropic_tool_history
90
92
  profile = provider_wire_profile(provider, pcfg, body)
91
93
  out = normalize_thinking_for_non_anthropic_provider(provider, pcfg, body)
92
- out = apply_provider_adapter_request_policy(provider, pcfg, out)
94
+ selected_protocol = str(profile.get("upstream_format") or "").replace("-", "_")
95
+ out = apply_provider_adapter_request_policy(
96
+ provider,
97
+ pcfg,
98
+ out,
99
+ selected_protocol or None,
100
+ )
93
101
  out = normalize_tool_choice_for_provider(provider, pcfg, out)
102
+ out = sanitize_invalid_anthropic_tool_history(out)
94
103
  out = sanitize_assistant_pseudo_tool_text_history(out)
95
104
  out = normalize_anthropic_tool_turns_for_provider(provider, pcfg, out)
96
105
  if profile.get("upstream_format") == "anthropic-messages":
@@ -50,6 +50,9 @@ class ProviderOptionPorts:
50
50
  sampling_options: tuple[str, ...]
51
51
  anthropic_runtime_hints: Callable[[str], dict[str, Any]]
52
52
  log: Callable[[str, str], None]
53
+ finalize_messages: Callable[[list[dict[str, Any]]], list[dict[str, Any]]] = (
54
+ lambda messages: messages
55
+ )
53
56
 
54
57
 
55
58
  class ProviderRequestBuilder:
@@ -198,7 +201,7 @@ class ProviderRequestBuilder:
198
201
  configured,
199
202
  _token_cache=token_cache,
200
203
  )
201
- return self.ollama.apply_optional(
204
+ request = self.ollama.apply_optional(
202
205
  request,
203
206
  provider,
204
207
  model,
@@ -206,6 +209,8 @@ class ProviderRequestBuilder:
206
209
  body,
207
210
  output_limit=num_predict,
208
211
  )
212
+ request["messages"] = self.options.finalize_messages(request["messages"])
213
+ return request
209
214
 
210
215
  def openai_chat(
211
216
  self,
@@ -255,7 +260,9 @@ class ProviderRequestBuilder:
255
260
  for key in ("temperature", "top_p"):
256
261
  if self.openai.sampling_allowed(provider, config) and config.get(key) is not None:
257
262
  request[key] = config[key]
258
- return self.openai.normalize_request(provider, config, request)
263
+ normalized = self.openai.normalize_request(provider, config, request)
264
+ normalized["messages"] = self.options.finalize_messages(normalized["messages"])
265
+ return normalized
259
266
 
260
267
 
261
268
  @dataclass(frozen=True, slots=True)
@@ -44,6 +44,7 @@ class ProviderResponsesPassthroughPorts:
44
44
  compact_responses: Callable[..., dict[str, Any]] = (
45
45
  lambda body, _budget, **_kwargs: body
46
46
  )
47
+ finalize_body: Callable[[dict[str, Any]], dict[str, Any]] = lambda body: body
47
48
 
48
49
 
49
50
  class ProviderResponsesPassthrough:
@@ -65,6 +66,7 @@ class ProviderResponsesPassthrough:
65
66
  upstream_body["model"] = self._ports.normalize_model(
66
67
  provider, config, str(body.get("model") or "")
67
68
  )
69
+ upstream_body = self._ports.finalize_body(upstream_body)
68
70
  data = self._encode(upstream_body)
69
71
  url = self._ports.join_url(
70
72
  self._ports.upstream_base(provider, config),
@@ -127,6 +129,7 @@ class ProviderResponsesPassthrough:
127
129
  provider=provider,
128
130
  model=str(current.get("model") or ""),
129
131
  )
132
+ compacted = self._ports.finalize_body(compacted)
130
133
  compacted_data = self._encode(compacted)
131
134
  self._ports.log(
132
135
  "WARN",
@@ -280,6 +283,7 @@ class ProviderResponsesPassthrough:
280
283
  upstream_body, delivery_body = self._ports.project_channel_context(
281
284
  upstream_body
282
285
  )
286
+ upstream_body = self._ports.finalize_body(upstream_body)
283
287
  self._ports.begin_channel_delivery(handler, delivery_body)
284
288
  url = self._ports.join_url(
285
289
  self._ports.upstream_base(provider, config),
@@ -10,6 +10,7 @@ from .constants import PROVIDER_DEFAULT_BASE_URLS, ZAI_MODEL_FALLBACK_IDS
10
10
  from .native import AgyProviderAdapter, CodexProviderAdapter
11
11
  from .ollama import OllamaCloudProviderAdapter, OllamaProviderAdapter
12
12
  from .openrouter import OpenRouterProviderAdapter
13
+ from .tabitoken import TabitokenProviderAdapter
13
14
  from .lm_studio import LMStudioProviderAdapter
14
15
  from .nim import SelfHostedNimProviderAdapter
15
16
  from .nvidia import NvidiaHostedProviderAdapter
@@ -56,6 +57,7 @@ __all__ = [
56
57
  "OllamaCloudProviderAdapter",
57
58
  "OllamaProviderAdapter",
58
59
  "OpenRouterProviderAdapter",
60
+ "TabitokenProviderAdapter",
59
61
  "LMStudioProviderAdapter",
60
62
  "NvidiaHostedProviderAdapter",
61
63
  "SelfHostedNimProviderAdapter",
@@ -20,13 +20,16 @@ from .base import HttpBearerProviderAdapter, provider_configuration
20
20
 
21
21
  QWEN38_MAX_MODEL = "qwen3.8-max"
22
22
  QWEN38_MAX_PREVIEW_MODEL = "qwen3.8-max-preview"
23
- QWEN38_CONTEXT_WINDOW = 1_048_576
23
+ QWEN38_CONTEXT_WINDOW = 1_000_000
24
+ QWEN38_MAX_INPUT = 991_808
25
+ QWEN38_THINKING_MAX_INPUT = 983_616
24
26
  QWEN38_MAX_OUTPUT = 131_072
27
+ QWEN38_MAX_REASONING = 262_144
25
28
  QWEN38_AUTO_COMPACT = 900_000
26
29
  ALIBABA_TOKEN_PLAN_RESPONSES_MAX_BYTES = 10 * 1024 * 1024
27
30
  QWEN38_CODEX_CATALOG = {
28
- "context_window": 983_616,
29
- "max_context_window": 983_616,
31
+ "context_window": QWEN38_THINKING_MAX_INPUT,
32
+ "max_context_window": QWEN38_THINKING_MAX_INPUT,
30
33
  "effective_context_window_percent": 95,
31
34
  "supports_parallel_tool_calls": False,
32
35
  "supports_image_detail_original": True,
@@ -58,6 +61,7 @@ ALIBABA_CODING_PLAN_MODELS = (
58
61
  "glm-4.7",
59
62
  )
60
63
  ALIBABA_MODEL_STUDIO_MODELS = (
64
+ QWEN38_MAX_MODEL,
61
65
  QWEN37_MAX_MODEL,
62
66
  "qwen3.7-plus",
63
67
  "qwen3.6-plus",
@@ -119,32 +123,33 @@ _EFFORTS = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
119
123
 
120
124
  @dataclass(frozen=True)
121
125
  class AlibabaModelStudioProviderAdapter(HttpBearerProviderAdapter):
122
- """Preserve Qwen Responses features while supporting Claude via Chat."""
126
+ """Expose Qwen's OpenAI and native Anthropic-compatible wire surfaces."""
123
127
 
124
128
  name: str = "alims-intl"
125
129
  base_url: str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
126
130
  configuration_defaults_value: dict = field(
127
131
  default_factory=lambda: provider_configuration(
128
- QWEN37_MAX_MODEL,
132
+ QWEN38_MAX_MODEL,
129
133
  custom_models=ALIBABA_MODEL_STUDIO_MODELS,
130
134
  native_compat=True,
131
135
  supports_tool_choice=True,
132
- context_window=QWEN37_CONTEXT_WINDOW,
133
- max_model_len=QWEN37_CONTEXT_WINDOW,
134
- max_output_tokens=QWEN37_MAX_OUTPUT,
136
+ context_window=QWEN38_CONTEXT_WINDOW,
137
+ max_model_len=QWEN38_CONTEXT_WINDOW,
138
+ max_output_tokens=QWEN38_MAX_OUTPUT,
135
139
  context_reserve_tokens=8192,
136
140
  auto_compact_window=QWEN38_AUTO_COMPACT,
137
141
  codex_auto_compact_window=QWEN38_AUTO_COMPACT,
138
142
  request_timeout_ms=DEFAULT_REQUEST_TIMEOUT_MS,
139
143
  stream_enabled=True,
140
144
  stream_word_chunking=False,
141
- effort_level="high",
145
+ effort_level="xhigh",
142
146
  explicit_cache=True,
143
147
  explicit_cache_markers=4,
144
148
  haiku_model="qwen3.6-flash",
145
- opus_model=QWEN37_MAX_MODEL,
149
+ opus_model=QWEN38_MAX_MODEL,
146
150
  sonnet_model="qwen3.7-plus",
147
151
  subagent_model="qwen3.7-plus",
152
+ region="ap-southeast-1",
148
153
  )
149
154
  )
150
155
  authorization_header: str = "authorization"
@@ -155,6 +160,7 @@ class AlibabaModelStudioProviderAdapter(HttpBearerProviderAdapter):
155
160
  default_factory=lambda: ProviderCapabilities(
156
161
  upstream_protocol="openai_responses",
157
162
  supports_thinking=True,
163
+ preserves_anthropic_thinking=True,
158
164
  requires_api_key=True,
159
165
  )
160
166
  )
@@ -282,6 +288,20 @@ class AlibabaModelStudioProviderAdapter(HttpBearerProviderAdapter):
282
288
  self._normalize_chat(config, normalized, model)
283
289
  return normalized
284
290
 
291
+ def normalize_request_options_for_protocol(
292
+ self,
293
+ config: ProviderConfig,
294
+ request: Mapping[str, Any],
295
+ protocol: MessageProtocol | None,
296
+ ) -> Mapping[str, Any]:
297
+ if protocol == "anthropic_messages":
298
+ # Alibaba's /apps/anthropic endpoint accepts the Anthropic Messages
299
+ # body directly. In particular, max_tokens and thinking must not be
300
+ # rewritten as OpenAI Chat fields merely because both formats use a
301
+ # top-level messages array.
302
+ return deepcopy(request)
303
+ return self.normalize_request_options(config, request)
304
+
285
305
  def openai_reasoning_effort(
286
306
  self, config: ProviderConfig, model: str, request: Mapping[str, Any]
287
307
  ) -> str | None:
@@ -640,6 +660,9 @@ __all__ = [
640
660
  "AlibabaTokenPlanProviderAdapter",
641
661
  "QWEN38_AUTO_COMPACT",
642
662
  "QWEN38_CONTEXT_WINDOW",
663
+ "QWEN38_MAX_INPUT",
643
664
  "QWEN38_MAX_MODEL",
644
665
  "QWEN38_MAX_OUTPUT",
666
+ "QWEN38_MAX_REASONING",
667
+ "QWEN38_THINKING_MAX_INPUT",
645
668
  ]
@@ -19,6 +19,7 @@ PROVIDER_DEFAULT_BASE_URLS: dict[str, str] = {
19
19
  "nvidia-hosted": "https://integrate.api.nvidia.com/v1",
20
20
  "self-hosted-nim": "http://127.0.0.1:8000",
21
21
  "openrouter": "https://openrouter.ai/api/v1",
22
+ "tabitoken": "https://tabitoken.com",
22
23
  "fireworks": "https://api.fireworks.ai/inference",
23
24
  "meta": "https://api.meta.ai/v1",
24
25
  }