Skip to content

Commit 88347f6

Browse files
authored
Python: Update hosting agent samples + fixes (#5485)
* Update foundry hosting samples * Add file data type support * Fix file content and add more tests * Fix README * Address comments * Fix int tests * remove temp
1 parent 9b22ecd commit 88347f6

63 files changed

Lines changed: 1459 additions & 278 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/python-integration-tests.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,53 @@ jobs:
336336
path: ./python/pytest.xml
337337
if-no-files-found: ignore
338338

339+
# Foundry Hosting integration tests
340+
python-tests-foundry-hosting:
341+
name: Python Integration Tests - Foundry Hosting
342+
runs-on: ubuntu-latest
343+
environment: integration
344+
timeout-minutes: 60
345+
env:
346+
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
347+
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
348+
defaults:
349+
run:
350+
working-directory: python
351+
steps:
352+
- uses: actions/checkout@v6
353+
with:
354+
ref: ${{ inputs.checkout-ref }}
355+
persist-credentials: false
356+
- name: Set up python and install the project
357+
id: python-setup
358+
uses: ./.github/actions/python-setup
359+
with:
360+
python-version: ${{ env.UV_PYTHON }}
361+
os: ${{ runner.os }}
362+
- name: Azure CLI Login
363+
uses: azure/login@v2
364+
with:
365+
client-id: ${{ secrets.AZURE_CLIENT_ID }}
366+
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
367+
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
368+
- name: Test with pytest (Foundry Hosting integration)
369+
timeout-minutes: 15
370+
run: >
371+
uv run pytest --import-mode=importlib
372+
packages/foundry_hosting/tests
373+
-m integration
374+
-n logical --dist worksteal
375+
--timeout=120 --session-timeout=900 --timeout_method thread
376+
--retries 2 --retry-delay 5
377+
--junitxml=pytest.xml
378+
- name: Upload test results
379+
if: always()
380+
uses: actions/upload-artifact@v7
381+
with:
382+
name: test-results-foundry-hosting
383+
path: ./python/pytest.xml
384+
if-no-files-found: ignore
385+
339386
# Azure Cosmos integration tests
340387
python-tests-cosmos:
341388
name: Python Integration Tests - Cosmos
@@ -402,6 +449,7 @@ jobs:
402449
python-tests-misc-integration,
403450
python-tests-functions,
404451
python-tests-foundry,
452+
python-tests-foundry-hosting,
405453
python-tests-cosmos,
406454
]
407455
runs-on: ubuntu-latest
@@ -465,6 +513,7 @@ jobs:
465513
python-tests-misc-integration,
466514
python-tests-functions,
467515
python-tests-foundry,
516+
python-tests-foundry-hosting,
468517
python-tests-cosmos
469518
]
470519
steps:

.github/workflows/python-merge-tests.yml

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ jobs:
3838
miscChanged: ${{ steps.filter.outputs.misc }}
3939
functionsChanged: ${{ steps.filter.outputs.functions }}
4040
foundryChanged: ${{ steps.filter.outputs.foundry }}
41+
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
4142
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
4243
steps:
4344
- uses: actions/checkout@v6
@@ -80,6 +81,8 @@ jobs:
8081
- 'python/packages/foundry/**'
8182
- 'python/samples/**/providers/foundry/**'
8283
- 'python/samples/02-agents/embeddings/foundry_embeddings.py'
84+
foundry_hosting:
85+
- 'python/packages/foundry_hosting/**'
8386
cosmos:
8487
- 'python/packages/azure-cosmos/**'
8588
# run only if 'python' files were changed
@@ -488,6 +491,67 @@ jobs:
488491
path: ./python/pytest.xml
489492
if-no-files-found: ignore
490493

