first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_CONFIG, loadConfig } from './config';
|
||||
import type { AppConfig } from '../types';
|
||||
|
||||
const store = new Map<string, string>();
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
store.delete(key);
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store.clear();
|
||||
}),
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.clear();
|
||||
});
|
||||
|
||||
describe('loadConfig', () => {
|
||||
it('migrates legacy OpenAI-compatible API configs to an explicit apiProtocol', () => {
|
||||
const legacyConfig: Partial<AppConfig> = {
|
||||
mode: 'api',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
};
|
||||
store.set('open-design:config', JSON.stringify(legacyConfig));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.mode).toBe('api');
|
||||
expect(config.baseUrl).toBe('https://api.deepseek.com');
|
||||
expect(config.model).toBe('deepseek-chat');
|
||||
expect(config.apiProtocol).toBe('openai');
|
||||
expect(config.configMigrationVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('migrates legacy Anthropic API configs to an explicit apiProtocol', () => {
|
||||
const legacyConfig: Partial<AppConfig> = {
|
||||
mode: 'api',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
model: 'claude-sonnet-4-5',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
};
|
||||
store.set('open-design:config', JSON.stringify(legacyConfig));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.apiProtocol).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('infers protocol for legacy daemon-mode API fields without changing mode', () => {
|
||||
const daemonConfig: Partial<AppConfig> = {
|
||||
mode: 'daemon',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
agentId: 'codex',
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
};
|
||||
store.set('open-design:config', JSON.stringify(daemonConfig));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.mode).toBe('daemon');
|
||||
expect(config.apiProtocol).toBe('openai');
|
||||
expect(config.configMigrationVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('does not overwrite an already explicit apiProtocol', () => {
|
||||
const explicitConfig: Partial<AppConfig> = {
|
||||
mode: 'api',
|
||||
apiProtocol: 'anthropic',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
};
|
||||
store.set('open-design:config', JSON.stringify(explicitConfig));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.apiProtocol).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('preserves saved settings when migration sees a malformed base URL', () => {
|
||||
const legacyConfig: Partial<AppConfig> = {
|
||||
mode: 'api',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://[broken-ipv6',
|
||||
model: 'custom-model',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
};
|
||||
store.set('open-design:config', JSON.stringify(legacyConfig));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.mode).toBe('api');
|
||||
expect(config.apiKey).toBe('sk-test');
|
||||
expect(config.baseUrl).toBe('https://[broken-ipv6');
|
||||
expect(config.model).toBe('custom-model');
|
||||
expect(config.apiProtocol).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('returns defaults for malformed localStorage JSON', () => {
|
||||
store.set('open-design:config', '{broken-json');
|
||||
|
||||
expect(loadConfig()).toEqual(DEFAULT_CONFIG);
|
||||
});
|
||||
|
||||
it('sets an explicit apiProtocol for new default configs', () => {
|
||||
expect(DEFAULT_CONFIG.apiProtocol).toBe('anthropic');
|
||||
expect(DEFAULT_CONFIG.configMigrationVersion).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
import type { AppConfigPrefs } from '@open-design/contracts';
|
||||
import { isOpenAICompatible } from '../providers/openai-compatible';
|
||||
import type {
|
||||
ApiProtocol,
|
||||
AppConfig,
|
||||
MediaProviderCredentials,
|
||||
NotificationsConfig,
|
||||
PetConfig,
|
||||
} from '../types';
|
||||
import {
|
||||
DEFAULT_FAILURE_SOUND_ID,
|
||||
DEFAULT_SUCCESS_SOUND_ID,
|
||||
} from '../utils/notifications';
|
||||
|
||||
const STORAGE_KEY = 'open-design:config';
|
||||
const CONFIG_MIGRATION_VERSION = 1;
|
||||
|
||||
// Hatched out of the box, but tucked away — the user has to go through
|
||||
// either the entry-view "adopt a pet" callout or Settings → Pets to
|
||||
// summon them. Keeps the workspace quiet for first-run users.
|
||||
// Both switches default off so first-run users are not greeted by a
|
||||
// surprise sound or a permission prompt; they can opt in from Settings →
|
||||
// Notifications when they want it.
|
||||
export const DEFAULT_NOTIFICATIONS: NotificationsConfig = {
|
||||
soundEnabled: false,
|
||||
successSoundId: DEFAULT_SUCCESS_SOUND_ID,
|
||||
failureSoundId: DEFAULT_FAILURE_SOUND_ID,
|
||||
desktopEnabled: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_PET: PetConfig = {
|
||||
adopted: false,
|
||||
enabled: false,
|
||||
petId: 'mochi',
|
||||
custom: {
|
||||
name: 'Buddy',
|
||||
glyph: '🦄',
|
||||
accent: '#c96442',
|
||||
greeting: 'Hi! I am here whenever you need me.',
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_CONFIG: AppConfig = {
|
||||
mode: 'daemon',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
model: 'claude-sonnet-4-5',
|
||||
// New configs should be explicit. loadConfig() still detects parsed legacy
|
||||
// saved configs that did not have this field and migrates those from their
|
||||
// saved baseUrl/model before applying the current migration version.
|
||||
apiProtocol: 'anthropic',
|
||||
apiVersion: '',
|
||||
apiProtocolConfigs: {},
|
||||
configMigrationVersion: CONFIG_MIGRATION_VERSION,
|
||||
apiProviderBaseUrl: 'https://api.anthropic.com',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
onboardingCompleted: false,
|
||||
theme: 'system',
|
||||
mediaProviders: {},
|
||||
agentModels: {},
|
||||
pet: DEFAULT_PET,
|
||||
notifications: DEFAULT_NOTIFICATIONS,
|
||||
};
|
||||
|
||||
/** Well-known providers with pre-filled base URLs. */
|
||||
export interface KnownProvider {
|
||||
label: string;
|
||||
protocol: ApiProtocol;
|
||||
baseUrl: string;
|
||||
/** Default model to apply when the provider is selected. */
|
||||
model: string;
|
||||
/** Optional provider-specific model choices shown in Settings. */
|
||||
models?: string[];
|
||||
}
|
||||
|
||||
// Some providers appear more than once because they expose both
|
||||
// Anthropic-compatible (/v1/messages) and OpenAI-compatible
|
||||
// (/v1/chat/completions) gateways. Keep those entries separate so the Settings
|
||||
// UI can scope quick-fill presets and model suggestions to the selected
|
||||
// protocol.
|
||||
//
|
||||
// Model lists are hand-curated from provider docs/current public presets rather
|
||||
// than fetched dynamically. To add a provider, include a user-facing label, the
|
||||
// protocol that determines request routing, the base URL, a default model, and
|
||||
// optional provider-specific model choices.
|
||||
export const KNOWN_PROVIDERS: KnownProvider[] = [
|
||||
{
|
||||
label: 'Anthropic (Claude)',
|
||||
protocol: 'anthropic',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
model: 'claude-sonnet-4-5',
|
||||
models: ['claude-sonnet-4-5', 'claude-opus-4-5', 'claude-haiku-4-5'],
|
||||
},
|
||||
{
|
||||
label: 'DeepSeek — Anthropic',
|
||||
protocol: 'anthropic',
|
||||
baseUrl: 'https://api.deepseek.com/anthropic',
|
||||
model: 'deepseek-chat',
|
||||
models: [
|
||||
'deepseek-chat',
|
||||
'deepseek-reasoner',
|
||||
'deepseek-v4-flash',
|
||||
'deepseek-v4-pro',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'MiniMax — Anthropic',
|
||||
protocol: 'anthropic',
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
model: 'MiniMax-M2.7-highspeed',
|
||||
models: [
|
||||
'MiniMax-M2.7-highspeed',
|
||||
'MiniMax-M2.7',
|
||||
'MiniMax-M2.5-highspeed',
|
||||
'MiniMax-M2.5',
|
||||
'MiniMax-M2.1-highspeed',
|
||||
'MiniMax-M2.1',
|
||||
'MiniMax-M2',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'OpenAI',
|
||||
protocol: 'openai',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
models: ['gpt-4o', 'gpt-4o-mini', 'o3', 'o4-mini'],
|
||||
},
|
||||
{
|
||||
label: 'Azure OpenAI',
|
||||
protocol: 'azure',
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
models: [],
|
||||
},
|
||||
{
|
||||
label: 'Google Gemini',
|
||||
protocol: 'google',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com',
|
||||
model: 'gemini-2.0-flash',
|
||||
models: ['gemini-2.0-flash', 'gemini-2.0-flash-lite', 'gemini-1.5-pro', 'gemini-1.5-flash'],
|
||||
},
|
||||
{
|
||||
label: 'DeepSeek — OpenAI',
|
||||
protocol: 'openai',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
models: [
|
||||
'deepseek-chat',
|
||||
'deepseek-reasoner',
|
||||
'deepseek-v4-flash',
|
||||
'deepseek-v4-pro',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'MiniMax — OpenAI',
|
||||
protocol: 'openai',
|
||||
baseUrl: 'https://api.minimaxi.com/v1',
|
||||
model: 'MiniMax-M2.7-highspeed',
|
||||
models: [
|
||||
'MiniMax-M2.7-highspeed',
|
||||
'MiniMax-M2.7',
|
||||
'MiniMax-M2.5-highspeed',
|
||||
'MiniMax-M2.5',
|
||||
'MiniMax-M2.1-highspeed',
|
||||
'MiniMax-M2.1',
|
||||
'MiniMax-M2',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'MiMo (Xiaomi) — OpenAI',
|
||||
protocol: 'openai',
|
||||
baseUrl: 'https://token-plan-cn.xiaomimimo.com/v1',
|
||||
model: 'mimo-v2.5-pro',
|
||||
models: ['mimo-v2.5-pro'],
|
||||
},
|
||||
{
|
||||
label: 'MiMo (Xiaomi) — Anthropic',
|
||||
protocol: 'anthropic',
|
||||
baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic',
|
||||
model: 'mimo-v2.5-pro',
|
||||
models: ['mimo-v2.5-pro'],
|
||||
},
|
||||
];
|
||||
|
||||
function normalizePet(input: Partial<PetConfig> | undefined): PetConfig {
|
||||
if (!input) return { ...DEFAULT_PET, custom: { ...DEFAULT_PET.custom } };
|
||||
// Merge stored values onto defaults so newly-added fields land safely
|
||||
// when an older config is rehydrated.
|
||||
return {
|
||||
...DEFAULT_PET,
|
||||
...input,
|
||||
custom: { ...DEFAULT_PET.custom, ...(input.custom ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNotifications(
|
||||
input: Partial<NotificationsConfig> | undefined,
|
||||
): NotificationsConfig {
|
||||
return { ...DEFAULT_NOTIFICATIONS, ...(input ?? {}) };
|
||||
}
|
||||
|
||||
function inferApiProtocol(model: string, baseUrl: string): ApiProtocol {
|
||||
try {
|
||||
return isOpenAICompatible(model, baseUrl) ? 'openai' : 'anthropic';
|
||||
} catch {
|
||||
// Preserve the rest of the user's settings even if an old saved base URL is
|
||||
// malformed enough for URL parsing to throw. Anthropic is the safest default
|
||||
// because it matches the original built-in provider.
|
||||
return 'anthropic';
|
||||
}
|
||||
}
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
pet: normalizePet(DEFAULT_PET),
|
||||
notifications: normalizeNotifications(DEFAULT_NOTIFICATIONS),
|
||||
};
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<AppConfig>;
|
||||
const parsedHasApiProtocol = Object.prototype.hasOwnProperty.call(
|
||||
parsed,
|
||||
'apiProtocol',
|
||||
);
|
||||
const merged: AppConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
...parsed,
|
||||
apiProtocolConfigs: { ...(parsed.apiProtocolConfigs ?? {}) },
|
||||
mediaProviders: { ...(parsed.mediaProviders ?? {}) },
|
||||
agentModels: { ...(parsed.agentModels ?? {}) },
|
||||
pet: normalizePet(parsed.pet),
|
||||
notifications: normalizeNotifications(parsed.notifications),
|
||||
};
|
||||
|
||||
if (parsed.configMigrationVersion !== CONFIG_MIGRATION_VERSION) {
|
||||
// Migration v1: configs saved before apiProtocol existed need an explicit
|
||||
// protocol so old OpenAI-compatible endpoints keep routing correctly.
|
||||
// This is version-gated instead of only field-gated so a later imported
|
||||
// legacy config can be migrated when it is loaded.
|
||||
if (!parsedHasApiProtocol) {
|
||||
merged.apiProtocol = inferApiProtocol(merged.model, merged.baseUrl);
|
||||
// Also set apiProviderBaseUrl so setApiProtocol() can correctly identify
|
||||
// whether the user is on a known provider and switch defaults appropriately.
|
||||
// null means "custom/unknown provider" so the protocol switch won't override
|
||||
// their custom base URL.
|
||||
const knownProvider = KNOWN_PROVIDERS.find(
|
||||
(p) => p.baseUrl === merged.baseUrl,
|
||||
);
|
||||
merged.apiProviderBaseUrl = knownProvider?.baseUrl ?? null;
|
||||
}
|
||||
merged.configMigrationVersion = CONFIG_MIGRATION_VERSION;
|
||||
}
|
||||
|
||||
return merged;
|
||||
} catch {
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
pet: normalizePet(DEFAULT_PET),
|
||||
notifications: normalizeNotifications(DEFAULT_NOTIFICATIONS),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveConfig(config: AppConfig): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
}
|
||||
|
||||
export function hasAnyConfiguredProvider(
|
||||
providers: Record<string, MediaProviderCredentials> | undefined,
|
||||
): boolean {
|
||||
if (!providers) return false;
|
||||
return Object.values(providers).some((entry) =>
|
||||
Boolean(entry?.apiKey?.trim() || entry?.baseUrl?.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncMediaProvidersToDaemon(
|
||||
providers: Record<string, MediaProviderCredentials> | undefined,
|
||||
options?: { force?: boolean },
|
||||
): Promise<void> {
|
||||
if (!providers) return;
|
||||
try {
|
||||
await fetch('/api/media/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ providers, force: Boolean(options?.force) }),
|
||||
});
|
||||
} catch {
|
||||
// Daemon offline; localStorage keeps the user's copy for the next save.
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDaemonConfig(): Promise<AppConfigPrefs | null> {
|
||||
try {
|
||||
const res = await fetch('/api/app-config');
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data?.config ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncConfigToDaemon(config: AppConfig): Promise<void> {
|
||||
const prefs: AppConfigPrefs = {
|
||||
onboardingCompleted: config.onboardingCompleted,
|
||||
agentId: config.agentId,
|
||||
agentModels: config.agentModels,
|
||||
skillId: config.skillId,
|
||||
designSystemId: config.designSystemId,
|
||||
};
|
||||
try {
|
||||
await fetch('/api/app-config', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(prefs),
|
||||
});
|
||||
} catch {
|
||||
// Daemon offline; localStorage keeps the user's copy for the next save.
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import litellmData from './litellm-models.json';
|
||||
import {
|
||||
effectiveMaxTokens,
|
||||
FALLBACK_MAX_TOKENS,
|
||||
MAX_MAX_TOKENS,
|
||||
MIN_MAX_TOKENS,
|
||||
modelMaxTokensDefault,
|
||||
} from './maxTokens';
|
||||
|
||||
describe('modelMaxTokensDefault', () => {
|
||||
it('falls through to LiteLLM data for canonical Anthropic ids', () => {
|
||||
// 64k for the 4.5 line is the upstream value; this guards against the
|
||||
// sync script silently dropping or rewriting these entries.
|
||||
expect(modelMaxTokensDefault('claude-sonnet-4-5')).toBe(64000);
|
||||
expect(modelMaxTokensDefault('claude-opus-4-5')).toBe(64000);
|
||||
expect(modelMaxTokensDefault('claude-haiku-4-5')).toBe(64000);
|
||||
});
|
||||
|
||||
it('lets OVERRIDES win over LiteLLM data', () => {
|
||||
// mimo-v2.5-pro is not in LiteLLM, so this asserts the OVERRIDES path
|
||||
// (not the LiteLLM path) supplied the answer.
|
||||
expect((litellmData.models as Record<string, number>)['mimo-v2.5-pro']).toBeUndefined();
|
||||
expect(modelMaxTokensDefault('mimo-v2.5-pro')).toBe(32768);
|
||||
});
|
||||
|
||||
it('returns FALLBACK_MAX_TOKENS for unknown ids', () => {
|
||||
expect(modelMaxTokensDefault('definitely-not-a-real-model-x9z')).toBe(FALLBACK_MAX_TOKENS);
|
||||
expect(FALLBACK_MAX_TOKENS).toBe(8192);
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveMaxTokens', () => {
|
||||
it('honors an explicit user override over the model default', () => {
|
||||
expect(effectiveMaxTokens({ maxTokens: 12345, model: 'claude-sonnet-4-5' })).toBe(12345);
|
||||
});
|
||||
|
||||
it('uses the model default when no override is set', () => {
|
||||
expect(effectiveMaxTokens({ model: 'mimo-v2.5-pro' })).toBe(32768);
|
||||
expect(effectiveMaxTokens({ model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
});
|
||||
|
||||
it('falls back to FALLBACK_MAX_TOKENS for unknown models with no override', () => {
|
||||
expect(effectiveMaxTokens({ model: 'unknown-model' })).toBe(FALLBACK_MAX_TOKENS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveMaxTokens override validation', () => {
|
||||
// Stale localStorage, hand-edited config, or future schema drift can put
|
||||
// anything in cfg.maxTokens. The Settings UI advertises a [1024, 200000]
|
||||
// integer-stepped range, and the daemon proxy already clamps `> 0`, so
|
||||
// we tighten this entry point to match the advertised contract.
|
||||
|
||||
it('rejects negative overrides and falls back to the model default', () => {
|
||||
expect(effectiveMaxTokens({ maxTokens: -5, model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
});
|
||||
|
||||
it('rejects zero', () => {
|
||||
expect(effectiveMaxTokens({ maxTokens: 0, model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
});
|
||||
|
||||
it('rejects overrides below MIN_MAX_TOKENS', () => {
|
||||
expect(effectiveMaxTokens({ maxTokens: MIN_MAX_TOKENS - 1, model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
});
|
||||
|
||||
it('rejects overrides above MAX_MAX_TOKENS', () => {
|
||||
expect(effectiveMaxTokens({ maxTokens: MAX_MAX_TOKENS + 1, model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
expect(effectiveMaxTokens({ maxTokens: 999_999_999, model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
});
|
||||
|
||||
it('rejects non-integer overrides', () => {
|
||||
expect(effectiveMaxTokens({ maxTokens: 123.9, model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
expect(effectiveMaxTokens({ maxTokens: Number.NaN, model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
expect(effectiveMaxTokens({ maxTokens: Number.POSITIVE_INFINITY, model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
});
|
||||
|
||||
it('accepts the boundary values exactly', () => {
|
||||
expect(effectiveMaxTokens({ maxTokens: MIN_MAX_TOKENS, model: 'claude-sonnet-4-5' })).toBe(MIN_MAX_TOKENS);
|
||||
expect(effectiveMaxTokens({ maxTokens: MAX_MAX_TOKENS, model: 'claude-sonnet-4-5' })).toBe(MAX_MAX_TOKENS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { AppConfig } from '../types';
|
||||
import litellmData from './litellm-models.json';
|
||||
|
||||
// Per-model output cap, used to default `max_tokens` so users on supported
|
||||
// models don't have to find Settings to avoid mid-stream truncation.
|
||||
//
|
||||
// Source of truth: vendored slice of BerriAI/litellm's
|
||||
// model_prices_and_context_window.json (MIT). Regenerate with:
|
||||
// node --experimental-strip-types scripts/sync-litellm-models.ts
|
||||
//
|
||||
// Anything LiteLLM doesn't track (or where its value is wrong for our
|
||||
// usage) goes in OVERRIDES; unknown models fall through to FALLBACK.
|
||||
export const FALLBACK_MAX_TOKENS = 8192;
|
||||
|
||||
// Bounds the user can express via the Settings override. Source of truth
|
||||
// for both the UI input attributes and runtime validation in
|
||||
// `effectiveMaxTokens`, so a stale or hand-edited localStorage value
|
||||
// can't sneak past the UI's promise.
|
||||
export const MIN_MAX_TOKENS = 1024;
|
||||
export const MAX_MAX_TOKENS = 200000;
|
||||
|
||||
const LITELLM_MODELS = litellmData.models as Record<string, number>;
|
||||
|
||||
const OVERRIDES: Record<string, number> = {
|
||||
// LiteLLM lists MiMo via OpenRouter and Novita aliases (16k / 32k) but
|
||||
// not the canonical `mimo-v2.5-pro` id we hand to Xiaomi's direct API.
|
||||
// 32k matches what issue #29 reports as the working ceiling.
|
||||
'mimo-v2.5-pro': 32768,
|
||||
};
|
||||
|
||||
export function modelMaxTokensDefault(model: string): number {
|
||||
return OVERRIDES[model] ?? LITELLM_MODELS[model] ?? FALLBACK_MAX_TOKENS;
|
||||
}
|
||||
|
||||
function isValidOverride(value: number | undefined): value is number {
|
||||
return (
|
||||
typeof value === 'number' &&
|
||||
Number.isInteger(value) &&
|
||||
value >= MIN_MAX_TOKENS &&
|
||||
value <= MAX_MAX_TOKENS
|
||||
);
|
||||
}
|
||||
|
||||
export function effectiveMaxTokens(cfg: Pick<AppConfig, 'maxTokens' | 'model'>): number {
|
||||
// Out-of-range or non-integer overrides (stale localStorage, hand-edited
|
||||
// config, future schema drift) fall back to the model default rather
|
||||
// than silently shipping an invalid `max_tokens` upstream.
|
||||
if (isValidOverride(cfg.maxTokens)) return cfg.maxTokens;
|
||||
return modelMaxTokensDefault(cfg.model);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// Project / conversation / message / tab persistence — backed by the
|
||||
// daemon's SQLite store. All writes round-trip through HTTP so projects
|
||||
// stay coherent across multiple browser tabs and across restarts.
|
||||
//
|
||||
// These helpers fail soft (returning null / [] on transport errors) so
|
||||
// the UI can stay rendered when the daemon is briefly unreachable.
|
||||
|
||||
import type {
|
||||
ChatMessage,
|
||||
Conversation,
|
||||
OpenTabsState,
|
||||
Project,
|
||||
ProjectMetadata,
|
||||
ProjectTemplate,
|
||||
} from '../types';
|
||||
|
||||
export async function listProjects(): Promise<Project[]> {
|
||||
try {
|
||||
const resp = await fetch('/api/projects');
|
||||
if (!resp.ok) return [];
|
||||
const json = (await resp.json()) as { projects: Project[] };
|
||||
return json.projects ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProject(id: string): Promise<Project | null> {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`);
|
||||
if (!resp.ok) return null;
|
||||
const json = (await resp.json()) as { project: Project };
|
||||
return json.project;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProject(input: {
|
||||
name: string;
|
||||
skillId: string | null;
|
||||
designSystemId: string | null;
|
||||
pendingPrompt?: string;
|
||||
metadata?: ProjectMetadata;
|
||||
}): Promise<{ project: Project; conversationId: string } | null> {
|
||||
try {
|
||||
const id = crypto.randomUUID();
|
||||
const resp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, ...input }),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as { project: Project; conversationId: string };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function importClaudeDesignZip(
|
||||
file: File,
|
||||
): Promise<{ project: Project; conversationId: string; entryFile: string } | null> {
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const resp = await fetch('/api/import/claude-design', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as {
|
||||
project: Project;
|
||||
conversationId: string;
|
||||
entryFile: string;
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- templates ----------
|
||||
|
||||
export async function listTemplates(): Promise<ProjectTemplate[]> {
|
||||
try {
|
||||
const resp = await fetch('/api/templates');
|
||||
if (!resp.ok) return [];
|
||||
const json = (await resp.json()) as { templates: ProjectTemplate[] };
|
||||
return json.templates ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTemplate(id: string): Promise<ProjectTemplate | null> {
|
||||
try {
|
||||
const resp = await fetch(`/api/templates/${encodeURIComponent(id)}`);
|
||||
if (!resp.ok) return null;
|
||||
const json = (await resp.json()) as { template: ProjectTemplate };
|
||||
return json.template;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveTemplate(input: {
|
||||
name: string;
|
||||
description?: string;
|
||||
sourceProjectId: string;
|
||||
}): Promise<ProjectTemplate | null> {
|
||||
try {
|
||||
const resp = await fetch('/api/templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const json = (await resp.json()) as { template: ProjectTemplate };
|
||||
return json.template;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTemplate(id: string): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`/api/templates/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return resp.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchProject(
|
||||
id: string,
|
||||
patch: Partial<Project>,
|
||||
): Promise<Project | null> {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const json = (await resp.json()) as { project: Project };
|
||||
return json.project;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return resp.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- conversations ----------
|
||||
|
||||
export async function listConversations(
|
||||
projectId: string,
|
||||
): Promise<Conversation[]> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/conversations`,
|
||||
);
|
||||
if (!resp.ok) return [];
|
||||
const json = (await resp.json()) as { conversations: Conversation[] };
|
||||
return json.conversations ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createConversation(
|
||||
projectId: string,
|
||||
title?: string,
|
||||
): Promise<Conversation | null> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/conversations`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) return null;
|
||||
const json = (await resp.json()) as { conversation: Conversation };
|
||||
return json.conversation;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchConversation(
|
||||
projectId: string,
|
||||
conversationId: string,
|
||||
patch: Partial<Conversation>,
|
||||
): Promise<Conversation | null> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) return null;
|
||||
const json = (await resp.json()) as { conversation: Conversation };
|
||||
return json.conversation;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteConversation(
|
||||
projectId: string,
|
||||
conversationId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
return resp.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- messages ----------
|
||||
|
||||
export async function listMessages(
|
||||
projectId: string,
|
||||
conversationId: string,
|
||||
): Promise<ChatMessage[]> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}/messages`,
|
||||
);
|
||||
if (!resp.ok) return [];
|
||||
const json = (await resp.json()) as { messages: ChatMessage[] };
|
||||
return json.messages ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMessage(
|
||||
projectId: string,
|
||||
conversationId: string,
|
||||
message: ChatMessage,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(message.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message),
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// best-effort persistence — UI keeps the message in-memory either way
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- tabs ----------
|
||||
|
||||
export async function loadTabs(projectId: string): Promise<OpenTabsState> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/tabs`,
|
||||
);
|
||||
if (!resp.ok) return { tabs: [], active: null };
|
||||
return (await resp.json()) as OpenTabsState;
|
||||
} catch {
|
||||
return { tabs: [], active: null };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveTabs(
|
||||
projectId: string,
|
||||
state: OpenTabsState,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fetch(`/api/projects/${encodeURIComponent(projectId)}/tabs`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(state),
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user