fix: harden replay and pi-ai request boundaries
This commit is contained in:
@@ -53,6 +53,16 @@ function profileOptions(profile: PiAiProviderProfile): SimpleStreamOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge deployment headers while removing case-insensitive attribution collisions. */
|
||||
function requestHeaders(headers: Readonly<Record<string, string>> | undefined): Record<string, string> {
|
||||
const attribution = attributionHeaders()
|
||||
const reserved = new Set(Object.keys(attribution).map(name => name.toLowerCase()))
|
||||
return {
|
||||
...Object.fromEntries(Object.entries(headers ?? {}).filter(([name]) => !reserved.has(name.toLowerCase()))),
|
||||
...attribution,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* pi-ai-backed multi-provider adapter. Model descriptors are resolved for each
|
||||
* request, so models need not be registered during the Cordis lifecycle.
|
||||
@@ -103,7 +113,7 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
signal: controller.signal,
|
||||
// Profile headers are deployment-owned; attribution names are
|
||||
// Harness-owned and therefore win collisions.
|
||||
headers: { ...profile.headers, ...attributionHeaders() },
|
||||
headers: requestHeaders(profile.headers),
|
||||
})
|
||||
yield* toStreamChunks(events)
|
||||
} finally {
|
||||
|
||||
@@ -58,10 +58,10 @@ const profile = z.object({
|
||||
thinkingBudgets,
|
||||
cacheRetention: z.union(['none', 'short', 'long']),
|
||||
transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']),
|
||||
timeoutMs: z.number(),
|
||||
websocketConnectTimeoutMs: z.number(),
|
||||
maxRetries: z.number(),
|
||||
maxRetryDelayMs: z.number(),
|
||||
timeoutMs: z.natural(),
|
||||
websocketConnectTimeoutMs: z.natural(),
|
||||
maxRetries: z.natural(),
|
||||
maxRetryDelayMs: z.natural(),
|
||||
})
|
||||
|
||||
/** Runtime schema for {@link Config}. */
|
||||
@@ -83,7 +83,7 @@ export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): PiAiP
|
||||
if (source.provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty')
|
||||
if (!supported.has(source.provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${source.provider}"`)
|
||||
if (seen.has(source.provider)) throw new Error(`llm-pi-ai: duplicate provider profile "${source.provider}"`)
|
||||
if (source.apiKey !== undefined && source.apiKey.length === 0) {
|
||||
if (source.apiKey !== undefined && source.apiKey.trim().length === 0) {
|
||||
throw new Error(`llm-pi-ai: provider "${source.provider}" has an empty apiKey; omit it to use ambient authentication`)
|
||||
}
|
||||
if (source.baseURL !== undefined && source.baseURL.length === 0) {
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('PiAiAdapter provider routing', () => {
|
||||
it('merges profile headers with Harness attribution winning', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url, {
|
||||
headers: { 'x-company': 'private', 'user-agent': 'wrong' },
|
||||
headers: { 'x-company': 'private', 'User-Agent': 'wrong' },
|
||||
})
|
||||
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(server.headers[0]?.['x-company']).toBe('private')
|
||||
@@ -219,9 +219,23 @@ describe('provider profile lifecycle', () => {
|
||||
expect(() => resolveProfiles([{ provider: 'not-real' }])).toThrow(/unknown/)
|
||||
expect(() => resolveProfiles([{ provider: 'openai' }, { provider: 'openai' }])).toThrow(/duplicate/)
|
||||
expect(() => resolveProfiles([{ provider: 'openai', apiKey: '' }])).toThrow(/empty apiKey/)
|
||||
expect(() => resolveProfiles([{ provider: 'openai', apiKey: ' ' }])).toThrow(/empty apiKey/)
|
||||
expect(() => resolveProfiles([{ provider: 'openai', baseURL: '' }])).toThrow(/empty baseURL/)
|
||||
})
|
||||
|
||||
it('rejects negative or fractional stream tunables at schema validation', () => {
|
||||
const invalid = [
|
||||
{ timeoutMs: -1 },
|
||||
{ websocketConnectTimeoutMs: -1 },
|
||||
{ maxRetries: -1 },
|
||||
{ maxRetries: 0.5 },
|
||||
{ maxRetryDelayMs: -1 },
|
||||
]
|
||||
for (const entry of invalid) {
|
||||
expect(() => new LlmPiAi.Config({ providers: [{ provider: 'openai', ...entry }] })).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('constructs the adapter directly and rejects routes it does not own', async () => {
|
||||
const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] })
|
||||
await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
|
||||
|
||||
Reference in New Issue
Block a user