Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 94 additions & 5 deletions packages/daemon/src/core/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ vi.mock('./engines.js', async (importOriginal) => {
};
});

import { CoreState } from './state.js';
import {
CoreState,
credentialUnavailableReason,
isModelUsable,
} from './state.js';

// ── Helpers ──────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -405,12 +409,68 @@ describe('CoreState.listEngines', () => {
});
});

describe('credentialUnavailableReason', () => {
it('describes missing env credentials', () => {
const msg = credentialUnavailableReason(
{ engine: 'openrouter', secret_store: 'env', api_key_env_var_name: 'OR_KEY', models: {} },
{ defaultEnvVar: 'OPENROUTER_API_KEY' },
);
expect(msg).toContain('OR_KEY');
expect(msg).toContain('daemon process');
});

it('describes missing store credentials per backend', () => {
expect(
credentialUnavailableReason(
{ engine: 'openrouter', secret_store: 'keychain', secret_name: 'K', models: {} },
{},
),
).toMatch(/keychain/i);

expect(
credentialUnavailableReason(
{ engine: 'openrouter', secret_store: 'file', secret_name: 'F', models: {} },
{},
),
).toMatch(/file secrets/i);

expect(
credentialUnavailableReason(
{ engine: 'openrouter', secret_store: 'memory', secret_name: 'M', models: {} },
{},
),
).toMatch(/memory/i);
});

it('falls back to engine default env or generic message', () => {
expect(
credentialUnavailableReason({ engine: 'openrouter', models: {} }, { defaultEnvVar: 'OPENROUTER_API_KEY' }),
).toContain('OPENROUTER_API_KEY');

expect(credentialUnavailableReason({ engine: 'mock', models: {} }, {})).toContain('provider wizard');
});
});

describe('isModelUsable', () => {
it('treats undefined available as usable', () => {
expect(isModelUsable({ id: 'p/m', name: 'm', engineModelId: 'm', provider: 'p', engine: 'mock', contextWindow: 0 })).toBe(true);
});

it('rejects explicitly unavailable models', () => {
expect(isModelUsable({
id: 'p/m', name: 'm', engineModelId: 'm', provider: 'p', engine: 'mock', contextWindow: 0, available: false,
})).toBe(false);
});
});

