dispatch_agents 0.10.1__tar.gz → 0.12.2__tar.gz

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 (148) hide show
  1. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/PKG-INFO +1 -1
  2. dispatch_agents-0.12.2/RELEASE_NOTES.md +1 -0
  3. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/config.py +114 -0
  4. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/integrations/github/__init__.py +17 -0
  5. dispatch_agents-0.12.2/dispatch_agents/integrations/github/client.py +121 -0
  6. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/llm.py +11 -1
  7. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/proxy/server.py +2 -2
  8. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/hello_world/agent.py +50 -1
  9. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/hello_world/uv.lock +48 -48
  10. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/pyproject.toml +2 -2
  11. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/uv.lock +11 -0
  12. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/pyproject.toml +1 -1
  13. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_config.py +207 -0
  14. dispatch_agents-0.12.2/tests/test_github_client.py +233 -0
  15. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_proxy_server.py +8 -0
  16. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/uv.lock +7 -7
  17. dispatch_agents-0.10.1/RELEASE_NOTES.md +0 -21
  18. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/.claude-plugin/marketplace.json +0 -0
  19. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/.github/scripts/change_scope.py +0 -0
  20. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/.github/scripts/ci_git.py +0 -0
  21. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/.github/scripts/version_policy.py +0 -0
  22. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/.github/tests/test_change_scope.py +0 -0
  23. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/.github/tests/test_ci_git.py +0 -0
  24. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/.github/tests/test_version_policy.py +0 -0
  25. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/.github/workflows/ci-reusable.yml +0 -0
  26. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/.github/workflows/feature-branch.yml +0 -0
  27. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/.github/workflows/release.yml +0 -0
  28. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/.github/workflows/version-policy-reusable.yml +0 -0
  29. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/.gitignore +0 -0
  30. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/CONTRIBUTING.md +0 -0
  31. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/LICENSE +0 -0
  32. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/LICENSE-3rdparty.csv +0 -0
  33. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/NOTICE +0 -0
  34. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/README.md +0 -0
  35. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/agentservice/__init__.py +0 -0
  36. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/agentservice/py.typed +0 -0
  37. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/agentservice/v1/__init__.py +0 -0
  38. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/agentservice/v1/message_pb2.py +0 -0
  39. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/agentservice/v1/message_pb2.pyi +0 -0
  40. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/agentservice/v1/message_pb2_grpc.py +0 -0
  41. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/agentservice/v1/request_response_pb2.py +0 -0
  42. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/agentservice/v1/request_response_pb2.pyi +0 -0
  43. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/agentservice/v1/request_response_pb2_grpc.py +0 -0
  44. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/agentservice/v1/service_pb2.py +0 -0
  45. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/agentservice/v1/service_pb2.pyi +0 -0
  46. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/agentservice/v1/service_pb2_grpc.py +0 -0
  47. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/__init__.py +0 -0
  48. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/agent_service.py +0 -0
  49. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/contrib/__init__.py +0 -0
  50. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/contrib/claude/__init__.py +0 -0
  51. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/contrib/openai/__init__.py +0 -0
  52. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/events.py +0 -0
  53. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/grpc_server.py +0 -0
  54. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/instrument.py +0 -0
  55. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/integrations/__init__.py +0 -0
  56. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/integrations/github/README.md +0 -0
  57. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/invocation.py +0 -0
  58. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/llm_langchain.py +0 -0
  59. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/logging_config.py +0 -0
  60. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/mcp.py +0 -0
  61. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/memory.py +0 -0
  62. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/models.py +0 -0
  63. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/proxy/__init__.py +0 -0
  64. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/proxy/sse_utils.py +0 -0
  65. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/py.typed +0 -0
  66. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/resources.py +0 -0
  67. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/dispatch_agents/version.py +0 -0
  68. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/README.md +0 -0
  69. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/company-researcher/.gitignore +0 -0
  70. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/company-researcher/AGENTS.md +0 -0
  71. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/company-researcher/README.md +0 -0
  72. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/company-researcher/agent.py +0 -0
  73. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/company-researcher/dispatch.yaml +0 -0
  74. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/company-researcher/pyproject.toml +0 -0
  75. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/company-researcher/uv.lock +0 -0
  76. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/conversational-agent/README.md +0 -0
  77. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/conversational-agent/agent.py +0 -0
  78. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/conversational-agent/dispatch.yaml +0 -0
  79. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/conversational-agent/pyproject.toml +0 -0
  80. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/daily-digest/README.md +0 -0
  81. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/daily-digest/agent.py +0 -0
  82. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/daily-digest/dispatch.yaml +0 -0
  83. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/daily-digest/pyproject.toml +0 -0
  84. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/deep-research/README.md +0 -0
  85. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/deep-research/agent.py +0 -0
  86. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/deep-research/configuration.py +0 -0
  87. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/deep-research/deep_researcher.py +0 -0
  88. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/deep-research/dispatch.yaml +0 -0
  89. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/deep-research/prompts.py +0 -0
  90. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/deep-research/pyproject.toml +0 -0
  91. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/deep-research/state.py +0 -0
  92. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/deep-research/tools.py +0 -0
  93. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/hello_world/.gitignore +0 -0
  94. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/hello_world/AGENTS.md +0 -0
  95. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/hello_world/dispatch.yaml +0 -0
  96. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/hello_world/pyproject.toml +0 -0
  97. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/hello_world/test_agent.py +0 -0
  98. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/knowledge-base-query/.env.example +0 -0
  99. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/knowledge-base-query/.gitignore +0 -0
  100. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/knowledge-base-query/AGENTS.md +0 -0
  101. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/knowledge-base-query/agent.py +0 -0
  102. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/knowledge-base-query/dispatch.yaml +0 -0
  103. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/knowledge-base-query/pyproject.toml +0 -0
  104. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/multi-framework/README.md +0 -0
  105. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/multi-framework/agent.py +0 -0
  106. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/multi-framework/dispatch.yaml +0 -0
  107. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/multi-framework/pyproject.toml +0 -0
  108. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/weather-assistant/.gitignore +0 -0
  109. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/weather-assistant/AGENTS.md +0 -0
  110. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/weather-assistant/agent.py +0 -0
  111. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/weather-assistant/dispatch.yaml +0 -0
  112. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/weather-assistant/pyproject.toml +0 -0
  113. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/weather-assistant/uv.lock +0 -0
  114. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/weather-service/.gitignore +0 -0
  115. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/weather-service/AGENTS.md +0 -0
  116. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/weather-service/agent.py +0 -0
  117. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/weather-service/dispatch.yaml +0 -0
  118. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/weather-service/pyproject.toml +0 -0
  119. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/examples/weather-service/uv.lock +0 -0
  120. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/internal/py.typed +0 -0
  121. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/plugins/README.md +0 -0
  122. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/__init__.py +0 -0
  123. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/e2e_claude_mcp_proxy.py +0 -0
  124. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/schemas/README.md +0 -0
  125. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/schemas/octokit-webhooks.json +0 -0
  126. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test.py +0 -0
  127. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_agent_service.py +0 -0
  128. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_agent_uid.py +0 -0
  129. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_contrib_claude.py +0 -0
  130. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_contrib_openai.py +0 -0
  131. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_dev_mode_isolation.py +0 -0
  132. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_extra_headers.py +0 -0
  133. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_fn_decorator.py +0 -0
  134. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_github_integration.py +0 -0
  135. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_github_schema_compliance.py +0 -0
  136. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_grpc_server.py +0 -0
  137. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_init.py +0 -0
  138. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_instrument.py +0 -0
  139. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_llm_langchain.py +0 -0
  140. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_llm_logging.py +0 -0
  141. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_logging_config.py +0 -0
  142. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_mcp.py +0 -0
  143. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_memory.py +0 -0
  144. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_proxy_e2e.py +0 -0
  145. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_resources.py +0 -0
  146. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_sse_utils.py +0 -0
  147. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_trace_context.py +0 -0
  148. {dispatch_agents-0.10.1 → dispatch_agents-0.12.2}/tests/test_typed_events.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dispatch_agents
