dispatch_agents 0.11.0__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.11.0 → dispatch_agents-0.12.2}/PKG-INFO +1 -1
  2. dispatch_agents-0.12.2/RELEASE_NOTES.md +1 -0
  3. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/config.py +114 -0
  4. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/integrations/github/client.py +4 -1
  5. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/llm.py +11 -1
  6. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/proxy/server.py +2 -2
  7. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/hello_world/agent.py +50 -1
  8. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/pyproject.toml +1 -1
  9. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_config.py +207 -0
  10. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_github_client.py +1 -0
  11. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_proxy_server.py +8 -0
  12. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/uv.lock +7 -7
  13. dispatch_agents-0.11.0/RELEASE_NOTES.md +0 -2
  14. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/.claude-plugin/marketplace.json +0 -0
  15. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/.github/scripts/change_scope.py +0 -0
  16. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/.github/scripts/ci_git.py +0 -0
  17. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/.github/scripts/version_policy.py +0 -0
  18. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/.github/tests/test_change_scope.py +0 -0
  19. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/.github/tests/test_ci_git.py +0 -0
  20. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/.github/tests/test_version_policy.py +0 -0
  21. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/.github/workflows/ci-reusable.yml +0 -0
  22. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/.github/workflows/feature-branch.yml +0 -0
  23. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/.github/workflows/release.yml +0 -0
  24. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/.github/workflows/version-policy-reusable.yml +0 -0
  25. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/.gitignore +0 -0
  26. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/CONTRIBUTING.md +0 -0
  27. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/LICENSE +0 -0
  28. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/LICENSE-3rdparty.csv +0 -0
  29. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/NOTICE +0 -0
  30. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/README.md +0 -0
  31. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/agentservice/__init__.py +0 -0
  32. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/agentservice/py.typed +0 -0
  33. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/agentservice/v1/__init__.py +0 -0
  34. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/agentservice/v1/message_pb2.py +0 -0
  35. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/agentservice/v1/message_pb2.pyi +0 -0
  36. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/agentservice/v1/message_pb2_grpc.py +0 -0
  37. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/agentservice/v1/request_response_pb2.py +0 -0
  38. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/agentservice/v1/request_response_pb2.pyi +0 -0
  39. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/agentservice/v1/request_response_pb2_grpc.py +0 -0
  40. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/agentservice/v1/service_pb2.py +0 -0
  41. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/agentservice/v1/service_pb2.pyi +0 -0
  42. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/agentservice/v1/service_pb2_grpc.py +0 -0
  43. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/__init__.py +0 -0
  44. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/agent_service.py +0 -0
  45. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/contrib/__init__.py +0 -0
  46. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/contrib/claude/__init__.py +0 -0
  47. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/contrib/openai/__init__.py +0 -0
  48. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/events.py +0 -0
  49. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/grpc_server.py +0 -0
  50. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/instrument.py +0 -0
  51. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/integrations/__init__.py +0 -0
  52. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/integrations/github/README.md +0 -0
  53. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/integrations/github/__init__.py +0 -0
  54. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/invocation.py +0 -0
  55. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/llm_langchain.py +0 -0
  56. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/logging_config.py +0 -0
  57. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/mcp.py +0 -0
  58. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/memory.py +0 -0
  59. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/models.py +0 -0
  60. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/proxy/__init__.py +0 -0
  61. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/proxy/sse_utils.py +0 -0
  62. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/py.typed +0 -0
  63. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/resources.py +0 -0
  64. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/dispatch_agents/version.py +0 -0
  65. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/README.md +0 -0
  66. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/company-researcher/.gitignore +0 -0
  67. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/company-researcher/AGENTS.md +0 -0
  68. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/company-researcher/README.md +0 -0
  69. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/company-researcher/agent.py +0 -0
  70. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/company-researcher/dispatch.yaml +0 -0
  71. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/company-researcher/pyproject.toml +0 -0
  72. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/company-researcher/uv.lock +0 -0
  73. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/conversational-agent/README.md +0 -0
  74. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/conversational-agent/agent.py +0 -0
  75. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/conversational-agent/dispatch.yaml +0 -0
  76. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/conversational-agent/pyproject.toml +0 -0
  77. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/daily-digest/README.md +0 -0
  78. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/daily-digest/agent.py +0 -0
  79. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/daily-digest/dispatch.yaml +0 -0
  80. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/daily-digest/pyproject.toml +0 -0
  81. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/deep-research/README.md +0 -0
  82. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/deep-research/agent.py +0 -0
  83. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/deep-research/configuration.py +0 -0
  84. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/deep-research/deep_researcher.py +0 -0
  85. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/deep-research/dispatch.yaml +0 -0
  86. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/deep-research/prompts.py +0 -0
  87. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/deep-research/pyproject.toml +0 -0
  88. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/deep-research/state.py +0 -0
  89. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/deep-research/tools.py +0 -0
  90. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/hello_world/.gitignore +0 -0
  91. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/hello_world/AGENTS.md +0 -0
  92. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/hello_world/dispatch.yaml +0 -0
  93. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/hello_world/pyproject.toml +0 -0
  94. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/hello_world/test_agent.py +0 -0
  95. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/hello_world/uv.lock +0 -0
  96. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/knowledge-base-query/.env.example +0 -0
  97. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/knowledge-base-query/.gitignore +0 -0
  98. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/knowledge-base-query/AGENTS.md +0 -0
  99. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/knowledge-base-query/agent.py +0 -0
  100. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/knowledge-base-query/dispatch.yaml +0 -0
  101. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/knowledge-base-query/pyproject.toml +0 -0
  102. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/multi-framework/README.md +0 -0
  103. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/multi-framework/agent.py +0 -0
  104. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/multi-framework/dispatch.yaml +0 -0
  105. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/multi-framework/pyproject.toml +0 -0
  106. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/pyproject.toml +0 -0
  107. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/uv.lock +0 -0
  108. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/weather-assistant/.gitignore +0 -0
  109. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/weather-assistant/AGENTS.md +0 -0
  110. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/weather-assistant/agent.py +0 -0
  111. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/weather-assistant/dispatch.yaml +0 -0
  112. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/weather-assistant/pyproject.toml +0 -0
  113. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/weather-assistant/uv.lock +0 -0
  114. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/weather-service/.gitignore +0 -0
  115. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/weather-service/AGENTS.md +0 -0
  116. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/weather-service/agent.py +0 -0
  117. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/weather-service/dispatch.yaml +0 -0
  118. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/weather-service/pyproject.toml +0 -0
  119. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/examples/weather-service/uv.lock +0 -0
  120. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/internal/py.typed +0 -0
  121. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/plugins/README.md +0 -0
  122. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/__init__.py +0 -0
  123. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/e2e_claude_mcp_proxy.py +0 -0
  124. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/schemas/README.md +0 -0
  125. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/schemas/octokit-webhooks.json +0 -0
  126. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test.py +0 -0
  127. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_agent_service.py +0 -0
  128. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_agent_uid.py +0 -0
  129. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_contrib_claude.py +0 -0
  130. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_contrib_openai.py +0 -0
  131. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_dev_mode_isolation.py +0 -0
  132. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_extra_headers.py +0 -0
  133. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_fn_decorator.py +0 -0
  134. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_github_integration.py +0 -0
  135. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_github_schema_compliance.py +0 -0
  136. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_grpc_server.py +0 -0
  137. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_init.py +0 -0
  138. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_instrument.py +0 -0
  139. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_llm_langchain.py +0 -0
  140. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_llm_logging.py +0 -0
  141. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_logging_config.py +0 -0
  142. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_mcp.py +0 -0
  143. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_memory.py +0 -0
  144. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_proxy_e2e.py +0 -0
  145. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_resources.py +0 -0
  146. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_sse_utils.py +0 -0
  147. {dispatch_agents-0.11.0 → dispatch_agents-0.12.2}/tests/test_trace_context.py +0 -0
  148. {dispatch_agents-0.11.0 → 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.11.0
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}
@@ -38,6 +38,7 @@ async def get_github_app_token() -> GitHubAppToken:
38
38
  RuntimeError: If DISPATCH_API_KEY environment variable is not set.
