From 6fcffc968f3c0015395978961cd03375e5749f72 Mon Sep 17 00:00:00 2001 From: shen <> Date: Tue, 15 Sep 2026 20:44:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8E=9F=E7=94=9F=E6=94=AF=E6=8C=81=20?= =?UTF-8?q?/v1/responses=20=E6=8E=A5=E5=8F=A3=E4=B8=8E=20Chat=20Completion?= =?UTF-8?q?s=20=E5=8F=8C=E5=8D=8F=E8=AE=AE=E8=87=AA=E9=80=82=E5=BA=94?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=8A=A0=E7=AB=AF=E5=88=B0=E7=AB=AF=E8=81=94?= =?UTF-8?q?=E8=B0=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kouyu_english/lib/core/ai_service.dart | 237 +++++++++++++-------- kouyu_english/test/ai_config_test.dart | 6 +- kouyu_english/test/live_endpoint_test.dart | 55 +++++ 3 files changed, 201 insertions(+), 97 deletions(-) create mode 100644 kouyu_english/test/live_endpoint_test.dart diff --git a/kouyu_english/lib/core/ai_service.dart b/kouyu_english/lib/core/ai_service.dart index dd2b6d3..c5e2b60 100644 --- a/kouyu_english/lib/core/ai_service.dart +++ b/kouyu_english/lib/core/ai_service.dart @@ -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 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 _buildOpenAiPayload({ + required Uri uri, + required String model, + required List> 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 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) 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? 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; + 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; final reply = data['reply'] as String?; final rawSlots = data['slots']; final rawEvidence = data['evidence']; diff --git a/kouyu_english/test/ai_config_test.dart b/kouyu_english/test/ai_config_test.dart index 8fac200..95a6fd5 100644 --- a/kouyu_english/test/ai_config_test.dart +++ b/kouyu_english/test/ai_config_test.dart @@ -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', () { diff --git a/kouyu_english/test/live_endpoint_test.dart b/kouyu_english/test/live_endpoint_test.dart new file mode 100644 index 0000000..3fd2af4 --- /dev/null +++ b/kouyu_english/test/live_endpoint_test.dart @@ -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); + }); +}