feat: 原生支持 /v1/responses 接口与 Chat Completions 双协议自适应,增加端到端联调测试

This commit is contained in:
shen
2026-09-15 20:44:27 +08:00
parent 4341726162
commit 6fcffc968f
3 changed files with 201 additions and 97 deletions
+143 -94
View File
@@ -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'];
+3 -3
View File
@@ -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);
});
}