3
- Version: 0.10.1
3
+ Version: 0.12.2
4
4
  Summary: Dispatch Agents SDK
5
5
  License-File: LICENSE
6
6
  License-File: LICENSE-3rdparty.csv
@@ -0,0 +1 @@
1
+ BREAKING CHANGE: Compatibility changes to remove LiteLLM from backend.
@@ -5,6 +5,7 @@ shared between CLI and backend.
5
5
  """
6
6
 
7
7
  import os
8
+ import re
8
9
  from enum import StrEnum
9
10
  from typing import Any
10
11
 
@@ -25,6 +26,13 @@ RESERVED_ENV_VARS: frozenset[str] = frozenset(
25
26
  }
26
27
  )
27
28
 
29
+ # Domain validation pattern for egress allow list.
30
+ # Accepts exact FQDNs (api.openai.com) and wildcard prefixes (*.github.com).
31
+ # Rejects URLs with schemes/ports/paths, IP addresses, and bare wildcards.
32
+ _DOMAIN_PATTERN = re.compile(
33
+ r"^(\*\.)?([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$"
34
+ )
35
+
28
36
 
29
37
  class VolumeMode(StrEnum):
30
38
  """Volume access mode for persistent storage.
@@ -322,6 +330,98 @@ class ResourceConfig(BaseModel):
322
330
  )
