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
|
||||
/// 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,47 +763,58 @@ 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?;
|
||||
}
|
||||
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;
|
||||
}
|
||||
final outputList = data['output'] as List?;
|
||||
if (outputList != null && outputList.isNotEmpty) {
|
||||
for (final item in outputList) {
|
||||
if (item is Map) {
|
||||
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 = (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) {
|
||||
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) {
|
||||
if (item is Map) {
|
||||
if (item['content'] is List) {
|
||||
for (final sub in item['content'] as List) {
|
||||
if (sub is Map && sub['text'] is String) {
|
||||
rawContent = sub['text'] as String;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (item['text'] is String) {
|
||||
rawContent = item['text'] as String;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (item['text'] is String) {
|
||||
return item['text'] as String;
|
||||
if (rawContent != null) break;
|
||||
}
|
||||
}
|
||||
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) {
|
||||
return data['response'] as String;
|
||||
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*```$'), '');
|
||||
}
|
||||
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;
|
||||
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'];
|
||||
|
||||
@@ -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 = '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