From f97b5a1d9ac1b8693107f1e63b5c53de5e2b4a43 Mon Sep 17 00:00:00 2001 From: Soumyaraj Bag Date: Mon, 30 Mar 2026 20:37:47 +0530 Subject: [PATCH] test(genai): add unit tests for OpenAI, Anthropic and Ollama providers --- .../interface/model_providers/anthropic.dart | 10 +- .../model_providers/anthropic_test.dart | 168 ++++++++++++++++++ .../model_providers/ollama_test.dart | 92 ++++++++++ .../model_providers/openai_test.dart | 138 ++++++++++++++ 4 files changed, 406 insertions(+), 2 deletions(-) create mode 100644 packages/genai/test/interface/model_providers/anthropic_test.dart create mode 100644 packages/genai/test/interface/model_providers/ollama_test.dart create mode 100644 packages/genai/test/interface/model_providers/openai_test.dart diff --git a/packages/genai/lib/interface/model_providers/anthropic.dart b/packages/genai/lib/interface/model_providers/anthropic.dart index c6d1f955eb..6155043516 100644 --- a/packages/genai/lib/interface/model_providers/anthropic.dart +++ b/packages/genai/lib/interface/model_providers/anthropic.dart @@ -30,9 +30,15 @@ class AnthropicModel extends ModelProvider { ), body: kJsonEncoder.convert({ "model": aiRequestModel.model, + if (aiRequestModel.systemPrompt.isNotEmpty) + "system": aiRequestModel.systemPrompt, "messages": [ - {"role": "system", "content": aiRequestModel.systemPrompt}, - {"role": "user", "content": aiRequestModel.userPrompt}, + { + "role": "user", + "content": aiRequestModel.userPrompt.isNotEmpty + ? aiRequestModel.userPrompt + : "Generate", + }, ], ...aiRequestModel.getModelConfigMap(), if (aiRequestModel.stream ?? false) ...{'stream': true}, diff --git a/packages/genai/test/interface/model_providers/anthropic_test.dart b/packages/genai/test/interface/model_providers/anthropic_test.dart new file mode 100644 index 0000000000..61306d5b30 --- /dev/null +++ b/packages/genai/test/interface/model_providers/anthropic_test.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:genai/interface/consts.dart'; +import 'package:genai/interface/model_providers/anthropic.dart'; +import 'package:genai/models/ai_request_model.dart'; + +void main() { + group('AnthropicModel', () { + test('should return default AIRequestModel with Anthropic provider', () { + final defaultModel = AnthropicModel.instance.defaultAIRequestModel; + + expect(defaultModel.modelApiProvider, equals(ModelAPIProvider.anthropic)); + expect(defaultModel.url, equals(kAnthropicUrl)); + }); + + test('should return null when aiRequestModel is null', () { + final result = AnthropicModel.instance.createRequest(null); + expect(result, isNull); + }); + + test('should set anthropic-version header', () { + const req = AIRequestModel( + modelApiProvider: ModelAPIProvider.anthropic, + url: kAnthropicUrl, + model: 'claude-3-5-sonnet-latest', + apiKey: 'test-key', + userPrompt: 'Hello', + systemPrompt: 'You are helpful', + stream: false, + ); + + final httpReq = AnthropicModel.instance.createRequest(req)!; + + expect( + httpReq.headers?.any( + (h) => h.name == 'anthropic-version' && h.value == '2023-06-01', + ), + isTrue, + ); + }); + + test('should place system prompt as top-level field, not inside messages', + () { + const req = AIRequestModel( + modelApiProvider: ModelAPIProvider.anthropic, + url: kAnthropicUrl, + model: 'claude-3-5-sonnet-latest', + apiKey: 'test-key', + userPrompt: 'Hello', + systemPrompt: 'You are a helpful assistant', + stream: false, + ); + + final httpReq = AnthropicModel.instance.createRequest(req)!; + final body = jsonDecode(httpReq.body!) as Map; + + // system prompt must be a top-level key + expect(body['system'], equals('You are a helpful assistant')); + + // messages must contain only the user role, never system + final messages = body['messages'] as List; + expect(messages, hasLength(1)); + expect(messages[0]['role'], equals('user')); + expect( + messages.any((m) => m['role'] == 'system'), + isFalse, + reason: 'Anthropic API does not support role:system inside messages', + ); + }); + + test('should omit system field when systemPrompt is empty', () { + const req = AIRequestModel( + modelApiProvider: ModelAPIProvider.anthropic, + url: kAnthropicUrl, + model: 'claude-3-5-sonnet-latest', + userPrompt: 'Hello', + systemPrompt: '', + stream: false, + ); + + final httpReq = AnthropicModel.instance.createRequest(req)!; + final body = jsonDecode(httpReq.body!) as Map; + + expect(body.containsKey('system'), isFalse); + }); + + test('should use "Generate" as fallback when userPrompt is empty', () { + const req = AIRequestModel( + modelApiProvider: ModelAPIProvider.anthropic, + url: kAnthropicUrl, + model: 'claude-3-5-sonnet-latest', + userPrompt: '', + systemPrompt: 'sys', + stream: false, + ); + + final httpReq = AnthropicModel.instance.createRequest(req)!; + final body = jsonDecode(httpReq.body!) as Map; + final messages = body['messages'] as List; + + expect(messages[0]['content'], equals('Generate')); + }); + + test('should include stream:true in body when streaming is enabled', () { + const req = AIRequestModel( + modelApiProvider: ModelAPIProvider.anthropic, + url: kAnthropicUrl, + model: 'claude-3-5-sonnet-latest', + apiKey: 'test-key', + userPrompt: 'Hello', + systemPrompt: 'Sys', + stream: true, + ); + + final httpReq = AnthropicModel.instance.createRequest(req)!; + final body = jsonDecode(httpReq.body!) as Map; + + expect(body['stream'], isTrue); + }); + + test('should not include stream key when streaming is disabled', () { + const req = AIRequestModel( + modelApiProvider: ModelAPIProvider.anthropic, + url: kAnthropicUrl, + model: 'claude-3-5-sonnet-latest', + userPrompt: 'Hello', + systemPrompt: 'Sys', + stream: false, + ); + + final httpReq = AnthropicModel.instance.createRequest(req)!; + final body = jsonDecode(httpReq.body!) as Map; + + expect(body.containsKey('stream'), isFalse); + }); + + test('should format non-streaming output correctly', () { + final response = { + 'content': [ + {'text': 'Hello from Claude'}, + ], + }; + + final output = AnthropicModel.instance.outputFormatter(response); + expect(output, equals('Hello from Claude')); + }); + + test('should return null for malformed non-streaming output', () { + final output = AnthropicModel.instance.outputFormatter({}); + expect(output, isNull); + }); + + test('should use POST method', () { + const req = AIRequestModel( + modelApiProvider: ModelAPIProvider.anthropic, + url: kAnthropicUrl, + model: 'claude-3-5-sonnet-latest', + userPrompt: 'Hi', + systemPrompt: '', + stream: false, + ); + + final httpReq = AnthropicModel.instance.createRequest(req)!; + expect(httpReq.method.name, equals('post')); + }); + }); +} diff --git a/packages/genai/test/interface/model_providers/ollama_test.dart b/packages/genai/test/interface/model_providers/ollama_test.dart new file mode 100644 index 0000000000..5a03e293f9 --- /dev/null +++ b/packages/genai/test/interface/model_providers/ollama_test.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:genai/interface/consts.dart'; +import 'package:genai/interface/model_providers/ollama.dart'; +import 'package:genai/models/ai_request_model.dart'; + +void main() { + group('OllamaModel', () { + test('should return default AIRequestModel with Ollama provider', () { + final defaultModel = OllamaModel.instance.defaultAIRequestModel; + + expect(defaultModel.modelApiProvider, equals(ModelAPIProvider.ollama)); + expect(defaultModel.url, equals(kOllamaUrl)); + }); + + test('should only include temperature and top_p model configs by default', + () { + final defaultModel = OllamaModel.instance.defaultAIRequestModel; + final configIds = defaultModel.modelConfigs.map((c) => c.id).toList(); + + expect(configIds, containsAll(['temperature', 'top_p'])); + expect(configIds, isNot(contains('max_tokens'))); + }); + + test('should use OpenAI-compatible request format', () { + const req = AIRequestModel( + modelApiProvider: ModelAPIProvider.ollama, + url: kOllamaUrl, + model: 'llama3', + userPrompt: 'Hello', + systemPrompt: 'You are helpful', + stream: false, + ); + + final httpReq = OllamaModel.instance.createRequest(req)!; + final body = jsonDecode(httpReq.body!) as Map; + + // Ollama inherits OpenAI format: system prompt in messages + expect(body.containsKey('messages'), isTrue); + final messages = body['messages'] as List; + expect(messages[0]['role'], equals('system')); + expect(messages[1]['role'], equals('user')); + }); + + test('should use POST method and point to Ollama URL', () { + const req = AIRequestModel( + modelApiProvider: ModelAPIProvider.ollama, + url: kOllamaUrl, + model: 'llama3', + userPrompt: 'Hi', + systemPrompt: '', + stream: false, + ); + + final httpReq = OllamaModel.instance.createRequest(req)!; + expect(httpReq.method.name, equals('post')); + expect(httpReq.url, equals(kOllamaUrl)); + }); + + test('should format output correctly (OpenAI-compatible)', () { + final response = { + 'choices': [ + { + 'message': {'content': ' Ollama response '}, + }, + ], + }; + + final output = OllamaModel.instance.outputFormatter(response); + expect(output, equals('Ollama response')); + }); + + test('should format streaming delta output correctly', () { + final response = { + 'choices': [ + { + 'delta': {'content': 'stream chunk'}, + }, + ], + }; + + final output = OllamaModel.instance.streamOutputFormatter(response); + expect(output, equals('stream chunk')); + }); + + test('should return null when aiRequestModel is null', () { + final result = OllamaModel.instance.createRequest(null); + expect(result, isNull); + }); + }); +} diff --git a/packages/genai/test/interface/model_providers/openai_test.dart b/packages/genai/test/interface/model_providers/openai_test.dart new file mode 100644 index 0000000000..f9042f54d7 --- /dev/null +++ b/packages/genai/test/interface/model_providers/openai_test.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:genai/interface/consts.dart'; +import 'package:genai/interface/model_providers/openai.dart'; +import 'package:genai/models/ai_request_model.dart'; + +void main() { + group('OpenAIModel', () { + test('should return default AIRequestModel with OpenAI provider', () { + final defaultModel = OpenAIModel.instance.defaultAIRequestModel; + + expect(defaultModel.modelApiProvider, equals(ModelAPIProvider.openai)); + expect(defaultModel.url, equals(kOpenAIUrl)); + }); + + test('should return null when aiRequestModel is null', () { + final result = OpenAIModel.instance.createRequest(null); + expect(result, isNull); + }); + + test('should use POST method', () { + const req = AIRequestModel( + modelApiProvider: ModelAPIProvider.openai, + url: kOpenAIUrl, + model: 'gpt-4o', + apiKey: 'sk-test', + userPrompt: 'Hello', + systemPrompt: 'You are helpful', + stream: false, + ); + + final httpReq = OpenAIModel.instance.createRequest(req)!; + expect(httpReq.method.name, equals('post')); + expect(httpReq.url, equals(kOpenAIUrl)); + }); + + test('should place system prompt inside messages with role:system', () { + const req = AIRequestModel( + modelApiProvider: ModelAPIProvider.openai, + url: kOpenAIUrl, + model: 'gpt-4o', + apiKey: 'sk-test', + userPrompt: 'Hello', + systemPrompt: 'You are a helpful assistant', + stream: false, + ); + + final httpReq = OpenAIModel.instance.createRequest(req)!; + final body = jsonDecode(httpReq.body!) as Map; + final messages = body['messages'] as List; + + expect(messages[0]['role'], equals('system')); + expect(messages[0]['content'], equals('You are a helpful assistant')); + expect(messages[1]['role'], equals('user')); + }); + + test('should use "Generate" as fallback when userPrompt is empty', () { + const req = AIRequestModel( + modelApiProvider: ModelAPIProvider.openai, + url: kOpenAIUrl, + model: 'gpt-4o', + userPrompt: '', + systemPrompt: 'sys', + stream: false, + ); + + final httpReq = OpenAIModel.instance.createRequest(req)!; + final body = jsonDecode(httpReq.body!) as Map; + final messages = body['messages'] as List; + + expect(messages.last['content'], equals('Generate')); + }); + + test('should include stream:true in body when streaming', () { + const req = AIRequestModel( + modelApiProvider: ModelAPIProvider.openai, + url: kOpenAIUrl, + model: 'gpt-4o', + userPrompt: 'Hello', + systemPrompt: 'Sys', + stream: true, + ); + + final httpReq = OpenAIModel.instance.createRequest(req)!; + final body = jsonDecode(httpReq.body!) as Map; + + expect(body['stream'], isTrue); + }); + + test('should not include stream key when streaming is disabled', () { + const req = AIRequestModel( + modelApiProvider: ModelAPIProvider.openai, + url: kOpenAIUrl, + model: 'gpt-4o', + userPrompt: 'Hello', + systemPrompt: 'Sys', + stream: false, + ); + + final httpReq = OpenAIModel.instance.createRequest(req)!; + final body = jsonDecode(httpReq.body!) as Map; + + expect(body.containsKey('stream'), isFalse); + }); + + test('should format non-streaming output correctly', () { + final response = { + 'choices': [ + { + 'message': {'content': ' Hello from GPT '}, + }, + ], + }; + + final output = OpenAIModel.instance.outputFormatter(response); + expect(output, equals('Hello from GPT')); + }); + + test('should format streaming delta output correctly', () { + final response = { + 'choices': [ + { + 'delta': {'content': 'chunk'}, + }, + ], + }; + + final output = OpenAIModel.instance.streamOutputFormatter(response); + expect(output, equals('chunk')); + }); + + test('should return null for malformed output', () { + expect(OpenAIModel.instance.outputFormatter({}), isNull); + expect(OpenAIModel.instance.streamOutputFormatter({}), isNull); + }); + }); +}