Skip to content

Commit 82db68a

Browse files
committed
fix: prompt for API key when catalog provider env var is unset
Catalog provider login (TUI /login and /provider, and openai-api / anthropic-api which route through the same path) failed with 'Environment variable X is not set or is empty' instead of asking for a key. Fall back to the API key dialog and store the literal key via applyCatalogProvider's existing apiKey field. The CLI command 'pythinker provider catalog add' gains --api-key for the same case.
1 parent 5da97d7 commit 82db68a

6 files changed

Lines changed: 282 additions & 32 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@pythoughts/pythinker-code': patch
3+
---
4+
5+
Prompt for an API key when connecting a catalog provider whose environment variable is not set, instead of failing with "Environment variable is not set or is empty". Applies to `/login`, `/provider`, and `pythinker provider catalog add`, which now also accepts `--api-key <key>`.

apps/pythinker-code/src/cli/sub/provider.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ interface CatalogListOptions {
6565
}
6666

6767
interface CatalogAddOptions {
68+
readonly apiKey?: string;
6869
readonly apiKeyEnv?: string;
6970
readonly defaultModel?: string;
7071
readonly url?: string;
@@ -331,19 +332,24 @@ export async function handleCatalogAdd(
331332
deps.exit(1);
332333
}
333334

335+
const literalApiKey = opts.apiKey?.trim();
334336
const apiKeyEnvVar = (opts.apiKeyEnv ?? entry.env?.[0])?.trim();
335-
if (apiKeyEnvVar === undefined || apiKeyEnvVar.length === 0) {
336-
deps.stderr.write(
337-
`Provider "${providerId}" does not declare an API key environment variable.\n`,
338-
);
339-
deps.exit(1);
340-
}
341-
const apiKey = deps.env[apiKeyEnvVar]?.trim();
342-
if (apiKey === undefined || apiKey.length === 0) {
343-
deps.stderr.write(
344-
`Environment variable "${apiKeyEnvVar}" is not set or is empty.\n`,
345-
);
346-
deps.exit(1);
337+
let useEnvVar = false;
338+
if (literalApiKey === undefined || literalApiKey.length === 0) {
339+
if (apiKeyEnvVar === undefined || apiKeyEnvVar.length === 0) {
340+
deps.stderr.write(
341+
`Provider "${providerId}" does not declare an API key environment variable. Pass --api-key <key>.\n`,
342+
);
343+
deps.exit(1);
344+
}
345+
const envValue = deps.env[apiKeyEnvVar]?.trim();
346+
if (envValue === undefined || envValue.length === 0) {
347+
deps.stderr.write(
348+
`Environment variable "${apiKeyEnvVar}" is not set or is empty. Set it or pass --api-key <key>.\n`,
349+
);
350+
deps.exit(1);
351+
}
352+
useEnvVar = true;
347353
}
348354

349355
const models = catalogProviderModels(entry);
@@ -386,7 +392,8 @@ export async function handleCatalogAdd(
386392
catalogUrl: url,
387393
wire,
388394
baseUrl,
389-
apiKeyEnvVar,
395+
apiKey: useEnvVar ? undefined : literalApiKey,
396+
apiKeyEnvVar: useEnvVar ? apiKeyEnvVar : undefined,
390397
models,
391398
selectedModelId: opts.defaultModel ?? '',
392399
thinking: false,
@@ -519,17 +526,19 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
519526
catalog
520527
.command('add <providerId>')
521528
.description('Import a known provider from the catalog by id.')
529+
.option('--api-key <key>', 'Provider API key to store in config.toml (takes precedence over --api-key-env).')
522530
.option('--api-key-env <name>', 'Environment variable containing the provider API key.')
523531
.option('--default-model <modelId>', 'Mark the imported model as default_model after import.')
524532
.option('--url <url>', `Override catalog URL. Defaults to ${DEFAULT_CATALOG_URL}.`)
525533
.action(
526534
async (
527535
providerId: string,
528-
options: { apiKeyEnv?: string; defaultModel?: string; url?: string },
536+
options: { apiKey?: string; apiKeyEnv?: string; defaultModel?: string; url?: string },
529537
) => {
530538
const resolved = resolveDeps(deps);
531539
await runAction(resolved, () =>
532540
handleCatalogAdd(resolved, providerId, {
541+
apiKey: options.apiKey,
533542
apiKeyEnv: options.apiKeyEnv,
534543
defaultModel: options.defaultModel,
535544
url: options.url,

apps/pythinker-code/src/tui/commands/auth.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -250,18 +250,24 @@ export async function connectCatalogProvider(
250250
return;
251251
}
252252

253+
const baseUrl = catalogBaseUrl(catalogEntry, wire);
254+
const platformName = displayName ?? catalogEntry.name ?? providerId;
255+
253256
const apiKeyEnvVar = catalogEntry.env?.[0]?.trim();
254-
if (apiKeyEnvVar === undefined || apiKeyEnvVar.length === 0) {
255-
host.showError(`Catalog provider "${providerId}" does not declare an API key environment variable.`);
256-
return;
257-
}
258-
if (process.env[apiKeyEnvVar]?.trim().length === 0 || process.env[apiKeyEnvVar] === undefined) {
259-
host.showError(`Environment variable "${apiKeyEnvVar}" is not set or is empty.`);
260-
return;
257+
const envVarHasValue =
258+
apiKeyEnvVar !== undefined &&
259+
apiKeyEnvVar.length > 0 &&
260+
(process.env[apiKeyEnvVar]?.trim().length ?? 0) > 0;
261+
let apiKey: string | undefined;
262+
if (!envVarHasValue) {
263+
const subtitleLines = [
264+
...(baseUrl === undefined ? [] : [`${'base_url'.padEnd(12)}${baseUrl}`]),
265+
`${'saved to'.padEnd(12)}~/.pythinker-code/config.toml`,
266+
];
267+
apiKey = await promptApiKey(host, platformName, subtitleLines);
268+
if (apiKey === undefined) return;
261269
}
262270

263-
const baseUrl = catalogBaseUrl(catalogEntry, wire);
264-
const platformName = displayName ?? catalogEntry.name ?? providerId;
265271
const models = catalogProviderModels(catalogEntry);
266272
if (models.length === 0) {
267273
host.showError('No models available for this platform.');
@@ -282,7 +288,8 @@ export async function connectCatalogProvider(
282288
catalogUrl: DEFAULT_CATALOG_URL,
283289
wire,
284290
baseUrl,
285-
apiKeyEnvVar,
291+
apiKey,
292+
apiKeyEnvVar: envVarHasValue ? apiKeyEnvVar : undefined,
286293
models,
287294
selectedModelId: selection.model.id,
288295
thinking: selection.effort !== 'off',
@@ -296,7 +303,7 @@ export async function connectCatalogProvider(
296303
});
297304

298305
await host.authFlow.refreshConfigAfterLogin();
299-
host.track('login', { provider: providerId, method: 'api_key_env' });
306+
host.track('login', { provider: providerId, method: envVarHasValue ? 'api_key_env' : 'api_key' });
300307
host.showStatus(`Setup complete: ${platformName} · ${selection.model.id}`);
301308
}
302309

apps/pythinker-code/test/cli/provider.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,4 +1009,63 @@ describe('pythinker provider catalog add', () => {
10091009
'CUSTOM_ANTHROPIC_API_KEY',
10101010
);
10111011
});
1012+
1013+
it('stores a literal --api-key when the environment variable is unset', async () => {
1014+
mockRegistryFetch(CATALOG_BODY);
1015+
const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig);
1016+
const { deps, exitCodes } = makeDeps(harness, { env: {} });
1017+
1018+
await tryRun(() => handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-literal' }));
1019+
1020+
expect(exitCodes).toEqual([]);
1021+
expect(current().providers['anthropic']?.apiKey).toBe('sk-literal');
1022+
expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined();
1023+
});
1024+
1025+
it('prefers a literal --api-key over a set environment variable', async () => {
1026+
mockRegistryFetch(CATALOG_BODY);
1027+
const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig);
1028+
const { deps, exitCodes } = makeDeps(harness, {
1029+
env: { ANTHROPIC_API_KEY: 'from-env' },
1030+
});
1031+
1032+
await tryRun(() => handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-literal' }));
1033+
1034+
expect(exitCodes).toEqual([]);
1035+
expect(current().providers['anthropic']?.apiKey).toBe('sk-literal');
1036+
expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined();
1037+
});
1038+
1039+
it('stores a literal --api-key even when the catalog declares no credential name', async () => {
1040+
mockRegistryFetch({
1041+
anthropic: { ...CATALOG_BODY.anthropic, env: undefined },
1042+
});
1043+
const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig);
1044+
const { deps, exitCodes } = makeDeps(harness, { env: {} });
1045+
1046+
await tryRun(() => handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-literal' }));
1047+
1048+
expect(exitCodes).toEqual([]);
1049+
expect(current().providers['anthropic']?.apiKey).toBe('sk-literal');
1050+
expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined();
1051+
});
1052+
1053+
it('routes --api-key through Commander', async () => {
1054+
mockRegistryFetch(CATALOG_BODY);
1055+
const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig);
1056+
const { deps, exitCodes } = makeDeps(harness, { env: {} });
1057+
const program = new Command('pythinker');
1058+
registerProviderCommand(program, deps);
1059+
1060+
await tryRun(() =>
1061+
program.parseAsync(
1062+
['node', 'pythinker', 'provider', 'catalog', 'add', 'anthropic', '--api-key', 'sk-flag'],
1063+
{ from: 'node' },
1064+
),
1065+
);
1066+
1067+
expect(exitCodes).toEqual([]);
1068+
expect(current().providers['anthropic']?.apiKey).toBe('sk-flag');
1069+
expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined();
1070+
});
10121071
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type { CatalogProviderEntry, PythinkerConfig } from '@pythoughts/pythinker-code-sdk';
3+
4+
import { connectCatalogProvider } from '#/tui/commands/auth';
5+
import type { SlashCommandHost } from '#/tui/commands/dispatch';
6+
7+
vi.mock('#/tui/commands/prompts', () => ({
8+
promptApiKey: vi.fn(),
9+
promptLogoutProviderSelection: vi.fn(),
10+
promptModelSelectionForCatalog: vi.fn(),
11+
promptModelSelectionForOpenPlatform: vi.fn(),
12+
promptPlatformSelection: vi.fn(),
13+
}));
14+
15+
const { promptApiKey, promptModelSelectionForCatalog } = await import('#/tui/commands/prompts');
16+
17+
const CATALOG_ENTRY: CatalogProviderEntry = {
18+
id: 'anthropic',
19+
name: 'Anthropic',
20+
npm: '@ai-sdk/anthropic',
21+
api: 'https://api.anthropic.com',
22+
env: ['TEST_CATALOG_API_KEY'],
23+
models: {
24+
'claude-opus-4-7': {
25+
id: 'claude-opus-4-7',
26+
name: 'Claude Opus 4.7',
27+
limit: { context: 200_000, output: 64_000 },
28+
tool_call: true,
29+
reasoning: true,
30+
modalities: { input: ['text', 'image'], output: ['text'] },
31+
},
32+
},
33+
} as CatalogProviderEntry;
34+
35+
function makeHost(initial: PythinkerConfig) {
36+
let config = initial;
37+
const errors: string[] = [];
38+
const host = {
39+
harness: {
40+
getConfig: vi.fn(async () => config),
41+
setConfig: vi.fn(async (patch: Partial<PythinkerConfig>) => {
42+
config = { ...config, ...patch };
43+
}),
44+
removeProvider: vi.fn(async (id: string) => {
45+
delete config.providers[id];
46+
return config;
47+
}),
48+
},
49+
authFlow: { refreshConfigAfterLogin: vi.fn(async () => undefined) },
50+
showError: vi.fn((msg: string) => errors.push(msg)),
51+
showStatus: vi.fn(),
52+
track: vi.fn(),
53+
restoreEditor: vi.fn(),
54+
mountEditorReplacement: vi.fn(),
55+
cancelInFlight: undefined,
56+
} as unknown as SlashCommandHost;
57+
return { host, errors, current: () => config };
58+
}
59+
60+
describe('connectCatalogProvider credential acquisition', () => {
61+
beforeEach(() => {
62+
vi.mocked(promptModelSelectionForCatalog).mockResolvedValue({
63+
model: { id: 'claude-opus-4-7' } as never,
64+
effort: 'off',
65+
});
66+
});
67+
68+
afterEach(() => {
69+
vi.unstubAllEnvs();
70+
vi.mocked(promptApiKey).mockReset();
71+
vi.mocked(promptModelSelectionForCatalog).mockReset();
72+
});
73+
74+
it('uses the env var without prompting when it is set', async () => {
75+
vi.stubEnv('TEST_CATALOG_API_KEY', 'from-env');
76+
const { host, current } = makeHost({ providers: {} } as PythinkerConfig);
77+
78+
await connectCatalogProvider(host, 'anthropic', CATALOG_ENTRY);
79+
80+
expect(promptApiKey).not.toHaveBeenCalled();
81+
expect(current().providers['anthropic']).toMatchObject({
82+
apiKeyEnvVar: 'TEST_CATALOG_API_KEY',
83+
});
84+
expect(current().providers['anthropic']?.apiKey).toBeUndefined();
85+
});
86+
87+
it('prompts for a key and stores it literally when the env var is unset', async () => {
88+
vi.stubEnv('TEST_CATALOG_API_KEY', '');
89+
vi.mocked(promptApiKey).mockResolvedValue('sk-typed-in');
90+
const { host, errors, current } = makeHost({ providers: {} } as PythinkerConfig);
91+
92+
await connectCatalogProvider(host, 'anthropic', CATALOG_ENTRY);
93+
94+
expect(errors).toEqual([]);
95+
expect(promptApiKey).toHaveBeenCalledWith(
96+
host,
97+
'Anthropic',
98+
expect.arrayContaining([expect.stringContaining('config.toml')]),
99+
);
100+
expect(current().providers['anthropic']?.apiKey).toBe('sk-typed-in');
101+
expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined();
102+
});
103+
104+
it('prompts for a key when the catalog entry declares no env var', async () => {
105+
vi.mocked(promptApiKey).mockResolvedValue('sk-typed-in');
106+
const entry = { ...CATALOG_ENTRY, env: undefined } as CatalogProviderEntry;
107+
const { host, errors, current } = makeHost({ providers: {} } as PythinkerConfig);
108+
109+
await connectCatalogProvider(host, 'anthropic', entry);
110+
111+
expect(errors).toEqual([]);
112+
expect(current().providers['anthropic']?.apiKey).toBe('sk-typed-in');
113+
expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined();
114+
});
115+
116+
it('aborts without writing config when the key prompt is cancelled', async () => {
117+
vi.stubEnv('TEST_CATALOG_API_KEY', '');
118+
vi.mocked(promptApiKey).mockResolvedValue(undefined);
119+
const { host, current } = makeHost({ providers: {} } as PythinkerConfig);
120+
121+
await connectCatalogProvider(host, 'anthropic', CATALOG_ENTRY);
122+
123+
expect(current().providers['anthropic']).toBeUndefined();
124+
expect(promptModelSelectionForCatalog).not.toHaveBeenCalled();
125+
});
126+
});

0 commit comments

Comments
 (0)