494+
# Foundry Hosting integration tests
495+
python-tests-foundry-hosting:
496+
name: Python Tests - Foundry Hosting Integration
497+
needs: paths-filter
498+
if: >
499+
github.event_name != 'pull_request' &&
500+
needs.paths-filter.outputs.pythonChanges == 'true' &&
501+
(github.event_name != 'merge_group' ||
502+
needs.paths-filter.outputs.foundryHostingChanged == 'true' ||
503+
needs.paths-filter.outputs.coreChanged == 'true')
504+
runs-on: ubuntu-latest
505+
environment: integration
506+
env:
507+
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
508+
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
509+
defaults:
510+
run:
511+
working-directory: python
512+
steps:
513+
- uses: actions/checkout@v6
514+
- name: Set up python and install the project
515+
id: python-setup
516+
uses: ./.github/actions/python-setup
517+
with:
518+
python-version: ${{ env.UV_PYTHON }}
519+
os: ${{ runner.os }}
520+
- name: Azure CLI Login
521+
if: github.event_name != 'pull_request'
522+
uses: azure/login@v2
523+
with:
524+
client-id: ${{ secrets.AZURE_CLIENT_ID }}
525+
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
526+
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
527+
- name: Test with pytest (Foundry Hosting integration)
528+
timeout-minutes: 15
529+
run: >
530+
uv run pytest --import-mode=importlib
531+
packages/foundry_hosting/tests
532+
-m integration
533+
-n logical --dist worksteal
534+
--timeout=120 --session-timeout=900 --timeout_method thread
535+
--retries 2 --retry-delay 5
536+
--junitxml=pytest.xml
537+
working-directory: ./python
538+
- name: Surface failing tests
539+
if: always()
540+
uses: pmeier/pytest-results-action@v0.7.2
541+
with:
542+
path: ./python/pytest.xml
543+
summary: true
544+
display-options: fEX
545+
fail-on-empty: false
546+
title: Foundry Hosting integration test results
547+
- name: Upload test results
548+
if: always()
549+
uses: actions/upload-artifact@v7
550+
with:
551+
name: test-results-foundry-hosting
552+
path: ./python/pytest.xml
553+
if-no-files-found: ignore
554+
491555
# TODO: Add python-tests-lab
492556

493557
# Azure Cosmos integration tests
@@ -569,6 +633,7 @@ jobs:
569633
python-tests-misc-integration,
570634
python-tests-functions,
571635
python-tests-foundry,
636+
python-tests-foundry-hosting,
572637
python-tests-cosmos,
573638
]
574639
runs-on: ubuntu-latest
@@ -629,6 +694,7 @@ jobs:
629694
python-tests-misc-integration,
630695
python-tests-functions,
631696
python-tests-foundry,
697+
python-tests-foundry-hosting,
632698
python-tests-cosmos,
633699
]
634700
steps:

python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import base64
67
import json
78
import logging
89
import os
@@ -1075,6 +1076,31 @@ def _convert_output_message_content(content: OutputMessageContent) -> Content:
10751076
raise ValueError(f"Unsupported OutputMessageContent type: {content.type}")
10761077

10771078

