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

This commit is contained in:
shen
2026-09-15 20:44:27 +08:00
parent a717365c10
commit 524788291b
3 changed files with 201 additions and 97 deletions
+128 -79
View File
@@ -14,7 +14,8 @@ class AiConnectionResult {
}
/// 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 {
AiService._();
static final instance = AiService._();
@@ -55,8 +56,9 @@ class AiService {
Future<String?> getApiKey() async => await resolveApiKey();
/// Resolves the target endpoint URI. For OpenAI and compatible endpoints,
/// all requests target the standard Chat Completions endpoint (/v1/chat/completions).
/// Resolves the target endpoint URI.
/// 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({
required AiProviderType provider,
required String endpoint,
@@ -73,17 +75,39 @@ class AiService {
if (base.endsWith('/chat/completions')) {
return Uri.tryParse(base);
}
if (base.endsWith('/responses')) {
return Uri.tryParse(base);
}
if (base.endsWith('/v1')) {
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');
}
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.
/// This is deliberately not a LexiconEntry and cannot affect review/mastery.
Future<String?> temporaryDefinition({
@@ -133,30 +157,33 @@ class AiService {
},
],
'generationConfig': {
'maxOutputTokens': 100,
'maxOutputTokens': 200,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{
'role': 'user',
'content': '$instruction\nText: $text',
},
],
'max_tokens': 100,
},
maxTokens: 200,
),
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) return null;
final raw = _extractResponseContent(provider, response.body);
final data = raw == null ? null : jsonDecode(raw);
final definition = data is Map ? data['definition'] : null;
return definition is String &&
definition.trim().isNotEmpty &&
definition.length <= 160
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final content = _extractResponseContent(provider, response.body);
if (content == null || content.length > 300) return null;
final parsed = jsonDecode(content);
if (parsed is! Map<String, dynamic>) return null;
final definition = parsed['definition'];
return definition is String && definition.trim().isNotEmpty
? definition.trim()
: null;
} catch (_) {
@@ -218,18 +245,19 @@ class AiService {
],
'generationConfig': {
'temperature': 0,
'maxOutputTokens': 60,
'maxOutputTokens': 200,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{'role': 'user', 'content': probe},
],
'temperature': 0,
'max_tokens': 60,
},
temperature: 0,
maxTokens: 200,
),
),
)
.timeout(const Duration(seconds: 15));
@@ -335,19 +363,20 @@ class AiService {
.toList(),
'generationConfig': {
'temperature': 0.3,
'maxOutputTokens': 60,
'maxOutputTokens': 300,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{'role': 'system', 'content': '$system$requiredTask'},
...history,
],
'temperature': 0.3,
'max_tokens': 60,
},
temperature: 0.3,
maxTokens: 300,
),
),
)
.timeout(const Duration(seconds: 30));
@@ -410,18 +439,19 @@ class AiService {
],
'generationConfig': {
'temperature': 0.2,
'maxOutputTokens': 120,
'maxOutputTokens': 300,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{'role': 'user', 'content': instruction},
],
'temperature': 0.2,
'max_tokens': 120,
},
temperature: 0.2,
maxTokens: 300,
),
),
)
.timeout(const Duration(seconds: 30));
@@ -502,18 +532,19 @@ Learner wrote: $answer''';
],
'generationConfig': {
'temperature': 0,
'maxOutputTokens': 150,
'maxOutputTokens': 300,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{'role': 'user', 'content': instruction},
],
'temperature': 0,
'max_tokens': 150,
},
temperature: 0,
maxTokens: 300,
),
),
)
.timeout(const Duration(seconds: 30));
@@ -579,18 +610,19 @@ Learner wrote: $answer''';
],
'generationConfig': {
'temperature': 0.1,
'maxOutputTokens': 650,
'maxOutputTokens': 850,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{'role': 'user', 'content': instruction},
],
'temperature': 0.1,
'max_tokens': 650,
},
temperature: 0.1,
maxTokens: 850,
),
),
)
.timeout(const Duration(seconds: 45));
@@ -686,18 +718,19 @@ Learner wrote: $answer''';
],
'generationConfig': {
'temperature': 0,
'maxOutputTokens': 120,
'maxOutputTokens': 200,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{'role': 'user', 'content': instruction},
],
'temperature': 0,
'max_tokens': 120,
},
temperature: 0,
maxTokens: 200,
),
),
)
.timeout(const Duration(seconds: 30));
@@ -730,21 +763,21 @@ Learner wrote: $answer''';
String? _extractResponseContent(AiProviderType provider, String body) {
try {
final data = jsonDecode(body) as Map<String, dynamic>;
String? rawContent;
if (provider == AiProviderType.gemini) {
final candidate = (data['candidates'] as List?)?.firstOrNull as Map?;
final candidateContent = candidate?['content'] as Map?;
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 choiceContent = (choice?['message'] as Map?)?['content'] as String? ??
choice?['text'] as String?;
if (choiceContent != null && choiceContent.isNotEmpty) {
return choiceContent;
}
if (data['output_text'] is String && (data['output_text'] as String).isNotEmpty) {
return data['output_text'] as String;
}
rawContent = choiceContent;
} else if (data['output_text'] is String && (data['output_text'] as String).isNotEmpty) {
rawContent = data['output_text'] as String;
} else {
final outputList = data['output'] as List?;
if (outputList != null && outputList.isNotEmpty) {
for (final item in outputList) {
@@ -752,25 +785,36 @@ Learner wrote: $answer''';
if (item['content'] is List) {
for (final sub in item['content'] as List) {
if (sub is Map && sub['text'] is String) {
return sub['text'] as String;
rawContent = sub['text'] as String;
break;
}
}
} else if (item['text'] is String) {
return item['text'] as String;
rawContent = item['text'] as String;
break;
}
}
if (rawContent != null) break;
}
}
if (rawContent == null) {
if (data['response'] is String && (data['response'] as String).isNotEmpty) {
return data['response'] as String;
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['text'] is String && (data['text'] as String).isNotEmpty) {
return data['text'] as String;
}
if (data['content'] is String && (data['content'] as String).isNotEmpty) {
return data['content'] as String;
}
return null;
}
if (rawContent == null) return null;
var trimmed = rawContent.trim();
if (trimmed.startsWith('```')) {
trimmed = trimmed.replaceFirst(RegExp(r'^```[a-zA-Z]*\s*'), '');
trimmed = trimmed.replaceFirst(RegExp(r'\s*```$'), '');
}
return trimmed.trim();
} catch (_) {
return null;
}
@@ -779,7 +823,12 @@ Learner wrote: $answer''';
DialogueAiResponse? _decodeDialogueResponse(String? raw) {
if (raw == null || raw.trim().isEmpty || raw.length > 1200) return null;
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 rawSlots = data['slots'];
final rawEvidence = data['evidence'];
+3 -3
View File
@@ -6,7 +6,7 @@ import 'package:kouyu_english/core/models.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('AiService resolveEndpointUri (/v1/chat/completions)', () {
group('AiService resolveEndpointUri', () {
test('resolves OpenAI endpoint ending in /v1 to /v1/chat/completions', () {
final uri = AiService.resolveEndpointUri(
provider: AiProviderType.openAi,
@@ -43,13 +43,13 @@ void main() {
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(
provider: AiProviderType.compatible,
endpoint: 'https://codex.slcydia.fun/v1/responses',
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', () {
@@ -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 = '***REMOVED***';
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);
});
}