Skip to content

Commit c5ad17f

Browse files
authored
feat(agent-core-v2): support api_key_env for provider credentials (#3762)
* feat(agent-core-v2): support api_key_env for provider credentials Providers can declare api_key_env = "VAR" in config.toml to read their Bearer token from an environment variable instead of storing it inline. The value is resolved per request (rotation-friendly); an unset or empty variable fails with config.invalid naming the provider and variable. apiKey+apiKeyEnv and apiKeyEnv+oauth are rejected as conflicts. The /models refresh path resolves the same chain (inline apiKey -> apiKeyEnv -> env sub-table); a provider whose variable is unset lands in failed[] without affecting others, and open-platform providers keep the env declaration instead of persisting the resolved secret. Custom registries: applyCustomRegistryProvider consumes the api.json env field — imported providers get apiKeyEnv, the legacy inline apiKey is dropped, and a hand-edited apiKeyEnv survives refreshes (tracked via source.envKey). kimi provider add --api-key and the TUI registry token field are now optional so public registries import without a key. * docs: document api_key_env provider credentials * fix(oauth): preserve hand-edited apiKeyEnv on registry refresh and address review nits * fix(oauth): validate apiKeyEnv conflicts during refresh and address review nits * fix(oauth): mirror apiKeyEnv in the klient provider contract and harden refresh validation * fix(oauth): stop auto-binding registry env names to provider credentials * fix(oauth): preserve hand-edited apiKeyEnv across registry re-imports * fix(oauth): scope apiKeyEnv preservation to the owning registry * fix(agent-core-v2): preserve apiKeyEnv across models.dev catalog re-imports * fix(kap-server): drop apiKeyEnv when the provider update route receives a replacement key * fix(agent-core-v2): replace provider credential fields on TOML writeback * fix(kap-server): normalize whitespace-only api_key in the provider update route * refactor(oauth): unify provider credential rules into shared primitives A new provider-credential module in the oauth package owns the credential precedence chain, the mutual-exclusion conflicts, the user-facing messages, and the write-side tri-state reconciliation; the refresh orchestrator, agent-core-v2 (model-auth, catalog, auth readiness), and the kap-server provider routes compose it instead of re-deriving the rules per layer. applyOpenPlatformConfig now takes credential: {apiKey}|{apiKeyEnv} and merges onto the existing record, so open-platform refreshes no longer drop hand-written fields (customHeaders, the env sub-table) on the inline-key path either, and the refresh's write-then-undo block is gone. Registry imports converge on removeCustomRegistryEntries + applyCustomRegistryEntries (prior-snapshot provenance): the exported capture/restore helpers are gone, the CLI persists a re-import with a single setConfig instead of per-id removeProvider RPCs, and default_model/default_provider survive a re-import whenever they still resolve. kap-server's importCustomRegistry keeps the purge-then-rewrite two-phase replace the config service's raw-merging writeback requires. kap-server create/replace routes accept api_key_env (tri-state, mutually exclusive with api_key), and GET /config plus the provider catalog item expose the binding. A declared-but-unset api_key_env now fails readiness checks with config.invalid (was auth.token_missing), matching request-time behavior. Registry 401/403 without a key prints an authentication hint. * fix(cli): persist registry re-import removals on both harness generations The v2 harness's setConfig deep-merges per domain (retaining omitted keys and skipping undefined defaults), so a shrunk re-import left removed providers and aliases in config.toml and kept a cleared default dangling. The CLI and TUI registry flows now share persistRegistryImport: on v2 the removal phase and the rebuilt records go out as two atomic replaceConfigSections writes (the writeback merges alias fields from raw TOML, so the purge must be persisted first); on v1 the removeProvider RPC cascades the removals before one merge write. defaultModel/defaultProvider snapshots are captured before any removal so the restore works on both paths. * fix(oauth): reject oauth before the legacy env fallback and persist cleared thinking on re-import refreshProviderModels' env sub-table fallback now throws the same apiKey+oauth conflict the chat path rejects, instead of authenticating with the fallback key and rewriting the provider to drop oauth. persistRegistryImport writes the cleared thinking section through the replacement payload, and drops the v1 fallback branch — registry imports always persist through replaceConfigSections, so the removal phase no longer needs per-id removeProvider RPCs. * chore: consolidate provider credential changesets into one entry * chore: trim changeset to the api_key_env entry * fix: harden provider credential status and registry imports * fix(oauth): expose browser-safe provider credential rules * fix(node-sdk): replace serialized provider credentials * fix(oauth): reject credential conflicts during refresh * fix: harden custom registry re-imports Reject reserved provider collisions and credential endpoint drift before mutation. Replace registry-owned sections atomically with compare-and-set guards, and carry the replacement contract through klient and node-sdk. * refactor(registry): centralize custom registry import ownership Route CLI and TUI imports through the engine via the typed SDK and Klient operation. Keep registry replacement policy inside the engine and preserve credential edits made while fetching the registry. Cover the shared entry point with real configuration storage and memory/IPC transport tests. * test: trim redundant api_key_env and registry-import coverage * fix(cli): reuse the stored registry key on keyless re-import Both first-party clients encoded "no key given" as an empty string, but the engine's fallback chain (options.apiKey ?? registryKeyFromExisting) only reuses the key parked in the provider's source blob when the option is undefined. A keyless re-import of a private registry therefore went out without an Authorization header and failed with 401 from the CLI and the TUI dialog, while the kap-server REST route (which passes undefined) reused the stored key fine. Pass undefined from the CLI handler and the TUI import dialog instead of normalizing to ''. The dialog's result type now marks apiKey optional, matching the engine contract. * test: pin CAS rejection and credential-conflict behavior The coverage trim went past redundancy in three places, leaving the PR's headline guards untested. Restore the compare-and-set rejection path (guarded value changed on disk -> retry error, no write) and the absent-section guard normalization in config.test.ts, the apiKey+apiKeyEnv / apiKeyEnv+oauth conflict rejections at runtime resolution (modelAuth) and at the refresh orchestrator (discovery, with per-provider failure isolation), and the open-platform refresh through api_key_env proving the resolved secret never lands in the provider record. Also add the first direct unit tests for the shared declaredProviderCredential / reconcileProviderCredentialUpdate primitives. * refactor(oauth): share reserved provider id rules between import and refresh * Delete .changeset/registry-import-concurrent-credentials.md Signed-off-by: 7Sageer <sag77r@hotmail.com> * test: trim cross-layer duplicate registry and credential coverage --------- Signed-off-by: 7Sageer <sag77r@hotmail.com>
1 parent 86e0820 commit c5ad17f

80 files changed

Lines changed: 2003 additions & 427 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
Providers can read their API key from a named environment variable (`api_key_env` in `config.toml` or the provider API).

‎apps/kimi-code/src/cli/sub/provider.ts‎

Lines changed: 33 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,12 @@
1212
* refresh is automatic.
1313
*/
1414

15-
import {
16-
applyCustomRegistryProvider,
17-
CustomRegistryApiError,
18-
fetchCustomRegistry,
19-
type CustomRegistrySource,
20-
type ManagedKimiConfigShape,
21-
} from '@moonshot-ai/kimi-code-oauth';
2215
import {
2316
applyCatalogProvider,
2417
catalogProviderModels,
2518
CatalogFetchError,
19+
RegistryImportError,
20+
type ImportCustomRegistryResult,
2621
createKimiHarness,
2722
DEFAULT_CATALOG_URL,
2823
resolveCatalogImport,
@@ -75,75 +70,51 @@ export async function handleProviderAdd(
7570
opts: AddOptions,
7671
): Promise<void> {
7772
const apiKey = resolveApiKey(opts.apiKey, deps.env);
78-
if (apiKey === undefined) {
79-
deps.stderr.write(
80-
'Missing API key. Pass --api-key <key> or set KIMI_REGISTRY_API_KEY.\n',
81-
);
82-
deps.exit(1);
83-
}
8473

8574
const trimmedUrl = url.trim();
8675
if (trimmedUrl.length === 0) {
8776
deps.stderr.write('Registry URL is required.\n');
8877
deps.exit(1);
8978
}
9079

91-
const source: CustomRegistrySource = {
92-
kind: 'apiJson',
93-
url: trimmedUrl,
94-
apiKey,
95-
};
96-
9780
const harness = deps.getHarness();
9881
await harness.ensureConfigFile();
9982

100-
let entries: Awaited<ReturnType<typeof fetchCustomRegistry>>;
83+
let result: ImportCustomRegistryResult;
10184
try {
102-
entries = await fetchCustomRegistry(source, { userAgent: createKimiCodeUserAgent() });
85+
result = await harness.importCustomRegistry({
86+
url: trimmedUrl,
87+
apiKey,
88+
setDefaultWhenUnset: false,
89+
});
10390
} catch (error) {
104-
const suffix = error instanceof CustomRegistryApiError ? ` (HTTP ${String(error.status)})` : '';
91+
if (!(error instanceof RegistryImportError) || error.phase === 'apply') throw error;
92+
if (error.phase === 'empty') {
93+
deps.stderr.write(`Registry at ${trimmedUrl} contained no usable providers.\n`);
94+
deps.exit(1);
95+
}
96+
const suffix = error.status === undefined ? '' : ` (HTTP ${String(error.status)})`;
10597
deps.stderr.write(`Failed to fetch registry${suffix}: ${errorMessage(error)}\n`);
98+
if (apiKey === undefined && (error.status === 401 || error.status === 403)) {
99+
deps.stderr.write(
100+
'This registry requires authentication — pass --api-key <key> or set KIMI_REGISTRY_API_KEY.\n',
101+
);
102+
}
106103
deps.exit(1);
107104
}
108105

109-
const entryList = Object.values(entries);
110-
if (entryList.length === 0) {
111-
deps.stderr.write(`Registry at ${trimmedUrl} contained no usable providers.\n`);
112-
deps.exit(1);
113-
}
114-
115-
// `harness.removeProvider` reloads the config from disk on each call (see
116-
// `core-impl.ts removeKimiProvider`), so calling it inside the apply loop
117-
// would discard providers we already applied in memory but have not yet
118-
// persisted. Drop every stale id up front in a single batch instead, then
119-
// apply against the resulting fresh config.
120-
let config = await harness.getConfig();
121-
const staleIds = entryList
122-
.filter((entry) => config.providers[entry.id] !== undefined)
123-
.map((entry) => entry.id);
124-
for (const id of staleIds) {
125-
config = await harness.removeProvider(id);
126-
}
127-
128-
const addedProviderIds: string[] = [];
129-
let modelCount = 0;
130-
for (const entry of entryList) {
131-
applyCustomRegistryProvider(asManaged(config), entry, source);
132-
addedProviderIds.push(entry.id);
133-
modelCount += Object.keys(entry.models).length;
134-
}
135-
136-
await harness.setConfig({
137-
providers: config.providers,
138-
models: config.models,
139-
});
140-
106+
const count = result.providers.length;
141107
deps.stdout.write(
142-
`Imported ${String(addedProviderIds.length)} provider${addedProviderIds.length === 1 ? '' : 's'} ` +
143-
`(${String(modelCount)} model${modelCount === 1 ? '' : 's'}) from ${trimmedUrl}:\n`,
108+
`Imported ${String(count)} provider${count === 1 ? '' : 's'} ` +
109+
`(${String(result.modelsImported)} model${result.modelsImported === 1 ? '' : 's'}) from ${trimmedUrl}:\n`,
144110
);
145-
for (const id of addedProviderIds) {
146-
deps.stdout.write(` - ${id}\n`);
111+
for (const provider of result.providers) {
112+
deps.stdout.write(` - ${provider.id}\n`);
113+
}
114+
for (const [id, envName] of Object.entries(result.credentialEnv)) {
115+
deps.stdout.write(
116+
`provider "${id}" declares credential env var "${envName}" — set api_key_env in config.toml to use it\n`,
117+
);
147118
}
148119
}
149120

@@ -474,7 +445,10 @@ export function registerProviderCommand(parent: Command, deps?: Partial<Provider
474445
provider
475446
.command('add <url>')
476447
.description('Import every provider listed in a custom registry (api.json).')
477-
.option('--api-key <key>', 'Registry API key. Falls back to KIMI_REGISTRY_API_KEY.')
448+
.option(
449+
'--api-key <key>',
450+
'Registry API key. Falls back to KIMI_REGISTRY_API_KEY; omit both for public registries.',
451+
)
478452
.action(async (url: string, options: { apiKey?: string }) => {
479453
const resolved = resolveDeps(deps);
480454
await runAction(resolved, () => handleProviderAdd(resolved, url, { apiKey: options.apiKey }));
@@ -582,10 +556,6 @@ function resolveApiKey(flag: string | undefined, env: NodeJS.ProcessEnv): string
582556
return undefined;
583557
}
584558

585-
function asManaged(config: KimiConfig): ManagedKimiConfigShape {
586-
return config as unknown as ManagedKimiConfigShape;
587-
}
588-
589559
function providerSourceLabel(provider: KimiConfig['providers'][string]): string {
590560
const source = provider.source;
591561
if (source !== undefined) {

‎apps/kimi-code/src/tui/commands/auth.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ async function handleOpenPlatformLogin(
184184
selection.thinking !== 'off' && selection.thinking !== 'on'
185185
? selection.thinking
186186
: undefined,
187-
apiKey,
187+
credential: { apiKey },
188188
});
189189

190190
await host.harness.setConfig({

‎apps/kimi-code/src/tui/commands/provider.ts‎

Lines changed: 30 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
1-
import {
2-
applyCustomRegistryEntries,
3-
fetchCustomRegistry,
4-
type CustomRegistrySource,
5-
type ManagedKimiConfigShape,
6-
} from '@moonshot-ai/kimi-code-oauth';
71
import {
82
applyCatalogProvider,
93
catalogProviderModels,
104
CatalogFetchError,
5+
RegistryImportError,
6+
type ImportCustomRegistryResult,
117
DEFAULT_CATALOG_URL,
128
resolveCatalogImport,
139
SECONDARY_DERIVED_MODEL_ALIAS,
@@ -22,6 +18,7 @@ import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
2218
import {
2319
CustomRegistryImportDialogComponent,
2420
type CustomRegistryImportResult,
21+
type CustomRegistryImportValue,
2522
} from '../components/dialogs/custom-registry-import';
2623
import {
2724
ProviderManagerComponent,
@@ -342,49 +339,49 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise
342339
const value = await promptCustomRegistryImport(host);
343340
if (value === undefined) return false;
344341

345-
const source: CustomRegistrySource = {
346-
kind: 'apiJson',
347-
url: value.url,
348-
apiKey: value.apiKey,
349-
};
350-
351-
let entries: Awaited<ReturnType<typeof fetchCustomRegistry>>;
342+
let result: ImportCustomRegistryResult;
352343
try {
353-
entries = await fetchCustomRegistry(source, { userAgent: createKimiCodeUserAgent() });
344+
result = await host.harness.importCustomRegistry({
345+
url: value.url,
346+
apiKey: value.apiKey,
347+
setDefaultWhenUnset: false,
348+
});
354349
} catch (error) {
355-
host.showError(`Failed to import registry: ${formatErrorMessage(error)}`);
350+
if (error instanceof RegistryImportError && error.phase === 'empty') {
351+
host.showStatus('Registry contained no providers.');
352+
return false;
353+
}
354+
const phase =
355+
error instanceof RegistryImportError && error.phase === 'fetch' ? 'import' : 'apply';
356+
host.showError(`Failed to ${phase} registry: ${formatErrorMessage(error)}`);
357+
if (
358+
value.apiKey === undefined &&
359+
error instanceof RegistryImportError &&
360+
(error.status === 401 || error.status === 403)
361+
) {
362+
host.showStatus('This registry requires authentication — paste its Bearer token.', 'warning');
363+
}
356364
return false;
357365
}
358-
359-
const addedProviderIds = Object.values(entries).map((entry) => entry.id);
360366
try {
361-
const config = await host.harness.getConfig();
362-
applyCustomRegistryEntries(
363-
config as unknown as ManagedKimiConfigShape,
364-
entries,
365-
source,
366-
);
367-
await host.harness.setConfig({
368-
providers: config.providers,
369-
models: config.models,
370-
});
371367
await host.authFlow.refreshConfigAfterLogin();
372368
} catch (error) {
373369
host.showError(`Failed to apply registry: ${formatErrorMessage(error)}`);
374370
return false;
375371
}
376-
372+
const addedProviderIds = result.providers.map((provider) => provider.id);
377373
const count = addedProviderIds.length;
378-
if (count === 0) {
379-
host.showStatus('Registry contained no providers.');
380-
return false;
381-
}
382374
host.showStatus(
383375
count === 1
384376
? 'Imported 1 provider from registry.'
385377
: `Imported ${String(count)} providers from registry.`,
386378
'success',
387379
);
380+
for (const [id, envName] of Object.entries(result.credentialEnv)) {
381+
host.showStatus(
382+
`provider "${id}" declares credential env var "${envName}" — set api_key_env in config.toml to use it`,
383+
);
384+
}
388385

389386
// Offer the model selector so the user can pick a default, just like the
390387
// catalog (known-provider) flow. Copy without the v1-synthesized
@@ -419,7 +416,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise
419416

420417
function promptCustomRegistryImport(
421418
host: SlashCommandHost,
422-
): Promise<{ readonly url: string; readonly apiKey: string } | undefined> {
419+
): Promise<CustomRegistryImportValue | undefined> {
423420
return new Promise((resolve) => {
424421
const dialog = new CustomRegistryImportDialogComponent(
425422
(result: CustomRegistryImportResult) => {

‎apps/kimi-code/src/tui/components/dialogs/custom-registry-import.ts‎

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
/**
22
* CustomRegistryImportDialog — blue rounded box that collects a custom
3-
* registry URL and a Bearer token before importing the registry's
3+
* registry URL and an optional Bearer token before importing the registry's
44
* provider entries.
55
*
66
* Geometry mirrors `ApiKeyInputDialogComponent` so the chrome stays
77
* consistent with the API-key login flow. Two fields, switched with
88
* Tab / Shift-Tab / Up / Down; Enter advances to the next field (and submits
9-
* on the last field), Esc cancels. Both fields are required.
9+
* on the last field), Esc cancels. Only the URL is required — public
10+
* registries take an empty token.
1011
*/
1112

1213
import {
@@ -23,17 +24,16 @@ import { currentTheme } from '#/tui/theme';
2324

2425
export interface CustomRegistryImportValue {
2526
readonly url: string;
26-
readonly apiKey: string;
27+
readonly apiKey?: string;
2728
}
2829

2930
export type CustomRegistryImportResult =
3031
| { readonly kind: 'ok'; readonly value: CustomRegistryImportValue }
3132
| { readonly kind: 'cancel' };
3233

3334
const TITLE = 'Import custom provider registry';
34-
const SUBTITLE_DEFAULT = 'Paste an api.json URL and its Bearer token.';
35+
const SUBTITLE_DEFAULT = 'Paste an api.json URL; the Bearer token is optional.';
3536
const SUBTITLE_URL_EMPTY = 'Registry URL cannot be empty.';
36-
const SUBTITLE_TOKEN_EMPTY = 'Bearer token cannot be empty.';
3737
const FOOTER_NOT_LAST = 'Tab / ↑↓ to switch · Enter for next field · Esc to cancel';
3838
const FOOTER_LAST = 'Tab / ↑↓ to switch · Enter to submit · Esc to cancel';
3939

@@ -72,7 +72,7 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo
7272
private readonly onDone: (result: CustomRegistryImportResult) => void;
7373
private activeField: FieldId = 'url';
7474
private done = false;
75-
private hint: 'none' | 'url-empty' | 'token-empty' = 'none';
75+
private hint: 'none' | 'url-empty' = 'none';
7676

7777
constructor(
7878
onDone: (result: CustomRegistryImportResult) => void,
@@ -144,20 +144,15 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo
144144

145145
const border = (s: string): string => currentTheme.fg('primary', s);
146146
const titleStyled = currentTheme.boldFg('textStrong', TITLE);
147-
const subtitleText =
148-
this.hint === 'url-empty'
149-
? SUBTITLE_URL_EMPTY
150-
: this.hint === 'token-empty'
151-
? SUBTITLE_TOKEN_EMPTY
152-
: SUBTITLE_DEFAULT;
147+
const subtitleText = this.hint === 'url-empty' ? SUBTITLE_URL_EMPTY : SUBTITLE_DEFAULT;
153148
const subtitleStyled = currentTheme.fg('textDim', subtitleText);
154149
const footerStyled = currentTheme.fg(
155150
'textDim',
156151
this.activeField === 'url' ? FOOTER_NOT_LAST : FOOTER_LAST,
157152
);
158153

159154
const urlLabelText = 'Registry URL';
160-
const tokenLabelText = 'Bearer token';
155+
const tokenLabelText = 'Bearer token (optional)';
161156
const urlLabelStyled =
162157
this.activeField === 'url'
163158
? currentTheme.boldFg('accent', urlLabelText)
@@ -233,14 +228,12 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo
233228
this.activeField = 'url';
234229
return;
235230
}
236-
if (tokenValue.length === 0) {
237-
this.hint = 'token-empty';
238-
this.activeField = 'token';
239-
return;
240-
}
241231

242232
this.done = true;
243-
this.onDone({ kind: 'ok', value: { url: urlValue, apiKey: tokenValue } });
233+
this.onDone({
234+
kind: 'ok',
235+
value: { url: urlValue, apiKey: tokenValue.length > 0 ? tokenValue : undefined },
236+
});
244237
}
245238

246239
private cancel(): void {

0 commit comments

Comments
 (0)