323
331
 
324
332
 
333
+ class DomainSelector(BaseModel):
334
+ """A single domain selector -- exactly one of match_name or match_pattern.
335
+
336
+ match_name is an exact FQDN (e.g. api.openai.com).
337
+ match_pattern is a wildcard prefix (e.g. *.github.com).
338
+
339
+ Serialises with camelCase aliases (matchName / matchPattern) to match the
340
+ downstream Cilium FQDN selector API.
341
+ """
342
+
343
+ match_name: str | None = Field(
344
+ default=None,
345
+ description="Exact FQDN to allow. Must match the entire domain name exactly "
346
+ "(e.g. 'api.openai.com' matches only 'api.openai.com').",
347
+ )
348
+ match_pattern: str | None = Field(
349
+ default=None,
350
+ description="Wildcard pattern to allow. Uses '*.domain.com' syntax to match "
351
+ "any subdomain of the specified domain (e.g. '*.github.com' matches "
352
+ "'api.github.com' and 'raw.github.com' but not 'github.com' itself).",
353
+ )
354
+
355
+ @model_validator(mode="after")
356
+ def _validate_exactly_one_field(self) -> "DomainSelector":
357
+ if self.match_name and self.match_pattern:
358
+ raise ValueError(
359
+ "Exactly one of match_name or match_pattern must be set, not both"
360
+ )
361
+ if not self.match_name and not self.match_pattern:
362
+ raise ValueError("Exactly one of match_name or match_pattern must be set")
363
+ domain = self.match_name or self.match_pattern or ""
364
+ if not _DOMAIN_PATTERN.match(domain):
365
+ raise ValueError(
366
+ f"Invalid domain '{domain}'. "
367
+ "Must be an exact FQDN (e.g., api.openai.com) "
368
+ "or wildcard prefix (e.g., *.github.com). "
369
+ "URL schemes, ports, paths, IP addresses, "
370
+ "and bare wildcards are not allowed."
371
+ )
372
+ return self
373
+
374
+ model_config = {"extra": "forbid"}
375
+
376
+
377
+ class EgressConfig(BaseModel):
378
+ """Configuration for network egress allow list.
379
+
380
+ Domains are specified as objects with either matchName (exact FQDN)
381
+ or matchPattern (wildcard prefix). This is a subset of the
382
+ downstream Cilium FQDN selector API.
383
+
384
+ Example:
385
+ network:
386
+ egress:
387
+ allow_domains:
388
+ - match_name: api.openai.com
389
+ - match_pattern: "*.github.com"
390
+ """
391
+
392
+ allow_domains: list[DomainSelector] = Field(
393
+ default_factory=list,
394
+ description="Domains allowed for egress as Cilium FQDN selectors.",
395
+ )
396
+
397
+ @field_validator("allow_domains")
398
+ @classmethod
399
+ def _validate_allow_domains(cls, v: list[DomainSelector]) -> list[DomainSelector]:
400
+ if len(v) > 50:
401
+ raise ValueError(
402
+ f"allow_domains cannot have more than 50 entries, got {len(v)}"
403
+ )
404
+ return v
405
+
406
+
407
+ class NetworkConfig(BaseModel):
408
+ """Network configuration for an agent.
409
+
410
+ When present in dispatch.yaml, CiliumNetworkPolicies are created to
411
+ restrict the agent's outbound traffic to platform services and any
412
+ listed allow_domains. When absent, all egress is unrestricted.
413
+
414
+ Example:
415
+ network:
416
+ egress:
417
+ allow_domains:
418
+ - match_name: api.openai.com
419
+ - match_pattern: "*.github.com"
420
+ """
421
+
422
+ egress: EgressConfig = Field(default_factory=EgressConfig)
423
+
424
+
325
425
  class DispatchConfig(BaseModel):
