From b88ca96064311ddecade5510628fbe5bf198242d Mon Sep 17 00:00:00 2001 From: Acbox Liu Date: Sun, 22 Mar 2026 17:24:45 +0800 Subject: [PATCH] refactor: provider & models (#277) * refactor: move client_type to provider, replace model fields with config JSONB - Move `client_type` from `models` to `llm_providers` table - Add `icon` field to `llm_providers` - Replace `dimensions`, `input_modalities`, `supports_reasoning` on `models` with a single `config` JSONB column containing `dimensions`, `compatibilities` (vision, tool-call, image-output, reasoning), and `context_window` - Auto-imported models default to vision + tool-call + reasoning - Update all backend consumers (agent, flow resolver, handlers, memory) - Regenerate sqlc, swagger, and TypeScript SDK - Update frontend forms, display, and i18n for new schema * ui: show provider icon avatar in sidebar and detail header, remove icon input * feat: add built-in provider registry with YAML definitions and enable toggle - Add `enable` column to llm_providers (default true, backward-compatible) - Create internal/registry package to load YAML provider/model definitions on startup and upsert into database (new providers disabled by default) - Add conf/providers/ with OpenAI, Anthropic, Google YAML definitions - Add RegistryConfig to TOML config (providers_dir, default conf/providers) - Model listing APIs and conversation flow now filter by enabled providers - Frontend: enable switch in provider form, green status dot in sidebar, enabled providers sorted to top * fix: make 0041 migration idempotent for fresh databases Guard data migration steps with column-existence checks so the migration succeeds on databases created from the updated init schema. --- .../web/src/components/add-provider/index.vue | 57 ++- .../web/src/components/create-model/index.vue | 172 +++---- .../components/import-models-dialog/index.vue | 33 +- apps/web/src/constants/client-types.ts | 4 +- apps/web/src/constants/compatibilities.ts | 11 + apps/web/src/i18n/locales/en.json | 27 +- apps/web/src/i18n/locales/zh.json | 27 +- .../pages/bots/components/bot-settings.vue | 2 +- .../pages/models/components/model-item.vue | 14 +- .../pages/models/components/provider-form.vue | 66 +++ apps/web/src/pages/models/index.vue | 45 +- apps/web/src/pages/models/model-setting.vue | 18 +- cmd/agent/main.go | 20 +- cmd/memoh/serve.go | 20 +- conf/app.example.toml | 3 + conf/providers/anthropic.yaml | 33 ++ conf/providers/google.yaml | 39 ++ conf/providers/openai.yaml | 73 +++ db/migrations/0001_init.up.sql | 16 +- .../0041_provider_model_refactor.down.sql | 38 ++ .../0041_provider_model_refactor.up.sql | 62 +++ db/migrations/0042_provider_enable.down.sql | 5 + db/migrations/0042_provider_enable.up.sql | 5 + db/queries/messages.sql | 3 +- db/queries/models.sql | 91 ++-- internal/agent/tools/subagent.go | 13 +- internal/agent/types.go | 1 - internal/compaction/service.go | 4 +- internal/config/config.go | 15 + .../conversation/flow/capability_policy.go | 36 +- .../flow/capability_policy_test.go | 40 +- internal/conversation/flow/resolver.go | 9 +- .../conversation/flow/resolver_attachments.go | 2 +- .../conversation/flow/resolver_compaction.go | 2 +- .../flow/resolver_model_selection.go | 4 +- internal/conversation/flow/resolver_test.go | 8 +- internal/conversation/flow/resolver_title.go | 2 +- internal/db/sqlc/compaction_logs.sql.go | 27 +- internal/db/sqlc/conversations.sql.go | 2 +- internal/db/sqlc/messages.sql.go | 26 +- internal/db/sqlc/models.go | 42 +- internal/db/sqlc/models.sql.go | 468 +++++++++++------- internal/handlers/models.go | 10 +- internal/handlers/providers.go | 27 +- .../memory/adapters/builtin/dense_runtime.go | 10 +- internal/models/models.go | 221 ++++----- internal/models/models_test.go | 195 +------- internal/models/probe.go | 2 +- internal/models/types.go | 86 ++-- internal/providers/service.go | 87 ++-- internal/providers/types.go | 43 +- internal/registry/registry.go | 112 +++++ internal/registry/types.go | 25 + packages/sdk/src/@pinia/colada.gen.ts | 6 +- packages/sdk/src/index.ts | 2 +- packages/sdk/src/sdk.gen.ts | 15 +- packages/sdk/src/types.gen.ts | 42 +- spec/docs.go | 130 ++--- spec/swagger.json | 130 ++--- spec/swagger.yaml | 95 ++-- 60 files changed, 1599 insertions(+), 1224 deletions(-) create mode 100644 apps/web/src/constants/compatibilities.ts create mode 100644 conf/providers/anthropic.yaml create mode 100644 conf/providers/google.yaml create mode 100644 conf/providers/openai.yaml create mode 100644 db/migrations/0041_provider_model_refactor.down.sql create mode 100644 db/migrations/0041_provider_model_refactor.up.sql create mode 100644 db/migrations/0042_provider_enable.down.sql create mode 100644 db/migrations/0042_provider_enable.up.sql create mode 100644 internal/registry/registry.go create mode 100644 internal/registry/types.go diff --git a/apps/web/src/components/add-provider/index.vue b/apps/web/src/components/add-provider/index.vue index e8ddf90a..4105da60 100644 --- a/apps/web/src/components/add-provider/index.vue +++ b/apps/web/src/components/add-provider/index.vue @@ -91,6 +91,25 @@ + + + + + + + + + - - - - - - - -

- {{ $t('models.importClientTypeHint') }} -

-
-
@@ -163,13 +159,23 @@ import { useI18n } from 'vue-i18n' import FormDialogShell from '@/components/form-dialog-shell/index.vue' import { useDialogMutation } from '@/composables/useDialogMutation' import SearchableSelectPopover from '@/components/searchable-select-popover/index.vue' -import { CLIENT_TYPE_LIST } from '@/constants/client-types' +import { CLIENT_TYPE_LIST, CLIENT_TYPE_META } from '@/constants/client-types' import { toast } from 'vue-sonner' +import { computed } from 'vue' const open = defineModel('open') const { t } = useI18n() const { run } = useDialogMutation() +const clientTypeOptions = computed(() => + CLIENT_TYPE_LIST.map((ct) => ({ + value: ct.value, + label: ct.label, + description: ct.hint, + keywords: [ct.label, ct.hint, CLIENT_TYPE_META[ct.value]?.value ?? ct.value], + })), +) + const queryCache = useQueryCache() const { mutateAsync: createProviderMutation, isLoading } = useMutation({ mutation: async (data: Record) => { @@ -182,7 +188,6 @@ const { mutateAsync: createProviderMutation, isLoading } = useMutation({ try { const { data: importResult } = await postProvidersByIdImportModels({ path: { id: result.id }, - body: { client_type: data.client_type as string }, throwOnError: true, }) if (importResult) { @@ -206,8 +211,8 @@ const providerSchema = toTypedSchema(z.object({ api_key: z.string().min(1), base_url: z.string().min(1), name: z.string().min(1), + client_type: z.string().min(1), auto_import: z.boolean().optional(), - client_type: z.string().optional(), })) const form = useForm({ diff --git a/apps/web/src/components/create-model/index.vue b/apps/web/src/components/create-model/index.vue index 3e93cdff..917b53b7 100644 --- a/apps/web/src/components/create-model/index.vue +++ b/apps/web/src/components/create-model/index.vue @@ -48,40 +48,6 @@ - -
- - - - -
- - +
- -
+ - - -
+ + + + + + + @@ -194,7 +168,6 @@ import { FormItem, Checkbox, Label, - Switch, } from '@memoh/ui' import { useForm } from 'vee-validate' import { inject, computed, watch, nextTick, type Ref, ref } from 'vue' @@ -204,24 +177,20 @@ import { useMutation, useQueryCache } from '@pinia/colada' import { postModels, putModelsById, putModelsModelByModelId } from '@memoh/sdk' import type { ModelsGetResponse, ModelsAddRequest, ModelsUpdateRequest } from '@memoh/sdk' import { useI18n } from 'vue-i18n' -import { CLIENT_TYPE_LIST, CLIENT_TYPE_META } from '@/constants/client-types' +import { COMPATIBILITY_OPTIONS } from '@/constants/compatibilities' import FormDialogShell from '@/components/form-dialog-shell/index.vue' -import SearchableSelectPopover from '@/components/searchable-select-popover/index.vue' -import type { SearchableSelectOption } from '@/components/searchable-select-popover/index.vue' import { useDialogMutation } from '@/composables/useDialogMutation' -const availableInputModalities = ['text', 'image', 'audio', 'video', 'file'] as const -const selectedModalities = ref(['text']) -const supportsReasoning = ref(false) +const selectedCompat = ref([]) const { t } = useI18n() const { run } = useDialogMutation() const formSchema = toTypedSchema(z.object({ type: z.string().min(1), - client_type: z.string().optional(), model_id: z.string().min(1), name: z.string().optional(), dimensions: z.coerce.number().min(1).optional(), + context_window: z.coerce.number().min(1).optional(), })) const form = useForm({ @@ -233,37 +202,22 @@ const form = useForm({ const selectedType = computed(() => form.values.type || 'chat') -const clientTypeModel = computed({ - get: () => form.values.client_type || '', - set: (value: string) => form.setFieldValue('client_type', value), -}) - -const clientTypeOptions = computed(() => - CLIENT_TYPE_LIST.map((ct) => ({ - value: ct.value, - label: ct.label, - description: ct.hint, - keywords: [ct.label, ct.hint, CLIENT_TYPE_META[ct.value]?.value ?? ct.value], - })), -) - const open = inject>('openModel', ref(false)) const title = inject>('openModelTitle', ref('title')) const editInfo = inject>('openModelState', ref(null)) const canSubmit = computed(() => { if (title.value === 'edit') return true - const { type, model_id, client_type } = form.values + const { type, model_id } = form.values if (!type || !model_id) return false - if (type === 'chat' && !client_type) return false return true }) -function toggleModality(mod: string, checked: boolean) { +function toggleCompat(cap: string, checked: boolean) { if (checked) { - selectedModalities.value = [...selectedModalities.value, mod] + selectedCompat.value = [...selectedCompat.value, cap] } else { - selectedModalities.value = selectedModalities.value.filter(m => m !== mod) + selectedCompat.value = selectedCompat.value.filter(c => c !== cap) } } @@ -318,44 +272,39 @@ const { mutateAsync: updateModelByLegacyModelID, isLoading: updateLegacyLoading const isLoading = computed(() => createLoading.value || updateLoading.value || updateLegacyLoading.value) async function addModel() { - - const isEdit = title.value === 'edit' && !!editInfo?.value + const isEdit = title.value === 'edit' && !!editInfo?.value const fallback = editInfo?.value const type = form.values.type || (isEdit ? fallback!.type : 'chat') - const client_type = type === 'chat' - ? (form.values.client_type || (isEdit ? fallback!.client_type : '')) - : undefined const model_id = form.values.model_id || (isEdit ? fallback!.model_id : '') const name = form.values.name ?? (isEdit ? fallback!.name : '') - const dimensions = form.values.dimensions ?? (isEdit ? fallback!.dimensions : undefined) if (!type || !model_id) return - if (type === 'chat' && !client_type) return + + const config: Record = {} + + if (type === 'embedding') { + const dim = form.values.dimensions ?? (isEdit ? fallback!.config?.dimensions : undefined) + if (dim) config.dimensions = dim + } + + if (type === 'chat') { + config.compatibilities = selectedCompat.value + const ctxWin = form.values.context_window ?? (isEdit ? fallback!.config?.context_window : undefined) + if (ctxWin) config.context_window = ctxWin + } const payload: Record = { type, model_id, llm_provider_id: id, - } - - if (type === 'chat' && client_type) { - payload.client_type = client_type + config, } if (name) { payload.name = name } - if (type === 'embedding' && dimensions) { - payload.dimensions = dimensions - } - - if (type === 'chat') { - payload.input_modalities = selectedModalities.value.length > 0 ? selectedModalities.value : ['text'] - payload.supports_reasoning = supportsReasoning.value - } - await run( () => { if (isEdit) { @@ -386,25 +335,24 @@ watch(open, async () => { await nextTick() if (editInfo?.value) { - const { client_type, type, model_id, name, dimensions, input_modalities } = editInfo.value - form.resetForm({ values: { type: type || 'chat', client_type: client_type || '', model_id, name, dimensions } }) - selectedModalities.value = input_modalities ?? ['text'] - supportsReasoning.value = !!editInfo.value.supports_reasoning + const { type, model_id, name, config } = editInfo.value + form.resetForm({ + values: { + type: type || 'chat', + model_id, + name, + dimensions: config?.dimensions, + context_window: config?.context_window, + }, + }) + selectedCompat.value = config?.compatibilities ?? [] userEditedName.value = !!(name && name !== model_id) } else { - form.resetForm({ values: { type: 'chat', client_type: '', model_id: '', name: '', dimensions: undefined } }) - selectedModalities.value = ['text'] - supportsReasoning.value = false + form.resetForm({ values: { type: 'chat', model_id: '', name: '', dimensions: undefined, context_window: undefined } }) + selectedCompat.value = [] userEditedName.value = false } }, { immediate: true, }) - -// Clear client_type when switching to embedding -watch(selectedType, (newType) => { - if (newType === 'embedding') { - form.setFieldValue('client_type', '') - } -}) diff --git a/apps/web/src/components/import-models-dialog/index.vue b/apps/web/src/components/import-models-dialog/index.vue index bd515fce..e77e53c7 100644 --- a/apps/web/src/components/import-models-dialog/index.vue +++ b/apps/web/src/components/import-models-dialog/index.vue @@ -4,7 +4,7 @@ :title="$t('models.importModels')" :cancel-text="$t('common.cancel')" :submit-text="$t('common.import')" - :submit-disabled="!clientType" + :submit-disabled="false" :loading="isLoading" @submit="handleImport" > @@ -19,17 +19,8 @@ @@ -37,15 +28,13 @@