Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .github/workflows/llm-media-integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: LLM Provider Media Integration

# Live-API regression coverage for the provider media fixes (#1238 Anthropic,
# #1241 Gemini, #1243 Grok/Perplexity, #1246 Cohere): each test sends an image
# embedding a machine-unguessable token to a vision model and asserts the model
# transcribes it — proving the adapter actually attached the media.
#
# Provider keys are org-level secrets. Tests self-skip for any key that is
# absent (COHERE_API_KEY is not configured today), so this workflow stays green
# while showing per-provider SKIPPED/PASSED status in the report.

on:
workflow_dispatch:
# GitHub Actions cron expressions are UTC. Weekly — these burn paid provider calls.
schedule:
- cron: "30 5 * * 1"
pull_request:
paths:
- ".github/workflows/llm-media-integration.yml"
# Provider adapter changes are exactly what these tests exist to catch;
# everything else in the repo never triggers a paid run.
- "ai/src/main/java/org/conductoross/conductor/ai/providers/**"

# A rapid series of pushes should not stack paid runs — keep only the latest.
concurrency:
group: llm-media-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
checks: write

jobs:
media-integration:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Zulu JDK 21
uses: actions/setup-java@v5
with:
distribution: "zulu"
java-version: "21"
- name: Cache Gradle packages
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-gradle-
- name: Run provider media integration tests
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GROK_API_KEY: ${{ secrets.GROK_API_KEY }}
PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }}
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
AWS_REGION: ${{ secrets.AWS_REGION }}
# Not configured at org level today; the Cohere test self-skips until it is.
COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
run: >
./gradlew :conductor-ai:test
--tests "*testChatCompletionWithImageMedia*"
--rerun
- name: Publish media integration test report
uses: mikepenz/action-junit-report@v6
if: always()
with:
report_paths: "ai/build/test-results/test/TEST-*.xml"
check_name: LLM Media Integration Report
include_passed: true
detailed_summary: true
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
package org.conductoross.conductor.ai.providers.anthropic;

import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;

import org.conductoross.conductor.ai.model.ChatCompletion;
import org.conductoross.conductor.ai.model.ToolSpec;
Expand All @@ -22,7 +24,10 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media;
import org.springframework.util.MimeTypeUtils;

import okhttp3.OkHttpClient;

Expand Down Expand Up @@ -228,6 +233,47 @@ void testChatCompletion() {
assertFalse(response.getResult().getOutput().getText().isEmpty());
}

@Test
void testChatCompletionWithImageMedia() throws Exception {
// Live regression for the media fix (PR #1238): the vision model must actually
// see the image bytes forwarded by the adapter. The image embeds a
// machine-unguessable token, so a correct transcription can only come from
// the image — pre-fix the media was silently dropped.
byte[] png =
Objects.requireNonNull(
getClass().getResourceAsStream("/media/melon7391.png"),
"test asset /media/melon7391.png missing")
.readAllBytes();

ChatCompletion input = new ChatCompletion();
input.setModel("claude-haiku-4-5");
input.setMaxTokens(100);

UserMessage userMsg =
UserMessage.builder()
.text(
"Transcribe the exact text shown in the image. Reply with"
+ " only that text and nothing else.")
.media(
List.of(
Media.builder()
.data(png)
.mimeType(MimeTypeUtils.IMAGE_PNG)
.build()))
.build();

ChatResponse response =
anthropic
.getChatModel()
.call(new Prompt(List.of(userMsg), anthropic.getChatOptions(input)));

String text = response.getResult().getOutput().getText();
assertNotNull(text);
assertTrue(
text.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "").contains("MELON7391"),
"vision model must transcribe the embedded token MELON7391; got: " + text);
}

@Test
void testChatCompletion_withThinking() {
// Lightweight smoke check for the legacy ``thinking.type=enabled`` shape on a model
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
package org.conductoross.conductor.ai.providers.azureopenai;

import java.util.List;
import java.util.Locale;
import java.util.Objects;

import org.conductoross.conductor.ai.model.ChatCompletion;
import org.conductoross.conductor.ai.model.EmbeddingGenRequest;
Expand All @@ -26,6 +28,8 @@
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media;
import org.springframework.util.MimeTypeUtils;

import okhttp3.OkHttpClient;

Expand Down Expand Up @@ -135,6 +139,47 @@ void testChatCompletion() {
assertFalse(response.getResult().getOutput().getText().isEmpty());
}

@Test
void testChatCompletionWithImageMedia() throws Exception {
// Live lock for Azure OpenAI media input (same OpenAIResponsesChatModel input_image
// path).
// The image embeds a machine-unguessable token, so a correct transcription
// can only come from the image actually reaching the model.
byte[] png =
Objects.requireNonNull(
getClass().getResourceAsStream("/media/melon7391.png"),
"test asset /media/melon7391.png missing")
.readAllBytes();

ChatCompletion input = new ChatCompletion();
input.setModel("gpt-4o-mini");
input.setMaxTokens(100);

UserMessage userMsg =
UserMessage.builder()
.text(
"Transcribe the exact text shown in the image. Reply with"
+ " only that text and nothing else.")
.media(
List.of(
Media.builder()
.data(png)
.mimeType(MimeTypeUtils.IMAGE_PNG)
.build()))
.build();

var response =
azureOpenAI
.getChatModel()
.call(new Prompt(List.of(userMsg), azureOpenAI.getChatOptions(input)));

String text = response.getResult().getOutput().getText();
assertNotNull(text);
assertTrue(
text.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "").contains("MELON7391"),
"vision model must transcribe the embedded token MELON7391; got: " + text);
}

