Skip to content

Commit 9e5dba3

Browse files
os-zhuangCopilot
andcommitted
feat(service-settings): add AI provider settings manifest
Adds a new 'ai' settings namespace so AI provider configuration can be managed through the Setup app instead of only via environment variables. - New manifest (`ai.manifest.ts`): provider selector (memory/gateway/openai/anthropic/google), per-provider conditional groups with encrypted API key fields, generation defaults (temperature/max_tokens/timeout), observability toggles, and a built-in `test` action. - Registers `aiTestActionHandler` as the default fallback handler in SettingsServicePlugin (alongside mail/storage stubs). Real live-call implementation can be wired by mounting @objectstack/service-ai. - Test handler now merges payload (unsaved form state) over saved values so operators can validate edits before clicking Save. - Adds nav entry `nav_settings_ai` to the Setup app under Configuration. - 10 unit tests; 55/55 service-settings tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4944f3a commit 9e5dba3

5 files changed

Lines changed: 267 additions & 0 deletions

File tree

packages/platform-objects/src/apps/setup.app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export const SETUP_APP: App = {
109109
{ id: 'nav_settings_hub', type: 'url', label: 'All Settings', url: '/apps/setup/system/settings', icon: 'settings-2' },
110110
{ id: 'nav_settings_mail', type: 'url', label: 'Email', url: '/apps/setup/system/settings/mail', icon: 'mail' },
111111
{ id: 'nav_settings_branding', type: 'url', label: 'Branding', url: '/apps/setup/system/settings/branding', icon: 'palette' },
112+
{ id: 'nav_settings_ai', type: 'url', label: 'AI', url: '/apps/setup/system/settings/ai', icon: 'sparkles' },
112113
{ id: 'nav_settings_feature_flags', type: 'url', label: 'Feature Flags', url: '/apps/setup/system/settings/feature_flags', icon: 'flag' },
113114
],
114115
},
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { SettingsManifestSchema } from '@objectstack/spec/system';
5+
import { aiSettingsManifest, aiTestActionHandler } from './ai.manifest';
6+
7+
describe('aiSettingsManifest', () => {
8+
it('parses against SettingsManifestSchema', () => {
9+
expect(() => SettingsManifestSchema.parse(aiSettingsManifest)).not.toThrow();
10+
});
11+
12+
it('declares namespace=ai, scope=global, version=1', () => {
13+
const parsed = SettingsManifestSchema.parse(aiSettingsManifest);
14+
expect(parsed.namespace).toBe('ai');
15+
expect(parsed.scope).toBe('global');
16+
expect(parsed.version).toBe(1);
17+
});
18+
19+
it('exposes provider select with memory + gateway + 3 SDK providers', () => {
20+
const provider = (aiSettingsManifest.specifiers as any[]).find(
21+
(s) => s.key === 'provider' && s.type === 'select',
22+
);
23+
expect(provider).toBeDefined();
24+
const values = provider.options.map((o: any) => o.value).sort();
25+
expect(values).toEqual(['anthropic', 'gateway', 'google', 'memory', 'openai']);
26+
expect(provider.default).toBe('memory');
27+
});
28+
29+
it('marks every per-provider api key field as encrypted password input', () => {
30+
const keys = ['openai_api_key', 'anthropic_api_key', 'google_api_key', 'gateway_api_key'];
31+
for (const key of keys) {
32+
const f = (aiSettingsManifest.specifiers as any[]).find((s) => s.key === key);
33+
expect(f, `${key} missing`).toBeDefined();
34+
expect(f.type).toBe('password');
35+
expect(f.encrypted).toBe(true);
36+
}
37+
});
38+
39+
it('exposes a test action that POSTs to /api/settings/ai/test', () => {
40+
const test = (aiSettingsManifest.specifiers as any[]).find(
41+
(s) => s.type === 'action_button' && s.id === 'test',
42+
);
43+
expect(test).toBeDefined();
44+
expect(test.handler).toEqual({ kind: 'http', method: 'POST', url: '/api/settings/ai/test' });
45+
});
46+
});
47+
48+
describe('aiTestActionHandler', () => {
49+
it('returns warning for memory provider (no external call to validate)', async () => {
50+
const r = await aiTestActionHandler({ values: { provider: 'memory' } } as any);
51+
expect(r.ok).toBe(true);
52+
expect(r.severity).toBe('warning');
53+
});
54+
55+
it('rejects gateway provider without gateway_model', async () => {
56+
const r = await aiTestActionHandler({ values: { provider: 'gateway' } } as any);
57+
expect(r.ok).toBe(false);
58+
expect(r.severity).toBe('error');
59+
});
60+
61+
it('rejects openai provider without api key', async () => {
62+
const r = await aiTestActionHandler({ values: { provider: 'openai' } } as any);
63+
expect(r.ok).toBe(false);
64+
expect(r.severity).toBe('error');
65+
});
66+
67+
it('accepts openai provider with api key and reports the model', async () => {
68+
const r = await aiTestActionHandler({
69+
values: { provider: 'openai', openai_api_key: 'sk-test', openai_model: 'gpt-4o' },
70+
} as any);
71+
expect(r.ok).toBe(true);
72+
expect(r.message).toContain('gpt-4o');
73+
});
74+
75+
it('accepts anthropic with api key', async () => {
76+
const r = await aiTestActionHandler({
77+
values: { provider: 'anthropic', anthropic_api_key: 'sk-ant-test' },
78+
} as any);
79+
expect(r.ok).toBe(true);
80+
});
81+
});
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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+
};

packages/services/service-settings/src/manifests/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@ export { mailSettingsManifest, mailTestActionHandler } from './mail.manifest.js'
55
export { brandingSettingsManifest } from './branding.manifest.js';
66
export { featureFlagsSettingsManifest } from './feature-flags.manifest.js';
77
export { storageSettingsManifest, storageTestActionHandler } from './storage.manifest.js';
8+
export { aiSettingsManifest, aiTestActionHandler } from './ai.manifest.js';
89

910
import { mailSettingsManifest } from './mail.manifest.js';
1011
import { brandingSettingsManifest } from './branding.manifest.js';
1112
import { featureFlagsSettingsManifest } from './feature-flags.manifest.js';
1213
import { storageSettingsManifest } from './storage.manifest.js';
14+
import { aiSettingsManifest } from './ai.manifest.js';
1315

1416
/** Convenience aggregate — pass to `SettingsServicePlugin({ manifests })`. */
1517
export const builtinSettingsManifests = [
1618
brandingSettingsManifest,
1719
mailSettingsManifest,
1820
storageSettingsManifest,
21+
aiSettingsManifest,
1922
featureFlagsSettingsManifest,
2023
];

packages/services/service-settings/src/settings-service-plugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
builtinSettingsManifests,
2020
mailTestActionHandler,
2121
storageTestActionHandler,
22+
aiTestActionHandler,
2223
} from './manifests/index.js';
2324
import { settingsBuiltinTranslations } from './translations/index.js';
2425

@@ -80,6 +81,7 @@ export class SettingsServicePlugin implements Plugin {
8081
actionHandlers: opts.actionHandlers ?? {
8182
mail: { test: mailTestActionHandler },
8283
storage: { test: storageTestActionHandler },
84+
ai: { test: aiTestActionHandler },
8385
},
8486
};
8587
}

0 commit comments

Comments
 (0)