- /changes diffs the last turn's file snapshot (added/modified/deleted), reusing undo infra via SnapshotStack.peek() - system prompt memoized behind version counters (notebook/memory/skills/plugins/tools/workspace); hit-rate in /cost, foundation for provider caching - web_search: DuckDuckGo Lite, keyless, 5 results, SSRF-filtered, in the net set with ask permission - /search <query>: full-text grep over saved sessions incl. tool-input JSON - workspace file list re-walks at a turn boundary after writes - maxSpendPerTurn: per-turn cap stops a runaway step with a notice - /fork: branch the session at the last turn boundary, original untouched 821 tests pass, typecheck clean, build green
165 lines
6.5 KiB
TypeScript
165 lines
6.5 KiB
TypeScript
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
import { createOpenAI } from '@ai-sdk/openai';
|
|
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
|
import type { LanguageModel } from 'ai';
|
|
import { homedir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { withFallback, type FallbackEvent } from './fallback';
|
|
import type { McpServerConfig } from './mcp';
|
|
import { parsePermissions, type PermissionConfig } from './permission';
|
|
import { readClaudeCodeSettings } from './providers';
|
|
import { isToolSetName, type ToolSetName } from './tools';
|
|
|
|
export type ProviderName = 'anthropic' | 'openai';
|
|
|
|
export type Config = {
|
|
provider: ProviderName;
|
|
model: string;
|
|
baseURL?: string;
|
|
apiKey?: string;
|
|
/** Preset id from providers.ts, kept so /provider can show what is configured. */
|
|
presetId?: string;
|
|
/** Retries per model call for transient failures. SDK default is 2. */
|
|
maxRetries?: number;
|
|
/** USD ceiling for a session's spend: warn at 80%, refuse the next turn at 100%. */
|
|
maxSpendUsd?: number;
|
|
/** USD ceiling per individual turn: abort a step if the turn's delta exceeds this. */
|
|
maxSpendPerTurn?: number;
|
|
/** Model id for subagents; omit to share the parent's. */
|
|
subagentModel?: string;
|
|
/** Default agent variant name. */
|
|
agent?: string;
|
|
/** Default thinking level. */
|
|
thinking?: string;
|
|
/** Plugin names to enable; omit for the default set. */
|
|
plugins?: string[];
|
|
/** Optional tool sets to offer beyond `core`; omit for all of them. */
|
|
toolSets?: ToolSetName[];
|
|
/** Which tool calls run, ask, or are refused. Omit for the defaults. */
|
|
permission?: PermissionConfig;
|
|
/** Index for `/registry`. Omit for the default one. */
|
|
registryUrl?: string;
|
|
mcpServers?: Record<string, McpServerConfig>;
|
|
};
|
|
|
|
const configPath = () => join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko', 'config.json');
|
|
|
|
const DEFAULT_MODEL: Record<ProviderName, string> = {
|
|
anthropic: 'claude-sonnet-4-5',
|
|
openai: 'gpt-5',
|
|
};
|
|
|
|
const DEFAULT_BASE_URL: Record<ProviderName, string> = {
|
|
anthropic: 'https://api.anthropic.com/v1',
|
|
openai: 'https://api.openai.com/v1',
|
|
};
|
|
|
|
/** Env key checked per provider when no explicit apiKey is configured. */
|
|
const ENV_KEY: Record<ProviderName, string> = {
|
|
anthropic: 'ANTHROPIC_API_KEY',
|
|
openai: 'OPENAI_API_KEY',
|
|
};
|
|
|
|
function isProvider(v: unknown): v is ProviderName {
|
|
return v === 'anthropic' || v === 'openai';
|
|
}
|
|
|
|
/** Raw file contents, without env overlay. Used when rewriting the file. */
|
|
export async function readConfigFile(): Promise<Partial<Config>> {
|
|
const f = Bun.file(configPath());
|
|
if (!(await f.exists())) return {};
|
|
try {
|
|
const parsed: unknown = await f.json();
|
|
return parsed && typeof parsed === 'object' ? (parsed as Partial<Config>) : {};
|
|
} catch {
|
|
throw new Error(`${configPath()} is not valid JSON`);
|
|
}
|
|
}
|
|
|
|
/** Merges patch into the config file, preserving unrelated keys such as mcpServers. */
|
|
export async function writeConfigFile(patch: Partial<Config>): Promise<string> {
|
|
const merged = { ...(await readConfigFile()), ...patch };
|
|
await Bun.write(configPath(), `${JSON.stringify(merged, null, 2)}\n`);
|
|
return configPath();
|
|
}
|
|
|
|
/** File config, then env overrides. Env wins so `SHIRO_MODEL=x shiro` works. */
|
|
export function unknownToolSetNames(raw: unknown): string[] {
|
|
if (!Array.isArray(raw)) return [];
|
|
return (raw as unknown[]).filter((v): v is string => typeof v === 'string' && !isToolSetName(v));
|
|
}
|
|
|
|
export async function loadConfig(): Promise<Config> {
|
|
const file = await readConfigFile();
|
|
|
|
const envProvider = process.env['SHIRO_PROVIDER'];
|
|
const provider = isProvider(envProvider) ? envProvider : isProvider(file.provider) ? file.provider : 'anthropic';
|
|
|
|
// When using the claude-code preset, read from ~/.claude/settings.json
|
|
const isClaudeCode = file.presetId === 'claude-code';
|
|
const claudeSettings = isClaudeCode ? await readClaudeCodeSettings() : {};
|
|
|
|
return {
|
|
provider,
|
|
model: process.env['SHIRO_MODEL'] ?? file.model ?? DEFAULT_MODEL[provider],
|
|
baseURL:
|
|
process.env['SHIRO_BASE_URL'] ??
|
|
file.baseURL ??
|
|
claudeSettings.baseURL ??
|
|
DEFAULT_BASE_URL[provider],
|
|
apiKey:
|
|
process.env['SHIRO_API_KEY'] ??
|
|
file.apiKey ??
|
|
claudeSettings.apiKey ??
|
|
process.env[ENV_KEY[provider]],
|
|
...(file.presetId ? { presetId: file.presetId } : {}),
|
|
...(file.maxRetries !== undefined ? { maxRetries: file.maxRetries } : {}),
|
|
...(typeof file.maxSpendUsd === 'number' && file.maxSpendUsd > 0 ? { maxSpendUsd: file.maxSpendUsd } : {}),
|
|
...(typeof file.maxSpendPerTurn === 'number' && file.maxSpendPerTurn > 0 ? { maxSpendPerTurn: file.maxSpendPerTurn } : {}),
|
|
...(file.subagentModel ? { subagentModel: file.subagentModel } : {}),
|
|
...(file.agent ? { agent: file.agent } : {}),
|
|
...(file.thinking ? { thinking: file.thinking } : {}),
|
|
...(Array.isArray(file.plugins) ? { plugins: file.plugins } : {}),
|
|
...(Array.isArray(file.toolSets) ? { toolSets: file.toolSets.filter(isToolSetName) } : {}),
|
|
...(() => {
|
|
const permission = parsePermissions(file.permission);
|
|
return permission ? { permission } : {};
|
|
})(),
|
|
...(typeof file.registryUrl === 'string' ? { registryUrl: file.registryUrl } : {}),
|
|
...(file.mcpServers ? { mcpServers: file.mcpServers } : {}),
|
|
};
|
|
}
|
|
|
|
export function missingKeyMessage(provider: ProviderName): string {
|
|
return `No API key for provider "${provider}". Run shiro and use /provider to set one, or set ${ENV_KEY[provider]} / SHIRO_API_KEY, or add "apiKey" to ${configPath()}`;
|
|
}
|
|
|
|
const isOfficialOpenAI = (baseURL: string | undefined) =>
|
|
!baseURL || /^https:\/\/api\.openai\.com(\/|$)/.test(baseURL);
|
|
|
|
/**
|
|
* Newer OpenAI reasoning models refuse function tools on /v1/chat/completions and
|
|
* demand /v1/responses. Rather than guess per model id, build both and let
|
|
* withFallback switch when the endpoint rejects the request shape.
|
|
*/
|
|
export function resolveModel(cfg: Config, onFallback?: (e: FallbackEvent) => void): LanguageModel {
|
|
if (!cfg.apiKey) throw new Error(missingKeyMessage(cfg.provider));
|
|
|
|
if (cfg.provider === 'anthropic') {
|
|
return createAnthropic({ apiKey: cfg.apiKey, baseURL: cfg.baseURL })(cfg.model);
|
|
}
|
|
|
|
const chat = createOpenAICompatible({
|
|
name: 'openai',
|
|
apiKey: cfg.apiKey,
|
|
baseURL: cfg.baseURL ?? DEFAULT_BASE_URL.openai,
|
|
})(cfg.model);
|
|
|
|
if (!isOfficialOpenAI(cfg.baseURL)) return chat;
|
|
|
|
const openai = createOpenAI({ apiKey: cfg.apiKey, baseURL: cfg.baseURL });
|
|
return withFallback([chat, openai.responses(cfg.model)], onFallback);
|
|
}
|
|
|
|
export { configPath, ENV_KEY };
|