From d9e88056e204ef5aa6eb8de1870674c1fead8170 Mon Sep 17 00:00:00 2001 From: Eric Bai <3200769991@qq.com> Date: Fri, 14 Aug 2026 13:47:21 +0800 Subject: [PATCH] feat: discover models from AI providers --- assets/translations/en-US.json | 3 ++ assets/translations/zh-CN.json | 3 ++ assets/translations/zh-TW.json | 3 ++ lib/agent/agent_model_catalog.dart | 54 +++++++++++++++++++++++ lib/agent/agent_page.dart | 59 +++++++++++++++++++++++++ lib/agent/agent_repository.dart | 17 ++++---- lib/servers/server_providers.dart | 5 +++ test/agent_model_catalog_test.dart | 69 ++++++++++++++++++++++++++++++ 8 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 lib/agent/agent_model_catalog.dart create mode 100644 test/agent_model_catalog_test.dart diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 71a7d71..4f854b4 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -56,6 +56,7 @@ "agentChats": "Chats", "agentConversations": "Conversations", "agentCouldNotAddModel": "Could not add model", + "agentCouldNotFetchModels": "Could not fetch models", "agentCouldNotAddSkill": "Could not add skill", "agentCouldNotSaveMcpServer": "Could not save MCP server", "agentCouldNotSaveProvider": "Could not save provider", @@ -77,12 +78,14 @@ "agentEditMcpServer": "Edit MCP server", "agentEditProvider": "Edit provider", "agentEditSkill": "Edit skill", + "agentDiscoveredModels": "Available models", "agentEmptyHint": "Describe the task. The agent can inspect any saved server.", "agentEnabled": "Enabled", "agentEnvironmentInvalidJson": "Environment must be a JSON object.", "agentError": "Error: {}", "agentGhostConversation": "Don't save this conversation", "agentGhostHint": "Ghost chat — messages are not saved to history.", + "agentFetchModels": "Fetch models", "agentHoursAgo": "{}h ago", "agentImportMcpAdded": "Added {} servers", "agentImportMcpConfig": "Import MCP config", diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 401abba..43177fc 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -56,6 +56,7 @@ "agentChats": "聊天", "agentConversations": "对话", "agentCouldNotAddModel": "无法添加模型", + "agentCouldNotFetchModels": "无法获取模型列表", "agentCouldNotAddSkill": "无法添加技能", "agentCouldNotSaveMcpServer": "无法保存 MCP 服务器", "agentCouldNotSaveProvider": "无法保存提供方", @@ -77,12 +78,14 @@ "agentEditMcpServer": "编辑 MCP 服务器", "agentEditProvider": "编辑提供方", "agentEditSkill": "编辑技能", + "agentDiscoveredModels": "可用模型", "agentEmptyHint": "描述任务。助手可以检查任何已保存的服务器。", "agentEnabled": "已启用", "agentEnvironmentInvalidJson": "环境变量必须是 JSON 对象。", "agentError": "错误:{}", "agentGhostConversation": "不保存此对话", "agentGhostHint": "幽灵聊天 — 消息不会保存到历史记录。", + "agentFetchModels": "获取模型列表", "agentHoursAgo": "{} 小时前", "agentImportMcpAdded": "已添加 {} 个服务器", "agentImportMcpConfig": "导入 MCP 配置", diff --git a/assets/translations/zh-TW.json b/assets/translations/zh-TW.json index 0d3fd26..477747f 100644 --- a/assets/translations/zh-TW.json +++ b/assets/translations/zh-TW.json @@ -56,6 +56,7 @@ "agentChats": "聊天", "agentConversations": "對話", "agentCouldNotAddModel": "無法新增模型", + "agentCouldNotFetchModels": "無法取得模型清單", "agentCouldNotAddSkill": "無法新增技能", "agentCouldNotSaveMcpServer": "無法儲存 MCP 伺服器", "agentCouldNotSaveProvider": "無法儲存提供方", @@ -77,12 +78,14 @@ "agentEditMcpServer": "編輯 MCP 伺服器", "agentEditProvider": "編輯提供方", "agentEditSkill": "編輯技能", + "agentDiscoveredModels": "可用模型", "agentEmptyHint": "描述任務。助手可以檢查任何已儲存的伺服器。", "agentEnabled": "已啟用", "agentEnvironmentInvalidJson": "環境變數必須是 JSON 物件。", "agentError": "錯誤:{}", "agentGhostConversation": "不儲存此對話", "agentGhostHint": "幽靈聊天 — 訊息不會儲存到歷史記錄。", + "agentFetchModels": "取得模型清單", "agentHoursAgo": "{} 小時前", "agentImportMcpAdded": "已新增 {} 個伺服器", "agentImportMcpConfig": "匯入 MCP 配置", diff --git a/lib/agent/agent_model_catalog.dart b/lib/agent/agent_model_catalog.dart new file mode 100644 index 0000000..3f3c68c --- /dev/null +++ b/lib/agent/agent_model_catalog.dart @@ -0,0 +1,54 @@ +import 'package:dio/dio.dart'; + +class AgentModelCatalog { + AgentModelCatalog({Dio? dio}) : _dio = dio ?? Dio(); + + final Dio _dio; + + Future> fetchModels({ + required String baseUrl, + required String apiKey, + }) async { + final endpoint = modelsEndpoint(baseUrl); + final response = await _dio.get( + endpoint.toString(), + options: Options( + headers: { + 'Accept': 'application/json', + if (apiKey.trim().isNotEmpty) + 'Authorization': 'Bearer ${apiKey.trim()}', + }, + ), + ); + final body = response.data; + if (body is! Map) { + throw const FormatException('The models response is not an object.'); + } + final data = body['data']; + if (data is! List) { + throw const FormatException('The models response has no data list.'); + } + final models = {}; + for (final item in data) { + if (item is! Map) continue; + final id = item['id']?.toString().trim(); + if (id != null && id.isNotEmpty) models.add(id); + } + if (models.isEmpty) { + throw const FormatException('The provider returned no model IDs.'); + } + return models.toList()..sort(); + } +} + +Uri modelsEndpoint(String baseUrl) { + final value = baseUrl.trim().replaceFirst(RegExp(r'/+$'), ''); + final base = Uri.tryParse(value); + if (base == null || !base.hasScheme || base.host.isEmpty) { + throw ArgumentError.value(baseUrl, 'baseUrl', 'must be an absolute URL'); + } + final path = base.path.endsWith('/v1') + ? '${base.path}/models' + : '${base.path}/v1/models'; + return base.replace(path: path, query: null, fragment: null); +} diff --git a/lib/agent/agent_page.dart b/lib/agent/agent_page.dart index 4b6eb24..c827f7a 100644 --- a/lib/agent/agent_page.dart +++ b/lib/agent/agent_page.dart @@ -867,6 +867,9 @@ class _AgentPageState extends ConsumerState { useRootNavigator: true, builder: (sheetContext) => _AgentProviderEditorSheet( existing: existing, + onFetchModels: (apiKey, baseUrl) => ref + .read(agentModelCatalogProvider) + .fetchModels(baseUrl: baseUrl, apiKey: apiKey), onSave: (draft) async { try { await ref @@ -1756,9 +1759,12 @@ class _ConversationTile extends StatelessWidget { class _AgentProviderEditorSheet extends StatefulWidget { const _AgentProviderEditorSheet({ required this.existing, + required this.onFetchModels, required this.onSave, }); final AgentProvider? existing; + final Future> Function(String apiKey, String baseUrl) + onFetchModels; final Future Function(AgentProviderDraft draft) onSave; @override @@ -1850,6 +1856,8 @@ class _AgentProviderEditorSheetState extends State<_AgentProviderEditorSheet> { late final _model = TextEditingController( text: widget.existing?.model ?? 'gpt-4o-mini', ); + var _models = []; + var _fetchingModels = false; var _saving = false; @override @@ -1865,12 +1873,16 @@ class _AgentProviderEditorSheetState extends State<_AgentProviderEditorSheet> { if (_saving) return; setState(() => _saving = true); try { + if (_key.text.trim().isNotEmpty && _models.isEmpty) { + await _fetchModels(showError: false); + } await widget.onSave( AgentProviderDraft( name: _name.text, apiKey: _key.text, baseUrl: _endpoint.text, model: _model.text, + models: _models, ), ); } finally { @@ -1878,6 +1890,25 @@ class _AgentProviderEditorSheetState extends State<_AgentProviderEditorSheet> { } } + Future _fetchModels({bool showError = true}) async { + if (_fetchingModels) return; + setState(() => _fetchingModels = true); + try { + final models = await widget.onFetchModels(_key.text, _endpoint.text); + if (!mounted) return; + setState(() { + _models = models; + if (!models.contains(_model.text)) _model.text = models.first; + }); + } catch (error) { + if (showError) { + showMaidKitErrorAlert(error, title: 'agentCouldNotFetchModels'.tr()); + } + } finally { + if (mounted) setState(() => _fetchingModels = false); + } + } + @override Widget build(BuildContext context) => SheetScaffold( titleText: widget.existing == null @@ -1901,6 +1932,7 @@ class _AgentProviderEditorSheetState extends State<_AgentProviderEditorSheet> { _name.text = preset.name; _endpoint.text = preset.baseUrl; _model.text = preset.models.first; + _models = []; }); }, ), @@ -1932,6 +1964,33 @@ class _AgentProviderEditorSheetState extends State<_AgentProviderEditorSheet> { decoration: InputDecoration(labelText: 'agentBaseUrl'.tr()), ), const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _fetchingModels ? null : _fetchModels, + icon: _fetchingModels + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Symbols.refresh), + label: Text('agentFetchModels'.tr()), + ), + if (_models.isNotEmpty) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _models.contains(_model.text) ? _model.text : null, + decoration: InputDecoration( + labelText: 'agentDiscoveredModels'.tr(), + ), + items: [ + for (final model in _models) + DropdownMenuItem(value: model, child: Text(model)), + ], + onChanged: (model) { + if (model != null) _model.text = model; + }, + ), + ], + const SizedBox(height: 12), TextField( controller: _model, onSubmitted: (_) => _save(), diff --git a/lib/agent/agent_repository.dart b/lib/agent/agent_repository.dart index d483abf..5947197 100644 --- a/lib/agent/agent_repository.dart +++ b/lib/agent/agent_repository.dart @@ -25,6 +25,7 @@ class AgentProviderDraft { required this.apiKey, required this.baseUrl, required this.model, + this.models = const [], }); final String name; @@ -32,6 +33,7 @@ class AgentProviderDraft { final String apiKey; final String? baseUrl; final String model; + final List models; } class AgentRepository { @@ -94,20 +96,17 @@ class AgentRepository { updatedAt: DateTime.now().toUtc(), ), ); - await _database - .into(_database.agentProviderModels) - .insert( - AgentProviderModelsCompanion.insert( - providerId: providerId, - model: model, - createdAt: DateTime.now().toUtc(), - ), - ); + for (final discoveredModel in {model, ...draft.models}) { + await addModel(providerId, discoveredModel); + } } else { await (_database.update( _database.agentProviders, )..where((table) => table.id.equals(existing.id))).write(values); await addModel(existing.id, model); + for (final discoveredModel in draft.models) { + await addModel(existing.id, discoveredModel); + } } } diff --git a/lib/servers/server_providers.dart b/lib/servers/server_providers.dart index 2cb8c19..c21dd1c 100644 --- a/lib/servers/server_providers.dart +++ b/lib/servers/server_providers.dart @@ -17,6 +17,7 @@ import 'package:maid_kit/agent/mcp_repository.dart'; import 'package:maid_kit/agent/skill_repository.dart'; import 'package:maid_kit/agent/skill_registry.dart'; import 'package:maid_kit/agent/agent_repository.dart'; +import 'package:maid_kit/agent/agent_model_catalog.dart'; import 'package:maid_kit/agent/conversation_store.dart'; import 'package:maid_kit/agent/agent_personality.dart'; import 'package:maid_kit/agent/agent_run_policy.dart'; @@ -426,6 +427,10 @@ final agentRepositoryProvider = Provider((ref) { ); }); +final agentModelCatalogProvider = Provider( + (ref) => AgentModelCatalog(), +); + final agentConfiguredProvider = StreamProvider((ref) { return ref .watch(agentRepositoryProvider) diff --git a/test/agent_model_catalog_test.dart b/test/agent_model_catalog_test.dart new file mode 100644 index 0000000..e41df30 --- /dev/null +++ b/test/agent_model_catalog_test.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maid_kit/agent/agent_model_catalog.dart'; + +class _FakeAdapter implements HttpClientAdapter { + _FakeAdapter(this.body); + + final String body; + RequestOptions? request; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + request = options; + return ResponseBody.fromString( + body, + 200, + headers: const { + 'content-type': ['application/json'], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +void main() { + test('builds model endpoints with or without an existing v1 path', () { + expect( + modelsEndpoint('https://api.openai.com').toString(), + 'https://api.openai.com/v1/models', + ); + expect( + modelsEndpoint('https://openrouter.ai/api/v1/').toString(), + 'https://openrouter.ai/api/v1/models', + ); + }); + + test('fetches, deduplicates, and sorts OpenAI-compatible models', () async { + final adapter = _FakeAdapter( + jsonEncode({ + 'data': [ + {'id': 'z-model'}, + {'id': 'a-model'}, + {'id': 'z-model'}, + ], + }), + ); + final dio = Dio()..httpClientAdapter = adapter; + + final models = await AgentModelCatalog( + dio: dio, + ).fetchModels(baseUrl: 'https://api.example.com', apiKey: 'secret'); + + expect(models, ['a-model', 'z-model']); + expect( + adapter.request!.uri.toString(), + 'https://api.example.com/v1/models', + ); + expect(adapter.request!.headers['Authorization'], 'Bearer secret'); + }); +}