diff --git a/backend/src/ai/LocalAIApi.js b/backend/src/ai/LocalAIApi.js index fd571ae..b965352 100644 --- a/backend/src/ai/LocalAIApi.js +++ b/backend/src/ai/LocalAIApi.js @@ -5,6 +5,7 @@ const path = require("path"); const http = require("http"); const https = require("https"); const { URL } = require("url"); +const { DEFAULT_AI_MODEL, normalizeAiModel } = require("./modelNormalizer"); let CONFIG_CACHE = null; @@ -46,9 +47,7 @@ async function createResponse(params, options = {}) { } const cfg = config(); - if (!payload.model) { - payload.model = cfg.defaultModel; - } + payload.model = normalizeAiModel(payload.model, cfg.defaultModel); const initial = await request(options.path, payload, options); if (!initial.success) { @@ -154,7 +153,7 @@ async function awaitResponse(aiRequestId, options = {}) { const interval = Math.max(Number(options.interval ?? 5), 1); const deadline = Date.now() + Math.max(timeout, interval) * 1000; - while (true) { + while (Date.now() < deadline) { const statusResp = await fetchStatus(aiRequestId, { headers: options.headers, timeout: options.timeout_per_call, @@ -184,16 +183,14 @@ async function awaitResponse(aiRequestId, options = {}) { return statusResp; } - if (Date.now() >= deadline) { - return { - success: false, - error: "timeout", - message: "Timed out waiting for AI response.", - }; - } - await sleep(interval * 1000); } + + return { + success: false, + error: "timeout", + message: "Timed out waiting for AI response.", + }; } function extractText(response) { @@ -283,7 +280,7 @@ function config() { projectId, projectUuid: process.env.PROJECT_UUID || null, projectHeader: process.env.AI_PROJECT_HEADER || "project-uuid", - defaultModel: process.env.AI_DEFAULT_MODEL || "gpt-5-mini", + defaultModel: process.env.AI_DEFAULT_MODEL || process.env.GEMINI_MODEL || DEFAULT_AI_MODEL, timeout, verifyTls, }; diff --git a/backend/src/ai/modelNormalizer.js b/backend/src/ai/modelNormalizer.js new file mode 100644 index 0000000..53ff0b5 --- /dev/null +++ b/backend/src/ai/modelNormalizer.js @@ -0,0 +1,109 @@ +const RAW_DEFAULT_AI_MODEL = process.env.AI_DEFAULT_MODEL + || process.env.GEMINI_MODEL + || 'appwizzy-default'; + +const SUPPORTED_AI_MODELS = new Set([ + 'appwizzy-default', + 'google/gemini-3-flash-preview', + 'google/gemini-2.5-flash', + 'google/gemini-2.0-flash', + 'gemini/gemini-3-flash-preview', + 'gemini/gemini-2.5-flash', + 'gemini-3-flash-preview', + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-1.5-flash', + 'gpt-5-mini', + 'gpt-5', + 'gpt-5.1', + 'gpt-5.1-mini', + 'gpt-5.2', + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4o', + 'gpt-4o-mini', + 'openai/gpt-4.1', + 'openai/gpt-4.1-mini', + 'openai/gpt-4o', + 'openai/gpt-4o-mini', +]); + +const MODEL_ALIASES = new Map([ + ['appwizzydefault', 'appwizzy-default'], + ['appwizzy-default', 'appwizzy-default'], + ['gpt4.1mini', 'gpt-4.1-mini'], + ['gpt41mini', 'gpt-4.1-mini'], + ['gpt4.1', 'gpt-4.1'], + ['gpt41', 'gpt-4.1'], + ['gpt5mini', 'gpt-5-mini'], + ['gpt5', 'gpt-5'], + ['gpt5.1mini', 'gpt-5.1-mini'], + ['gpt51mini', 'gpt-5.1-mini'], + ['gpt5.1', 'gpt-5.1'], + ['gpt51', 'gpt-5.1'], + ['gpt5.2', 'gpt-5.2'], + ['gpt52', 'gpt-5.2'], + ['gpt-5.5', 'appwizzy-default'], + ['gpt5.5', 'appwizzy-default'], + ['gpt55', 'appwizzy-default'], +]); + +function normalizeModelText(value) { + if (typeof value !== 'string') { + return ''; + } + + const trimmed = value.trim().toLowerCase(); + + if (!trimmed) { + return ''; + } + + if (MODEL_ALIASES.has(trimmed)) { + return MODEL_ALIASES.get(trimmed); + } + + const hyphenated = trimmed + .replace(/[\s_]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + if (MODEL_ALIASES.has(hyphenated)) { + return MODEL_ALIASES.get(hyphenated); + } + + return hyphenated; +} + +function isSafeModelId(value) { + return /^[a-z0-9][a-z0-9._/-]*$/.test(value); +} + +const DEFAULT_AI_MODEL = normalizeModelText(RAW_DEFAULT_AI_MODEL) || 'appwizzy-default'; + +function normalizeAiModel(value, fallback = DEFAULT_AI_MODEL) { + const normalizedFallback = normalizeModelText(fallback) || DEFAULT_AI_MODEL; + const safeFallback = isSafeModelId(normalizedFallback) + ? normalizedFallback + : DEFAULT_AI_MODEL; + const normalizedModel = normalizeModelText(value); + + if (!normalizedModel) { + return safeFallback; + } + + if (isSafeModelId(normalizedModel)) { + return normalizedModel; + } + + console.warn( + `Unsafe AI model "${value}" requested. Falling back to "${safeFallback}".`, + ); + return safeFallback; +} + +module.exports = { + DEFAULT_AI_MODEL, + SUPPORTED_AI_MODELS, + normalizeAiModel, +}; diff --git a/backend/src/db/api/agents.js b/backend/src/db/api/agents.js index d6cd621..99a4433 100644 --- a/backend/src/db/api/agents.js +++ b/backend/src/db/api/agents.js @@ -1,7 +1,6 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); +const { normalizeAiModel } = require('../../ai/modelNormalizer'); const Utils = require('../utils'); @@ -71,7 +70,7 @@ module.exports = class AgentsDBApi { null , - model: data.model + model: normalizeAiModel(data.model) || null , @@ -143,7 +142,7 @@ module.exports = class AgentsDBApi { null , - model: item.model + model: normalizeAiModel(item.model) || null , @@ -222,7 +221,7 @@ module.exports = class AgentsDBApi { if (data.description !== undefined) updatePayload.description = data.description; - if (data.model !== undefined) updatePayload.model = data.model; + if (data.model !== undefined) updatePayload.model = normalizeAiModel(data.model); if (data.system_prompt !== undefined) updatePayload.system_prompt = data.system_prompt; @@ -384,9 +383,6 @@ module.exports = class AgentsDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ diff --git a/backend/src/db/migrations/20260605000000-normalize-agent-models.js b/backend/src/db/migrations/20260605000000-normalize-agent-models.js new file mode 100644 index 0000000..ba604a9 --- /dev/null +++ b/backend/src/db/migrations/20260605000000-normalize-agent-models.js @@ -0,0 +1,43 @@ +const { DEFAULT_AI_MODEL } = require('../../ai/modelNormalizer'); + +module.exports = { + async up(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await queryInterface.sequelize.query( + ` + UPDATE "agents" + SET "model" = :defaultModel, + "updatedAt" = NOW() + WHERE "deletedAt" IS NULL + AND ( + "model" IS NULL + OR BTRIM("model") = '' + OR LOWER(BTRIM("model")) IN ( + 'gpt-4.1-mini', + 'gpt 4.1 mini', + 'gpt_4.1_mini', + 'gpt-5.5', + 'gpt 5.5', + 'gpt_5.5' + ) + ) + `, + { + replacements: { defaultModel: DEFAULT_AI_MODEL }, + transaction, + }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down() { + // Intentionally left as a no-op to avoid overwriting user-selected models on rollback. + }, +}; diff --git a/backend/src/db/migrations/20260605001000-use-configured-agent-model.js b/backend/src/db/migrations/20260605001000-use-configured-agent-model.js new file mode 100644 index 0000000..4e61187 --- /dev/null +++ b/backend/src/db/migrations/20260605001000-use-configured-agent-model.js @@ -0,0 +1,46 @@ +const { DEFAULT_AI_MODEL } = require('../../ai/modelNormalizer'); + +module.exports = { + async up(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await queryInterface.sequelize.query( + ` + UPDATE "agents" + SET "model" = :defaultModel, + "updatedAt" = NOW() + WHERE "deletedAt" IS NULL + AND ( + "model" IS NULL + OR BTRIM("model") = '' + OR LOWER(BTRIM("model")) IN ( + 'gpt-5-mini', + 'gpt 5 mini', + 'gpt_5_mini', + 'gpt-4.1-mini', + 'gpt 4.1 mini', + 'gpt_4.1_mini', + 'gpt-5.5', + 'gpt 5.5', + 'gpt_5.5' + ) + ) + `, + { + replacements: { defaultModel: DEFAULT_AI_MODEL }, + transaction, + }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down() { + // Intentionally left as a no-op to avoid overwriting user-selected models on rollback. + }, +}; diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js index fa55986..c7159e9 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -65,7 +65,7 @@ const AgentsData = [ - "model": "gpt-4.1-mini", + "model": "appwizzy-default", @@ -132,7 +132,7 @@ const AgentsData = [ - "model": "gpt-4.1-mini", + "model": "appwizzy-default", @@ -199,7 +199,7 @@ const AgentsData = [ - "model": "gpt-4.1-mini", + "model": "appwizzy-default", @@ -266,7 +266,7 @@ const AgentsData = [ - "model": "gpt-4.1-mini", + "model": "appwizzy-default", @@ -333,7 +333,7 @@ const AgentsData = [ - "model": "gpt-4.1-mini", + "model": "appwizzy-default", @@ -1581,7 +1581,7 @@ const UsageEventsData = [ - "model": "gpt-4.1-mini", + "model": "appwizzy-default", @@ -1676,7 +1676,7 @@ const UsageEventsData = [ - "model": "gpt-4.1-mini", + "model": "appwizzy-default", @@ -1771,7 +1771,7 @@ const UsageEventsData = [ - "model": "gpt-4.1-mini", + "model": "appwizzy-default", @@ -1866,7 +1866,7 @@ const UsageEventsData = [ - "model": "gpt-4.1-mini", + "model": "appwizzy-default", @@ -1961,7 +1961,7 @@ const UsageEventsData = [ - "model": "gpt-4.1-mini", + "model": "appwizzy-default", diff --git a/backend/src/services/workspace.js b/backend/src/services/workspace.js index 8a2a2d9..3ec6913 100644 --- a/backend/src/services/workspace.js +++ b/backend/src/services/workspace.js @@ -4,6 +4,7 @@ const path = require('path'); const config = require('../config'); const db = require('../db/models'); const { LocalAIApi } = require('../ai/LocalAIApi'); +const { DEFAULT_AI_MODEL, normalizeAiModel } = require('../ai/modelNormalizer'); const AttachmentsDBApi = require('../db/api/attachments'); const ValidationError = require('./notifications/errors/validation'); @@ -16,7 +17,7 @@ const MAX_CONTEXT_MESSAGES = 12; const MAX_ATTACHMENTS = 5; const MAX_ATTACHMENT_TEXT_LENGTH = 12000; const MAX_INLINE_IMAGE_BYTES = 2 * 1024 * 1024; -const IMAGE_INPUT_MODEL = 'gpt-5.2'; +const IMAGE_INPUT_MODEL = process.env.AI_IMAGE_MODEL || DEFAULT_AI_MODEL; function normalizeText(value) { if (typeof value !== 'string') { @@ -270,6 +271,22 @@ function estimateTokens(content) { return Math.max(1, Math.ceil(normalized.length / 4)); } + +function buildAiProviderError(response, model) { + const reason = normalizeText(response?.error || response?.message || response?.data?.error) + || 'AI proxy request failed.'; + const modelLabel = model ? ` for model "${model}"` : ''; + + if (/status\s+(400|403)|unknown model/i.test(reason)) { + return [ + `AI provider request failed${modelLabel}: ${reason}.`, + 'Check the configured AI provider credentials and model access, or switch the agent to a model supported by this project.', + ].join(' '); + } + + return `AI provider request failed${modelLabel}: ${reason}`; +} + function buildAssistantFailureMessage(errorMessage) { const normalized = cleanMarkdownPreview(errorMessage).slice(0, 400) || 'Unknown AI error.'; @@ -382,9 +399,9 @@ async function requestAssistantReply(conversationId, assistantMessageId, agent) }; if (hasImageInput) { - payload.model = IMAGE_INPUT_MODEL; - } else if (agent?.model) { - payload.model = agent.model; + payload.model = normalizeAiModel(IMAGE_INPUT_MODEL); + } else { + payload.model = normalizeAiModel(agent?.model, DEFAULT_AI_MODEL); } if (agent?.temperature !== undefined && agent?.temperature !== null && agent.temperature !== '') { @@ -401,7 +418,11 @@ async function requestAssistantReply(conversationId, assistantMessageId, agent) }); if (!response.success) { - throw new Error(response.error || response.message || 'AI proxy request failed.'); + console.error('[workspace] AI proxy request failed.', { + payload, + response, + }); + throw new Error(buildAiProviderError(response, payload.model)); } const text = normalizeText(LocalAIApi.extractText(response)); @@ -1099,7 +1120,6 @@ module.exports = class WorkspaceService { const createTransaction = await db.sequelize.transaction(); let conversationId = null; let assistantMessageId = null; - let userMessageId = null; let agentId = null; let agentModel = null; let titleSeed = content || buildAttachmentTitleSeed(attachments); @@ -1237,7 +1257,6 @@ module.exports = class WorkspaceService { conversationId = conversation.id; assistantMessageId = assistantMessage.id; - userMessageId = userMessage.id; agentId = agent?.id || null; agentModel = agent?.model || 'default-ai-model'; } catch (error) { diff --git a/frontend/src/components/Agents/AgentFormSections.tsx b/frontend/src/components/Agents/AgentFormSections.tsx index a7089d1..3f231f0 100644 --- a/frontend/src/components/Agents/AgentFormSections.tsx +++ b/frontend/src/components/Agents/AgentFormSections.tsx @@ -91,7 +91,7 @@ export default function AgentFormSections({ setFieldValue, values }: Props) { className={inputClassName} id="model" name="model" - placeholder="gpt-5.5" + placeholder="appwizzy-default" />
diff --git a/frontend/src/pages/agents/agents-new.tsx b/frontend/src/pages/agents/agents-new.tsx index b08816e..2089d91 100644 --- a/frontend/src/pages/agents/agents-new.tsx +++ b/frontend/src/pages/agents/agents-new.tsx @@ -22,7 +22,7 @@ const initialValues = { is_default: false, max_output_tokens: '', metadata_json: '', - model: '', + model: 'appwizzy-default', name: '', system_prompt: '', temperature: '',