39
39
  RuntimeError: If no GitHub installation is configured for this org (404).
40
40
  RuntimeError: If authentication fails — check DISPATCH_API_KEY (401).
41
+ RuntimeError: If access is forbidden (403).
41
42
  RuntimeError: If the backend returns an unexpected HTTP status code.
42
43
 
43
44
  Note:
@@ -76,7 +77,7 @@ async def get_github_app_token() -> GitHubAppToken:
76
77
  if not os.getenv("DISPATCH_API_KEY"):
77
78
  raise RuntimeError(
78
79
  "DISPATCH_API_KEY environment variable is not set. "
79
- "GitHub installation token requires authentication with the Dispatch backend."
80
+ "GitHub installation token requires an authenticated Dispatch agent."
80
81
  )
81
82
 
82
83
  if _cached_token is not None:
@@ -96,6 +97,8 @@ async def get_github_app_token() -> GitHubAppToken:
96
97
  "GitHub installation token request failed: unauthorized. "
97
98
  "Check that DISPATCH_API_KEY is valid."
98
99
  )
100
+ if response.status_code == 403:
101
+ raise RuntimeError("GitHub installation token request failed: forbidden.")
99
102
  if response.status_code == 404:
100
103
  raise RuntimeError(
101
104
  "No GitHub installation found for this organization. "
@@ -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
+ )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dispatch_agents"
7
- version = "0.11.0"
7
+ version = "0.12.2"
8
8
  description = "Dispatch Agents SDK"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -4,7 +4,10 @@ import pytest
