This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
// Type declarations for the dependency-free setup helpers in setup-lib.mjs.
|
||||
// The browser setup wizard server (src/bridge/setup-api.ts) imports this module
|
||||
// at runtime from BOTH the dev (ts-node) and dist layouts via the relative path
|
||||
// `../../scripts/setup-lib.mjs`, which resolves to <root>/scripts in either
|
||||
// case (Codex P2 #7). These types let TypeScript check that usage.
|
||||
|
||||
export type ConnectionType = 'direct' | 'aao_gateway';
|
||||
|
||||
export const ALL_ROLES: readonly ['auto', 'fast', 'quality', 'title', 'reflection'];
|
||||
export const CONNECTION_TYPES: readonly ConnectionType[];
|
||||
|
||||
export function parseEndpoint(
|
||||
input: unknown,
|
||||
): { endpoint: string; base: string; error?: undefined } | { error: string; endpoint?: undefined; base?: undefined };
|
||||
|
||||
export interface SnakeWorkerEntry {
|
||||
id: string;
|
||||
connection_type: ConnectionType;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
roles: string[];
|
||||
max_concurrency: number;
|
||||
enabled: boolean;
|
||||
vlm: boolean;
|
||||
api_key?: string;
|
||||
}
|
||||
|
||||
export interface CamelWorkerEntry {
|
||||
id: string;
|
||||
connectionType: ConnectionType;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
roles: string[];
|
||||
maxConcurrency: number;
|
||||
enabled: boolean;
|
||||
vlm: boolean;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
export interface BuildWorkerArgs {
|
||||
connectionType: ConnectionType;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
export function buildWorkerEntry(args: BuildWorkerArgs): SnakeWorkerEntry;
|
||||
export function buildLlmWorkerCamel(args: BuildWorkerArgs): CamelWorkerEntry;
|
||||
|
||||
export function isLlmConfigured(
|
||||
workers: ReadonlyArray<{ enabled?: boolean; endpoint?: unknown; model?: unknown } | null | undefined> | unknown,
|
||||
): boolean;
|
||||
|
||||
export function renderConfigYaml(worker: SnakeWorkerEntry): string;
|
||||
export function renderDotenv(existingText: string | undefined, opts: { port: number | string }): string;
|
||||
|
||||
export function parseAnswersFromEnv(
|
||||
env: Record<string, string | undefined>,
|
||||
): { answers: Record<string, unknown>; error?: undefined } | { error: string; answers?: undefined };
|
||||
|
||||
export interface ProbeArgs {
|
||||
endpoint: string;
|
||||
base: string;
|
||||
apiKey?: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export function probeModels(
|
||||
args: ProbeArgs,
|
||||
): Promise<{ ok: boolean; models: string[]; error?: string }>;
|
||||
@@ -38,6 +38,60 @@ export function buildWorkerEntry({ connectionType, endpoint, model, apiKey }) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Build the single worker entry in **camelCase v2 shape** that
|
||||
// ConfigManager.updateConfig expects (it snake-cases on save). The CLI writes
|
||||
// raw YAML via buildWorkerEntry (snake_case); the browser wizard instead PUTs a
|
||||
// JSON config object through updateConfig, which requires camelCase keys.
|
||||
// Passing buildWorkerEntry's snake_case output to updateConfig double-converts
|
||||
// and corrupts keys (Codex review P1 #1) — so the wizard MUST use this builder.
|
||||
export function buildLlmWorkerCamel({ connectionType, endpoint, model, apiKey }) {
|
||||
const entry = {
|
||||
id: connectionType === 'aao_gateway' ? 'gateway' : 'local-llm',
|
||||
connectionType,
|
||||
endpoint,
|
||||
model,
|
||||
roles: [...ALL_ROLES],
|
||||
maxConcurrency: 1,
|
||||
enabled: true,
|
||||
vlm: false,
|
||||
};
|
||||
if (connectionType === 'aao_gateway') entry.apiKey = apiKey;
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Detect whether the runtime has a usable LLM connection. Operate on the
|
||||
// RESOLVED worker list (provider.workers from ConfigManager.getConfig(), after
|
||||
// loadConfig has applied OLLAMA_* env overrides). A fresh install's synthetic
|
||||
// default worker carries an endpoint but an EMPTY model (config-normalize.ts
|
||||
// EMPTY_MODEL=''), so requiring a non-empty model naturally excludes it.
|
||||
// An env override (OLLAMA_MODEL) fills the model, so it counts as configured.
|
||||
// (Codex review P1 #2.)
|
||||
// Execution roles a worker must cover for normal task running. A worker that
|
||||
// only serves e.g. 'title' or 'reflection' can't run user tasks, so the wizard
|
||||
// must NOT consider the install configured (Codex P2 #6). Empty/missing roles
|
||||
// mean "all roles" at runtime (src/worker.ts defaults to auto/fast/quality), so
|
||||
// they count as covering execution.
|
||||
const EXECUTION_ROLES = ['auto', 'fast', 'quality'];
|
||||
function workerCoversExecution(w) {
|
||||
const roles = w.roles;
|
||||
if (!Array.isArray(roles) || roles.length === 0) return true;
|
||||
return roles.some((r) => EXECUTION_ROLES.includes(r));
|
||||
}
|
||||
|
||||
export function isLlmConfigured(workers) {
|
||||
const list = Array.isArray(workers) ? workers : [];
|
||||
return list.some(
|
||||
(w) =>
|
||||
w &&
|
||||
w.enabled !== false &&
|
||||
typeof w.endpoint === 'string' &&
|
||||
w.endpoint.trim() !== '' &&
|
||||
typeof w.model === 'string' &&
|
||||
w.model.trim() !== '' &&
|
||||
workerCoversExecution(w),
|
||||
);
|
||||
}
|
||||
|
||||
// Quote a scalar when a YAML plain scalar would be ambiguous or invalid.
|
||||
function yamlScalar(v) {
|
||||
if (typeof v === 'boolean' || typeof v === 'number') return String(v);
|
||||
|
||||
@@ -1,7 +1,68 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseEndpoint, buildWorkerEntry, ALL_ROLES, renderConfigYaml, renderDotenv, parseAnswersFromEnv, probeModels } from './setup-lib.mjs';
|
||||
import { parseEndpoint, buildWorkerEntry, buildLlmWorkerCamel, isLlmConfigured, ALL_ROLES, renderConfigYaml, renderDotenv, parseAnswersFromEnv, probeModels } from './setup-lib.mjs';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
|
||||
describe('buildLlmWorkerCamel', () => {
|
||||
it('returns camelCase v2 keys for a direct worker (no apiKey)', () => {
|
||||
const w = buildLlmWorkerCamel({ connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'llama3' });
|
||||
expect(w).toEqual({
|
||||
id: 'local-llm',
|
||||
connectionType: 'direct',
|
||||
endpoint: 'http://localhost:11434/v1',
|
||||
model: 'llama3',
|
||||
roles: [...ALL_ROLES],
|
||||
maxConcurrency: 1,
|
||||
enabled: true,
|
||||
vlm: false,
|
||||
});
|
||||
// Must NOT carry snake_case keys that would double-convert in updateConfig (P1 #1).
|
||||
expect(w).not.toHaveProperty('connection_type');
|
||||
expect(w).not.toHaveProperty('max_concurrency');
|
||||
});
|
||||
it('adds apiKey (camelCase) for aao_gateway', () => {
|
||||
const w = buildLlmWorkerCamel({ connectionType: 'aao_gateway', endpoint: 'http://gw:9876/v1', model: 'm', apiKey: 'sk-aao-x' });
|
||||
expect(w.id).toBe('gateway');
|
||||
expect(w.apiKey).toBe('sk-aao-x');
|
||||
expect(w).not.toHaveProperty('api_key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLlmConfigured', () => {
|
||||
it('false for empty / non-array', () => {
|
||||
expect(isLlmConfigured([])).toBe(false);
|
||||
expect(isLlmConfigured(undefined)).toBe(false);
|
||||
});
|
||||
it('false for the synthetic default (endpoint set, empty model)', () => {
|
||||
expect(isLlmConfigured([{ id: 'default', endpoint: 'http://localhost:11434/v1', model: '', enabled: true }])).toBe(false);
|
||||
});
|
||||
it('false when the only usable worker is disabled', () => {
|
||||
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: 'm', enabled: false }])).toBe(false);
|
||||
});
|
||||
it('true when a usable worker has endpoint + model', () => {
|
||||
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: 'm' }])).toBe(true);
|
||||
});
|
||||
it('true if any one worker is usable (mixed)', () => {
|
||||
expect(isLlmConfigured([
|
||||
{ endpoint: 'http://a/v1', model: '', enabled: true },
|
||||
{ endpoint: 'http://b/v1', model: 'good', enabled: true },
|
||||
])).toBe(true);
|
||||
});
|
||||
it('treats whitespace-only model/endpoint as empty', () => {
|
||||
expect(isLlmConfigured([{ endpoint: ' ', model: 'm' }])).toBe(false);
|
||||
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: ' ' }])).toBe(false);
|
||||
});
|
||||
it('requires execution-role coverage — a title/reflection-only worker does not count', () => {
|
||||
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: 'm', roles: ['title'] }])).toBe(false);
|
||||
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: 'm', roles: ['title', 'reflection'] }])).toBe(false);
|
||||
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: 'm', roles: ['auto'] }])).toBe(true);
|
||||
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: 'm', roles: ['quality', 'title'] }])).toBe(true);
|
||||
});
|
||||
it('treats empty/missing roles as covering execution (runtime default auto/fast/quality)', () => {
|
||||
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: 'm', roles: [] }])).toBe(true);
|
||||
expect(isLlmConfigured([{ endpoint: 'http://h/v1', model: 'm' }])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseEndpoint', () => {
|
||||
it('keeps a full http URL and derives the /v1-stripped base', () => {
|
||||
expect(parseEndpoint('http://localhost:11434/v1')).toEqual({
|
||||
|
||||
Reference in New Issue
Block a user