This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new df93716a6bc Say in the AI Model tab which values are kept in external
storage (#76747)
df93716a6bc is described below
commit df93716a6bcc8834dc1f44ef866ec440b124b1ba
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Mon Nov 9 19:07:47 2026 +0100
Say in the AI Model tab which values are kept in external storage (#76747)
* Say in the AI Model tab which values are kept in external storage
With an XCom backend that offloads values, the public XCom endpoint returns
the stored reference, so the tab showed the reference as the model name and
silently dropped usage and skills. Users could not tell an offloaded value from
a task that published nothing.
* Check that a value offloaded alone leaves the other values rendered
Drops the claim that a model name never holds a URI scheme: vLLM serves the
--model value as the name, so a model streamed from s3:// is listed as kept in
external storage. That case is accepted rather than tied to one backend's path
layout.
---
providers/common/ai/docs/observability.rst | 15 ++++-----
.../ai/plugins/www/src/components/ModelPanel.tsx | 30 ++++++++++++++---
.../ai/plugins/www/src/hooks/useModelInfo.ts | 14 ++++----
.../common/ai/plugins/www/src/model-api.ts | 35 +++++++++++--------
.../common/ai/plugins/www/tests/model-api.test.ts | 22 ++++++++++++
.../common/ai/plugins/www/tests/model.test.mjs | 39 ++++++++++++++++++++++
6 files changed, 123 insertions(+), 32 deletions(-)
diff --git a/providers/common/ai/docs/observability.rst
b/providers/common/ai/docs/observability.rst
index 29e2206198d..9673b5cb240 100644
--- a/providers/common/ai/docs/observability.rst
+++ b/providers/common/ai/docs/observability.rst
@@ -116,14 +116,13 @@ How it works
schema-compare subclasses (and their decorator forms) -- the match is on the
exact
operator name, so a subclass of your own does not get the tab automatically.
Like the
span-level cost above, the tab's number is a best-effort ``genai-prices``
estimate, not
- provider-reported billing. With a custom XCom backend that offloads large
values to external storage (e.g. ``common.io`` with
- ``xcom_objectstorage_threshold`` set low), the tab reads the stored
reference rather than
- the real value, since the public XCom endpoint it calls never runs the
backend's
- ``deserialize_value``: the model name badge would show the reference string
(e.g. an
- ``s3://...`` path) instead of the model, and ``usage`` would come back as
that same
- string rather than an object -- the tab treats a non-object ``usage`` as
absent and
- hides the stat boxes instead of rendering ``undefined`` in each one. The
skills XCom
- comes back as that string too, so the skills section is hidden.
+ provider-reported billing. With a custom XCom backend that keeps large
values in external
+ storage (e.g. ``common.io`` with ``xcom_objectstorage_threshold`` set low),
the tab cannot
+ show those values: the public XCom endpoint it calls returns the reference
the backend
+ stored in the database, since the API server never runs the backend's
+ ``deserialize_value``. The tab lists the model name, usage or skills kept
that way with
+ their reference (e.g. an ``s3://...`` path) instead of rendering them. A
model name that is
+ itself a URI, such as one vLLM serves from ``--model s3://...``, is listed
that way too.
* **Content is off by default.** Only token counts, model id, latency, tool
names, and finish reason are recorded. Prompt and completion text is never
emitted unless you opt in (see below).
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/plugins/www/src/components/ModelPanel.tsx
b/providers/common/ai/src/airflow/providers/common/ai/plugins/www/src/components/ModelPanel.tsx
index 8e195278224..ae26b128d43 100644
---
a/providers/common/ai/src/airflow/providers/common/ai/plugins/www/src/components/ModelPanel.tsx
+++
b/providers/common/ai/src/airflow/providers/common/ai/plugins/www/src/components/ModelPanel.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
-import { Badge, Box, Flex, HStack, Heading, SimpleGrid, Spinner, Text } from
"@chakra-ui/react";
+import { Badge, Box, Code, Flex, HStack, Heading, List, SimpleGrid, Spinner,
Text } from "@chakra-ui/react";
import type { FC, ReactNode } from "react";
import { LuInfo } from "react-icons/lu";
@@ -25,6 +25,7 @@ import { NoModelInfo } from "src/components/NoModelInfo";
import { SkillsSection } from "src/components/SkillsSection";
import { Tooltip } from "src/components/Tooltip";
import { useModelInfo } from "src/hooks/useModelInfo";
+import { isOffloaded } from "src/model-api";
import type { TaskRegion } from "src/region";
interface ModelPanelProps {
@@ -97,6 +98,12 @@ export const ModelPanel: FC<ModelPanelProps> = ({ dagId,
runId, taskId, mapIndex
return <NoModelInfo error={error} />;
}
+ const offloaded = [
+ { label: "Model name", value: modelName },
+ { label: "Usage", value: usage },
+ { label: "Skills", value: skills },
+ ].flatMap(({ label, value }) => (isOffloaded(value) ? [{ label, reference:
value.reference }] : []));
+
return (
<Box p={2}>
<Box borderBottomWidth="1px" mb={4} pb={4}>
@@ -126,13 +133,28 @@ export const ModelPanel: FC<ModelPanelProps> = ({ dagId,
runId, taskId, mapIndex
</Box>
)}
- {modelName && (
+ {offloaded.length > 0 && (
+ <Box bg="bg.subtle" borderRadius="lg" borderWidth="1px" fontSize="sm"
mb={4} p={3}>
+ <Text mb={1}>
+ The XCom backend keeps these values in external storage, which
this tab cannot read:
+ </Text>
+ <List.Root ps={5}>
+ {offloaded.map(({ label, reference }) => (
+ <List.Item key={label}>
+ {label}: <Code fontSize="xs">{reference}</Code>
+ </List.Item>
+ ))}
+ </List.Root>
+ </Box>
+ )}
+
+ {modelName && !isOffloaded(modelName) && (
<Badge borderRadius="full" colorPalette="brand" fontSize="sm" mb={5}
px={3} py={1}>
{modelName}
</Badge>
)}
- {usage && (
+ {usage && !isOffloaded(usage) && (
<SimpleGrid columns={2} gap={4} maxW="640px">
<StatBox label="Requests" value={usage.requests} />
<StatBox label="Tool calls" value={usage.tool_calls} />
@@ -143,7 +165,7 @@ export const ModelPanel: FC<ModelPanelProps> = ({ dagId,
runId, taskId, mapIndex
</SimpleGrid>
)}
- {skills && <SkillsSection skills={skills} />}
+ {skills && !isOffloaded(skills) && <SkillsSection skills={skills} />}
</Box>
);
};
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/plugins/www/src/hooks/useModelInfo.ts
b/providers/common/ai/src/airflow/providers/common/ai/plugins/www/src/hooks/useModelInfo.ts
index b3a2fdafa29..e197b8956b5 100644
---
a/providers/common/ai/src/airflow/providers/common/ai/plugins/www/src/hooks/useModelInfo.ts
+++
b/providers/common/ai/src/airflow/providers/common/ai/plugins/www/src/hooks/useModelInfo.ts
@@ -19,14 +19,14 @@
import { useEffect, useState } from "react";
-import { createModelApi } from "src/model-api";
+import { createModelApi, type OffloadedValue } from "src/model-api";
import { regionOf, type TaskRegion } from "src/region";
import type { SkillInfo, UsageInfo } from "src/types/model";
interface UseModelInfoReturn {
- modelName: string | null;
- usage: UsageInfo | null;
- skills: SkillInfo[] | null;
+ modelName: string | OffloadedValue | null;
+ usage: UsageInfo | OffloadedValue | null;
+ skills: SkillInfo[] | OffloadedValue | null;
loading: boolean;
error: string | null;
}
@@ -43,9 +43,9 @@ export function useModelInfo(
mapIndex: number,
region?: TaskRegion,
): UseModelInfoReturn {
- const [modelName, setModelName] = useState<string | null>(null);
- const [usage, setUsage] = useState<UsageInfo | null>(null);
- const [skills, setSkills] = useState<SkillInfo[] | null>(null);
+ const [modelName, setModelName] = useState<string | OffloadedValue |
null>(null);
+ const [usage, setUsage] = useState<UsageInfo | OffloadedValue | null>(null);
+ const [skills, setSkills] = useState<SkillInfo[] | OffloadedValue |
null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/plugins/www/src/model-api.ts
b/providers/common/ai/src/airflow/providers/common/ai/plugins/www/src/model-api.ts
index d5046717cf1..8b4a96917a6 100644
---
a/providers/common/ai/src/airflow/providers/common/ai/plugins/www/src/model-api.ts
+++
b/providers/common/ai/src/airflow/providers/common/ai/plugins/www/src/model-api.ts
@@ -67,8 +67,17 @@ async function fetchXComValue<T>(
return data.value;
}
-function isUsageInfo(value: unknown): value is UsageInfo {
- return typeof value === "object" && value !== null;
+/**
+ * A value the XCom backend kept in external storage. The public XCom endpoint
never runs the
+ * backend's deserialize_value, so it returns the reference the backend stored
in its place
+ * (e.g. an ``s3://...`` path from common.io with a low
xcom_objectstorage_threshold).
+ */
+export interface OffloadedValue {
+ reference: string;
+}
+
+export function isOffloaded(value: unknown): value is OffloadedValue {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
&& "reference" in value;
}
export function createModelApi(
@@ -79,20 +88,20 @@ export function createModelApi(
region?: TaskRegion,
) {
return {
- fetchModelName: () =>
- fetchXComValue<string>(dagId, runId, taskId, mapIndex,
MODEL_NAME_XCOM_KEY, region),
- fetchUsage: async () => {
+ fetchModelName: async (): Promise<string | OffloadedValue | null> => {
+ const value = await fetchXComValue<string>(dagId, runId, taskId,
mapIndex, MODEL_NAME_XCOM_KEY, region);
+ // A reference is a string too. A model name rarely holds a URI scheme,
but can: vLLM serves
+ // ``--model s3://...`` as the name, which this reports as a reference.
+ return typeof value === "string" && value.includes("://") ? { reference:
value } : value;
+ },
+ fetchUsage: async (): Promise<UsageInfo | OffloadedValue | null> => {
const value = await fetchXComValue<unknown>(dagId, runId, taskId,
mapIndex, "usage", region);
- // A reference-storing XCom backend (e.g. common.io with a low
- // xcom_objectstorage_threshold) returns the stored reference string
here instead of
- // the real value, since this public endpoint never runs the backend's
- // deserialize_value. Treat anything that isn't a plain object as absent
rather than
- // let every stat box below render "undefined".
- return isUsageInfo(value) ? value : null;
+ if (typeof value === "string") return { reference: value };
+ return typeof value === "object" && value !== null ? (value as
UsageInfo) : null;
},
- fetchSkills: async () => {
+ fetchSkills: async (): Promise<SkillInfo[] | OffloadedValue | null> => {
const value = await fetchXComValue<unknown>(dagId, runId, taskId,
mapIndex, SKILLS_XCOM_KEY, region);
- // Same reference-storing backend caveat as `usage` above.
+ if (typeof value === "string") return { reference: value };
return Array.isArray(value) ? (value as SkillInfo[]) : null;
},
};
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/plugins/www/tests/model-api.test.ts
b/providers/common/ai/src/airflow/providers/common/ai/plugins/www/tests/model-api.test.ts
index b9602343940..9ebff936fbf 100644
---
a/providers/common/ai/src/airflow/providers/common/ai/plugins/www/tests/model-api.test.ts
+++
b/providers/common/ai/src/airflow/providers/common/ai/plugins/www/tests/model-api.test.ts
@@ -66,3 +66,25 @@ test("a task outside a loop keeps the map-only XCom read",
async () => {
assert.equal(url.searchParams.has("region_index"), false);
}
});
+
+function serveXComValue(value: unknown) {
+ mock.method(globalThis, "fetch", async () => new Response(JSON.stringify({
value }), { status: 200 }));
+}
+
+test("values kept in external storage come back as their reference", async ()
=> {
+ const reference = "s3://aws_default@bucket/xcom/dag/run/agent/0d6e.json";
+ serveXComValue(reference);
+ const api = createModelApi("dag", "run", "agent", -1);
+
+ assert.deepEqual(await Promise.all([api.fetchModelName(), api.fetchUsage(),
api.fetchSkills()]), [
+ { reference },
+ { reference },
+ { reference },
+ ]);
+});
+
+test("a model name stored in the database comes back as is", async () => {
+ serveXComValue("anthropic:claude-sonnet-4-6");
+
+ assert.equal(await createModelApi("dag", "run", "agent",
-1).fetchModelName(), "anthropic:claude-sonnet-4-6");
+});
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/plugins/www/tests/model.test.mjs
b/providers/common/ai/src/airflow/providers/common/ai/plugins/www/tests/model.test.mjs
index 29702020ed0..5b564287bfb 100644
---
a/providers/common/ai/src/airflow/providers/common/ai/plugins/www/tests/model.test.mjs
+++
b/providers/common/ai/src/airflow/providers/common/ai/plugins/www/tests/model.test.mjs
@@ -195,3 +195,42 @@ test("a skill badge is a button, so its details are
reachable from the keyboard"
const badge = document.querySelector('button[aria-label="About the
sql-review skill"]');
assert.equal(badge?.textContent, "sql-review");
});
+
+test("values kept in external storage are listed by their reference instead of
rendered", async (t) => {
+ const reference =
"s3://aws_default@bucket/xcom/dag/run/refine.agent/0d6e.json";
+ mock.method(globalThis, "fetch", async () => new Response(JSON.stringify({
value: reference }), { status: 200 }));
+ const plugin = mountPlugin(t);
+
+ await plugin.render(taskProps);
+
+ assert.deepEqual(
+ [...document.querySelectorAll("li")].map((item) =>
[item.firstChild.textContent, item.querySelector("code").textContent]),
+ [
+ ["Model name", reference],
+ ["Usage", reference],
+ ["Skills", reference],
+ ],
+ );
+ assert.doesNotMatch(plugin.text(), /Requests|No AI Model Info|No skills
found/);
+});
+
+test("a value kept in external storage leaves the values stored in the
database rendered", async (t) => {
+ const reference =
"s3://aws_default@bucket/xcom/dag/run/refine.agent/ti-0d6e/__AIRFLOW__COMMON_AI_SKILLS__.json";
+ mock.method(globalThis, "fetch", async (input) => {
+ const key = new URL(input, "http://localhost").pathname.split("/").at(-1);
+ const value = key === "__AIRFLOW__COMMON_AI_SKILLS__" ? reference : key
=== "usage" ? { requests: 3 } : "model";
+ return new Response(JSON.stringify({ value }), { status: 200 });
+ });
+ const plugin = mountPlugin(t);
+
+ await plugin.render(taskProps);
+
+ assert.deepEqual(
+ [...document.querySelectorAll("li")].map((item) =>
[item.firstChild.textContent, item.querySelector("code").textContent]),
+ [["Skills", reference]],
+ );
+ const paragraphs = [...document.querySelectorAll("p")].map((paragraph) =>
paragraph.textContent);
+ assert.equal(paragraphs[paragraphs.indexOf("Requests") + 1], "3");
+ assert.equal(document.querySelector(".chakra-badge")?.textContent, "model");
+ assert.equal(document.querySelector('[data-testid="skills-section"]'), null);
+});