feat(llm): configurable first-token timeout#212
Open
lin-snow wants to merge 9 commits into
Open
Conversation
Canonical connection-error subtype raised when an LLM does not return its first token within a configured first_token_timeout. Defined in the kernel so host transport adapters share one exception type.
Add an opt-in per-node first_token_timeout (milliseconds, 0 = disabled) plus a first_token_timeout_seconds property that maps 0 to None so a missing/disabled value flows down as 'no gate'. Inherited by every node via BaseNodeData.retry_config; backward compatible with workflows serialized before the field existed.
Add an optional first_token_timeout to LLMProtocol.invoke_llm and its structured-output variant, and forward it from the LLM, question-classifier and parameter-extractor nodes (read from retry_config). graphon only carries the value; enforcement is the host transport adapter's responsibility. The built-in SlimLLM runtime accepts the parameter for protocol conformance but does not enforce it.
069a2e9 to
5895fe4
Compare
- question-classifier: assert the default (unset retry_config) forwards first_token_timeout=None, matching the LLM and parameter-extractor tests. - LLM polling path: pin that start_llm_polling does not receive first_token_timeout, so a future change that threads it there is flagged as out-of-scope for v1.
5 tasks
first_token_timeout is a client-side invocation deadline, not a retry concept. Keeping it on the shared RetryConfig (which BaseNodeData attaches to every node) polluted non-LLM nodes with an LLM-only field and conflated 'deadline' with 'retry container'. Introduce a FirstTokenTimeoutConfig mixin in nodes/llm/entities.py and mix it into the LLM, question-classifier, and parameter-extractor node data; RetryConfig / base_node_data.py are left untouched. Nodes now read self.node_data.first_token_timeout_seconds directly.
Inheriting a config mixin onto node data overstated the relationship (has-a, not is-a) and broke the codebase idiom, where shared LLM config (ModelConfig, VisionConfig) is composed as fields rather than inherited. Declare first_token_timeout as a plain int field on each of the LLM / question-classifier / parameter-extractor node data, and convert ms->seconds via a shared first_token_timeout_seconds() helper at the invoke call sites.
…onfig
Replace the free-function helper with an LLMInvocationConfig value object
(field + ms->seconds property), composed onto the LLM / question-classifier /
parameter-extractor node data as an 'invocation' field — the same
composition idiom the codebase uses for ModelConfig / VisionConfig. Nodes
read self.node_data.invocation.first_token_timeout_seconds; no shared
function or mixin. DSL nests as 'invocation: { first_token_timeout: <ms> }'.
A negative value is a typo, not an intentional disable (0 is the disable sentinel). Without a bound it silently degrades to "no timeout" via the seconds property. Add ge=0 so the misconfiguration fails loudly at model validation instead.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an opt-in, per-node
first_token_timeoutand threads it through graphon's LLM invoke path. graphon carries the value from node config down to theLLMProtocolinvoke calls — it does not enforce the timeout. Enforcement (closing the connection when the first token is late, then raisingFirstTokenTimeoutError) is the host transport adapter's job (e.g. Dify'sModelInstance), delivered separately.How it fits together
flowchart LR subgraph G["graphon — this PR (carrier only)"] direction TB CFG["LLM-family node data:<br/>invocation.first_token_timeout (ms, 0 = disabled)"] NODE["LLM / QuestionClassifier /<br/>ParameterExtractor node"] PROTO["LLMProtocol.invoke_llm(<br/>..., first_token_timeout=...)"] CFG --> NODE --> PROTO end subgraph H["host adapter — e.g. Dify (separate, later phase)"] direction TB ADP["ModelInstance / DifyPreparedLLM"] GATE{"first token<br/>within timeout?"} ADP --> GATE GATE -->|"yes"| OK["stream normally"] GATE -->|"no"| ERR["raise FirstTokenTimeoutError"] end PROTO -->|"protocol call — contract boundary"| ADP ERR -.->|"bubbles up as node failure"| STRAT["node error-strategy:<br/>retry / fail-branch / default"]Changes
model_runtime/errors/invoke.pyFirstTokenTimeoutError(InvokeConnectionError)— canonical kernel type for hosts to raise/import. Defined here, not raised by graphon (carrier only).nodes/llm/entities.pyLLMInvocationConfigvalue object:first_token_timeout: int = Field(default=0, ge=0)(ms;0= disabled, negatives rejected at validation) +first_token_timeout_secondsproperty (maps0toNone). Composed onto the LLM / question-classifier / parameter-extractor node data as aninvocationfield — same idiom asModelConfig/VisionConfig.RetryConfig/base_node_data.pyare untouched — the deadline is a client-side invocation concept, not a retry one, and must not sit on the shared base that every node inherits.nodes/llm/runtime_protocols.pyfirst_token_timeout: float | None = NoneonLLMProtocol.invoke_llmandinvoke_llm_with_structured_output.nodes/llm/node.pyLLMNode.invoke_llmforwards the value;_invoke_llm_for_runreadsself.node_data.invocation.first_token_timeout_seconds(non-polling path).nodes/question_classifier/…,nodes/parameter_extractor/…LLMInvocationConfig; each node readsinvocation.first_token_timeout_secondsand passes it through.dsl/slim/llm.pySlimLLMaccepts the parameter for protocol conformance; does not enforce it (v1 standalone/DSL limitation).Compatibility & release ordering⚠️
Backward compatible at graphon's config / serialization layer: workflows saved before this field deserialize fine (
invocationdefaults to a config withfirst_token_timeout = 0= disabled; no migration).It is not transparent at the host call contract.
LLMNode.invoke_llmpassesfirst_token_timeout=unconditionally (even when unset, it passesNone). AnyLLMProtocolimplementation that lacks the parameter and lacks**kwargsraisesTypeErroron every LLM call — not only when a timeout is configured.LLMProtocolimpl**kwargsTypeErroron every LLM / QC / PE invokeOrdering requirement: the host adapter must accept
first_token_timeout(accept-and-ignore is enough) before the host bumps its graphon pin to a version containing this PR. graphon's own merge/release is independent — the only gate is the host's pin bump.flowchart LR A["host: adapter accepts kwarg<br/>(accept-and-ignore shim)"] --> B["host: bump graphon pin<br/>to a version with this PR"] B --> C["host: implement real enforcement<br/>+ raise FirstTokenTimeoutError"]Out of scope
Transport-level enforcement; the lower
LLMModelRuntimeprotocol (not on the node invoke path); the LLM polling branch (start_llm_pollinghas no such parameter — intentionally not threaded, pinned by a test).Tests
LLMInvocationConfigdefaults / ms→s conversion /0-disables / a negative value rejected at validation (ge=0), plus a guard asserting the three LLM-family node data classes compose aninvocationfield and that the deadline does not leak ontoRetryConfig/BaseNodeData; forwarding assertions covering both a configured value and the defaultNonefor the LLM, question-classifier, and parameter-extractor nodes (question-classifier and parameter-extractor drive it end-to-end throughmodel_validate({"invocation": {...}})); a test pinning that the polling path does not receive the parameter.just check(ruff + ty) and the full suite (618 tests) pass.