1079+
def _convert_file_data(data_uri: str, filename: str | None = None) -> Content:
1080+
"""Convert a file_data data URI to a Content object.
1081+
1082+
For text/* MIME types, decodes the base64 content and returns it as text.
1083+
For other types, returns a URI-based Content with the filename preserved.
1084+
"""
1085+
# Parse data URI: data:<media_type>;base64,<data>
1086+
if data_uri.startswith("data:") and ";base64," in data_uri:
1087+
header, encoded = data_uri.split(";base64,", 1)
1088+
media_type = header[len("data:") :]
1089+
if media_type.startswith("text/"):
1090+
try:
1091+
decoded_text = base64.b64decode(encoded).decode("utf-8")
1092+
except (ValueError, UnicodeDecodeError):
1093+
logger.warning(
1094+
"Failed to decode text/* file_data as UTF-8, falling through to URI passthrough.",
1095+
exc_info=True,
1096+
)
1097+
else:
1098+
prefix = f"[File: {filename}]\n" if filename else ""
1099+
return Content.from_text(f"{prefix}{decoded_text}")
1100+
additional_properties = {"filename": filename} if filename else None
1101+
return Content.from_uri(data_uri, additional_properties=additional_properties)
1102+
1103+
10781104
def _convert_message_content(content: MessageContent) -> Content:
10791105
"""Converts a MessageContent to a Content object.
10801106
@@ -1108,7 +1134,9 @@ def _convert_message_content(content: MessageContent) -> Content:
11081134
if content.type == "input_image":
11091135
image = cast(MessageContentInputImageContent, content)
11101136
if image.image_url:
1111-
return Content.from_uri(image.image_url)
1137+
if image.image_url.startswith("data:"):
1138+
return Content.from_uri(image.image_url)
1139+
return Content.from_uri(image.image_url, media_type="image/*")
11121140
if image.file_id:
11131141
return Content.from_hosted_file(image.file_id)
11141142
if content.type == "input_file":
@@ -1117,6 +1145,8 @@ def _convert_message_content(content: MessageContent) -> Content:
11171145
return Content.from_uri(file.file_url)
11181146
if file.file_id:
11191147
return Content.from_hosted_file(file.file_id, name=file.filename)
1148+
if file.file_data:
1149+
return _convert_file_data(file.file_data, file.filename)
11201150
if content.type == "computer_screenshot":
11211151
screenshot = cast(ComputerScreenshotContent, content)
11221152
return Content.from_uri(screenshot.image_url)
11.6 KB
Binary file not shown.
69.7 KB
Loading

python/packages/foundry_hosting/tests/test_responses.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1507,6 +1507,121 @@ async def test_text_and_file_input_single_turn(self) -> None:
15071507
assert messages[0].contents[1].type == "uri"
15081508
assert messages[0].contents[1].uri == "https://example.com/doc.pdf"
15091509

1510+
async def test_text_and_file_data_input_single_turn(self) -> None:
1511+
"""Agent receives a message with text and file content via inline file_data."""
1512+
agent = _make_agent(
1513+
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("File received")])])
1514+
)
1515+
server = _make_server(agent)
1516+
1517+
resp = await _post_json(
1518+
server,
1519+
{
1520+
"model": "test-model",
1521+
"input": [
1522+
{
1523+
"type": "message",
1524+
"role": "user",
1525+
"content": [
1526+
{"type": "input_text", "text": "Summarize this document"},
1527+
{
1528+
"type": "input_file",
1529+
"file_data": "data:application/pdf;base64,JVBERi0xLjQ=",
1530+
"filename": "doc.pdf",
1531+
},
1532+
],
1533+
}
1534+
],
1535+
"stream": False,
1536+
},
1537+
)
1538+
1539+
assert resp.status_code == 200
1540+
body = resp.json()
1541+
assert body["status"] == "completed"
1542+
1543+
messages = agent.run.call_args.kwargs["messages"]
1544+
assert len(messages) == 1
1545+
assert len(messages[0].contents) == 2
1546+
assert messages[0].contents[0].type == "text"
1547+
assert messages[0].contents[0].text == "Summarize this document"
1548+
assert messages[0].contents[1].type == "data"
1549+
assert messages[0].contents[1].uri == "data:application/pdf;base64,JVBERi0xLjQ="
1550+
1551+
async def test_text_mime_file_data_decoded(self) -> None:
1552+
"""Agent receives a text/* file_data that is base64-decoded to plain text."""
1553+
agent = _make_agent(
1554+
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Got it")])])
1555+
)
1556+
server = _make_server(agent)
1557+
1558+
import base64
1559+
1560+
encoded = base64.b64encode(b"Hello, world!").decode()
1561+
1562+
resp = await _post_json(
1563+
server,
1564+
{
1565+
"model": "test-model",
1566+
"input": [
1567+
{
1568+
"type": "message",
1569+
"role": "user",
1570+
"content": [
1571+
{
1572+
"type": "input_file",
1573+
"file_data": f"data:text/plain;base64,{encoded}",
1574+
"filename": "greeting.txt",
1575+
},
1576+
],
1577+
}
1578+
],
1579+
"stream": False,
1580+
},
1581+
)
1582+
1583+
assert resp.status_code == 200
1584+
1585+
messages = agent.run.call_args.kwargs["messages"]
1586+
assert len(messages) == 1
1587+
assert messages[0].contents[0].type == "text"
1588+
assert messages[0].contents[0].text == "[File: greeting.txt]\nHello, world!"
1589+
1590+
async def test_text_mime_file_data_invalid_base64_falls_through(self) -> None:
1591+
"""Invalid base64 in a text/* file_data falls through to URI passthrough."""
1592+
agent = _make_agent(
1593+
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Got it")])])
1594+
)
1595+
server = _make_server(agent)
1596+
1597+
resp = await _post_json(
1598+
server,
1599+
{
1600+
"model": "test-model",
1601+
"input": [
1602+
{
1603+
"type": "message",
1604+
"role": "user",
1605+
"content": [
1606+
{
1607+
"type": "input_file",
1608+
"file_data": "data:text/plain;base64,!!!invalid!!!",
1609+
"filename": "bad.txt",
1610+
},
1611+
],
1612+
}
1613+
],
1614+
"stream": False,
1615+
},
1616+
)
1617+
1618+
assert resp.status_code == 200
1619+
1620+
messages = agent.run.call_args.kwargs["messages"]
1621+
assert len(messages) == 1
1622+
assert messages[0].contents[0].type == "data"
1623+
assert messages[0].contents[0].uri == "data:text/plain;base64,!!!invalid!!!"
1624+
15101625
async def test_mixed_text_and_image_input(self) -> None:
15111626
"""Agent receives a single message with both text and image content."""
15121627
agent = _make_agent(

0 commit comments

Comments
 (0)