fix(llm): atomic route replacement, whole-snapshot requests, and loud credential misses
Four review findings across the seam and both adapters. registerAdapter now returns a handle carrying replace(providers): the candidate route set is validated in full before anything moves, so a route another adapter owns leaves the previous registration intact, and the swap itself is one synchronous section with no observable gap. pi-ai uses it instead of dispose-then-register — the old shape dropped every route when the new set conflicted, and its facts cache could then equal the registry's, so reverting to a working configuration never re-applied. Its registration facts are also sorted by provider, so a settings document that merely reorders keys no longer triggers a swap. DeepSeek's per-request snapshot now carries the credential facts, and resolveApiKey receives it instead of re-reading the raw config: a settings generation the resolver rejects can no longer contribute its literal key to a request the previous generation's endpoint serves. pi-ai only defers to the SDK's provider-native discovery when a profile names no credential at all; a configured apiKeyEnv that misses now fails with MISSING_CREDENTIAL naming the route and the reference, instead of handing pi-ai undefined and letting it authenticate with an unrelated ambient key. The eager boot-time credential probe is gone: it could run before the credentials service mounted and reported every failure as a missing key. The route stays registered and browsable; the first request gives the accurate error, whose guidance now leads with the credential store and mentions a literal apiKey last.
This commit is contained in:
@@ -27,7 +27,7 @@ async function harness(baseURL: string, overrides: Record<string, unknown> = {})
|
||||
function adapterOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>): PiAiAdapter {
|
||||
return new PiAiAdapter({
|
||||
profiles: () => resolveProfiles(providers),
|
||||
resolveApiKey: profile => Promise.resolve(profile.apiKey),
|
||||
resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -385,13 +385,19 @@ describe('provider profile lifecycle', () => {
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer custom-ref-key')
|
||||
})
|
||||
|
||||
it('treats an empty apiKeyEnv variable as absent and defers to SDK ambient discovery', async () => {
|
||||
it('fails a named-but-missing apiKeyEnv instead of using another ambient key', async () => {
|
||||
// The exact confusion this guards: the named reference is empty while an
|
||||
// unrelated provider key sits in the environment. Deferring to pi-ai's own
|
||||
// discovery here would authenticate as another tenant.
|
||||
vi.stubEnv('PI_CUSTOM_REF_KEY', '')
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' })
|
||||
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer ambient-key')
|
||||
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
|
||||
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
|
||||
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
|
||||
.rejects.toThrow(/provider route "deepseek".*PI_CUSTOM_REF_KEY/s)
|
||||
expect(server.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => {
|
||||
|
||||
Reference in New Issue
Block a user