326
426
  """Configuration model for dispatch.yaml files.
327
427
 
@@ -416,6 +516,10 @@ class DispatchConfig(BaseModel):
416
516
  default_factory=ResourceConfig,
417
517
  description="Container resource limits (CPU and memory)",
418
518
  )
519
+ network: NetworkConfig | None = Field(
520
+ default=None,
521
+ description="Network egress restrictions. When set, CiliumNetworkPolicies restrict outbound traffic.",
522
+ )
419
523
 
420
524
  @field_validator("env")
421
525
  @classmethod
@@ -485,6 +589,16 @@ class DispatchConfig(BaseModel):
485
589
  }
486
590
  result["resources"] = {"limits": limits_dict}
487
591
 
592
+ if self.network is not None:
593
+ result["network"] = {
594
+ "egress": {
595
+ "allow_domains": [
596
+ d.model_dump(exclude_none=True)
597
+ for d in self.network.egress.allow_domains
598
+ ],
599
+ }
600
+ }
601
+
488
602
  return result
489
603
 
490
604
  model_config = {"populate_by_name": True}
@@ -88,6 +88,10 @@ from typing import Any, ClassVar, Literal
88
88
  from pydantic import ConfigDict, Field
89
89
 
90
90
  from dispatch_agents.events import BasePayload
91
+ from dispatch_agents.integrations.github.client import GitHubAppToken
92
+ from dispatch_agents.integrations.github.client import (
93
+ get_github_app_token as _get_github_app_token,
94
+ )
91
95
  from dispatch_agents.models import StrictBaseModel
92
96
 
93
97
  # =============================================================================
@@ -98,6 +102,16 @@ from dispatch_agents.models import StrictBaseModel
98
102
  GITHUB_TOPIC_PREFIX = "github."
99
103
 
100
104
 
105
+ async def get_github_app_token() -> GitHubAppToken:
106
+ """Return a GitHub App installation token.
107
+
108
+ This package-level wrapper is the canonical public import path for GitHub
109
+ token retrieval and delegates to the lower-level client implementation.
110
+ """
111
+
112
+ return await _get_github_app_token()
113
+
114
+
101
115
  # =============================================================================
102
116
  # GitHubEvent Enum
103
117
  # =============================================================================
@@ -4056,6 +4070,9 @@ __all__ = [
4056
4070
  "GitHubIssuePullRequest",
4057
4071
  "GitHubChangeValue",
4058
4072
  "GitHubChanges",
4073
+ # GitHub client
4074
+ "get_github_app_token",
4075
+ "GitHubAppToken",
4059
4076
  # Watch events
4060
4077
  "WatchBase",
4061
4078
  "WatchStarted",
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from datetime import UTC, datetime, timedelta
6
+
7
+ import httpx
8
+
9
+ from dispatch_agents.events import _get_auth_headers, _get_router_url
10
+
11
+ # Module-level cache for the current agent process.
12
+ # The SDK assumes a single agent run does not switch orgs or API keys in-process;
13
+ # multi-org access is enforced by the backend, not modeled in this client cache.
14
+ _cached_token: tuple[GitHubAppToken, datetime] | None = None
15
+
16
+ _TOKEN_BUFFER_MINUTES = 5
17
+
18
+
19
+ @dataclass
20
+ class GitHubAppToken:
21
+ token: str
22
+ expires_at: datetime
23
+
24
+
25
+ async def get_github_app_token() -> GitHubAppToken:
26
+ """Return a GitHub App installation token for use with any HTTP client.
27
+
28
+ Fetches a GitHub App installation token from the Dispatch backend. The token
29
+ is cached transparently; subsequent calls return the cached token until it is
30
+ near expiry (< 5 min), at which point a fresh token is fetched automatically.
31
+
32
+ Returns:
33
+ GitHubAppToken: Dataclass with ``token`` (str) and
34
+ ``expires_at`` (timezone-aware datetime) fields. Pass ``token`` as a
35
+ Bearer credential to any GitHub API client of your choice.
36
+
37
+ Raises:
38
+ RuntimeError: If DISPATCH_API_KEY environment variable is not set.
39
+ RuntimeError: If no GitHub installation is configured for this org (404).
40
+ RuntimeError: If authentication fails — check DISPATCH_API_KEY (401).
41
+ RuntimeError: If access is forbidden (403).
42
+ RuntimeError: If the backend returns an unexpected HTTP status code.
43
+
44
+ Note:
45
+ Refresh is lazy and call-time: a new token is fetched on the NEXT call
46
+ after the cached token nears expiry (<5 min remaining). Call
47
+ get_github_app_token() at the start of each handler invocation
48
+ rather than once at module load.
49
+
50
+ Network errors (httpx.ConnectError, httpx.TimeoutException) propagate
51
+ as-is and are not caught.
52
+
53
+ Examples:
54
+ Using PyGithub::
55
+
56
+ from github import Auth, Github
57
+ from dispatch_agents.integrations.github import get_github_app_token
58
+
59
+ tok = await get_github_app_token()
60
+ gh = Github(auth=Auth.Token(tok.token))
61
+ repo = gh.get_repo("my-org/my-repo")
62
+
63
+ Using httpx directly::
64
+
65
+ import httpx
66
+ from dispatch_agents.integrations.github import get_github_app_token
67
+
68
+ tok = await get_github_app_token()
69
+ async with httpx.AsyncClient() as client:
70
+ resp = await client.get(
71
+ "https://api.github.com/repos/my-org/my-repo",
72
+ headers={"Authorization": f"Bearer {tok.token}"},
73
+ )
74
+ """
75
+ global _cached_token
76
+
77
+ if not os.getenv("DISPATCH_API_KEY"):
78
+ raise RuntimeError(
79
+ "DISPATCH_API_KEY environment variable is not set. "
80
+ "GitHub installation token requires an authenticated Dispatch agent."
81
+ )
82
+
83
+ if _cached_token is not None:
84
+ cached, expires_at = _cached_token
85
+ if expires_at >= datetime.now(UTC) + timedelta(minutes=_TOKEN_BUFFER_MINUTES):
86
+ return cached
87
+
88
+ url = _get_router_url() + "/api/unstable/integrations/github/installation-token"
89
+
90
+ async with httpx.AsyncClient() as http_client:
91
+ response = await http_client.post(
92
+ url, headers=_get_auth_headers(), timeout=10.0
93
+ )
94
+
95
+ if response.status_code == 401:
96
+ raise RuntimeError(
97
+ "GitHub installation token request failed: unauthorized. "
98
+ "Check that DISPATCH_API_KEY is valid."
99
+ )
100
+ if response.status_code == 403:
101
+ raise RuntimeError("GitHub installation token request failed: forbidden.")
102
+ if response.status_code == 404:
103
+ raise RuntimeError(
104
+ "No GitHub installation found for this organization. "
105
+ "Ensure the GitHub App is installed and configured in Dispatch."
106
+ )
107
+ if response.status_code != 200:
108
+ raise RuntimeError(
109
+ f"Failed to fetch GitHub installation token: "
110
+ f"backend returned HTTP {response.status_code}"
111
+ )
112
+
113
+ data = response.json()
114
+ token: str = data["token"]
115
+ expires_at = datetime.fromisoformat(data["expires_at"])
116
+ if expires_at.tzinfo is None:
117
+ expires_at = expires_at.replace(tzinfo=UTC)
118
+
119
+ result = GitHubAppToken(token=token, expires_at=expires_at)
120
+ _cached_token = (result, expires_at)
121
+ return result
@@ -470,7 +470,17 @@ class LLMClient:
470
470
  headers=auth_headers,
471
471
  timeout=600.0, # 10min — matches ALB idle timeout for long-context LLM calls
472
472
  )
473
- response.raise_for_status()
473
+ if response.status_code >= 400:
474
+ try:
475
+ error_body = response.json()
476
+ detail = error_body.get("detail", response.text)
477
+ except Exception:
478
+ detail = response.text
479
+ raise httpx.HTTPStatusError(
480
+ f"LLM inference failed ({response.status_code}): {detail}",
481
+ request=response.request,
482
+ response=response,
483
+ )
474
484
  data = response.json()
475
485
 
476
486
  # Parse tool calls if present
@@ -764,7 +764,7 @@ async def _call_provider_directly_streaming(
764
764
  ) -> Response:
765
765
  """Call the LLM provider directly with streaming (fallback when backend has no config).
