|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import type { SettingsManifest } from '@objectstack/spec/system'; |
| 4 | +import type { SettingsActionHandler } from '../settings-service.types.js'; |
| 5 | + |
| 6 | +// Visibility expressions are written as inline strings here for |
| 7 | +// readability. The spec's ExpressionInputSchema accepts a bare string |
| 8 | +// and normalises it at parse time, but the inferred TypeScript output |
| 9 | +// type expects `{ dialect, source }` objects. Build the manifest as |
| 10 | +// `unknown` first, then cast — keeps the manifest source compact. |
| 11 | +// |
| 12 | +// The actual LLM adapter selection still happens in |
| 13 | +// `@objectstack/service-ai`'s plugin (env-var driven by default). |
| 14 | +// This manifest gives operators a UI to inspect and override those |
| 15 | +// env-derived defaults without redeploying; the AIServicePlugin can |
| 16 | +// later prefer settings over env when implemented. Until then this |
| 17 | +// manifest is the canonical surface for "what AI provider is wired, |
| 18 | +// and which API key is in use" — a request operators repeatedly need |
| 19 | +// answered during onboarding and incident response. |
| 20 | +const manifest = { |
| 21 | + namespace: 'ai', |
| 22 | + version: 1, |
| 23 | + label: 'AI', |
| 24 | + icon: 'Sparkles', |
| 25 | + description: |
| 26 | + 'LLM provider, model, and credentials used by the platform AI service. ' + |
| 27 | + 'Provider SDK packages (e.g. @ai-sdk/openai) must be installed on the host ' + |
| 28 | + 'for the chosen provider to be loadable at runtime.', |
| 29 | + scope: 'global', |
| 30 | + readPermission: 'setup.access', |
| 31 | + writePermission: 'setup.write', |
| 32 | + category: 'Infrastructure', |
| 33 | + order: 30, |
| 34 | + specifiers: [ |
| 35 | + // ── Provider selection ──────────────────────────────────────── |
| 36 | + { type: 'group', id: 'provider', label: 'Provider', required: false, |
| 37 | + description: 'Choose the LLM backend. Memory mode echoes input — useful for tests but never for production.' }, |
| 38 | + { type: 'select', key: 'provider', label: 'Provider', required: true, default: 'memory', |
| 39 | + options: [ |
| 40 | + { value: 'memory', label: 'Memory (echo — testing only)' }, |
| 41 | + { value: 'gateway', label: 'Vercel AI Gateway' }, |
| 42 | + { value: 'openai', label: 'OpenAI' }, |
| 43 | + { value: 'anthropic', label: 'Anthropic' }, |
| 44 | + { value: 'google', label: 'Google Generative AI' }, |
| 45 | + ], |
| 46 | + }, |
| 47 | + |
| 48 | + // ── Vercel AI Gateway ───────────────────────────────────────── |
| 49 | + { type: 'group', id: 'gateway', label: 'Vercel AI Gateway', required: false, |
| 50 | + visible: "${data.provider === 'gateway'}", |
| 51 | + description: 'Multi-provider router. The model spec follows `provider/model`, e.g. `openai/gpt-4o`.' }, |
| 52 | + { type: 'text', key: 'gateway_model', label: 'Gateway model', required: true, |
| 53 | + description: 'Forwarded as AI_GATEWAY_MODEL. Example: openai/gpt-4o', |
| 54 | + visible: "${data.provider === 'gateway'}" }, |
| 55 | + { type: 'password', key: 'gateway_api_key', label: 'Gateway API key', |
| 56 | + required: false, encrypted: true, |
| 57 | + description: 'Optional — required only if the gateway enforces auth.', |
| 58 | + visible: "${data.provider === 'gateway'}" }, |
| 59 | + |
| 60 | + // ── OpenAI ─────────────────────────────────────────────────── |
| 61 | + { type: 'group', id: 'openai', label: 'OpenAI', required: false, |
| 62 | + visible: "${data.provider === 'openai'}" }, |
| 63 | + { type: 'password', key: 'openai_api_key', label: 'OpenAI API key', |
| 64 | + required: true, encrypted: true, |
| 65 | + description: 'Forwarded as OPENAI_API_KEY. Stored encrypted at rest.', |
| 66 | + visible: "${data.provider === 'openai'}" }, |
| 67 | + { type: 'text', key: 'openai_model', label: 'Model', required: false, default: 'gpt-4o', |
| 68 | + description: 'Default model id. Per-agent overrides take precedence.', |
| 69 | + visible: "${data.provider === 'openai'}" }, |
| 70 | + { type: 'text', key: 'openai_base_url', label: 'Base URL', required: false, |
| 71 | + description: 'Override for Azure OpenAI or self-hosted gateways. Leave blank for api.openai.com.', |
| 72 | + visible: "${data.provider === 'openai'}" }, |
| 73 | + |
| 74 | + // ── Anthropic ──────────────────────────────────────────────── |
| 75 | + { type: 'group', id: 'anthropic', label: 'Anthropic', required: false, |
| 76 | + visible: "${data.provider === 'anthropic'}" }, |
| 77 | + { type: 'password', key: 'anthropic_api_key', label: 'Anthropic API key', |
| 78 | + required: true, encrypted: true, |
| 79 | + description: 'Forwarded as ANTHROPIC_API_KEY. Stored encrypted at rest.', |
| 80 | + visible: "${data.provider === 'anthropic'}" }, |
| 81 | + { type: 'text', key: 'anthropic_model', label: 'Model', required: false, |
| 82 | + default: 'claude-sonnet-4-20250514', |
| 83 | + visible: "${data.provider === 'anthropic'}" }, |
| 84 | + |
| 85 | + // ── Google Generative AI ───────────────────────────────────── |
| 86 | + { type: 'group', id: 'google', label: 'Google', required: false, |
| 87 | + visible: "${data.provider === 'google'}" }, |
| 88 | + { type: 'password', key: 'google_api_key', label: 'Google API key', |
| 89 | + required: true, encrypted: true, |
| 90 | + description: 'Forwarded as GOOGLE_GENERATIVE_AI_API_KEY. Stored encrypted at rest.', |
| 91 | + visible: "${data.provider === 'google'}" }, |
| 92 | + { type: 'text', key: 'google_model', label: 'Model', required: false, |
| 93 | + default: 'gemini-2.0-flash', |
| 94 | + visible: "${data.provider === 'google'}" }, |
| 95 | + |
| 96 | + // ── Generation defaults ────────────────────────────────────── |
| 97 | + { type: 'group', id: 'defaults', label: 'Generation defaults', required: false, |
| 98 | + description: 'Applied when an agent or chat request does not specify its own value.', |
| 99 | + visible: "${data.provider !== 'memory'}" }, |
| 100 | + { type: 'slider', key: 'temperature', label: 'Temperature', |
| 101 | + required: false, default: 0.7, min: 0, max: 2, step: 0.1, |
| 102 | + description: '0 = deterministic, 2 = highly creative.', |
| 103 | + visible: "${data.provider !== 'memory'}" }, |
| 104 | + { type: 'number', key: 'max_tokens', label: 'Max output tokens', |
| 105 | + required: false, default: 4096, min: 1, max: 1048576, |
| 106 | + description: 'Hard cap on tokens generated per response.', |
| 107 | + visible: "${data.provider !== 'memory'}" }, |
| 108 | + { type: 'number', key: 'request_timeout_ms', label: 'Request timeout (ms)', |
| 109 | + required: false, default: 60000, min: 1000, max: 600000, |
| 110 | + visible: "${data.provider !== 'memory'}" }, |
| 111 | + |
| 112 | + // ── Observability ──────────────────────────────────────────── |
| 113 | + { type: 'group', id: 'observability', label: 'Observability', required: false }, |
| 114 | + { type: 'toggle', key: 'trace_enabled', label: 'Record traces', |
| 115 | + required: false, default: true, |
| 116 | + description: 'Persist prompt/response traces to sys_ai_trace for debugging and replay.' }, |
| 117 | + { type: 'toggle', key: 'log_prompts', label: 'Log full prompts', |
| 118 | + required: false, default: false, |
| 119 | + description: 'Include rendered prompts (not just metadata) in trace rows. ⚠ May leak PII — disable in regulated environments.' }, |
| 120 | + |
| 121 | + // ── Probe ──────────────────────────────────────────────────── |
| 122 | + { type: 'action_button', id: 'test', label: 'Test connection', |
| 123 | + required: false, icon: 'Plug', |
| 124 | + handler: { kind: 'http', method: 'POST', url: '/api/settings/ai/test' } }, |
| 125 | + ], |
| 126 | +}; |
| 127 | + |
| 128 | +/** AI — provider / model / credentials configuration. */ |
| 129 | +export const aiSettingsManifest = manifest as unknown as SettingsManifest; |
| 130 | + |
| 131 | +/** |
| 132 | + * Built-in fallback action handler for `ai/test`. The real |
| 133 | + * implementation that issues a live `chat()` round-trip lives in |
| 134 | + * `@objectstack/service-ai` and overrides this stub via |
| 135 | + * `registerAction` on `kernel:ready` (mirrors the storage pattern). |
| 136 | + * |
| 137 | + * This fallback only validates the form so the button is still useful |
| 138 | + * when the AI plugin is absent (e.g. in a unit-test kernel that mounts |
| 139 | + * settings only). |
| 140 | + */ |
| 141 | +export const aiTestActionHandler: SettingsActionHandler = async ({ values, payload }) => { |
| 142 | + // The Settings UI may POST the current (possibly unsaved) form state |
| 143 | + // as `{ values: {...} }`. Prefer those over the persisted snapshot so |
| 144 | + // operators can validate edits before hitting "Save". |
| 145 | + const overrides = |
| 146 | + payload && typeof payload === 'object' && payload !== null && 'values' in payload |
| 147 | + ? ((payload as { values?: Record<string, unknown> }).values ?? {}) |
| 148 | + : {}; |
| 149 | + const merged: Record<string, unknown> = { ...values, ...overrides }; |
| 150 | + const provider = String(merged.provider ?? 'memory'); |
| 151 | + values = merged; |
| 152 | + if (provider === 'memory') { |
| 153 | + return { |
| 154 | + ok: true, |
| 155 | + severity: 'warning', |
| 156 | + message: 'Memory provider is an echo stub — no external call to validate. Switch to a real provider for production.', |
| 157 | + }; |
| 158 | + } |
| 159 | + if (provider === 'gateway') { |
| 160 | + if (!values.gateway_model) { |
| 161 | + return { ok: false, severity: 'error', message: 'Gateway model is required (e.g. openai/gpt-4o).' }; |
| 162 | + } |
| 163 | + return { |
| 164 | + ok: true, |
| 165 | + severity: 'info', |
| 166 | + message: `Vercel AI Gateway configured (model=${values.gateway_model}). Mount @objectstack/service-ai to exercise live calls.`, |
| 167 | + }; |
| 168 | + } |
| 169 | + const keyField = `${provider}_api_key`; |
| 170 | + if (!values[keyField]) { |
| 171 | + return { ok: false, severity: 'error', message: `${provider} API key is required.` }; |
| 172 | + } |
| 173 | + const modelField = `${provider}_model`; |
| 174 | + const model = values[modelField] ?? '(default)'; |
| 175 | + return { |
| 176 | + ok: true, |
| 177 | + severity: 'info', |
| 178 | + message: `${provider} configured (model=${model}). Mount @objectstack/service-ai to exercise live calls.`, |
| 179 | + }; |
| 180 | +}; |
0 commit comments