@Test
void testEmbeddings() {
EmbeddingGenRequest request = new EmbeddingGenRequest();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
package org.conductoross.conductor.ai.providers.bedrock;

import java.util.List;
import java.util.Locale;
import java.util.Objects;

import org.conductoross.conductor.ai.model.ChatCompletion;
import org.conductoross.conductor.ai.model.EmbeddingGenRequest;
Expand All @@ -22,13 +24,16 @@
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.content.Media;
import org.springframework.util.MimeTypeUtils;

import static org.junit.jupiter.api.Assertions.*;

class BedrockTest {

private static final String ENV_ACCESS_KEY = "AWS_ACCESS_KEY_ID";
private static final String ENV_SECRET_KEY = "AWS_SECRET_ACCESS_KEY";
private static final String ENV_BEARER_TOKEN = "AWS_BEARER_TOKEN_BEDROCK";

@Nested
class UnitTests {
Expand Down Expand Up @@ -95,7 +100,7 @@ void setUp() {
@Test
void testChatCompletion() {
ChatCompletion input = new ChatCompletion();
input.setModel("anthropic.claude-3-haiku-20240307-v1:0");
input.setModel("us.anthropic.claude-haiku-4-5-20251001-v1:0");
input.setMaxTokens(100);
input.setTemperature(0.7);

Expand Down Expand Up @@ -123,4 +128,71 @@ void testEmbeddings() {
assertFalse(embeddings.isEmpty());
}
}

/**
* Live tests using Bedrock API-key (bearer token) auth — the dedicated {@code
* AWS_BEARER_TOKEN_BEDROCK} credential, matching the server's {@code
* conductor.ai.bedrock.bearerToken} binding. Separate from {@link IntegrationTests} because the
* generic AWS access keys in CI are provisioned for artifact publishing and may not carry
* Bedrock invoke permissions.
*/
@Nested
@EnabledIfEnvironmentVariable(named = ENV_BEARER_TOKEN, matches = ".+")
class BearerIntegrationTests {

private Bedrock bedrock;

@BeforeEach
void setUp() {
BedrockConfiguration config = new BedrockConfiguration();
config.setBearerToken(System.getenv(ENV_BEARER_TOKEN));
config.setRegion(
System.getenv("AWS_REGION") != null
? System.getenv("AWS_REGION")
: "us-east-1");
bedrock = new Bedrock(config);
}

@Test
void testChatCompletionWithImageMedia() throws Exception {
// Live lock for Bedrock media input (Spring AI Converse maps Media to image
// blocks). The image embeds a machine-unguessable token, so a correct
// transcription can only come from the image actually reaching the model.
byte[] png =
Objects.requireNonNull(
getClass().getResourceAsStream("/media/melon7391.png"),
"test asset /media/melon7391.png missing")
.readAllBytes();

ChatCompletion input = new ChatCompletion();
// Inference-profile id (bare model ids are rejected for on-demand
// throughput), on a current model: claude-3-haiku is marked Legacy by the
// provider and Bedrock blocks it after 30 days of inactivity.
input.setModel("us.anthropic.claude-haiku-4-5-20251001-v1:0");
input.setMaxTokens(100);

UserMessage userMsg =
UserMessage.builder()
.text(
"Transcribe the exact text shown in the image. Reply with"
+ " only that text and nothing else.")
.media(
List.of(
Media.builder()
.data(png)
.mimeType(MimeTypeUtils.IMAGE_PNG)
.build()))
.build();

var response =
bedrock.getChatModel()
.call(new Prompt(List.of(userMsg), bedrock.getChatOptions(input)));

String text = response.getResult().getOutput().getText();
assertNotNull(text);
assertTrue(
text.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "").contains("MELON7391"),
"vision model must transcribe the embedded token MELON7391; got: " + text);
}
}
}
Loading
Loading