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);
+});

Reply via email to