Skip to content

Commit d100707

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(ai/settings): surface, reject, and recover AI provider misconfiguration (#1788)
A half-saved `ai` settings row (provider=cloudflare, empty key) silently overrode env auto-detection and the only symptom was a bare "Bad Request" in chat. Close every gap in that chain: - GET /api/v1/ai/status — active adapter provenance (source: explicit/ env/settings/fallback, provider, model) plus settingsError explaining why saved settings were NOT applied. - Server-side save validation in SettingsService.setMany: visible+required empty fields and pattern mismatches reject the batch with field-level errors (400 SETTINGS_VALIDATION). Visibility expressions are evaluated by a restricted-grammar parser; resets and unparseable expressions stay lenient. gateway_model/cloudflare_model gain provider/model patterns. - Built-in `reset` settings action per namespace; the ai override also re-runs env adapter detection. Manifest ships a "Reset to environment defaults" button — no more hand-editing sys_setting. - Chat/agent/assistant stream errors now carry the adapter description and actionable hints (400 → model-id format, 401 → credential, …). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 9b4e870 commit d100707

19 files changed

Lines changed: 1132 additions & 31 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/service-ai": minor
3+
"@objectstack/service-settings": minor
4+
---
5+
6+
AI provider misconfiguration is now visible, rejected at save time, and recoverable from the UI. Background: a half-saved `ai` settings row (provider=cloudflare, empty key) silently overrode env auto-detection and the only symptom was a bare "Bad Request" in chat.
7+
8+
- `GET /api/v1/ai/status` — active adapter provenance: `source` (explicit/env/settings/fallback), provider, model, plus `settingsError` explaining why saved settings were NOT applied. `AIServicePlugin` tracks this through boot detection, settings rebuilds, and resets.
9+
- Save-time validation in `SettingsService.setMany` (fulfilling the spec promise that `required` is enforced server-side): visible+required fields and `pattern` mismatches reject the whole batch with field-level errors (`400 SETTINGS_VALIDATION`). Visibility expressions (`${data.provider === '…'}`) are evaluated server-side by a restricted-grammar parser; unparseable expressions and all-null patches (resets) stay lenient. `gateway_model` / `cloudflare_model` gain `provider/model` patterns.
10+
- Built-in `reset` settings action for every namespace (`SettingsService.resetNamespace`), overridden for `ai` to also re-run env adapter detection immediately; the AI manifest ships a "Reset to environment defaults" button — no more hand-editing `sys_setting`.
11+
- Chat/agent/assistant stream errors are enriched with the active adapter description and actionable hints (400 → model-id format, 401/403 → credential, 404 → unknown model, 429 → rate limit) instead of a bare HTTP status.
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Adapter provenance — `GET /api/v1/ai/status` and the settings-apply
4+
// status tracking on AIServicePlugin. Persisted settings silently override
5+
// env auto-detection, so the plugin must expose WHICH config is live and
6+
// WHY a saved config was not applied (broken settings used to be
7+
// indistinguishable from working ones).
8+
9+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10+
import { AIServicePlugin } from '../plugin.js';
11+
import { AIService } from '../ai-service.js';
12+
import { buildAIRoutes } from '../routes/ai-routes.js';
13+
import { InMemoryConversationService } from '../conversation/in-memory-conversation-service.js';
14+
import { MemoryLLMAdapter } from '../adapters/memory-adapter.js';
15+
import type { Logger } from '@objectstack/spec/contracts';
16+
17+
const silentLogger: Logger = {
18+
debug: vi.fn(),
19+
info: vi.fn(),
20+
warn: vi.fn(),
21+
error: vi.fn(),
22+
fatal: vi.fn(),
23+
};
24+
25+
function createMockContext() {
26+
const services = new Map<string, unknown>();
27+
const hooks = new Map<string, Function[]>();
28+
services.set('manifest', { register: vi.fn() });
29+
30+
return {
31+
services,
32+
hooks,
33+
registerService: vi.fn((name: string, service: unknown) => services.set(name, service)),
34+
replaceService: vi.fn((name: string, service: unknown) => services.set(name, service)),
35+
getService: vi.fn(<T,>(name: string): T => {
36+
if (!services.has(name)) throw new Error(`Service "${name}" not found`);
37+
return services.get(name) as T;
38+
}),
39+
getServices: vi.fn(() => services),
40+
hook: vi.fn((name: string, handler: Function) => {
41+
if (!hooks.has(name)) hooks.set(name, []);
42+
hooks.get(name)!.push(handler);
43+
}),
44+
trigger: vi.fn(async () => {}),
45+
logger: silentLogger,
46+
getKernel: vi.fn(),
47+
} as any;
48+
}
49+
50+
async function fireHook(ctx: any, name: string): Promise<void> {
51+
for (const handler of ctx.hooks.get(name) ?? []) await handler();
52+
}
53+
54+
/** Settings-service stub returning the given `ai` namespace values. */
55+
function settingsStub(values: Record<string, { value: unknown; source?: string }>) {
56+
const actions = new Map<string, Function>();
57+
return {
58+
actions,
59+
getNamespace: vi.fn(async () => ({ values })),
60+
subscribe: vi.fn(),
61+
registerAction: vi.fn((ns: string, id: string, fn: Function) => actions.set(`${ns}/${id}`, fn)),
62+
resetNamespace: vi.fn(async () => 3),
63+
};
64+
}
65+
66+
const AI_ENV_KEYS = [
67+
'AI_GATEWAY_MODEL',
68+
'AI_GATEWAY_API_KEY',
69+
'OPENAI_API_KEY',
70+
'ANTHROPIC_API_KEY',
71+
'GOOGLE_GENERATIVE_AI_API_KEY',
72+
];
73+
74+
describe('GET /api/v1/ai/status route', () => {
75+
it('returns adapter name plus the provenance from getAdapterStatus', async () => {
76+
const service = new AIService({
77+
adapter: new MemoryLLMAdapter(),
78+
conversationService: new InMemoryConversationService(),
79+
});
80+
const routes = buildAIRoutes(service, service.conversationService, silentLogger, {
81+
getAdapterStatus: () => ({
82+
description: 'Vercel AI Gateway (model: anthropic/claude-sonnet-4.6)',
83+
source: 'env',
84+
provider: 'gateway',
85+
model: 'anthropic/claude-sonnet-4.6',
86+
settingsError: null,
87+
}),
88+
});
89+
const statusRoute = routes.find(r => r.method === 'GET' && r.path === '/api/v1/ai/status');
90+
expect(statusRoute).toBeDefined();
91+
expect(statusRoute!.permissions).toEqual(['ai:read']);
92+
93+
const response = await statusRoute!.handler({});
94+
expect(response.status).toBe(200);
95+
expect(response.body).toMatchObject({
96+
adapter: 'memory',
97+
source: 'env',
98+
provider: 'gateway',
99+
model: 'anthropic/claude-sonnet-4.6',
100+
settingsError: null,
101+
});
102+
});
103+
104+
it('still serves adapter name when no status getter is wired', async () => {
105+
const service = new AIService({
106+
adapter: new MemoryLLMAdapter(),
107+
conversationService: new InMemoryConversationService(),
108+
});
109+
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
110+
const statusRoute = routes.find(r => r.path === '/api/v1/ai/status')!;
111+
const response = await statusRoute.handler({});
112+
expect(response.status).toBe(200);
113+
expect((response.body as any).adapter).toBe('memory');
114+
});
115+
});
116+
117+
describe('AIServicePlugin adapter provenance', () => {
118+
const savedEnv: Record<string, string | undefined> = {};
119+
120+
beforeEach(() => {
121+
for (const key of AI_ENV_KEYS) {
122+
savedEnv[key] = process.env[key];
123+
delete process.env[key];
124+
}
125+
});
126+
127+
afterEach(() => {
128+
for (const key of AI_ENV_KEYS) {
129+
if (savedEnv[key] === undefined) delete process.env[key];
130+
else process.env[key] = savedEnv[key];
131+
}
132+
});
133+
134+
async function statusOf(ctx: any): Promise<Record<string, unknown>> {
135+
// The plugin caches routes on trigger('ai:routes', routes).
136+
const call = ctx.trigger.mock.calls.find((c: unknown[]) => c[0] === 'ai:routes');
137+
const routes = call![1] as Array<{ path: string; handler: (req: object) => Promise<{ body?: unknown }> }>;
138+
const route = routes.find(r => r.path === '/api/v1/ai/status')!;
139+
return (await route.handler({})).body as Record<string, unknown>;
140+
}
141+
142+
it('reports source=fallback when nothing is configured', async () => {
143+
const plugin = new AIServicePlugin();
144+
const ctx = createMockContext();
145+
await plugin.init(ctx);
146+
await plugin.start!(ctx);
147+
148+
const body = await statusOf(ctx);
149+
expect(body.adapter).toBe('memory');
150+
expect(body.source).toBe('fallback');
151+
expect(body.provider).toBe('memory');
152+
});
153+
154+
it('reports source=explicit for a constructor-supplied adapter', async () => {
155+
const plugin = new AIServicePlugin({
156+
adapter: {
157+
name: 'custom-test',
158+
chat: async () => ({ content: 'ok' }),
159+
complete: async () => ({ content: '' }),
160+
},
161+
});
162+
const ctx = createMockContext();
163+
await plugin.init(ctx);
164+
await plugin.start!(ctx);
165+
166+
const body = await statusOf(ctx);
167+
expect(body.adapter).toBe('custom-test');
168+
expect(body.source).toBe('explicit');
169+
});
170+
171+
it('flags settingsError when saved settings cannot build an adapter (broken cloudflare)', async () => {
172+
const plugin = new AIServicePlugin();
173+
const ctx = createMockContext();
174+
// provider=cloudflare with an empty key — exactly the broken leftover
175+
// a half-filled Setup form produces.
176+
ctx.services.set('settings', settingsStub({
177+
provider: { value: 'cloudflare', source: 'database' },
178+
cloudflare_account_id: { value: '2846eb40a60f4738e292b90dcd8cce10', source: 'database' },
179+
cloudflare_api_key: { value: '', source: 'database' },
180+
cloudflare_model: { value: 'claude/sonnet-4.6', source: 'database' },
181+
}));
182+
183+
await plugin.init(ctx);
184+
await plugin.start!(ctx);
185+
await fireHook(ctx, 'kernel:ready');
186+
187+
const body = await statusOf(ctx);
188+
// The broken settings must NOT have replaced the fallback adapter…
189+
expect(body.adapter).toBe('memory');
190+
expect(body.source).toBe('fallback');
191+
// …and the failure must be visible, naming the saved provider.
192+
expect(body.settingsProvider).toBe('cloudflare');
193+
expect(String(body.settingsError)).toContain('cloudflare');
194+
});
195+
196+
it('reports source=settings when saved settings apply cleanly', async () => {
197+
const plugin = new AIServicePlugin();
198+
const ctx = createMockContext();
199+
// `memory` stored explicitly (source=database) is a valid, buildable choice.
200+
ctx.services.set('settings', settingsStub({
201+
provider: { value: 'memory', source: 'database' },
202+
}));
203+
204+
await plugin.init(ctx);
205+
await plugin.start!(ctx);
206+
await fireHook(ctx, 'kernel:ready');
207+
208+
const body = await statusOf(ctx);
209+
expect(body.source).toBe('settings');
210+
expect(body.settingsProvider).toBe('memory');
211+
expect(body.settingsError).toBeNull();
212+
});
213+
214+
it('ai/reset clears saved values and re-runs env adapter detection', async () => {
215+
const plugin = new AIServicePlugin();
216+
const ctx = createMockContext();
217+
const settings = settingsStub({
218+
provider: { value: 'memory', source: 'database' },
219+
});
220+
ctx.services.set('settings', settings);
221+
222+
await plugin.init(ctx);
223+
await plugin.start!(ctx);
224+
await fireHook(ctx, 'kernel:ready');
225+
226+
// Saved settings are in effect…
227+
expect((await statusOf(ctx)).source).toBe('settings');
228+
229+
// …then the operator hits "Reset to environment defaults".
230+
const reset = settings.actions.get('ai/reset');
231+
expect(reset).toBeDefined();
232+
const result = await reset!({ ctx: {} });
233+
expect(result.ok).toBe(true);
234+
expect(result.message).toContain('Cleared 3');
235+
expect(settings.resetNamespace).toHaveBeenCalledWith('ai', {});
236+
237+
const body = await statusOf(ctx);
238+
// No AI env vars in this test → detection falls back to echo mode.
239+
expect(body.source).toBe('fallback');
240+
expect(body.settingsProvider).toBeUndefined();
241+
expect(body.settingsError).toBeNull();
242+
});
243+
244+
it('keeps env provenance and clears settingsError when no settings are saved', async () => {
245+
const plugin = new AIServicePlugin();
246+
const ctx = createMockContext();
247+
ctx.services.set('settings', settingsStub({
248+
provider: { value: 'memory', source: 'default' },
249+
}));
250+
251+
await plugin.init(ctx);
252+
await plugin.start!(ctx);
253+
await fireHook(ctx, 'kernel:ready');
254+
255+
const body = await statusOf(ctx);
256+
expect(body.source).toBe('fallback');
257+
expect(body.settingsProvider).toBeUndefined();
258+
expect(body.settingsError).toBeNull();
259+
});
260+
});

packages/services/service-ai/src/__tests__/ai-service.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -533,9 +533,10 @@ describe('AI Routes', () => {
533533

534534
it('should build all expected routes', () => {
535535
const routes = buildAIRoutes(service, service.conversationService, silentLogger);
536-
expect(routes.length).toBe(10);
536+
expect(routes.length).toBe(11);
537537

538538
const paths = routes.map(r => `${r.method} ${r.path}`);
539+
expect(paths).toContain('GET /api/v1/ai/status');
539540
expect(paths).toContain('POST /api/v1/ai/chat');
540541
expect(paths).toContain('POST /api/v1/ai/chat/stream');
541542
expect(paths).toContain('POST /api/v1/ai/complete');
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
import { describeProviderError } from '../stream/error-hints.js';
5+
import { encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
6+
7+
const GATEWAY = 'Vercel AI Gateway (model: claude/sonnet-4.6)';
8+
9+
describe('describeProviderError', () => {
10+
it('names the adapter and maps HTTP 400 to a model-id hint', () => {
11+
const msg = describeProviderError(
12+
{ message: 'Bad Request', statusCode: 400 },
13+
GATEWAY,
14+
);
15+
expect(msg).toContain('Bad Request (HTTP 400)');
16+
expect(msg).toContain(GATEWAY);
17+
expect(msg).toContain('provider/model');
18+
});
19+
20+
it('maps auth failures to a credential hint', () => {
21+
const msg = describeProviderError(
22+
{ name: 'GatewayAuthenticationError', message: 'Unauthorized', statusCode: 401 },
23+
GATEWAY,
24+
);
25+
expect(msg).toContain('API key');
26+
expect(msg).toContain('Test connection');
27+
});
28+
29+
it('appends the provider response body excerpt', () => {
30+
const msg = describeProviderError({
31+
message: 'Bad Request',
32+
statusCode: 400,
33+
responseBody: '{"error":"model claude/sonnet-4.6 not found"}',
34+
});
35+
expect(msg).toContain('provider says:');
36+
expect(msg).toContain('not found');
37+
});
38+
39+
it('reads nested cause status and survives non-object errors', () => {
40+
expect(describeProviderError({ message: 'fail', cause: { statusCode: 429 } })).toContain('HTTP 429');
41+
expect(describeProviderError('plain text error')).toContain('plain text error');
42+
expect(describeProviderError(undefined)).toContain('Unknown provider error');
43+
});
44+
});
45+
46+
describe('encodeVercelDataStream error enrichment', () => {
47+
async function* failingStream(): AsyncIterable<any> {
48+
yield { type: 'error', error: { message: 'Bad Request', statusCode: 400 } };
49+
}
50+
51+
it('emits the enriched error text on provider error parts', async () => {
52+
const frames: string[] = [];
53+
for await (const f of encodeVercelDataStream(failingStream() as any, { adapterDescription: GATEWAY })) {
54+
frames.push(f);
55+
}
56+
const errFrame = frames.find((f) => f.includes('"type":"error"'))!;
57+
expect(errFrame).toContain('HTTP 400');
58+
expect(errFrame).toContain('claude/sonnet-4.6');
59+
const finish = frames.find((f) => f.includes('"type":"finish"'))!;
60+
expect(finish).toContain('"finishReason":"error"');
61+
});
62+
63+
it('enriches thrown errors too', async () => {
64+
async function* throwingStream(): AsyncIterable<any> {
65+
throw Object.assign(new Error('Unauthorized'), { statusCode: 401 });
66+
yield undefined; // unreachable — makes this a generator
67+
}
68+
const frames: string[] = [];
69+
for await (const f of encodeVercelDataStream(throwingStream() as any, { adapterDescription: GATEWAY })) {
70+
frames.push(f);
71+
}
72+
const errFrame = frames.find((f) => f.includes('"type":"error"'))!;
73+
expect(errFrame).toContain('HTTP 401');
74+
expect(errFrame).toContain('API key');
75+
});
76+
});

packages/services/service-ai/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export type { AIServiceConfig } from './ai-service.js';
66

77
// Kernel plugin
88
export { AIServicePlugin } from './plugin.js';
9-
export type { AIServicePluginOptions } from './plugin.js';
9+
export type { AIServicePluginOptions, AIAdapterStatus } from './plugin.js';
1010

1111
// Adapters
1212
export { MemoryLLMAdapter } from './adapters/memory-adapter.js';

0 commit comments

Comments
 (0)