766
766
 
767
- Uses raw httpx streaming (not litellm) since litellm is not available in the SDK.
767
+ Uses raw httpx streaming.
768
768
  Buffers SSE events for usage extraction and logs to /llm/log after stream completes.
769
769
  """
770
770
 
@@ -947,10 +947,10 @@ async def _proxy_passthrough(request: Request, provider_format: str) -> Response
947
947
  )
948
948
 
949
949
  # Build passthrough request for backend.
950
- # provider_format is no longer sent; the backend derives it from path.
951
950
  backend_payload: dict[str, Any] = {
952
951
  "path": path,
953
952
  "method": method,
953
+ "provider_format": provider_format,
954
954
  }
955
955
  if body_dict is not None:
956
956
  backend_payload["body"] = body_dict
@@ -2,6 +2,7 @@
2
2
 
3
3
  import asyncio
4
4
 
5
+ import aiohttp
5
6
  import dispatch_agents
6
7
  from dispatch_agents import BasePayload, fn, on
7
8
  from dispatch_agents.integrations.github import PullRequestReviewCommentCreated
@@ -11,7 +12,7 @@ from pydantic import Field, PositiveInt
11
12
  class GreetingPayload(BasePayload):
12
13
  """Input payload for greeting requests."""
13
14
 
14
- subject: str = Field(description="The name or subject to greet")
15
+ subject: str = Field(default="World", description="The name or subject to greet")
15
16
 
16
17
 
17
18
  class GreetingResponse(BasePayload):
@@ -110,3 +111,51 @@ async def reverse(payload: ReverseRequest) -> ReverseResponse:
110
111
  """Reverse the provided text string."""
111
112
  print(f"Reversing: {payload.text!r}")
112
113
  return ReverseResponse(reversed_text=payload.text[::-1])
114
+
115
+
116
+ class EgressTestRequest(BasePayload):
117
+ """Input for the egress test function."""
118
+
119
+ url: str = Field(
120
+ default="https://jsonplaceholder.typicode.com/todos/1",
121
+ description="URL to attempt to fetch",
122
+ )
123
+
124
+
125
+ class EgressTestResponse(BasePayload):
126
+ """Output of the egress test function."""
127
+
128
+ success: bool = Field(description="Whether the request succeeded")
129
+ status_code: int | None = Field(
130
+ default=None, description="HTTP status code if successful"
131
+ )
132
+ body: str = Field(default="", description="Response body or error message")
133
+
134
+
135
+ @fn()
136
+ async def test_egress(payload: EgressTestRequest) -> EgressTestResponse:
137
+ """Test outbound HTTP connectivity by fetching a URL.
138
+
139
+ Useful for verifying network egress controls. When network.egress is
140
+ configured, this request will be blocked unless the target domain is
141
+ in allow_domains.
142
+ """
143
+ print(f"Testing egress to: {payload.url}")
144
+ try:
145
+ async with aiohttp.ClientSession(
146
+ timeout=aiohttp.ClientTimeout(total=10)
147
+ ) as session:
148
+ async with session.get(payload.url) as resp:
149
+ body = await resp.text()
150
+ print(f"Response: {resp.status} ({len(body)} bytes)")
151
+ return EgressTestResponse(
152
+ success=True,
153
+ status_code=resp.status,
154
+ body=body[:1000],
155
+ )
156
+ except Exception as e:
157
+ print(f"Request failed: {type(e).__name__}: {e}")
158
+ return EgressTestResponse(
159
+ success=False,
160
+ body=f"{type(e).__name__}: {e}",
161
+ )