feat: 原生支持 /v1/responses 接口与 Chat Completions 双协议自适应,增加端到端联调测试
This commit is contained in:
@@ -14,7 +14,8 @@ class AiConnectionResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Stores the secret separately from normal app settings. Compatible endpoints
|
/// Stores the secret separately from normal app settings. Compatible endpoints
|
||||||
/// use the OpenAI chat-completions shape (/v1/chat/completions), including a user-run CLIProxyAPI.
|
/// support both OpenAI chat-completions shape (/v1/chat/completions) and
|
||||||
|
/// responses shape (/v1/responses), including a user-run CLIProxyAPI.
|
||||||
class AiService {
|
class AiService {
|
||||||
AiService._();
|
AiService._();
|
||||||
static final instance = AiService._();
|
static final instance = AiService._();
|
||||||
@@ -55,8 +56,9 @@ class AiService {
|
|||||||
|
|
||||||
Future<String?> getApiKey() async => await resolveApiKey();
|
Future<String?> getApiKey() async => await resolveApiKey();
|
||||||
|
|
||||||
/// Resolves the target endpoint URI. For OpenAI and compatible endpoints,
|
/// Resolves the target endpoint URI.
|
||||||
/// all requests target the standard Chat Completions endpoint (/v1/chat/completions).
|
/// If explicitly set to /responses or /chat/completions, it respects that path.
|
||||||
|
/// If ending in /v1 or base URL, it defaults to /v1/chat/completions.
|
||||||
static Uri? resolveEndpointUri({
|
static Uri? resolveEndpointUri({
|
||||||
required AiProviderType provider,
|
required AiProviderType provider,
|
||||||
required String endpoint,
|
required String endpoint,
|
||||||
@@ -73,17 +75,39 @@ class AiService {
|
|||||||
if (base.endsWith('/chat/completions')) {
|
if (base.endsWith('/chat/completions')) {
|
||||||
return Uri.tryParse(base);
|
return Uri.tryParse(base);
|
||||||
}
|
}
|
||||||
|
if (base.endsWith('/responses')) {
|
||||||
|
return Uri.tryParse(base);
|
||||||
|
}
|
||||||
if (base.endsWith('/v1')) {
|
if (base.endsWith('/v1')) {
|
||||||
return Uri.tryParse('$base/chat/completions');
|
return Uri.tryParse('$base/chat/completions');
|
||||||
}
|
}
|
||||||
if (base.endsWith('/responses')) {
|
|
||||||
return Uri.tryParse(
|
|
||||||
base.replaceFirst(RegExp(r'/responses$'), '/chat/completions'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Uri.tryParse('$base/v1/chat/completions');
|
return Uri.tryParse('$base/v1/chat/completions');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> _buildOpenAiPayload({
|
||||||
|
required Uri uri,
|
||||||
|
required String model,
|
||||||
|
required List<Map<String, String>> messages,
|
||||||
|
double? temperature,
|
||||||
|
int? maxTokens,
|
||||||
|
}) {
|
||||||
|
final isResponses = uri.path.endsWith('/responses');
|
||||||
|
if (isResponses) {
|
||||||
|
return {
|
||||||
|
'model': model,
|
||||||
|
'input': messages,
|
||||||
|
if (temperature != null) 'temperature': temperature,
|
||||||
|
if (maxTokens != null) 'max_output_tokens': maxTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'model': model,
|
||||||
|
'messages': messages,
|
||||||
|
if (temperature != null) 'temperature': temperature,
|
||||||
|
if (maxTokens != null) 'max_tokens': maxTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns a display-only Chinese gloss for an unknown word or phrase.
|
/// Returns a display-only Chinese gloss for an unknown word or phrase.
|
||||||
/// This is deliberately not a LexiconEntry and cannot affect review/mastery.
|
/// This is deliberately not a LexiconEntry and cannot affect review/mastery.
|
||||||
Future<String?> temporaryDefinition({
|
Future<String?> temporaryDefinition({
|
||||||
@@ -133,30 +157,33 @@ class AiService {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
'generationConfig': {
|
'generationConfig': {
|
||||||
'maxOutputTokens': 100,
|
'maxOutputTokens': 200,
|
||||||
'responseMimeType': 'application/json',
|
'responseMimeType': 'application/json',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: _buildOpenAiPayload(
|
||||||
'model': model,
|
uri: uri,
|
||||||
'messages': [
|
model: model,
|
||||||
|
messages: [
|
||||||
{
|
{
|
||||||
'role': 'user',
|
'role': 'user',
|
||||||
'content': '$instruction\nText: $text',
|
'content': '$instruction\nText: $text',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'max_tokens': 100,
|
maxTokens: 200,
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 30));
|
.timeout(const Duration(seconds: 30));
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) return null;
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
final raw = _extractResponseContent(provider, response.body);
|
return null;
|
||||||
final data = raw == null ? null : jsonDecode(raw);
|
}
|
||||||
final definition = data is Map ? data['definition'] : null;
|
final content = _extractResponseContent(provider, response.body);
|
||||||
return definition is String &&
|
if (content == null || content.length > 300) return null;
|
||||||
definition.trim().isNotEmpty &&
|
final parsed = jsonDecode(content);
|
||||||
definition.length <= 160
|
if (parsed is! Map<String, dynamic>) return null;
|
||||||
|
final definition = parsed['definition'];
|
||||||
|
return definition is String && definition.trim().isNotEmpty
|
||||||
? definition.trim()
|
? definition.trim()
|
||||||
: null;
|
: null;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -218,18 +245,19 @@ class AiService {
|
|||||||
],
|
],
|
||||||
'generationConfig': {
|
'generationConfig': {
|
||||||
'temperature': 0,
|
'temperature': 0,
|
||||||
'maxOutputTokens': 60,
|
'maxOutputTokens': 200,
|
||||||
'responseMimeType': 'application/json',
|
'responseMimeType': 'application/json',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: _buildOpenAiPayload(
|
||||||
'model': model,
|
uri: uri,
|
||||||
'messages': [
|
model: model,
|
||||||
|
messages: [
|
||||||
{'role': 'user', 'content': probe},
|
{'role': 'user', 'content': probe},
|
||||||
],
|
],
|
||||||
'temperature': 0,
|
temperature: 0,
|
||||||
'max_tokens': 60,
|
maxTokens: 200,
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 15));
|
.timeout(const Duration(seconds: 15));
|
||||||
@@ -335,19 +363,20 @@ class AiService {
|
|||||||
.toList(),
|
.toList(),
|
||||||
'generationConfig': {
|
'generationConfig': {
|
||||||
'temperature': 0.3,
|
'temperature': 0.3,
|
||||||
'maxOutputTokens': 60,
|
'maxOutputTokens': 300,
|
||||||
'responseMimeType': 'application/json',
|
'responseMimeType': 'application/json',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: _buildOpenAiPayload(
|
||||||
'model': model,
|
uri: uri,
|
||||||
'messages': [
|
model: model,
|
||||||
|
messages: [
|
||||||
{'role': 'system', 'content': '$system$requiredTask'},
|
{'role': 'system', 'content': '$system$requiredTask'},
|
||||||
...history,
|
...history,
|
||||||
],
|
],
|
||||||
'temperature': 0.3,
|
temperature: 0.3,
|
||||||
'max_tokens': 60,
|
maxTokens: 300,
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 30));
|
.timeout(const Duration(seconds: 30));
|
||||||
@@ -410,18 +439,19 @@ class AiService {
|
|||||||
],
|
],
|
||||||
'generationConfig': {
|
'generationConfig': {
|
||||||
'temperature': 0.2,
|
'temperature': 0.2,
|
||||||
'maxOutputTokens': 120,
|
'maxOutputTokens': 300,
|
||||||
'responseMimeType': 'application/json',
|
'responseMimeType': 'application/json',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: _buildOpenAiPayload(
|
||||||
'model': model,
|
uri: uri,
|
||||||
'messages': [
|
model: model,
|
||||||
|
messages: [
|
||||||
{'role': 'user', 'content': instruction},
|
{'role': 'user', 'content': instruction},
|
||||||
],
|
],
|
||||||
'temperature': 0.2,
|
temperature: 0.2,
|
||||||
'max_tokens': 120,
|
maxTokens: 300,
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 30));
|
.timeout(const Duration(seconds: 30));
|
||||||
@@ -502,18 +532,19 @@ Learner wrote: $answer''';
|
|||||||
],
|
],
|
||||||
'generationConfig': {
|
'generationConfig': {
|
||||||
'temperature': 0,
|
'temperature': 0,
|
||||||
'maxOutputTokens': 150,
|
'maxOutputTokens': 300,
|
||||||
'responseMimeType': 'application/json',
|
'responseMimeType': 'application/json',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: _buildOpenAiPayload(
|
||||||
'model': model,
|
uri: uri,
|
||||||
'messages': [
|
model: model,
|
||||||
|
messages: [
|
||||||
{'role': 'user', 'content': instruction},
|
{'role': 'user', 'content': instruction},
|
||||||
],
|
],
|
||||||
'temperature': 0,
|
temperature: 0,
|
||||||
'max_tokens': 150,
|
maxTokens: 300,
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 30));
|
.timeout(const Duration(seconds: 30));
|
||||||
@@ -579,18 +610,19 @@ Learner wrote: $answer''';
|
|||||||
],
|
],
|
||||||
'generationConfig': {
|
'generationConfig': {
|
||||||
'temperature': 0.1,
|
'temperature': 0.1,
|
||||||
'maxOutputTokens': 650,
|
'maxOutputTokens': 850,
|
||||||
'responseMimeType': 'application/json',
|
'responseMimeType': 'application/json',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: _buildOpenAiPayload(
|
||||||
'model': model,
|
uri: uri,
|
||||||
'messages': [
|
model: model,
|
||||||
|
messages: [
|
||||||
{'role': 'user', 'content': instruction},
|
{'role': 'user', 'content': instruction},
|
||||||
],
|
],
|
||||||
'temperature': 0.1,
|
temperature: 0.1,
|
||||||
'max_tokens': 650,
|
maxTokens: 850,
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 45));
|
.timeout(const Duration(seconds: 45));
|
||||||
@@ -686,18 +718,19 @@ Learner wrote: $answer''';
|
|||||||
],
|
],
|
||||||
'generationConfig': {
|
'generationConfig': {
|
||||||
'temperature': 0,
|
'temperature': 0,
|
||||||
'maxOutputTokens': 120,
|
'maxOutputTokens': 200,
|
||||||
'responseMimeType': 'application/json',
|
'responseMimeType': 'application/json',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: _buildOpenAiPayload(
|
||||||
'model': model,
|
uri: uri,
|
||||||
'messages': [
|
model: model,
|
||||||
|
messages: [
|
||||||
{'role': 'user', 'content': instruction},
|
{'role': 'user', 'content': instruction},
|
||||||
],
|
],
|
||||||
'temperature': 0,
|
temperature: 0,
|
||||||
'max_tokens': 120,
|
maxTokens: 200,
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 30));
|
.timeout(const Duration(seconds: 30));
|
||||||
@@ -730,47 +763,58 @@ Learner wrote: $answer''';
|
|||||||
String? _extractResponseContent(AiProviderType provider, String body) {
|
String? _extractResponseContent(AiProviderType provider, String body) {
|
||||||
try {
|
try {
|
||||||
final data = jsonDecode(body) as Map<String, dynamic>;
|
final data = jsonDecode(body) as Map<String, dynamic>;
|
||||||
|
String? rawContent;
|
||||||
if (provider == AiProviderType.gemini) {
|
if (provider == AiProviderType.gemini) {
|
||||||
final candidate = (data['candidates'] as List?)?.firstOrNull as Map?;
|
final candidate = (data['candidates'] as List?)?.firstOrNull as Map?;
|
||||||
final candidateContent = candidate?['content'] as Map?;
|
final candidateContent = candidate?['content'] as Map?;
|
||||||
final parts = candidateContent?['parts'] as List?;
|
final parts = candidateContent?['parts'] as List?;
|
||||||
return (parts?.firstOrNull as Map?)?['text'] as String?;
|
rawContent = (parts?.firstOrNull as Map?)?['text'] as String?;
|
||||||
}
|
} else {
|
||||||
final choice = (data['choices'] as List?)?.firstOrNull as Map?;
|
final choice = (data['choices'] as List?)?.firstOrNull as Map?;
|
||||||
final choiceContent = (choice?['message'] as Map?)?['content'] as String? ??
|
final choiceContent = (choice?['message'] as Map?)?['content'] as String? ??
|
||||||
choice?['text'] as String?;
|
choice?['text'] as String?;
|
||||||
if (choiceContent != null && choiceContent.isNotEmpty) {
|
if (choiceContent != null && choiceContent.isNotEmpty) {
|
||||||
return choiceContent;
|
rawContent = choiceContent;
|
||||||
}
|
} else if (data['output_text'] is String && (data['output_text'] as String).isNotEmpty) {
|
||||||
if (data['output_text'] is String && (data['output_text'] as String).isNotEmpty) {
|
rawContent = data['output_text'] as String;
|
||||||
return data['output_text'] as String;
|
} else {
|
||||||
}
|
final outputList = data['output'] as List?;
|
||||||
final outputList = data['output'] as List?;
|
if (outputList != null && outputList.isNotEmpty) {
|
||||||
if (outputList != null && outputList.isNotEmpty) {
|
for (final item in outputList) {
|
||||||
for (final item in outputList) {
|
if (item is Map) {
|
||||||
if (item is Map) {
|
if (item['content'] is List) {
|
||||||
if (item['content'] is List) {
|
for (final sub in item['content'] as List) {
|
||||||
for (final sub in item['content'] as List) {
|
if (sub is Map && sub['text'] is String) {
|
||||||
if (sub is Map && sub['text'] is String) {
|
rawContent = sub['text'] as String;
|
||||||
return sub['text'] as String;
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (item['text'] is String) {
|
||||||
|
rawContent = item['text'] as String;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (item['text'] is String) {
|
if (rawContent != null) break;
|
||||||
return item['text'] as String;
|
}
|
||||||
|
}
|
||||||
|
if (rawContent == null) {
|
||||||
|
if (data['response'] is String && (data['response'] as String).isNotEmpty) {
|
||||||
|
rawContent = data['response'] as String;
|
||||||
|
} else if (data['text'] is String && (data['text'] as String).isNotEmpty) {
|
||||||
|
rawContent = data['text'] as String;
|
||||||
|
} else if (data['content'] is String && (data['content'] as String).isNotEmpty) {
|
||||||
|
rawContent = data['content'] as String;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (data['response'] is String && (data['response'] as String).isNotEmpty) {
|
if (rawContent == null) return null;
|
||||||
return data['response'] as String;
|
var trimmed = rawContent.trim();
|
||||||
|
if (trimmed.startsWith('```')) {
|
||||||
|
trimmed = trimmed.replaceFirst(RegExp(r'^```[a-zA-Z]*\s*'), '');
|
||||||
|
trimmed = trimmed.replaceFirst(RegExp(r'\s*```$'), '');
|
||||||
}
|
}
|
||||||
if (data['text'] is String && (data['text'] as String).isNotEmpty) {
|
return trimmed.trim();
|
||||||
return data['text'] as String;
|
|
||||||
}
|
|
||||||
if (data['content'] is String && (data['content'] as String).isNotEmpty) {
|
|
||||||
return data['content'] as String;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -779,7 +823,12 @@ Learner wrote: $answer''';
|
|||||||
DialogueAiResponse? _decodeDialogueResponse(String? raw) {
|
DialogueAiResponse? _decodeDialogueResponse(String? raw) {
|
||||||
if (raw == null || raw.trim().isEmpty || raw.length > 1200) return null;
|
if (raw == null || raw.trim().isEmpty || raw.length > 1200) return null;
|
||||||
try {
|
try {
|
||||||
final data = jsonDecode(raw) as Map<String, dynamic>;
|
var sanitized = raw.trim();
|
||||||
|
if (sanitized.startsWith('```')) {
|
||||||
|
sanitized = sanitized.replaceFirst(RegExp(r'^```[a-zA-Z]*\s*'), '');
|
||||||
|
sanitized = sanitized.replaceFirst(RegExp(r'\s*```$'), '');
|
||||||
|
}
|
||||||
|
final data = jsonDecode(sanitized.trim()) as Map<String, dynamic>;
|
||||||
final reply = data['reply'] as String?;
|
final reply = data['reply'] as String?;
|
||||||
final rawSlots = data['slots'];
|
final rawSlots = data['slots'];
|
||||||
final rawEvidence = data['evidence'];
|
final rawEvidence = data['evidence'];
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import 'package:kouyu_english/core/models.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
group('AiService resolveEndpointUri (/v1/chat/completions)', () {
|
group('AiService resolveEndpointUri', () {
|
||||||
test('resolves OpenAI endpoint ending in /v1 to /v1/chat/completions', () {
|
test('resolves OpenAI endpoint ending in /v1 to /v1/chat/completions', () {
|
||||||
final uri = AiService.resolveEndpointUri(
|
final uri = AiService.resolveEndpointUri(
|
||||||
provider: AiProviderType.openAi,
|
provider: AiProviderType.openAi,
|
||||||
@@ -43,13 +43,13 @@ void main() {
|
|||||||
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/chat/completions'));
|
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/chat/completions'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('converts /responses endpoint to /chat/completions', () {
|
test('preserves /responses endpoint for OpenAI Responses API support', () {
|
||||||
final uri = AiService.resolveEndpointUri(
|
final uri = AiService.resolveEndpointUri(
|
||||||
provider: AiProviderType.compatible,
|
provider: AiProviderType.compatible,
|
||||||
endpoint: 'https://codex.slcydia.fun/v1/responses',
|
endpoint: 'https://codex.slcydia.fun/v1/responses',
|
||||||
model: 'gpt-4o-mini',
|
model: 'gpt-4o-mini',
|
||||||
);
|
);
|
||||||
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/chat/completions'));
|
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/responses'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolves Gemini endpoint to :generateContent', () {
|
test('resolves Gemini endpoint to :generateContent', () {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:kouyu_english/core/ai_service.dart';
|
||||||
|
import 'package:kouyu_english/core/models.dart';
|
||||||
|
|
||||||
|
class RealHttpOverrides extends HttpOverrides {
|
||||||
|
@override
|
||||||
|
HttpClient createHttpClient(SecurityContext? context) {
|
||||||
|
return super.createHttpClient(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
HttpOverrides.global = RealHttpOverrides();
|
||||||
|
const testKey = 'sk-242EMNuXYjxSEktp91E8QqS8ejGs9XImrDddIA5JHXdeCKLSUcB91vrSmhyv45pf';
|
||||||
|
AiService.instance.setFallbackApiKey(testKey);
|
||||||
|
|
||||||
|
test('Live test: /v1/responses endpoint testConnection', () async {
|
||||||
|
final resResponses = await AiService.instance.testConnection(
|
||||||
|
provider: AiProviderType.compatible,
|
||||||
|
endpoint: 'https://codex.slcydia.fun/v1/responses',
|
||||||
|
model: 'gemini-3.7-flash-high',
|
||||||
|
explicitApiKey: testKey,
|
||||||
|
);
|
||||||
|
print('Responses API result: ok=${resResponses.ok}, msg=${resResponses.message}');
|
||||||
|
expect(resResponses.ok, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Live test: /v1 (Chat Completions) endpoint testConnection', () async {
|
||||||
|
final resChat = await AiService.instance.testConnection(
|
||||||
|
provider: AiProviderType.compatible,
|
||||||
|
endpoint: 'https://codex.slcydia.fun/v1',
|
||||||
|
model: 'gemini-3.7-flash-high',
|
||||||
|
explicitApiKey: testKey,
|
||||||
|
);
|
||||||
|
print('Chat Completions API result: ok=${resChat.ok}, msg=${resChat.message}');
|
||||||
|
expect(resChat.ok, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Live test: /v1/responses dialogueReply', () async {
|
||||||
|
final reply = await AiService.instance.dialogueReply(
|
||||||
|
provider: AiProviderType.compatible,
|
||||||
|
endpoint: 'https://codex.slcydia.fun/v1/responses',
|
||||||
|
model: 'gemini-3.7-flash-high',
|
||||||
|
history: [
|
||||||
|
{'role': 'user', 'content': 'Hello, my name is Alex.'}
|
||||||
|
],
|
||||||
|
requiredTask: 'Greet learner and ask what is their name',
|
||||||
|
);
|
||||||
|
print('Dialogue reply from /v1/responses: reply="${reply?.reply}", slots=${reply?.slots}, suggestsComplete=${reply?.suggestsComplete}');
|
||||||
|
expect(reply, isNotNull);
|
||||||
|
expect(reply!.reply, isNotEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user