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:
Yichen Jiang
2026-07-30 15:51:35 +08:00
parent 8f045bfdbd
commit 54f95d7669
9 changed files with 252 additions and 76 deletions
+17 -5
View File
@@ -17,6 +17,7 @@ import type {
ResolvedRetryPolicy,
StreamChunk,
} from '@deepseek-ai/dsh-llm'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { serializeRequest } from './serialize.ts'
import type { RequestDefaults } from './serialize.ts'
@@ -45,6 +46,14 @@ export interface DeepSeekCatalogModel {
export interface DeepSeekConnectionOptions {
/** Endpoint base; `/chat/completions` is appended. */
baseURL: string
/**
* Literal API key of this same resolution, when the configuration carried
* one. Travelling with the endpoint is the point: a request can never pair
* one generation's URL with another generation's secret.
*/
apiKey?: string
/** Credential reference of this same resolution, resolved per request when no literal key exists. */
apiKeyEnv: CredentialRef
/** Request defaults applied to every call (thinking mode, effort). */
defaults: RequestDefaults
/** Positive context capacity used when the selected model has no exact value. */
@@ -62,11 +71,12 @@ export interface DeepSeekAdapterOptions {
/** Current validated connection facts; called once per operation. */
options: () => DeepSeekConnectionOptions
/**
* Resolve the bearer token for one request; called once per stream call and
* frozen for that call. Throws `LlmError` `MISSING_CREDENTIAL` when no key
* is available anywhere.
* Resolve the bearer token for the connection facts of one request. The
* snapshot is passed in — never re-read — so the key can only ever come
* from the same resolution as the endpoint it is sent to. Throws `LlmError`
* `MISSING_CREDENTIAL` when no key is available anywhere.
*/
resolveApiKey: () => Promise<string>
resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise<string>
}
/** Default maximum idle interval while an adapter stream read is outstanding. */
@@ -189,8 +199,10 @@ export class DeepSeekAdapter extends LlmAdapter {
// One resolution per stream call: connection facts and the credential
// freeze here and hold for this whole request, so an in-flight stream
// never observes a configuration change and the next call re-resolves.
// The key resolves *from this snapshot*, so an endpoint and the secret
// sent to it can never come from different configuration generations.
const connection = this.config.options()
const apiKey = await this.config.resolveApiKey()
const apiKey = await this.config.resolveApiKey(connection)
const consumer = new AbortController()
const upstream = options.signal === undefined
? consumer.signal