* feat(core): add Black Forest Labs image adapter Black Forest Labs (FLUX) generates asynchronously: a submit returns a polling_url, the task is polled until ready, and the image is downloaded from a result URL. BlackForestLabsImageAdapter runs that flow with fetch, sends input images as input_image, input_image_2, and so on, and maps a WIDTHxHEIGHT size to width and height. A moderated task shows up only in the poll status. The adapter reports Request Moderated and Content Moderated as a non-retryable content_policy failure as soon as it sees them, so runImage moves to the next model instead of polling until the attempt deadline and retrying. Failed and lost tasks are retryable. The API key goes only to the API origin and HTTPS hosts under bfl.ai, keyed requests refuse redirects, and the pre-signed image download carries no key. providerOptions follows the same rule as the other adapters: fields that carry the prompt or input images are rejected at construction, and width and height are defaults that request.size overrides. Follow-up to #613. * fix(core): report detected image media type and the BFL dimensions sent The built-in image adapters declared a fixed media type, which was wrong whenever providerOptions chose another output format, for example output_format png on Black Forest Labs or jpeg on OpenAI. All four now report the type read from the returned bytes and fall back to the old default only when the bytes are not a recognized image. The Black Forest Labs attempt parameters kept default width and height from providerOptions even when request.size replaced them in the request body. They now record the same width and height the request sent. * fix(core): keep a submitted BFL task through transient poll failures A transient failure while polling or downloading, such as a 429, a 5xx, or a network error, escaped the adapter as retryable, so runImage retried generate() and submitted another billable task while the first kept running. The Black Forest Labs adapter now retries those failures in place until the attempt deadline, honoring Retry-After. Non-retryable errors still throw at once, and so does a 4xx on the download, since an expired delivery link cannot recover without a new task. The adapter contract now says what it meant: an adapter must not resubmit a generation internally, while retrying a status poll or download of an already-submitted task is allowed. * fix(core): type rate-limited and timed-out image downloads downloadImage turned every non-2xx status into api_error, so a 429 or 408 on a pre-signed result URL did not count as transient for the Black Forest Labs in-place retry, escaped to runImage, and resubmitted a billable task. A download 429 is now rate_limit with Retry-After and a 408 is timeout, so both are retried in place against the existing task. * fix(core): scope BFL credentials and keep a submitted task final at the deadline The Black Forest Labs adapter sent its key to any HTTPS host under bfl.ai. Behind a proxy that key belongs to the proxy, and a forwarded BFL polling URL would receive it. Sibling BFL hosts now qualify only when baseURL is itself a BFL host; otherwise the key goes to the configured origin alone. A transient outage that lasted past the attempt deadline still led runImage to retry generate() and submit another billable task. Once a task is submitted, the adapter now reports a deadline as a non-retryable timeout, and runImage keeps a non-retryable adapter verdict at the deadline instead of replacing it with a retryable timeout, so the chain moves to the next model. A deadline before the submit completes stays retryable. * fix(core): tell deadlines from cancellation and end BFL turns on permanent download failures runImage now aborts an attempt's signal with a DOMException named TimeoutError when the deadline fires, the same convention as AbortSignal.timeout(), and with the caller's own reason on cancellation. The Black Forest Labs adapter turns only a deadline after the submit into a final timeout; a caller that cancels a direct generate() call gets its own abort reason back. A permanent failure downloading a submitted task, such as an expired or missing result link, was still retryable, so runImage submitted and paid for another task. Once the task exists, a download failure that is not transient now ends that model's turn as non-retryable. * fix(core): end a submitted BFL task's turn on any failure and keep its cost Several failures after a Black Forest Labs task was submitted were still retryable: a Ready status without result.sample, an Error or Failed task, and a lost task. runImage retried them by submitting and paying for another task. Every failure after the submit now ends that model's turn as non-retryable. The adapter's in-place retry of transient poll and download failures slept through any Retry-After, so a header above runImage's maxRetryAfterMs stalled the attempt until its deadline. runImage now passes its cap to adapters as ImageCallOptions.maxRetryAfterMs, and a longer wait ends the turn at once. A failure after the submit also dropped the task ID and the cost BFL reported, because adapter params were only returned on success. ImageModelError now carries optional params, runImage copies them into the failed attempt record, and the BFL adapter attaches the task and its cost to every post-submit failure. * fix(core): close the last BFL paths that resubmit a created task Two failures after a successful Black Forest Labs submit still reached runImage as retryable and led it to submit and pay for another task: a submit response without polling_url, and a result download that answers 200 with bytes that are not an image. Both now end that model's turn as non-retryable invalid_output and carry the task ID and cost into the failed attempt record. The adapter checks the downloaded bytes itself instead of leaving it to runImage, whose retry of an invalid output would resubmit.
7.7 KiB
Framework-owned LLM egress policy
egressPolicy restricts network requests that OMA can identify and guard
before a built-in LLM adapter opens them. It is an application configuration
control, not a process sandbox or host firewall.
Omitting the policy preserves existing behavior. Once a policy is configured, OMA fails closed on a built-in adapter surface only when this document says the surface is enforced.
Configuration and precedence
The same EgressPolicy type is accepted by OpenMultiAgent, AgentConfig, and
the options for each top-level run:
import { OpenMultiAgent } from '@open-multi-agent/core'
const oma = new OpenMultiAgent({
egressPolicy: {
mode: 'allowlist',
allowedOrigins: [
'https://api.anthropic.com',
'http://localhost:11434',
],
},
})
const result = await oma.runAgent(
{
name: 'local',
model: 'llama3.1',
provider: 'openai',
baseURL: 'http://localhost:11434/v1',
apiKey: 'ollama',
},
'Summarize this text.',
{ egressPolicy: { mode: 'offline' } },
)
Orchestrator, run, and agent policies are intersected. Omitted scopes do not change the result; a more specific scope can narrow access but cannot widen a parent scope. An empty intersection denies every framework-owned LLM origin. The same effective policy reaches workers, the coordinator, synthesis, delegated agents, model-route fallbacks, consensus agents, and the built-in LLM semantic profiler.
The modes are:
offline: allow only URL hostnameslocalhost,*.localhost, IPv4127.0.0.0/8, and IPv6::1. Private LAN, link-local, and arbitrary names that happen to resolve to loopback are not allowed.allowlist: allow only the listed HTTP(S) origins. Entries are normalized with standard URL origin semantics and must not include credentials, a path, a query string, or a fragment. A non-default port is part of the origin.
The provider request may include any path under an allowed origin. Every
guarded fetch uses redirect: 'error', so a permitted endpoint cannot silently
redirect a credential-bearing SDK request to another origin.
For built-in provider target selection, AgentConfig.baseURL takes precedence
over OpenMultiAgent.defaultBaseURL; the effective explicit value then takes
precedence over the provider endpoint environment variable, which takes
precedence over the built-in default. Relevant endpoint variables are
ANTHROPIC_BASE_URL, OPENAI_BASE_URL, AZURE_OPENAI_ENDPOINT,
MINIMAX_BASE_URL, MIMO_BASE_URL, and HUNYUAN_BASE_URL. Copilot uses fixed
origins and ignores baseURL. Azure OpenAI requires an explicit/default
endpoint or AZURE_OPENAI_ENDPOINT when a policy is active.
Enforcement matrix
| Surface | Behavior while egressPolicy is configured |
|---|---|
| Anthropic, OpenAI, Azure OpenAI, DeepSeek, Doubao, Grok, Hunyuan, MiniMax, MiMo, and Qiniu built-in adapters | Enforced. OMA resolves and checks the effective provider origin before loading the optional SDK, injects a guarded fetch transport, checks every request again, and rejects redirects. |
Built-in image adapters (BlackForestLabsImageAdapter, OpenAIImageAdapter, OpenRouterImageAdapter, SeedreamImageAdapter) |
Enforced when egressPolicy is passed to the adapter. Every request is checked against the policy before it opens, and redirects are rejected. The OpenAI, OpenRouter, and Seedream adapters request base64 output and never fetch a returned image URL. The Black Forest Labs adapter polls and downloads from the URLs its API returns, and each of those requests is checked against the policy too. A denial fails the attempt as a non-retryable invalid_request; see image generation. |
| Gemini built-in adapter | Unsupported and fail-closed before import or connection. The current Google GenAI SDK path uses module-global fetch and exposes no per-client transport hook to this adapter. |
| AWS Bedrock built-in adapter | Unsupported and fail-closed before import or connection. The current adapter cannot bound all request and AWS credential-provider endpoints, including identity/metadata paths. |
AI SDK bridge or another custom LLMAdapter |
Unsupported and fail-closed before adapter invocation. The model object does not expose a reliable target and transport contract to OMA. This also applies when a custom adapter is supplied to the built-in semantic profiler or coordinator. |
| GitHub Copilot auth and API | Enforced. A pre-supplied GitHub token requires https://api.github.com and https://api.githubcopilot.com. Interactive device login additionally requires https://github.com. OMA checks all required origins before the first auth request and guards both token exchange and model API fetches. |
Custom TaskProfiler, execution router, hooks, and other application callbacks |
Not covered. They are application-owned in-process code. |
| MCP stdio child | Not covered. OMA starts the configured child and exchanges stdio messages; it cannot constrain connections opened inside the MCP server. See MCP tools. |
process and ACP backends |
Not covered. OMA starts a child process and uses stdio; the child owns its network behavior. ACP permission callbacks are not a network sandbox. |
Built-in bash tool |
Not covered. The shell process can use its host permissions and network stack. |
Custom tools, including tools that call fetch |
Not covered. Tool code and its clients are supplied by the application. |
@open-multi-agent/otel and application-owned trace/OTel exporters |
Not covered. OMA invokes the supplied tracer, provider, sink, or exporter; the application owns its transport and lifecycle. |
Any adapter instance supplied through AgentConfig.adapter is a custom adapter
for this policy, even if the application constructed it from an OMA adapter
class. Select an enforceable built-in provider through provider and baseURL
so OMA owns construction and can inject the guarded transport.
The MCP, backend, shell, and custom-tool rows stay outside the policy even when
they are launched by a policy-configured agent. Use process/container network
namespaces, an egress proxy, or an OS firewall when those surfaces must be
contained. Do not treat offline as evidence that the whole Node.js process or
its descendants are offline.
Errors and audit behavior
Invalid policy shapes and allowlist entries throw EgressPolicyError with
code: 'INVALID_EGRESS_POLICY'; invalid entries are never ignored. Direct
createAdapter() calls reject with the same error class. During an agent LLM
run, denial or an unsupported adapter follows the existing LLM-failure path:
the agent result is unsuccessful with status.code: 'rejected',
errorInfo.kind: 'validation', and a non-retryable stable code. It is not
converted into a tool ToolResult.
The remaining stable codes are:
| Code | Meaning |
|---|---|
EGRESS_POLICY_DENIED |
A concrete resolved origin is outside the effective policy. |
EGRESS_POLICY_TARGET_UNRESOLVED |
The built-in adapter needs an endpoint that configuration and environment did not provide. |
EGRESS_POLICY_UNSUPPORTED |
OMA cannot truthfully enforce the selected adapter transport surface. |
Existing team/task failure and dependency-cascade behavior remains unchanged. No policy outcome is retried because another attempt cannot widen policy.
Security boundary
The guard evaluates the configured/request URL before the fetch implementation runs. It is not DNS resolution pinning, proxy enforcement, socket interception, or protection against a compromised provider SDK. An allowed hostname can resolve according to the process's DNS environment. For a hard containment boundary, pair this declarative audit control with infrastructure-level egress enforcement.