Two product decisions from tonight: 1. Public AI providers can now be published by an admin as named presets (lib/ai/types.ts's PublicAiPreset: name/baseUrl/model/apiKeyEnvVar). The admin names an env var, never a secret value - the actual key is whatever ops has set in the server's real environment, same custody model as the existing AI_SERVER_BASE_URL var. A new server route (app/api/ai/public/chat) resolves it and makes the call itself, which also sidesteps the CORS/wrong-base-URL failure class chatPublic hit earlier tonight. Users pick a preset from a dropdown in Settings - Answer with - no key field at all; personal BYOK (paste your own key) stays available as a secondary "Add your own key" option, not removed. Admin UI: new "Public - org-managed presets" card in the AI policy tab. 2. AI now defaults ON instead of requiring setup (lib/ai/auto-provision.ts): on first load, if no provider is chosen yet, probe OpenCode (this app auto-spawns `opencode serve` itself, so it's the one local option with zero external install step) then Ollama via the existing auto-discovery, and adopt whichever answers. Never overrides an explicit choice - only fires while provider is still null. Wired into both AI entry points (the Ask button and the Settings pane) so it resolves before either renders its "not configured" state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
88 lines
3.5 KiB
TypeScript
88 lines
3.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { DEFAULT_AI_ENTITLEMENT, DEFAULT_AI_POLICY, type AiPolicy } from '../types';
|
|
import { loadAiSettings } from '../local-settings';
|
|
|
|
const { listOpencodeModels } = vi.hoisted(() => ({ listOpencodeModels: vi.fn() }));
|
|
vi.mock('../local-client', () => ({ listOpencodeModels }));
|
|
|
|
const { discoverLocalOllama, recommendDefaultModel } = vi.hoisted(() => ({
|
|
discoverLocalOllama: vi.fn(),
|
|
recommendDefaultModel: vi.fn(),
|
|
}));
|
|
vi.mock('../local-discovery', () => ({ discoverLocalOllama, recommendDefaultModel }));
|
|
|
|
const { supportsLocalLlm } = vi.hoisted(() => ({ supportsLocalLlm: vi.fn(() => true) }));
|
|
vi.mock('../../platform-capabilities', () => ({ supportsLocalLlm }));
|
|
|
|
// Imported after the mocks so it picks up the mocked modules.
|
|
const { ensureDefaultProvider, _resetAutoProvisionForTests } = await import('../auto-provision');
|
|
|
|
function policyWith(classes: AiPolicy['entitlement']['classes']): AiPolicy {
|
|
return { ...DEFAULT_AI_POLICY, entitlement: { ...DEFAULT_AI_ENTITLEMENT, classes } };
|
|
}
|
|
|
|
describe('ensureDefaultProvider', () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
_resetAutoProvisionForTests();
|
|
listOpencodeModels.mockReset();
|
|
discoverLocalOllama.mockReset();
|
|
recommendDefaultModel.mockReset();
|
|
supportsLocalLlm.mockReturnValue(true);
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('prefers OpenCode when it has a usable model', async () => {
|
|
listOpencodeModels.mockResolvedValue([{ ref: 'opencode/deepseek-v4-flash-free', label: 'DeepSeek V4 Flash Free' }]);
|
|
|
|
const next = await ensureDefaultProvider(policyWith(['opencode', 'local']));
|
|
|
|
expect(next.provider).toBe('opencode');
|
|
expect(next.opencodeModel).toBe('opencode/deepseek-v4-flash-free');
|
|
expect(discoverLocalOllama).not.toHaveBeenCalled();
|
|
expect(loadAiSettings().provider).toBe('opencode'); // persisted, not just returned
|
|
});
|
|
|
|
it('falls back to Ollama when OpenCode is unreachable', async () => {
|
|
listOpencodeModels.mockRejectedValue(new Error('No local OpenCode server is running'));
|
|
discoverLocalOllama.mockResolvedValue({ baseUrl: 'http://127.0.0.1:11434', models: [{ name: 'qwen2.5:32b' }] });
|
|
recommendDefaultModel.mockReturnValue('qwen2.5:32b');
|
|
|
|
const next = await ensureDefaultProvider(policyWith(['opencode', 'local']));
|
|
|
|
expect(next.provider).toBe('local');
|
|
expect(next.localModel).toBe('qwen2.5:32b');
|
|
});
|
|
|
|
it('leaves provider unset when neither is available', async () => {
|
|
listOpencodeModels.mockResolvedValue([]);
|
|
discoverLocalOllama.mockResolvedValue(null);
|
|
|
|
const next = await ensureDefaultProvider(policyWith(['opencode', 'local']));
|
|
|
|
expect(next.provider).toBeNull();
|
|
});
|
|
|
|
it('never overrides an explicit choice already saved', async () => {
|
|
const { saveAiSettings, DEFAULT_AI_SETTINGS } = await import('../local-settings');
|
|
saveAiSettings({ ...DEFAULT_AI_SETTINGS, provider: 'server', serverModel: 'qwen2.5:32b' });
|
|
|
|
const next = await ensureDefaultProvider(policyWith(['opencode', 'local', 'server']));
|
|
|
|
expect(next.provider).toBe('server');
|
|
expect(listOpencodeModels).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('only probes once per module lifetime even if called again', async () => {
|
|
listOpencodeModels.mockResolvedValue([]);
|
|
discoverLocalOllama.mockResolvedValue(null);
|
|
|
|
await ensureDefaultProvider(policyWith(['opencode', 'local']));
|
|
await ensureDefaultProvider(policyWith(['opencode', 'local']));
|
|
|
|
expect(listOpencodeModels).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|