diff --git a/.env.example b/.env.example index 4f3821a8..a4694cad 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,27 @@ GIN_MODE=release # 可选值:true(自动放在 LOG_PATH 同目录下 llm_debug.log)、false/空(关闭)、或指定文件路径 # LLM_DEBUG_LOG=true +# ========== 内置模型(Built-in Models)声明式配置(可选) ========== +# 如果启用了 config/builtin_models.yaml(见 config/builtin_models.yaml.example), +# YAML 中的 ${NAME} 占位符会在应用启动期从这里读取真实值。变量名由 YAML 自行 +# 指定,下面给出常见命名作为参考;按你自己的部署改名也可以。详见 +# docs/BUILTIN_MODELS.md。 +# +# LLM_MODEL_NAME= +# LLM_BASE_URL= +# LLM_API_KEY= +# LLM_PROVIDER=openai +# +# EMBEDDING_MODEL_NAME= +# EMBEDDING_BASE_URL= +# EMBEDDING_API_KEY= +# EMBEDDING_PROVIDER=openai +# +# RERANK_MODEL_NAME= +# RERANK_BASE_URL= +# RERANK_API_KEY= +# RERANK_PROVIDER=generic + # ========== Langfuse 可观测性(可选) ========== # 用于追踪 chat / embedding / rerank / VLM / ASR 模型调用,统计 token 消耗。 # 详细说明:docs/Langfuse集成.md diff --git a/config/builtin_models.yaml.example b/config/builtin_models.yaml.example new file mode 100644 index 00000000..a873a3bc --- /dev/null +++ b/config/builtin_models.yaml.example @@ -0,0 +1,75 @@ +# config/builtin_models.yaml — declarative built-in models. +# +# Copy to config/builtin_models.yaml (or point BUILTIN_MODELS_CONFIG at a custom +# path) to enable. Each entry below is inserted into the `models` table with +# is_builtin=true and becomes visible to every tenant. +# +# How values reach the file +# ------------------------- +# Any string field can reference an env var with ${NAME}. Unset env vars are +# left as the literal ${NAME} so misconfiguration surfaces clearly in upstream +# API calls instead of producing a silent empty token. Non-string fields +# (type, source, is_default, dimension, truncate_prompt_tokens) must stay +# literal because YAML parses them as their target type. +# +# How env vars reach the container +# -------------------------------- +# By default the app service in docker-compose.yml loads .env via +# env_file: +# - path: .env +# required: false +# so anything you put in .env is auto-passed to the container. There is no +# need to list each var separately under `environment:`. +# +# Re-applied on every application startup — editing the file and restarting is +# enough for changes to take effect. Entries removed from the file are NOT +# auto-deleted from the database; clean them up manually if you need to. + +builtin_models: [] + +# ----- Example: env-driven entries — uncomment and adapt -------------------- +# +# builtin_models: +# - id: builtin-llm-default +# type: KnowledgeQA # KnowledgeQA | Embedding | Rerank | VLLM | ASR +# source: remote # remote (default) | local | ... +# is_default: true +# name: ${LLM_MODEL_NAME} +# parameters: +# base_url: ${LLM_BASE_URL} +# api_key: ${LLM_API_KEY} +# provider: ${LLM_PROVIDER} # openai | generic | aliyun | moonshot | ... +# +# - id: builtin-embedding-default +# type: Embedding +# source: remote +# is_default: true +# name: ${EMBEDDING_MODEL_NAME} +# parameters: +# base_url: ${EMBEDDING_BASE_URL} +# api_key: ${EMBEDDING_API_KEY} +# provider: ${EMBEDDING_PROVIDER} +# embedding_parameters: +# dimension: 1536 +# truncate_prompt_tokens: 0 +# +# - id: builtin-rerank-default +# type: Rerank +# source: remote +# parameters: +# base_url: ${RERANK_BASE_URL} +# api_key: ${RERANK_API_KEY} +# provider: ${RERANK_PROVIDER} +# +# ----- Example: literal values (no env indirection) ------------------------- +# +# builtin_models: +# - id: builtin-openai-chat +# name: gpt-4o-mini +# type: KnowledgeQA +# source: remote +# is_default: false +# parameters: +# base_url: https://api.openai.com/v1 +# api_key: ${OPENAI_API_KEY} +# provider: openai diff --git a/docker-compose.yml b/docker-compose.yml index ad8e5333..7c3b6834 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,12 +41,23 @@ services: - ./config/config.yaml:/app/config/config.yaml # Optional: mount custom skills directory (allows adding skills without rebuilding image) - ./skills/preloaded:/app/skills/preloaded + # Optional: declarative built-in models. Copy config/builtin_models.yaml.example + # to config/builtin_models.yaml, edit it for your deployment, then + # uncomment the line below. See docs/BUILTIN_MODELS.md. + # - ./config/builtin_models.yaml:/app/config/builtin_models.yaml:ro healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s timeout: 10s retries: 3 start_period: 60s + # Optional: source .env so deployment-specific vars (e.g. those referenced + # by config/builtin_models.yaml via ${ENV}) reach the app container without + # having to be listed individually under `environment:`. Falls back + # silently when .env is absent (e.g. fresh upstream clone). + env_file: + - path: .env + required: false environment: - LOG_LEVEL=${LOG_LEVEL:-} - COS_SECRET_ID=${COS_SECRET_ID:-} diff --git a/docs/BUILTIN_MODELS.md b/docs/BUILTIN_MODELS.md index 38f92019..d5ddf29d 100644 --- a/docs/BUILTIN_MODELS.md +++ b/docs/BUILTIN_MODELS.md @@ -13,163 +13,245 @@ ## 如何添加内置模型 -内置模型需要通过数据库直接插入。以下是添加内置模型的步骤: +WeKnora 支持两种方式添加内置模型:**推荐**使用 YAML 声明式配置(自动幂等下发),保留 SQL 直插作为兼容路径。 -### 1. 准备模型数据 +### 方式一(推荐):YAML 配置文件 -首先,确保你已经有了要设置为内置模型的模型配置信息,包括: -- 模型名称(name) -- 模型类型(type):`KnowledgeQA`、`Embedding`、`Rerank` 或 `VLLM` -- 模型来源(source):`local` 或 `remote` -- 模型参数(parameters):包括 base_url、api_key、provider 等 -- 租户ID(tenant_id):建议使用小于10000的租户ID,避免冲突 +#### 文件位置 -**支持的服务商(provider)**:`generic`(自定义)、`openai`、`aliyun`、`zhipu`、`volcengine`、`hunyuan`、`deepseek`、`minimax`、`mimo`、`siliconflow`、`jina`、`openrouter`、`gemini`、`modelscope`、`moonshot`、`qianfan`、`qiniu`、`longcat`、`gpustack` +默认路径是 `config/builtin_models.yaml`(与 `config.yaml`、`builtin_agents.yaml` 同目录)。如需挂载到其他位置,设置环境变量 `BUILTIN_MODELS_CONFIG=/absolute/path/builtin_models.yaml` 覆盖。 -### 2. 执行 SQL 插入语句 +文件不存在时启动期会跳过、不报错;解析失败仅记录 Warning、不影响主流程。每次应用启动会重新读取并按 `id` 字段 UPSERT 到 `models` 表(保留 `created_at`,刷新其他字段)。 -使用以下 SQL 语句插入内置模型: +#### Schema + +```yaml +builtin_models: + - id: + tenant_id: # 与 tenants_id_seq 起点对齐 + name: + type: KnowledgeQA | Embedding | Rerank | VLLM | ASR + source: # local | remote | aliyun | ... + description: + is_default: + status: + parameters: + base_url: + api_key: + provider: # openai | generic | moonshot | ... + embedding_parameters: # 仅 Embedding 类型 + dimension: + truncate_prompt_tokens: +``` + +#### 完整示例 + +```yaml +builtin_models: + - id: builtin-openai-chat + name: gpt-4o-mini + type: KnowledgeQA + source: remote + description: OpenAI 默认对话模型 + is_default: false + parameters: + base_url: https://api.openai.com/v1 + api_key: ${OPENAI_API_KEY} + provider: openai + + - id: builtin-openai-embeddings + name: text-embedding-3-small + type: Embedding + source: remote + parameters: + base_url: https://api.openai.com/v1 + api_key: ${OPENAI_API_KEY} + provider: openai + embedding_parameters: + dimension: 1536 + truncate_prompt_tokens: 0 + + - id: builtin-rerank + name: bge-reranker-v2-m3 + type: Rerank + source: remote + parameters: + base_url: ${RERANK_BASE_URL} + api_key: ${RERANK_API_KEY} + provider: generic +``` + +#### `${ENV}` 插值 + +`api_key` / `base_url` / `name` 等任何**字符串**字段都可以引用环境变量:`${OPENAI_API_KEY}` 会在启动时被对应的 `os.Getenv("OPENAI_API_KEY")` 替换。 + +- 环境变量存在 → 替换为实际值 +- 环境变量不存在 → **保留字面 `${OPENAI_API_KEY}`** 字符串(让 401 错误能直接看出来 env 没设,便于排查) +- 不支持 `${VAR:-default}` 这种 shell 扩展,行为与现有 `config.yaml` 的插值实现一致 +- **非字符串字段不能 env 化**(如 `type`、`dimension`、`is_default`),因为它们必须按 YAML 的目标类型解析 + +#### env 变量怎么进入容器 + +`docker-compose.yml` 的 `app` 服务已经预置了: + +```yaml +env_file: + - path: .env + required: false +``` + +意味着把变量值写到项目根目录的 `.env` 文件里,启动时自动透传到容器。**无需**在 `environment:` 块里逐个透传。`required: false` 保证 `.env` 不存在时容器仍可启动(适配上游 fresh clone 场景)。 + +仓库的 `.env.example` 顶部预留了 **Built-in Models** 注释段,列出 LLM / Embedding / Rerank 的参考变量名作为起点;复制 `.env.example` 为 `.env` 后取消注释并填值即可。变量名由 YAML 自行决定,参考段只是常见样板,不是保留字。 + +完整端到端示例: + +`.env` +```bash +LLM_MODEL_NAME=gpt-4o-mini +LLM_BASE_URL=https://api.openai.com/v1 +LLM_API_KEY=sk-... +LLM_PROVIDER=openai +``` + +`config/builtin_models.yaml` +```yaml +builtin_models: + - id: builtin-llm-default + type: KnowledgeQA + is_default: true + name: ${LLM_MODEL_NAME} + parameters: + base_url: ${LLM_BASE_URL} + api_key: ${LLM_API_KEY} + provider: ${LLM_PROVIDER} +``` + +启动: +```bash +docker compose up -d +``` + +#### 启动后验证 + +```bash +docker compose logs app | grep -E 'Built-in models? config' +``` + +会看到类似: + +``` +Built-in model upserted: id=builtin-openai-chat name=gpt-4o-mini type=KnowledgeQA +Built-in model upserted: id=builtin-openai-embeddings name=text-embedding-3-small type=Embedding +Built-in models config applied: 2 entries from /app/config/builtin_models.yaml. +``` + +#### Docker 部署 + +在 `docker-compose.yml` 的 `app` 服务 `volumes` 块挂载文件: + +```yaml +services: + app: + volumes: + - ./config/builtin_models.yaml:/app/config/builtin_models.yaml:ro +``` + +仓库提供了 `config/builtin_models.yaml.example` 作为起点,复制为 `config/builtin_models.yaml` 后按需修改。 + +### 方式二:直接 SQL 插入 + +支持的 provider:`generic`(自定义)、`openai`、`aliyun`、`zhipu`、`volcengine`、`hunyuan`、`deepseek`、`minimax`、`mimo`、`siliconflow`、`jina`、`openrouter`、`gemini`、`modelscope`、`moonshot`、`qianfan`、`qiniu`、`longcat`、`gpustack` ```sql --- 示例:插入一个 LLM 内置模型 +-- 示例:LLM 内置模型 INSERT INTO models ( - id, - tenant_id, - name, - type, - source, - description, - parameters, - is_default, - status, - is_builtin + id, tenant_id, name, type, source, description, + parameters, is_default, status, is_builtin ) VALUES ( - 'builtin-llm-001', -- 使用固定ID,建议使用 builtin- 前缀 - 10000, -- 租户ID(使用第一个租户) - 'GPT-4', -- 模型名称 - 'KnowledgeQA', -- 模型类型 - 'remote', -- 模型来源 - '内置 LLM 模型', -- 描述 - '{"base_url": "https://api.openai.com/v1", "api_key": "sk-xxx", "provider": "openai"}'::jsonb, -- 参数(JSON格式) - false, -- 是否默认 - 'active', -- 状态 - true -- 标记为内置模型 + 'builtin-llm-001', + 10000, + 'gpt-4o-mini', + 'KnowledgeQA', + 'remote', + '系统内置 LLM 模型', + '{"base_url": "https://api.openai.com/v1", "api_key": "sk-xxx", "provider": "openai"}'::jsonb, + false, + 'active', + true ) ON CONFLICT (id) DO NOTHING; --- 示例:插入一个 Embedding 内置模型 +-- Embedding INSERT INTO models ( - id, - tenant_id, - name, - type, - source, - description, - parameters, - is_default, - status, - is_builtin + id, tenant_id, name, type, source, description, + parameters, is_default, status, is_builtin ) VALUES ( 'builtin-embedding-001', 10000, - 'text-embedding-ada-002', + 'text-embedding-3-small', 'Embedding', 'remote', - '内置 Embedding 模型', + '系统内置 Embedding 模型', '{"base_url": "https://api.openai.com/v1", "api_key": "sk-xxx", "provider": "openai", "embedding_parameters": {"dimension": 1536, "truncate_prompt_tokens": 0}}'::jsonb, false, 'active', true ) ON CONFLICT (id) DO NOTHING; --- 示例:插入一个 ReRank 内置模型 +-- Rerank INSERT INTO models ( - id, - tenant_id, - name, - type, - source, - description, - parameters, - is_default, - status, - is_builtin + id, tenant_id, name, type, source, description, + parameters, is_default, status, is_builtin ) VALUES ( 'builtin-rerank-001', 10000, - 'bge-reranker-base', + 'bge-reranker-v2-m3', 'Rerank', 'remote', - '内置 ReRank 模型', + '系统内置 Rerank 模型', '{"base_url": "https://api.jina.ai/v1", "api_key": "jina-xxx", "provider": "jina"}'::jsonb, false, 'active', true ) ON CONFLICT (id) DO NOTHING; - --- 示例:插入一个 VLLM 内置模型 -INSERT INTO models ( - id, - tenant_id, - name, - type, - source, - description, - parameters, - is_default, - status, - is_builtin -) VALUES ( - 'builtin-vllm-001', - 10000, - 'gpt-4-vision', - 'VLLM', - 'remote', - '内置 VLLM 模型', - '{"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "api_key": "sk-xxx", "provider": "aliyun"}'::jsonb, - false, - 'active', - true -) ON CONFLICT (id) DO NOTHING; ``` -### 3. 验证插入结果 - -执行以下 SQL 查询验证内置模型是否成功插入: +### 验证插入结果 ```sql -SELECT id, name, type, is_builtin, status -FROM models +SELECT id, name, type, is_builtin, status +FROM models WHERE is_builtin = true ORDER BY type, created_at; ``` -## 注意事项 - -1. **ID 命名规范**:建议使用 `builtin-{type}-{序号}` 的格式,例如 `builtin-llm-001`、`builtin-embedding-001` -2. **租户ID**:内置模型可以属于任意租户,但建议使用第一个租户ID(通常是 10000) -3. **参数格式**:`parameters` 字段必须是有效的 JSON 格式 -4. **幂等性**:使用 `ON CONFLICT (id) DO NOTHING` 确保重复执行不会报错 -5. **安全性**:内置模型的 API Key 和 Base URL 在前端会被自动隐藏,但数据库中的原始数据仍然存在,请妥善保管数据库访问权限 - ## 将现有模型设置为内置模型 -如果你已经有一个模型,想将其设置为内置模型,可以使用 UPDATE 语句: +如果你已经手工创建了一个普通模型,想把它升级为内置模型: ```sql -UPDATE models -SET is_builtin = true -WHERE id = '模型ID' AND name = '模型名称'; +UPDATE models +SET is_builtin = true +WHERE id = '模型ID'; ``` ## 移除内置模型 -如果需要移除内置模型标记(恢复为普通模型),执行: +YAML 配置删除条目后**不会**自动清理 DB 里的对应行(避免误删 ops 手工维护的内置模型)。如需删除: ```sql -UPDATE models -SET is_builtin = false -WHERE id = '模型ID'; +-- 取消 builtin 标记,恢复为普通模型 +UPDATE models SET is_builtin = false WHERE id = '模型ID'; + +-- 或直接删除 +DELETE FROM models WHERE id = '模型ID'; ``` -注意:移除内置模型标记后,该模型将恢复为普通模型,可以被编辑和删除。 +## 注意事项 +1. **ID 命名规范**:建议使用 `builtin-{type}-{slug}` 的格式,例如 `builtin-openai-chat`、`builtin-rerank` +2. **租户ID**:内置模型可以属于任意租户,默认 `10000`(与 `tenants_id_seq` 起点一致) +3. **YAML 与 SQL 并存**:两种方式可以同时使用,YAML 不会覆盖通过 SQL 插入但未在 YAML 出现的 builtin 行 +4. **重启即生效**:修改 YAML 后 `docker compose restart app` 即可让新配置生效 +5. **加密**:API Key 在 `parameters` JSONB 中以加密形式存储(若 `SYSTEM_AES_KEY` 已配置),未配置时降级为明文兼容路径 +6. **安全性**:前端会自动隐藏内置模型的 API Key 和 Base URL,但数据库中的原始数据仍然存在,请妥善保管数据库访问权限 diff --git a/internal/config/config.go b/internal/config/config.go index 4a568897..938b569f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -427,6 +427,24 @@ type FebriText struct { WithNoTag string `yaml:"with_no_tag" json:"with_no_tag"` } +// resolvedConfigDir holds the directory of the loaded config file. Populated by +// LoadConfig and read by ConfigDir(); empty until LoadConfig has run. +var resolvedConfigDir string + +// ConfigDir returns the directory containing the loaded config.yaml. Other +// startup code (e.g. builtin model loader) uses this to locate sibling config +// files like builtin_models.yaml without re-implementing viper search rules. +// Falls back to "./config" when LoadConfig has not been called yet. +func ConfigDir() string { + if resolvedConfigDir != "" { + return resolvedConfigDir + } + if f := viper.ConfigFileUsed(); f != "" { + return filepath.Dir(f) + } + return "./config" +} + // LoadConfig 从配置文件加载配置 func LoadConfig() (*Config, error) { // 设置配置文件名和路径 @@ -478,6 +496,7 @@ func LoadConfig() (*Config, error) { // 加载提示词模板(从目录或配置文件) configDir := filepath.Dir(viper.ConfigFileUsed()) + resolvedConfigDir = configDir promptTemplates, err := loadPromptTemplates(configDir) if err != nil { fmt.Printf("Warning: failed to load prompt templates from directory: %v\n", err) diff --git a/internal/container/container.go b/internal/container/container.go index 112b8bdb..4dcc9190 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -536,6 +536,11 @@ func initDatabase(cfg *config.Config) (*gorm.DB, error) { // The SQL migration marks KBs that have documents but no provider with "__pending_env__"; // we replace that with the actual STORAGE_TYPE from the environment. resolveStorageProviderPending(db) + + // Post-migration: declarative built-in models from config/builtin_models.yaml (optional). + if err := types.LoadBuiltinModelsConfig(context.Background(), db, config.ConfigDir()); err != nil { + logger.Warnf(context.Background(), "Load builtin models config failed: %v", err) + } } else { logger.Infof(context.Background(), "Auto-migration is disabled (AUTO_MIGRATE=false)") } diff --git a/internal/types/builtin_models_config.go b/internal/types/builtin_models_config.go new file mode 100644 index 00000000..1992a175 --- /dev/null +++ b/internal/types/builtin_models_config.go @@ -0,0 +1,150 @@ +package types + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + + "gopkg.in/yaml.v3" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// BuiltinModelEntry mirrors one entry in builtin_models.yaml. +// Each entry becomes a row in the models table with is_builtin=true. +type BuiltinModelEntry struct { + ID string `yaml:"id"` + TenantID uint64 `yaml:"tenant_id"` + Name string `yaml:"name"` + Type ModelType `yaml:"type"` + Source ModelSource `yaml:"source"` + Description string `yaml:"description"` + IsDefault bool `yaml:"is_default"` + Status ModelStatus `yaml:"status"` + Parameters ModelParameters `yaml:"parameters"` +} + +type builtinModelsFile struct { + BuiltinModels []BuiltinModelEntry `yaml:"builtin_models"` +} + +// builtinModelEnvPattern matches ${NAME} placeholders. Mirrors the pattern in +// internal/config/config.go so YAML interpolation behaves identically to the +// main config.yaml flow. +var builtinModelEnvPattern = regexp.MustCompile(`\${([^}]+)}`) + +// interpolateBuiltinModelEnv substitutes ${NAME} occurrences with the +// corresponding os.Getenv value. Unset vars are left as the literal ${NAME} +// so misconfiguration surfaces visibly in downstream provider calls instead +// of failing silently with an empty token. +func interpolateBuiltinModelEnv(s string) string { + return builtinModelEnvPattern.ReplaceAllStringFunc(s, func(m string) string { + name := m[2 : len(m)-1] + if v := os.Getenv(name); v != "" { + return v + } + return m + }) +} + +// LoadBuiltinModelsConfig reads builtin_models.yaml (or the path pointed to by +// BUILTIN_MODELS_CONFIG) and UPSERTs each entry into the models table. +// +// Behaviour: +// - file not found / mount point is a directory / path unset: no-op +// - YAML parse error: prints a warning, returns nil (never aborts startup) +// - per-entry UPSERT error: prints a warning, continues with the next entry +// - never deletes entries that disappeared from YAML: operators may have +// seeded extras manually via SQL; removing them here would be surprising +func LoadBuiltinModelsConfig(ctx context.Context, db *gorm.DB, configDir string) error { + path := os.Getenv("BUILTIN_MODELS_CONFIG") + if path == "" { + path = filepath.Join(configDir, "builtin_models.yaml") + } + + // Treat "missing", "is a directory", and other non-regular-file cases the + // same way. Docker bind-mounting a non-existent source file silently + // substitutes a directory; we don't want that to spam WARN logs. + info, statErr := os.Stat(path) + if statErr != nil || !info.Mode().IsRegular() { + fmt.Printf("Built-in models config not present at %s; skipping.\n", path) + return nil + } + + raw, err := os.ReadFile(path) + if err != nil { + fmt.Printf("Warning: read built-in models config %s failed: %v; skipping.\n", path, err) + return nil + } + + expanded := interpolateBuiltinModelEnv(string(raw)) + + var file builtinModelsFile + if err := yaml.Unmarshal([]byte(expanded), &file); err != nil { + fmt.Printf("Warning: parse built-in models config %s failed: %v; skipping.\n", path, err) + return nil + } + + if len(file.BuiltinModels) == 0 { + fmt.Printf("Built-in models config %s contains no entries.\n", path) + return nil + } + + applied := 0 + for i := range file.BuiltinModels { + e := &file.BuiltinModels[i] + if e.ID == "" { + fmt.Printf("Warning: built-in model entry %d has empty id; skipping.\n", i) + continue + } + m := e.toModel() + res := db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "tenant_id", "name", "type", "source", "description", + "parameters", "is_default", "status", "is_builtin", "updated_at", + }), + }).Create(&m) + if res.Error != nil { + fmt.Printf("Warning: upsert built-in model %s failed: %v; continuing.\n", e.ID, res.Error) + continue + } + applied++ + fmt.Printf("Built-in model upserted: id=%s name=%s type=%s\n", e.ID, e.Name, e.Type) + } + fmt.Printf("Built-in models config applied: %d entries from %s.\n", applied, path) + return nil +} + +// toModel converts a YAML entry to a runtime Model with sensible defaults. +// tenant_id defaults to 10000 (matches the seed value of tenants_id_seq); +// source defaults to "remote"; status defaults to "active". IsBuiltin is +// always forced to true regardless of YAML input. +func (e *BuiltinModelEntry) toModel() Model { + tenantID := e.TenantID + if tenantID == 0 { + tenantID = 10000 + } + source := e.Source + if source == "" { + source = ModelSourceRemote + } + status := e.Status + if status == "" { + status = ModelStatusActive + } + return Model{ + ID: e.ID, + TenantID: tenantID, + Name: e.Name, + Type: e.Type, + Source: source, + Description: e.Description, + Parameters: e.Parameters, + IsDefault: e.IsDefault, + IsBuiltin: true, + Status: status, + } +} diff --git a/internal/types/model.go b/internal/types/model.go index 28752918..24458271 100644 --- a/internal/types/model.go +++ b/internal/types/model.go @@ -164,14 +164,13 @@ func (c *ModelParameters) Scan(value interface{}) error { return nil } -// BeforeCreate is a GORM hook that runs before creating a new model record -// Automatically generates a UUID for new models -// Parameters: -// - tx: GORM database transaction -// -// Returns: -// - error: Any error encountered during the hook execution +// BeforeCreate is a GORM hook that runs before creating a new model record. +// Generates a UUID only when the caller has not supplied an ID — preserves +// stable IDs declared in built-in model YAML config while keeping the +// existing UUID behaviour for API-driven model creation. func (m *Model) BeforeCreate(tx *gorm.DB) (err error) { - m.ID = uuid.New().String() + if m.ID == "" { + m.ID = uuid.New().String() + } return nil }