4
4
 
5
5
  from dispatch_agents.config import (
6
6
  DispatchConfig,
7
+ DomainSelector,
8
+ EgressConfig,
7
9
  MCPServerConfig,
10
+ NetworkConfig,
8
11
  ResourceConfig,
9
12
  ResourceLimits,
10
13
  SecretConfig,
@@ -510,3 +513,207 @@ class TestVolumeMode:
510
513
  def test_string_comparison(self):
511
514
  """Should compare as string."""
512
515
  assert VolumeMode.READ_WRITE_MANY == "read_write_many"
516
+
517
+
518
+ class TestDomainSelector:
519
+ """Tests for DomainSelector model."""
520
+
521
+ def test_valid_match_name(self):
522
+ """Should accept exact FQDN via matchName."""
523
+ ds = DomainSelector(match_name="api.openai.com")
524
+ assert ds.match_name == "api.openai.com"
525
+ assert ds.match_pattern is None
526
+
527
+ def test_valid_match_pattern(self):
528
+ """Should accept wildcard via matchPattern."""
529
+ ds = DomainSelector(match_pattern="*.github.com")
530
+ assert ds.match_pattern == "*.github.com"
531
+ assert ds.match_name is None
532
+
533
+ def test_rejects_both_fields(self):
534
+ """Should reject when both matchName and matchPattern are set."""
535
+ with pytest.raises(ValueError, match="not both"):
536
+ DomainSelector(match_name="api.openai.com", match_pattern="*.github.com")
537
+
538
+ def test_rejects_neither_field(self):
539
+ """Should reject when neither field is set."""
540
+ with pytest.raises(ValueError, match="must be set"):
541
+ DomainSelector()
542
+
543
+ def test_rejects_invalid_match_name(self):
544
+ """Should reject invalid domain in matchName."""
545
+ with pytest.raises(ValueError, match="Invalid domain"):
546
+ DomainSelector(match_name="https://api.openai.com")
547
+
548
+ def test_rejects_invalid_match_pattern(self):
549
+ """Should reject invalid pattern in matchPattern."""
550
+ with pytest.raises(ValueError, match="Invalid domain"):
551
+ DomainSelector(match_pattern="*")
552
+
553
+ def test_model_dump_excludes_none(self):
554
+ """Should produce clean dict when dumped with exclude_none."""
555
+ ds = DomainSelector(match_name="api.openai.com")
556
+ assert ds.model_dump(exclude_none=True) == {"match_name": "api.openai.com"}
557
+
558
+
559
+ class TestEgressConfig:
560
+ """Tests for EgressConfig model and validators."""
561
+
562
+ def test_empty_allow_domains(self):
563
+ """Should accept empty allow_domains list."""
564
+ config = EgressConfig()
565
+ assert config.allow_domains == []
566
+
567
+ def test_valid_exact_domain(self):
568
+ """Should accept exact FQDNs via matchName."""
569
+ config = EgressConfig(
570
+ allow_domains=[DomainSelector(match_name="api.openai.com")]
571
+ )
572
+ assert config.allow_domains[0].match_name == "api.openai.com"
573
+
574
+ def test_valid_wildcard_domain(self):
575
+ """Should accept wildcard prefixes via matchPattern."""
576
+ config = EgressConfig(
577
+ allow_domains=[DomainSelector(match_pattern="*.github.com")]
578
+ )
579
+ assert config.allow_domains[0].match_pattern == "*.github.com"
580
+
581
+ def test_valid_multiple_domains(self):
582
+ """Should accept multiple valid domains."""
583
+ domains = [
584
+ DomainSelector(match_name="api.openai.com"),
585
+ DomainSelector(match_pattern="*.github.com"),
586
+ DomainSelector(match_name="httpbin.org"),
587
+ ]
588
+ config = EgressConfig(allow_domains=domains)
589
+ assert len(config.allow_domains) == 3
590
+
591
+ def test_rejects_url_with_scheme(self):
592
+ """Should reject domains with URL schemes."""
593
+ with pytest.raises(ValueError, match="Invalid domain"):
594
+ EgressConfig(
595
+ allow_domains=[DomainSelector(match_name="https://api.openai.com")]
596
+ )
597
+
598
+ def test_rejects_domain_with_port(self):
599
+ """Should reject domains with ports."""
600
+ with pytest.raises(ValueError, match="Invalid domain"):
601
+ EgressConfig(
602
+ allow_domains=[DomainSelector(match_name="api.openai.com:443")]
603
+ )
604
+
605
+ def test_rejects_domain_with_path(self):
606
+ """Should reject domains with paths."""
607
+ with pytest.raises(ValueError, match="Invalid domain"):
608
+ EgressConfig(allow_domains=[DomainSelector(match_name="api.openai.com/v1")])
609
+
610
+ def test_rejects_ip_address(self):
611
+ """Should reject IP addresses."""
612
+ with pytest.raises(ValueError, match="Invalid domain"):
613
+ EgressConfig(allow_domains=[DomainSelector(match_name="192.168.1.1")])
614
+
615
+ def test_rejects_bare_wildcard(self):
616
+ """Should reject bare wildcard."""
617
+ with pytest.raises(ValueError, match="Invalid domain"):
618
+ EgressConfig(allow_domains=[DomainSelector(match_pattern="*")])
619
+
620
+ def test_rejects_wildcard_tld(self):
621
+ """Should reject wildcard on TLD only."""
622
+ with pytest.raises(ValueError, match="Invalid domain"):
623
+ EgressConfig(allow_domains=[DomainSelector(match_pattern="*.com")])
624
+
625
+ def test_rejects_too_many_domains(self):
626
+ """Should reject more than 50 entries."""
627
+ domains = [
628
+ DomainSelector(match_name=f"domain{i}.example.com") for i in range(51)
629
+ ]
630
+ with pytest.raises(ValueError, match="cannot have more than 50"):
631
+ EgressConfig(allow_domains=domains)
632
+
633
+ def test_accepts_50_domains(self):
634
+ """Should accept exactly 50 entries."""
635
+ domains = [
636
+ DomainSelector(match_name=f"domain{i}.example.com") for i in range(50)
637
+ ]
638
+ config = EgressConfig(allow_domains=domains)
639
+ assert len(config.allow_domains) == 50
640
+
641
+ def test_rejects_bare_tld(self):
642
+ """Should reject bare TLD."""
643
+ with pytest.raises(ValueError, match="Invalid domain"):
644
+ EgressConfig(allow_domains=[DomainSelector(match_name="com")])
645
+
646
+
647
+ class TestNetworkConfig:
648
+ """Tests for NetworkConfig model."""
649
+
650
+ def test_default_egress(self):
651
+ """Should default to empty allow_domains."""
652
+ config = NetworkConfig()
653
+ assert config.egress.allow_domains == []
654
+
655
+ def test_custom_egress(self):
656
+ """Should accept custom egress config."""
657
+ config = NetworkConfig(
658
+ egress=EgressConfig(
659
+ allow_domains=[DomainSelector(match_name="api.openai.com")]
660
+ )
661
+ )
662
+ assert config.egress.allow_domains[0].match_name == "api.openai.com"
663
+
664
+
665
+ class TestDispatchConfigNetwork:
666
+ """Tests for network field on DispatchConfig."""
667
+
668
+ def test_network_none_by_default(self):
669
+ """Should default to None (no restrictions)."""
670
+ config = DispatchConfig()
671
+ assert config.network is None
672
+
673
+ def test_network_with_egress(self):
674
+ """Should accept network config with egress."""
675
+ config = DispatchConfig(
676
+ network=NetworkConfig(
677
+ egress=EgressConfig(
678
+ allow_domains=[DomainSelector(match_name="api.openai.com")]
679
+ )
680
+ )
681
+ )
682
+ assert config.network is not None
683
+ assert config.network.egress.allow_domains[0].match_name == "api.openai.com"
684
+
685
+ def test_to_yaml_dict_includes_network(self):
686
+ """Should include network in YAML serialization when set."""
687
+ config = DispatchConfig(
688
+ network=NetworkConfig(
689
+ egress=EgressConfig(
690
+ allow_domains=[
691
+ DomainSelector(match_name="api.openai.com"),
692
+ DomainSelector(match_pattern="*.github.com"),
693
+ ]
694
+ )
695
+ )
696
+ )
697
+ result = config.to_yaml_dict()
698
+ assert "network" in result
699
+ assert result["network"] == {
700
+ "egress": {
701
+ "allow_domains": [
702
+ {"match_name": "api.openai.com"},
703
+ {"match_pattern": "*.github.com"},
704
+ ],
705
+ }
706
+ }
707
+
708
+ def test_to_yaml_dict_includes_network_empty_domains(self):
709
+ """Should include network even with empty allow_domains (triggers policy creation)."""
710
+ config = DispatchConfig(network=NetworkConfig())
711
+ result = config.to_yaml_dict()
712
+ assert "network" in result
713
+ assert result["network"] == {"egress": {"allow_domains": []}}
714
+
715
+ def test_to_yaml_dict_excludes_network_when_none(self):
716
+ """Should not include network when None."""
717
+ config = DispatchConfig()
718
+ result = config.to_yaml_dict()
719
+ assert "network" not in result
@@ -213,6 +213,7 @@ async def test_normalizes_naive_expiry_timestamp(monkeypatch):
213
213
  ("status_code", "expected_message"),
214
214
  [
215
215
  (401, "DISPATCH_API_KEY"),
216
+ (403, "request failed: forbidden"),
216
217
  (404, "No GitHub installation found"),
217
218
  (500, "backend returned HTTP 500"),
218
219
  ],
@@ -1054,6 +1054,10 @@ class TestPassthroughRoute:
1054
1054
  resp = client.get("/openai/v1/models")
1055
1055
  assert resp.status_code == 200
1056
1056
 
1057
+ # Verify provider_format is sent in the payload
1058
+ payload = mock_client.post.call_args.kwargs["json"]
1059
+ assert payload["provider_format"] == "openai"
1060
+
1057
1061
  @patch("dispatch_agents.proxy.server.httpx.AsyncClient")
1058
1062
  def test_anthropic_passthrough_get(self, mock_client_cls, client):
1059
1063
  """GET /anthropic/v1/models goes through passthrough."""
@@ -1071,6 +1075,10 @@ class TestPassthroughRoute:
1071
1075
  resp = client.get("/anthropic/v1/models")
1072
1076
  assert resp.status_code == 200
1073
1077
 
1078
+ # Verify provider_format is sent in the payload
1079
+ payload = mock_client.post.call_args.kwargs["json"]
1080
+ assert payload["provider_format"] == "anthropic"
1081
+
1074
1082
 
1075
1083
  # ── Auth Error Detection ─────────────────────────────────────────────
1076
1084
 
@@ -3,15 +3,15 @@ revision = 3
3
3
  requires-python = ">=3.11"
4
4
 
5
5
  [options]
6
- exclude-newer = "2026-02-28T15:12:16.136865Z"
6
+ exclude-newer = "2026-03-03T20:09:23.684706Z"
7
7
  exclude-newer-span = "P30D"
8
8
 
9
9
  [options.exclude-newer-package]
10
- dispatch-agents = { timestamp = "2026-03-30T15:12:16.136874Z", span = "PT0S" }
11
- claude-agent-sdk = { timestamp = "2026-03-16T15:12:16.136882Z", span = "P14D" }
12
- openai = { timestamp = "2026-03-16T15:12:16.136883Z", span = "P14D" }
13
- langchain-openai = { timestamp = "2026-03-16T15:12:16.136885Z", span = "P14D" }
14
- openai-agents = { timestamp = "2026-03-16T15:12:16.136884Z", span = "P14D" }
10
+ dispatch-agents = { timestamp = "2026-04-02T20:09:23.684712Z", span = "PT0S" }
11
+ claude-agent-sdk = { timestamp = "2026-03-19T20:09:23.684719Z", span = "P14D" }
12
+ openai = { timestamp = "2026-03-19T20:09:23.684719Z", span = "P14D" }
13
+ langchain-openai = { timestamp = "2026-03-19T20:09:23.68472Z", span = "P14D" }
14
+ openai-agents = { timestamp = "2026-03-19T20:09:23.684719Z", span = "P14D" }
15
15
 
16
16
  [[package]]
17
17
  name = "aiohappyeyeballs"
@@ -551,7 +551,7 @@ wheels = [
551
551
 
552
552
  [[package]]
553
553
  name = "dispatch-agents"
554
- version = "0.11.0"
554
+ version = "0.12.2"
555
555
  source = { editable = "." }
556
556
  dependencies = [
557
557
  { name = "aiohttp" },
@@ -1,2 +0,0 @@
1
- ## Features
2
- - Added `get_github_app_token()` to the SDK's GitHub integration module, enabling GitHub App-based authentication for agents that interact with GitHub APIs.