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'];
|
||||
|
||||
Reference in New Issue
Block a user