describe('CoreState.listModels', () => {
it('returns composite model ids for configured mock provider', async () => {
const core = createCore({ config: mockProviderConfig });
const models = await core.listModels();
expect(models.map((m) => m.id).sort()).toEqual(['my-mock/echo', 'my-mock/fixed']);
expect(models[0].engineModelId).toBeDefined();
const mockModels = models.filter((m) => m.provider === 'my-mock');
expect(mockModels.map((m) => m.id).sort()).toEqual(['my-mock/echo', 'my-mock/fixed']);
expect(mockModels[0].engineModelId).toBeDefined();
expect(mockModels.every((m) => m.available !== false)).toBe(true);
});

it('applies an explicit vision capability override', async () => {
Expand All @@ -429,10 +489,17 @@ describe('CoreState.listModels', () => {
expect(models.find((model) => model.id === 'my-mock/vision')?.capabilities.supportsVision).toBe(true);
});

it('skips providers without keys when engine requires key', async () => {
it('lists saved models as unavailable when engine requires key but key is missing', async () => {
mockFetchModels.mockClear();
const core = createCore({ config: mockProviderConfig });
const models = await core.listModels();
expect(models.every((m) => m.provider !== 'openrouter')).toBe(true);
const orModels = models.filter((m) => m.provider === 'openrouter');
expect(orModels.length).toBeGreaterThan(0);
expect(orModels.every((m) => m.available === false)).toBe(true);
expect(orModels[0].unavailableReason).toMatch(/keychain|API key/i);
// fetchModels should have been called for mock (no key needed) but NOT for openrouter
const calls = mockFetchModels.mock.calls;
expect(calls.every((c: unknown[]) => c[0] !== 'openrouter')).toBe(true);
});

it('continues when fetchModels throws for one provider', async () => {
Expand Down Expand Up @@ -484,6 +551,28 @@ describe('CoreState.chat', () => {
expect(chunks.at(-1)?.finishReason).toBe('error');
});

it('errors when model is not in the provider enabled list', async () => {
const core = createCore({ config: mockProviderConfig });
const chunks = await collectChat(core, 'my-mock/not-in-config');
expect(chunks.some((c) => c.type === 'error' && c.error?.includes('not enabled'))).toBe(true);
});

it('allows chat when modelName matches a configured model_id alias', async () => {
const core = createCore({
config: {
providers: {
'my-mock': {
engine: 'mock',
models: { 'claude-precise': { model_id: 'echo' } },
},
},
},
});
const chunks = await collectChat(core, 'my-mock/echo');
expect(chunks.some((c) => c.type === 'error' && c.error?.includes('not enabled'))).toBe(false);
expect(mockStreamChat).toHaveBeenCalled();
});

it('errors when provider or api key missing', async () => {
const core = createCore({ config: mockProviderConfig });
const missingProvider = await collectChat(core, 'missing/echo');
Expand Down
77 changes: 70 additions & 7 deletions packages/daemon/src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,46 @@
};
/** Per-model parameter overrides from config */
params?: ModelConfig;
/** False when config exists but credentials cannot be resolved at runtime */
available?: boolean;
/** Human-readable reason when available is false */
unavailableReason?: string;
}

/** Models that can be selected for chat / OpenAI-compatible listing */
export function isModelUsable(model: ModelInfo): boolean {
return model.available !== false;
}

/**
* Explain why a provider's API key could not be resolved (for UI and listModels).
*/
export function credentialUnavailableReason(
providerCfg: ProviderConfig,
engineInfo: { defaultEnvVar?: string },
): string {
const source = providerCredentialSource(providerCfg);
if (source?.kind === 'env') {
return (
`Environment variable "${source.name}" is not set in the daemon process. ` +
'Set it in the shell before starting the daemon, or use Keychain/File and enter the key in the provider wizard.'
);
}
if (source?.kind === 'store') {
const storeLabel =
source.backend === 'file' ? 'file secrets' : source.backend === 'memory' ? 'memory' : 'keychain';

Check warning on line 126 in packages/daemon/src/core/state.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_abbenay&issues=AaELOZu9g7hdaRA9ebzI&open=AaELOZu9g7hdaRA9ebzI&pullRequest=136
return (
`API key "${source.name}" is not available in ${storeLabel}. ` +
'Re-enter the API key in the provider settings, or fix secret storage.'
);
}
if (engineInfo.defaultEnvVar) {
return (
`No API key found (expected env "${engineInfo.defaultEnvVar}" or a saved secret). ` +
'Complete provider setup in the dashboard.'
);
}
return 'No API key available for this provider. Add credentials via the provider wizard.';
}

// ── Chat tool options ──────────────────────────────────────────────────
Expand Down Expand Up @@ -468,15 +508,15 @@
}

const apiKey = await this.resolveApiKey(providerId, providerCfg);
if (engineInfo.requiresKey && !apiKey) {
continue;
}
const keyMissing = engineInfo.requiresKey && !apiKey;

let discoveredModels: DiscoveredModel[] = [];
try {
discoveredModels = await fetchModels(providerCfg.engine, apiKey || undefined, providerCfg.base_url);
} catch (error) {
console.error(`[State] Failed to fetch models for provider ${providerId}:`, error);
if (!keyMissing) {
try {
discoveredModels = await fetchModels(providerCfg.engine, apiKey || undefined, providerCfg.base_url);
} catch (error) {
console.error(`[State] Failed to fetch models for provider ${providerId}:`, error);
}
}

const discoveryMap = new Map<string, DiscoveredModel>();
Expand Down Expand Up @@ -508,6 +548,10 @@
contextWindow: discovered?.contextWindow || 0,
capabilities,
params: Object.keys(modelCfg).length > 0 ? modelCfg : undefined,
available: !keyMissing,
unavailableReason: keyMissing
? credentialUnavailableReason(providerCfg, engineInfo)
: undefined,
});
}
}
Expand Down Expand Up @@ -595,6 +639,25 @@
return;
}

const enabledModels = providerCfg.models;
const matchesEngineModelId = Object.entries(enabledModels || {}).some(
([name, cfg]) => resolveEngineModelId(name, cfg) === modelName,
);
if (
enabledModels
&& Object.keys(enabledModels).length > 0
&& !enabledModels[modelName]
&& !matchesEngineModelId
) {
console.error(`[State] Model not enabled in config: ${compositeModelId}`);
yield {
type: 'error',
error: `Model "${compositeModelId}" is not enabled. Add it in the provider settings or pick a listed model.`,
};
yield { type: 'done', finishReason: 'error' };
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ── Resolve API key ──
const apiKey = await this.resolveApiKey(providerId, providerCfg);
if (engineInfo.requiresKey && !apiKey) {
Expand Down
18 changes: 13 additions & 5 deletions packages/daemon/src/daemon/model-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,18 @@ export async function selectModel(): Promise<ModelPickerResult | null> {
}

const models = await state.listModels();
if (models.length === 0) {
console.error(
`${YELLOW}No models configured.${RESET} Run: ${DIM}aby start${RESET} -> open the web UI to add a provider.`,
);
const usable = models.filter((m) => m.available !== false);
if (usable.length === 0) {
if (models.length > 0) {
console.error(
`${YELLOW}No usable models — saved models exist but API keys are missing in the daemon.${RESET} ` +
`Open the web UI to fix provider credentials.`,
);
} else {
console.error(
`${YELLOW}No models configured.${RESET} Run: ${DIM}aby start${RESET} -> open the web UI to add a provider.`,
);
}
return null;
}

Expand All @@ -40,7 +48,7 @@ export async function selectModel(): Promise<ModelPickerResult | null> {
});

try {
const model = await promptModelPicker(models, rl);
const model = await promptModelPicker(usable, rl);
if (!model) return null;
return { model, state };
} finally {
Expand Down
25 changes: 24 additions & 1 deletion packages/daemon/src/daemon/select-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,30 @@ describe('selectModel', () => {
expect(result!.model).toBe('anthropic/claude-sonnet');
expect(result!.state).toBeDefined();
expect(result!.state.listModels).toBeDefined();
expect(promptModelPicker).toHaveBeenCalledWith(models, expect.anything());
expect(promptModelPicker).toHaveBeenCalledWith(
models.filter((m) => (m as { available?: boolean }).available !== false),
expect.anything(),
);
});

it('returns null when saved models exist but none are usable', async () => {
const models = [
{ id: 'openrouter/m1', name: 'm1', provider: 'openrouter', available: false },
];
const { DaemonState } = await import('./state.js');
vi.mocked(DaemonState).mockImplementation(function () {
return mockDaemonState(models);
});

const { promptModelPicker } = await import('./chat.js');
const { selectModel } = await import('./model-picker.js');
const result = await selectModel();

expect(result).toBeNull();
expect(promptModelPicker).not.toHaveBeenCalled();
const output = consoleSpy.mock.calls.map((c) => c[0]).join('');
expect(output).toContain('No usable models');
expect(output).toContain('API keys are missing');
});

it('returns null when user cancels the picker (Ctrl+C)', async () => {
Expand Down
30 changes: 30 additions & 0 deletions packages/daemon/src/daemon/server/abbenay-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,36 @@ describe('createAbbenayService handlers', () => {
expect(response?.workspaces).toEqual(['/ws/a']);
});

it('ListModels omits models that are not usable at runtime', async () => {
const state = createMockState({
listModels: vi.fn().mockResolvedValue([
{
id: 'mock/echo',
name: 'echo',
engineModelId: 'echo',
provider: 'mock',
engine: 'mock',
contextWindow: 8192,
available: true,
},
{
id: 'or/missing-key',
name: 'missing-key',
engineModelId: 'missing-key',
provider: 'or',
engine: 'openrouter',
contextWindow: 0,
available: false,
unavailableReason: 'API key missing',
},
]),
});
const service = createServiceHandlers(state);
const models = await invokeUnary(service.ListModels, {});
const ids = rpcArray<{ id?: string }>(models.response, 'models').map((m) => m.id);
expect(ids).toEqual(['mock/echo']);
});

it('ListProviders and ListModels map response fields', async () => {
const state = createMockState({
listModels: vi.fn().mockResolvedValue([{
Expand Down
3 changes: 2 additions & 1 deletion packages/daemon/src/daemon/server/abbenay-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,8 +644,9 @@ export function createAbbenayService(
): void {
const workspacePaths = call.request.workspace_paths || call.request.workspacePaths || [];
state.listModels(workspacePaths).then((models) => {
const usable = models.filter((m) => m.available !== false);
callback(null, {
models: models.map((m) => ({
models: usable.map((m) => ({
id: m.id,
name: m.name,
engine_model_id: m.engineModelId,
Expand Down
32 changes: 32 additions & 0 deletions packages/daemon/src/daemon/web/openai-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,38 @@ describe('registerOpenAIRoutes', () => {
vi.restoreAllMocks();
});

it('GET /v1/models omits models that are not usable at runtime', async () => {
const state = createOpenAIState([], async () => ([
{
id: 'mock/echo',
name: 'echo',
engineModelId: 'echo',
provider: 'mock',
engine: 'mock',
contextWindow: 8192,
available: true,
} as ModelInfo,
{
id: 'or/missing',
name: 'missing',
engineModelId: 'missing',
provider: 'or',
engine: 'openrouter',
contextWindow: 0,
available: false,
} as ModelInfo,
]));
const { server, baseUrl } = await startOpenAIApp(state);
try {
const res = await openaiRequest(baseUrl, 'GET', '/v1/models');
expect(res.statusCode).toBe(200);
const data = (res.body as { data: Array<{ id: string }> }).data;
expect(data.map((m) => m.id)).toEqual(['mock/echo']);
} finally {
await stopOpenAIApp(server);
}
});

it('GET /v1/models returns OpenAI-formatted models', async () => {
const { server, baseUrl } = await startOpenAIApp(createOpenAIState([]));
try {
Expand Down
2 changes: 1 addition & 1 deletion packages/daemon/src/daemon/web/openai-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ export function registerOpenAIRoutes(app: Express, state: DaemonState): void {
*/
app.get('/v1/models', async (_req: Request, res: Response) => {
try {
const models = await state.listModels();
const models = (await state.listModels()).filter((m) => m.available !== false);
res.json({
object: 'list',
data: models.map(mapModelToOpenAI),
Expand Down
Loading
Loading