Skip to content

Commit a08add5

Browse files
rafaelgilnRafaelclaude
authored
docs(model-provider): record the cross-provider model substitution as LE-2156 (#1372) (#1374)
A flow run can execute a model from a different provider than the node selects, and neither the database nor the widget predicts it. This lands the evidence, the ledger row and the warning for the next reader. It changes no test, on purpose — see the last section. ## What was established `POST /api/v2/workflows` carries a `data` field that `WorkflowRunRequest` declares as an "Optional live-canvas override of the flow's nodes/edges; takes priority over the saved flow data", and a capture of a HEALTHY Playground run confirms the frontend always sends it. So the backend builds the canvas, not the row, and `GET /api/v1/flows/{id}` is not a weak observable for the executed model — it is the wrong object. The runtime does not substitute either: `get_llm` raises "A model selection is required" on an empty list and "The selected model is missing a provider" on a blank provider. What substitutes is the editor build config, where an EMPTY `ModelInput` value is filled with `options[0]` — and `options` is a FLAT list across every enabled provider, so the fill need not be the node's own provider. Measured through `POST /api/v1/custom_component/update` on 1.12.0.dev19, an OpenAI-Compatible node with an empty value came back `claude-opus-5` / Anthropic. It fails loudly in `openai-compatible-provider-setup.spec.ts` only because that provider's endpoint-derived default set starts with completions-only ids (`404 ... This is not a chat model`). On 6 of the 8 providers measured, `options[0]` is a working chat model, so the same substitution produces a PASSING run against a model nobody selected, on a provider nobody selected, billed to that provider's account. ## What was refuted, and why that is the load-bearing half Two backend explanations were tested and both died, which is what narrows the trigger to editor state and saves upstream the same investigation: - an INVALID selection is not reset — a deliberately impossible model (`definitely-not-a-model` / `Nope`) is preserved, so the `[options[0]]` branch does not fire for out-of-catalog values; - an empty `options` list does not wipe a real selection — with every provider credential deleted, `options` comes back as 1, not 0, because the selection is injected into the options. What EMPTIES the field on a node that had a selection is still unknown, and the report says so rather than implying a complete chain. ## Honesty about the rate The pre-fix baseline over 12 runs of the untouched spec reproduced the symptom ZERO times. The runner reported "3/12 failed (25%)", but two of those carry `unexpected=0` — no test failed, they were counted because a backend error was logged — and the single genuine failure carries `TimeoutError: page.waitForResponse: Timeout 60000ms exceeded`, a different symptom. The finding rests on the deterministic experiment, not on frequency. ## Why no spec changed Strengthening the existing persisted-binding assertion would be engineering a spec to pass against an observable that structurally cannot see the defect. A re-selection repair loop was already written, measured against two real occurrences and removed for exactly that reason (#1369). The spec doc now states that the pre-send re-read is attribution and must NOT grow into a gate, so the next reader does not spend a cycle re-strengthening the wrong object; a guard that genuinely covers this belongs on the run request and is tracked separately. Refs #1372 — deliberately NOT "Closes": the issue stays open per its own deliverable, pending the trigger and the upstream fix landing in the nightly. Co-authored-by: Rafael <rafael@oriontech.me> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e0ed7a5 commit a08add5

3 files changed

Lines changed: 197 additions & 3 deletions

File tree

REGRESSIONS.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,18 @@ issue that carries the evidence. Both shapes are allowed; what is not allowed is
3636
a reference that does not resolve.
3737

3838
<!-- REGRESSIONS:START -->
39-
**Regressions caught:** 8**Open:** 2 · **Fixed:** 6
39+
**Regressions caught:** 9**Open:** 3 · **Fixed:** 6
4040

41-
**By severity:** High 2 · Medium 6 · Low 0
41+
**By severity:** High 3 · Medium 6 · Low 0
4242

43-
**By area:** model-provider 2 · api 1 · auth 1 · core-components 1 · flows 1 · mcp 1 · ui-ux 1
43+
**By area:** model-provider 3 · api 1 · auth 1 · core-components 1 · flows 1 · mcp 1 · ui-ux 1
4444
<!-- REGRESSIONS:END -->
4545

4646
## Ledger
4747

4848
| Found | Area / Test | Regression | Severity | Detected by | Upstream | Status | Fixed in | Report |
4949
|-------|-------------|------------|----------|-------------|----------|--------|----------|--------|
50+
| 2026-08-07 | model-provider · openai-compatible-provider-setup.spec.ts | A flow run can execute a model from a **different provider than the node selects**. An empty `ModelInput` value is filled by `update_build_config` with `options[0]`, and `options` is a **flat list across every enabled provider** (`get_language_model_options`), so the fill is the first default-enabled model of the first *configured* provider — not of the node's own. `POST /api/v2/workflows` then carries the live-canvas `data`, declared in `WorkflowRunRequest` as taking **priority over the saved flow data**, so the fill is what runs while the persisted flow still holds the correct selection. Measured through `POST /api/v1/custom_component/update` on 1.12.0.dev19: an OpenAI-Compatible node with an empty value came back `claude-opus-5` / **Anthropic**. Loud here only because that provider's endpoint-derived default set starts with completions-only ids (`404 … not a chat model`); `options[0]` is a working chat model for Anthropic, Google, OpenAI, Azure AI Foundry, Ollama and OpenRouter — **6 of 8** — where the same substitution runs green on a provider nobody selected. Two backend explanations were tested and **refuted** (an invalid selection is preserved; an empty `options` list does not wipe it, because the selection is injected into the options), and the run path is exonerated (`get_llm` raises rather than defaulting), so the trigger that empties the field is editor state and remains open | High | #1334 spec validation (PR #1369) · #1372 | [LE-2156](https://datastax.jira.com/browse/LE-2156) | Open | — | docs/upstream-bugs/UPSTREAM-BUG-model-input-cross-provider-default-fill.md |
5051
| 2026-08-04 | ui-ux · global-variable-edit.spec.ts | Global Variables grid silently drops row interactions while the RBAC permission query (`POST /api/v1/authz/me/permissions`) is loading — a row click never opens the Update Variable modal and checkbox ticking selects nothing, so the delete button stays disabled, with no spinner or feedback of any kind. Normally a 1–2 s window, but one transient network/5xx failure on the first call holds the gate through the query wrapper's 5× exponential-backoff retry ladder: ~31 s of dropped input, measured to the decimal (retries at 0.8/1.8/3.8/7.8/15.8/31.8 s under an injected 503). Introduced by the release-1.12 RBAC gate (langflow#14215, `2e677bf843`). The gate blocks checkbox selection too (proven by the 503 injection), but this row rests on `global-variable-edit.spec.ts` alone: the recorded flake of `remove-provider-api-key.spec.ts:17` is NOT evidence of it — the same signature reproduces at ~75 % on a healthy 1.12.0 with the gate open, from a test defect (#1235) | Medium | daily 07-27 + 08-03 · #1231 → #1235 | [LE-2123](https://datastax.jira.com/browse/LE-2123) | Open | — | docs/upstream-bugs/UPSTREAM-BUG-global-variables-permission-gate-dead-window.md |
5152
| 2026-07-28 | core-components · nested-grouping-regression.spec.ts | Grouping two connected non-IO components raises a false `Error while updating the Component` notification although the grouping fully succeeds — the `PATCH /api/v1/flows/{id}` that persists the grouped shape returns `200`, the console logs no error and no request fails, yet the message persists in the Notifications panel until dismissed by hand. Deterministic; reproduced independently in a manual browser session | Medium | #942 spec validation | [LE-2045](https://datastax.jira.com/browse/LE-2045) | Fixed | [langflow#14314](https://github.com/langflow-ai/langflow/pull/14314) | docs/upstream-bugs/UPSTREAM-BUG-group-cosmetic-error-toast.md |
5253
| 2026-07-27 | api · api-folders-crud.spec.ts | `DELETE /api/v1/projects/{id}` answers `500` (`sqlite3.OperationalError: database is locked`) instead of `204` while any other write is in flight, and the project survives. Not new — stable 1.10.3 emits the same instant `500` — but 1.12 raises the rate ~7× (6 % → 44 % at 2 concurrent clients, A/B/A/B) and flips the mode: 1.10.3 blocks and mostly honours the contract, 1.12 gives up in 0.03 s. Sibling write endpoints (`POST /projects`, `POST /flows`, `DELETE /flows`) survive the identical contention | Medium | daily 07-22 + 07-27 · #962#965 | [LE-2020](https://datastax.jira.com/browse/LE-2020) | Open || docs/upstream-bugs/UPSTREAM-BUG-project-delete-500-under-contention.md |

docs/core-functionality/model-provider/openai-compatible-provider-setup.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,38 @@ it is how a spec passes on one instance and dies on a clean one. Test 5 therefor
164164
`POST /api/v1/models/enabled_models` — the endpoint the **Azure AI Foundry sibling in this
165165
same folder already uses** — and disables it again in cleanup.
166166

167+
### The persisted binding cannot predict the executed model — do not strengthen it (#1372, LE-2156)
168+
169+
Test 5's pre-send re-read of `GET /api/v1/flows/{id}` is **attribution, never repair**, and
170+
the reason is structural rather than a matter of tuning. `POST /api/v2/workflows` — the run
171+
the Playground issues — carries a `data` field that `WorkflowRunRequest` declares as an
172+
*"Optional live-canvas override of the flow's nodes/edges; **takes priority over the saved
173+
flow data**"*, and a capture of a **healthy** run confirms the frontend always sends it.
174+
The backend therefore builds the canvas, not the row. `GET /api/v1/flows/{id}` is not a
175+
weak observable here; it is the **wrong object**, and no amount of polling it harder can
176+
close the gap.
177+
178+
Measured consequence: on 2 of 12 full-file runs the run answered
179+
`404 … This is not a chat model` while the persisted read at the last influenceable
180+
instant — after the Playground modal opened, immediately before `button-send` — returned
181+
`{models: ["gpt-4o-mini"], providers: ["OpenAI Compatible"]}` and the widget agreed. A
182+
re-selection repair loop was written, measured against exactly that, and **removed**:
183+
re-selecting cannot fix a state that is already correct.
184+
185+
The mechanism is upstream (`LE-2156`, full report in
186+
`docs/upstream-bugs/UPSTREAM-BUG-model-input-cross-provider-default-fill.md`): an **empty**
187+
`ModelInput` value is filled with `options[0]`, and `options` is a **flat list across every
188+
enabled provider**, so the fill need not even be this provider — an OpenAI-Compatible node
189+
came back `claude-opus-5` / **Anthropic**. It fails loudly here only because this
190+
provider's endpoint-derived default set starts with completions-only ids; on 6 of the 8
191+
providers measured, `options[0]` is a working chat model and the same substitution runs
192+
**green** against a model nobody selected.
193+
194+
So the only observable that predicts the executed model is the `data` payload of the run
195+
request. Two things follow for anyone editing this spec: the pre-send read stays as an
196+
attribution line and must not grow into a gate that pretends to prevent this, and a future
197+
guard that genuinely covers it belongs on the run request, not on the flow row.
198+
167199
---
168200

169201
## Tags *(required)*
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# A flow run can execute a model from a different provider than the node selects
2+
3+
**Upstream:** [LE-2156](https://datastax.jira.com/browse/LE-2156)
4+
**Found:** 2026-08-07 · **Measured on:** `1.12.0.dev19` (`langflowai/langflow-nightly`)
5+
**Detected by:** `#1334` spec validation (PR #1369) → dedicated issue `#1372`
6+
**Status:** Open
7+
8+
---
9+
10+
## Summary
11+
12+
When a `ModelInput` field's value is empty, `update_build_config` fills it with
13+
`options[0]`. `options` is a **flat list across every enabled provider**, so the
14+
model filled in is the first default-enabled model of the **first configured
15+
provider** — frequently not the provider the node is configured for.
16+
17+
Because `POST /api/v2/workflows` carries the live-canvas `data` override, which
18+
by its own schema *"takes priority over the saved flow data"*, whatever the
19+
editor filled in is what actually runs. The persisted flow can still hold the
20+
correct selection at that moment.
21+
22+
**Net effect:** a flow can execute a model the persisted flow does not name,
23+
from a provider the user never selected, with no error.
24+
25+
## How it surfaced here
26+
27+
`openai-compatible-provider-setup.spec.ts` failed **2 in 12** full-file runs
28+
with:
29+
30+
```text
31+
Error code: 404 - {'error': {'message': 'This is not a chat model and thus not
32+
supported in the v1/chat/completions endpoint. Did you mean to use
33+
v1/completions?', 'type': 'invalid_request_error', 'param': 'model'}}
34+
```
35+
36+
`gpt-4o-mini` **is** a chat model. The only ids that provider offers which
37+
produce that error are its completions-only ones (`babbage-002` / `davinci-002`)
38+
— the first entries of its default-enabled set.
39+
40+
## Reproduction (deterministic)
41+
42+
Against `POST /api/v1/custom_component/update` — the endpoint the editor itself
43+
uses — on an instance with Anthropic, Google Generative AI and OpenAI
44+
configured, with a `LanguageModelComponent` node and `field: "model"`:
45+
46+
| `field_value` sent | returned `model.value` | verdict |
47+
|---|---|---|
48+
| `[{name: "gpt-4o-mini", provider: "OpenAI Compatible"}]` | unchanged | preserved |
49+
| `[{name: "definitely-not-a-model", provider: "Nope"}]` | unchanged | preserved |
50+
| `[{name: "gpt-4o-mini", provider: "OpenAI"}]` | unchanged | preserved |
51+
| `[]` | `[{name: "claude-opus-5", provider: "Anthropic", …}]` | **replaced** |
52+
53+
The last row is the defect: the node's own provider is irrelevant to what it is
54+
filled with. `options[0]` is Anthropic's first default-enabled model purely
55+
because Anthropic comes first in the enabled-provider iteration order.
56+
57+
## Why it is usually silent — the reason this matters beyond one 404
58+
59+
The OpenAI-Compatible case fails **loudly** only because that provider's
60+
default-enabled set is derived from the endpoint's own `GET <base_url>/v1/models`
61+
and starts with completions-only ids. Measured on `1.12.0.dev19`, every other
62+
provider's `options[0]` is a working chat model:
63+
64+
| Provider | `options[0]` |
65+
|---|---|
66+
| Anthropic | `claude-opus-5` |
67+
| Google Generative AI | `gemini-3.5-flash-lite` |
68+
| OpenAI | `gpt-5.6-sol` |
69+
| Azure AI Foundry | `gpt-4o` |
70+
| Ollama | `llama3.3` |
71+
| OpenRouter | `anthropic/claude-opus-4.7` |
72+
73+
**6 of 8.** In those cases the substitution produces a *passing* run against a
74+
model nobody selected, on a provider nobody selected, billed to that provider's
75+
account — the #1169 silent-substitution class, one layer deeper.
76+
77+
## Source
78+
79+
`lfx/base/models/unified_models/build_config.py`:
80+
81+
```python
82+
build_config[model_field_name]["value"] = (
83+
field_value if value_is_valid else [options[0]] if options else "")
84+
```
85+
86+
`lfx/base/models/unified_models/model_catalog.py``get_language_model_options`
87+
accumulates **one flat list** across providers:
88+
89+
```python
90+
for provider_data in all_models:
91+
if provider not in enabled_providers:
92+
continue
93+
for model_data in models:
94+
... # appended to the same `options`
95+
```
96+
97+
## What is NOT the cause — refuted by experiment, not by argument
98+
99+
What *empties* the field on a node that had a selection is **not established**.
100+
Two candidate backend paths were tested and both died:
101+
102+
1. **Not an invalid-selection reset.** A deliberately impossible model
103+
(`definitely-not-a-model` / `Nope`) is **preserved**, so the `[options[0]]`
104+
branch does not fire for out-of-catalog values.
105+
2. **Not an empty `options` list wiping the value.** With every provider
106+
credential deleted, `options` comes back as **1**, not 0 — the current
107+
selection is injected into the options — and the value is **preserved**.
108+
109+
Both are backend paths, so the trigger looks like **editor/frontend state** that
110+
never round-trips through `update_build_config`.
111+
112+
The **run path is not responsible** either: `get_llm`
113+
(`lfx/base/models/unified_models/instantiation.py`) raises
114+
`"A model selection is required"` on an empty list and `"The selected model is
115+
missing a provider"` on a blank provider. It never falls back to a default.
116+
117+
## The observable, and why the obvious one cannot work
118+
119+
`POST /api/v2/workflows` sends the canvas. Captured on a **healthy** run — no
120+
flake needed:
121+
122+
```text
123+
keys = flow_id, input_value, mode, stream_protocol, session_id, start_component_id, data
124+
data = PRESENT data.nodes = 6
125+
node LanguageModelComponent-FLeYF
126+
model.value = [{"name":"claude-opus-5","provider":"Anthropic",...,"default":true}]
127+
```
128+
129+
`lfx/schema/workflow.py`, `WorkflowRunRequest`:
130+
131+
> `data`*"Optional live-canvas override of the flow's nodes/edges; takes
132+
> priority over the saved flow data."*
133+
134+
So `GET /api/v1/flows/{id}` is **structurally incapable** of predicting the
135+
executed model. That is not a weak observable — it is the wrong object, and no
136+
amount of strengthening the persisted-binding poll could have caught this. The
137+
only observable that predicts the run is the `data` payload of the run request.
138+
139+
This is why `openai-compatible-provider-setup.spec.ts` keeps its pre-send
140+
re-read as **attribution, not repair** (#1369): a re-selection loop was written,
141+
measured against this, and removed — re-selecting cannot fix a state that is
142+
already correct.
143+
144+
## Honesty about the rate
145+
146+
The pre-fix baseline over 12 runs of the untouched spec reproduced the 404
147+
**zero** times. The runner reported *"3/12 failed (25 %)"*, but two of those
148+
three carry `unexpected=0` — no test failed at all, they were classified as
149+
failures because a backend error was logged — and the single genuine failure
150+
carries `TimeoutError: page.waitForResponse: Timeout 60000ms exceeded`, a
151+
different symptom.
152+
153+
The finding therefore rests on the deterministic experiment above, not on
154+
frequency.
155+
156+
## Suggested fix direction
157+
158+
The fill is reasonable on a genuinely new node; crossing providers is not.
159+
Scoping the default to the node's current provider — or refusing to fill when
160+
the node already carries a provider identity — turns a silent cross-provider
161+
substitution into either the right model or a visible error.

0 commit comments

Comments
 (0)