+
{
+ it('returns the string as-is when given a string', () => {
+ expect(getTextContent('plain text')).toBe('plain text');
+ });
+
+ it('extracts text from ContentPart array', () => {
+ expect(getTextContent(mixedParts)).toBe('hello');
+ });
+
+ it('concatenates multiple text parts', () => {
+ expect(getTextContent(textOnlyParts)).toBe('onetwo');
+ });
+
+ it('returns empty string when only images', () => {
+ expect(getTextContent(imageOnlyParts)).toBe('');
+ });
+
+ it('returns empty string for empty array', () => {
+ expect(getTextContent([])).toBe('');
+ });
+});
+
+describe('hasImages', () => {
+ it('returns false for a plain string', () => {
+ expect(hasImages('text')).toBe(false);
+ });
+
+ it('returns true when array contains an image part', () => {
+ expect(hasImages(mixedParts)).toBe(true);
+ });
+
+ it('returns false when array has only text parts', () => {
+ expect(hasImages(textOnlyParts)).toBe(false);
+ });
+
+ it('returns false for empty array', () => {
+ expect(hasImages([])).toBe(false);
+ });
+});
+
+describe('toOpenAIContent', () => {
+ it('returns string as-is', () => {
+ expect(toOpenAIContent('hello')).toBe('hello');
+ });
+
+ it('returns plain text when array has no images', () => {
+ expect(toOpenAIContent(textOnlyParts)).toBe('onetwo');
+ });
+
+ it('converts mixed parts to OpenAI format', () => {
+ const result = toOpenAIContent(mixedParts);
+ expect(result).toEqual([
+ { type: 'text', text: 'hello' },
+ {
+ type: 'image_url',
+ image_url: { url: 'data:image/png;base64,abc123' },
+ },
+ ]);
+ });
+
+ it('converts image-only parts to OpenAI format', () => {
+ const result = toOpenAIContent(imageOnlyParts);
+ expect(result).toEqual([
+ {
+ type: 'image_url',
+ image_url: { url: 'data:image/png;base64,abc123' },
+ },
+ ]);
+ });
+});
+
+describe('toAnthropicContent', () => {
+ it('returns string as-is', () => {
+ expect(toAnthropicContent('hello')).toBe('hello');
+ });
+
+ it('returns plain text when array has no images', () => {
+ expect(toAnthropicContent(textOnlyParts)).toBe('onetwo');
+ });
+
+ it('converts mixed parts to Anthropic format', () => {
+ const result = toAnthropicContent(mixedParts);
+ expect(result).toEqual([
+ { type: 'text', text: 'hello' },
+ {
+ type: 'image',
+ source: { type: 'base64', media_type: 'image/png', data: 'abc123' },
+ },
+ ]);
+ });
+});
+
+describe('toGoogleParts', () => {
+ it('wraps a string in a text part array', () => {
+ expect(toGoogleParts('hello')).toEqual([{ text: 'hello' }]);
+ });
+
+ it('converts text ContentPart to Google format', () => {
+ expect(toGoogleParts([textPart])).toEqual([{ text: 'hello' }]);
+ });
+
+ it('converts image ContentPart to inlineData format', () => {
+ expect(toGoogleParts(imageOnlyParts)).toEqual([
+ { inlineData: { mimeType: 'image/png', data: 'abc123' } },
+ ]);
+ });
+
+ it('converts mixed parts to Google format', () => {
+ expect(toGoogleParts(mixedParts)).toEqual([
+ { text: 'hello' },
+ { inlineData: { mimeType: 'image/png', data: 'abc123' } },
+ ]);
+ });
+});
+
+describe('toOllamaMessage', () => {
+ it('returns content string when given a string', () => {
+ expect(toOllamaMessage('hello')).toEqual({ content: 'hello' });
+ });
+
+ it('returns content without images key when no images', () => {
+ const result = toOllamaMessage(textOnlyParts);
+ expect(result).toEqual({ content: 'onetwo' });
+ expect(result).not.toHaveProperty('images');
+ });
+
+ it('returns content and images for mixed parts', () => {
+ expect(toOllamaMessage(mixedParts)).toEqual({
+ content: 'hello',
+ images: ['abc123'],
+ });
+ });
+
+ it('returns empty content with images for image-only parts', () => {
+ expect(toOllamaMessage(imageOnlyParts)).toEqual({
+ content: '',
+ images: ['abc123'],
+ });
+ });
+});
diff --git a/src/services/providers/adapters/anthropicRequestBuilder.ts b/src/services/providers/adapters/anthropicRequestBuilder.ts
index 4482229..40bb6e5 100644
--- a/src/services/providers/adapters/anthropicRequestBuilder.ts
+++ b/src/services/providers/adapters/anthropicRequestBuilder.ts
@@ -8,6 +8,7 @@ import { Message } from '@/types/chat';
import { BaseProviderConfig, ProviderOptions } from '@/types/providers';
import { RequestBuilder } from '@/types/providers';
import { deriveV1ApiBase } from '../core/urlResolvers';
+import { getTextContent, toAnthropicContent } from '../core/messageUtils';
const ANTHROPIC_DEFAULT_ORIGIN = 'https://api.anthropic.com';
const ANTHROPIC_API_VERSION = '2023-06-01';
@@ -42,15 +43,15 @@ export const anthropicRequestBuilder: RequestBuilder = {
buildChatBody(messages: Message[], options: ProviderOptions): any {
// Separate system prompt from conversation messages
let systemPrompt = '';
- const anthropicMessages: { role: string; content: string }[] = [];
+ const anthropicMessages: { role: string; content: string | any[] }[] = [];
for (const message of messages) {
if (message.role === 'system') {
- systemPrompt = message.content;
+ systemPrompt = getTextContent(message.content);
} else {
anthropicMessages.push({
role: message.role,
- content: message.content,
+ content: toAnthropicContent(message.content),
});
}
}
diff --git a/src/services/providers/adapters/azureRequestBuilder.ts b/src/services/providers/adapters/azureRequestBuilder.ts
index b694188..fcad89a 100644
--- a/src/services/providers/adapters/azureRequestBuilder.ts
+++ b/src/services/providers/adapters/azureRequestBuilder.ts
@@ -7,6 +7,7 @@
import { Message } from '@/types/chat';
import { BaseProviderConfig, AzureOpenAIConfig, ProviderOptions } from '@/types/providers';
import { RequestBuilder } from '@/types/providers';
+import { toOpenAIContent } from '../core/messageUtils';
export const azureRequestBuilder: RequestBuilder = {
providerName: 'Azure OpenAI',
@@ -43,18 +44,23 @@ export const azureRequestBuilder: RequestBuilder = {
},
buildChatBody(messages: Message[], options: ProviderOptions): any {
+ const mapped = messages.map((m) => ({
+ role: m.role,
+ content: toOpenAIContent(m.content),
+ }));
+
// Azure OpenAI: model is NOT in the body (it's in the URL)
if (options.reasoning) {
// Reasoning models: use max_completion_tokens, drop temperature/top_p
return {
- messages,
+ messages: mapped,
max_completion_tokens: options.max_tokens,
stream: options.stream !== undefined ? options.stream : true,
};
}
return {
- messages,
+ messages: mapped,
max_tokens: options.max_tokens,
stream: options.stream !== undefined ? options.stream : true,
temperature: options.temperature,
diff --git a/src/services/providers/adapters/foundryRequestBuilder.ts b/src/services/providers/adapters/foundryRequestBuilder.ts
index 1c2fcbb..03e3459 100644
--- a/src/services/providers/adapters/foundryRequestBuilder.ts
+++ b/src/services/providers/adapters/foundryRequestBuilder.ts
@@ -1,6 +1,7 @@
import { Message } from '@/types/chat';
import { BaseProviderConfig, FoundryConfig, ProviderOptions, RequestBuilder } from '@/types/providers';
import { deriveFoundryProjectApiBase, deriveFoundryResourceOrigin } from '../core/urlResolvers';
+import { getTextContent, toAnthropicContent, toOpenAIContent } from '../core/messageUtils';
const DEFAULT_ENTRA_SCOPE = 'https://ai.azure.com/.default';
const ANTHROPIC_API_VERSION = '2023-06-01';
@@ -106,12 +107,12 @@ export const foundryRequestBuilder: RequestBuilder = {
buildChatBody(messages: Message[], options: ProviderOptions): any {
if (isClaudeModel(options.model)) {
let systemPrompt = '';
- const anthropicMessages: { role: string; content: string }[] = [];
+ const anthropicMessages: { role: string; content: string | any[] }[] = [];
for (const message of messages) {
if (message.role === 'system') {
- systemPrompt = message.content;
+ systemPrompt = getTextContent(message.content);
} else {
- anthropicMessages.push({ role: message.role, content: message.content });
+ anthropicMessages.push({ role: message.role, content: toAnthropicContent(message.content) });
}
}
@@ -151,10 +152,15 @@ export const foundryRequestBuilder: RequestBuilder = {
return body;
}
+ const mapped = messages.map((m) => ({
+ role: m.role,
+ content: toOpenAIContent(m.content),
+ }));
+
if (options.reasoning) {
const body: any = {
model: options.model,
- messages,
+ messages: mapped,
max_completion_tokens: options.max_tokens,
stream: options.stream !== undefined ? options.stream : true,
};
@@ -166,7 +172,7 @@ export const foundryRequestBuilder: RequestBuilder = {
const body: any = {
model: options.model,
- messages,
+ messages: mapped,
stream: options.stream !== undefined ? options.stream : true,
};
if (requiresMaxCompletionTokens(options.model)) {
diff --git a/src/services/providers/adapters/googleRequestBuilder.ts b/src/services/providers/adapters/googleRequestBuilder.ts
index d468b83..614ae26 100644
--- a/src/services/providers/adapters/googleRequestBuilder.ts
+++ b/src/services/providers/adapters/googleRequestBuilder.ts
@@ -11,10 +11,11 @@ import { Message } from '@/types/chat';
import { BaseProviderConfig, GoogleConfig, ProviderOptions } from '@/types/providers';
import { RequestBuilder } from '@/types/providers';
import { deriveGoogleApiBase } from '../core/urlResolvers';
+import { toGoogleParts, getTextContent } from '../core/messageUtils';
interface GoogleMessage {
role: string;
- parts: Array<{ text: string }>;
+ parts: any[];
}
const GOOGLE_DEFAULT_ORIGIN = 'https://generativelanguage.googleapis.com';
@@ -51,11 +52,11 @@ export const googleRequestBuilder: RequestBuilder = {
for (const message of messages) {
if (message.role === 'system') {
- systemPrompt = message.content;
+ systemPrompt = getTextContent(message.content);
} else if (message.role === 'user') {
- googleMessages.push({ role: 'user', parts: [{ text: message.content }] });
+ googleMessages.push({ role: 'user', parts: toGoogleParts(message.content) });
} else if (message.role === 'assistant') {
- googleMessages.push({ role: 'model', parts: [{ text: message.content }] });
+ googleMessages.push({ role: 'model', parts: toGoogleParts(message.content) });
}
}
@@ -63,8 +64,12 @@ export const googleRequestBuilder: RequestBuilder = {
if (systemPrompt && googleMessages.length > 0) {
for (let i = 0; i < googleMessages.length; i++) {
if (googleMessages[i].role === 'user') {
- const originalContent = googleMessages[i].parts[0].text;
- googleMessages[i].parts[0].text = `${systemPrompt}\n\n${originalContent}`;
+ const firstPart = googleMessages[i].parts[0];
+ if (firstPart && 'text' in firstPart) {
+ firstPart.text = `${systemPrompt}\n\n${firstPart.text}`;
+ } else {
+ googleMessages[i].parts.unshift({ text: systemPrompt });
+ }
break;
}
}
diff --git a/src/services/providers/adapters/ollamaRequestBuilder.ts b/src/services/providers/adapters/ollamaRequestBuilder.ts
index e68a871..6429364 100644
--- a/src/services/providers/adapters/ollamaRequestBuilder.ts
+++ b/src/services/providers/adapters/ollamaRequestBuilder.ts
@@ -10,6 +10,7 @@ import { Message } from '@/types/chat';
import { BaseProviderConfig, ProviderOptions } from '@/types/providers';
import { RequestBuilder } from '@/types/providers';
import { deriveOllamaApiBase } from '../core/urlResolvers';
+import { toOllamaMessage } from '../core/messageUtils';
const OLLAMA_DEFAULT_ORIGIN = 'http://localhost:11434';
@@ -55,7 +56,7 @@ export const ollamaRequestBuilder: RequestBuilder = {
const body: any = {
model: options?.model,
- messages: messages.map((m) => ({ role: m.role, content: m.content })),
+ messages: messages.map((m) => ({ role: m.role, ...toOllamaMessage(m.content) })),
stream: options?.stream !== false,
};
const think = ollamaThinkFromReasoning(options?.reasoning, options?.model);
diff --git a/src/services/providers/adapters/openaiRequestBuilder.ts b/src/services/providers/adapters/openaiRequestBuilder.ts
index fd5436e..f6375e0 100644
--- a/src/services/providers/adapters/openaiRequestBuilder.ts
+++ b/src/services/providers/adapters/openaiRequestBuilder.ts
@@ -4,6 +4,7 @@
import { Message } from '@/types/chat';
import { BaseProviderConfig, ProviderOptions } from '@/types/providers';
import { RequestBuilder } from '@/types/providers';
+import { toOpenAIContent } from '../core/messageUtils';
export interface OpenAIRequestBuilderOptions {
/** Human-readable name (e.g. "OpenAI", "DeepSeek"). */
@@ -52,6 +53,11 @@ export function createOpenAIRequestBuilder(opts: OpenAIRequestBuilderOptions): R
},
buildChatBody(messages: Message[], options: ProviderOptions): any {
+ const mapped = messages.map((m) => ({
+ role: m.role,
+ content: toOpenAIContent(m.content),
+ }));
+
const applyOpenAIWebSearch = (body: any) => {
// Chat Completions: web search requires search-capable models + web_search_options (OpenAI only).
if (options.webSearch && providerName === 'OpenAI') {
@@ -70,7 +76,7 @@ export function createOpenAIRequestBuilder(opts: OpenAIRequestBuilderOptions): R
// - Add reasoning_effort for OpenAI o-series models
const body: any = {
model: options.model,
- messages,
+ messages: mapped,
max_completion_tokens: options.max_tokens,
stream: options.stream !== undefined ? options.stream : true,
};
@@ -84,7 +90,7 @@ export function createOpenAIRequestBuilder(opts: OpenAIRequestBuilderOptions): R
const body: any = {
model: options.model,
- messages,
+ messages: mapped,
max_tokens: options.max_tokens,
stream: options.stream !== undefined ? options.stream : true,
temperature: options.temperature,
diff --git a/src/services/providers/core/messageUtils.ts b/src/services/providers/core/messageUtils.ts
new file mode 100644
index 0000000..99cc6e0
--- /dev/null
+++ b/src/services/providers/core/messageUtils.ts
@@ -0,0 +1,65 @@
+import { ContentPart } from '@/types/chat';
+
+export function getTextContent(content: string | ContentPart[]): string {
+ if (typeof content === 'string') return content;
+ return content
+ .filter((p): p is Extract => p.type === 'text')
+ .map((p) => p.text)
+ .join('');
+}
+
+export function hasImages(content: string | ContentPart[]): boolean {
+ if (typeof content === 'string') return false;
+ return content.some((p) => p.type === 'image');
+}
+
+export function toOpenAIContent(content: string | ContentPart[]): string | any[] {
+ if (typeof content === 'string') return content;
+ if (!hasImages(content)) return getTextContent(content);
+ return content.map((part) => {
+ if (part.type === 'text') {
+ return { type: 'text', text: part.text };
+ }
+ return {
+ type: 'image_url',
+ image_url: { url: `data:${part.mimeType};base64,${part.data}` },
+ };
+ });
+}
+
+export function toAnthropicContent(content: string | ContentPart[]): string | any[] {
+ if (typeof content === 'string') return content;
+ if (!hasImages(content)) return getTextContent(content);
+ return content.map((part) => {
+ if (part.type === 'text') {
+ return { type: 'text', text: part.text };
+ }
+ return {
+ type: 'image',
+ source: { type: 'base64', media_type: part.mimeType, data: part.data },
+ };
+ });
+}
+
+export function toGoogleParts(content: string | ContentPart[]): any[] {
+ if (typeof content === 'string') return [{ text: content }];
+ return content.map((part) => {
+ if (part.type === 'text') {
+ return { text: part.text };
+ }
+ return { inlineData: { mimeType: part.mimeType, data: part.data } };
+ });
+}
+
+export function toOllamaMessage(content: string | ContentPart[]): {
+ content: string;
+ images?: string[];
+} {
+ if (typeof content === 'string') return { content };
+ const text = getTextContent(content);
+ const images = content
+ .filter((p): p is Extract => p.type === 'image')
+ .map((p) => p.data);
+ if (images.length === 0) return { content: text };
+ return { content: text, images };
+}
diff --git a/src/types/chat.d.ts b/src/types/chat.d.ts
index a66a053..9e2e223 100644
--- a/src/types/chat.d.ts
+++ b/src/types/chat.d.ts
@@ -3,8 +3,21 @@ export type ChatSource = {
title?: string;
};
+export type TextContentPart = {
+ type: 'text';
+ text: string;
+};
+
+export type ImageContentPart = {
+ type: 'image';
+ data: string;
+ mimeType: string;
+};
+
+export type ContentPart = TextContentPart | ImageContentPart;
+
export type Message = {
role: 'user' | 'assistant' | 'system';
- content: string;
+ content: string | ContentPart[];
sources?: ChatSource[];
};