A pi-ai route had to name an installed catalog provider, served that catalog's models verbatim, and could override only the endpoint. An OpenAI-compatible gateway, a self-hosted server, or a model newer than the pinned pi-ai release was therefore unreachable, and a stale context window could not be corrected without upgrading the package. A route is now a declaration whose defaults come from the installed catalog. `catalog.ts` merges that catalog under the profile's own model entries, `provider.ts` builds the pi-ai Provider (reusing the catalog provider when the route keeps its protocol, so implementations this package cannot reconstruct keep working), and the adapter serves every operation from one `createModels()` collection. That also retires the `@earendil-works/pi-ai/compat` import, which pi-ai documents as a temporary entry point it deletes with its ModelManager migration. Credentials stay on the harness seam: the resolved key rides the request as pi-ai's highest-priority auth override, so `Models` holds no credential store and a named-but-missing reference still fails loud instead of falling back to an unrelated ambient key. A model's configured maxTokens now reaches the seam as defaultMaxTokens.
75 lines
2.6 KiB
TypeScript
75 lines
2.6 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
|
|
const streamSimple = vi.hoisted(() => vi.fn())
|
|
|
|
// A hand-declared route is built by `createProvider` over the protocol table in
|
|
// `src/provider.ts`, so the table's lazy api module is the SDK boundary this
|
|
// test can observe. A catalog route dispatches through pi-ai's own provider and
|
|
// would not see this mock.
|
|
vi.mock('@earendil-works/pi-ai/api/openai-completions.lazy', () => ({
|
|
openAICompletionsApi: () => ({ stream: streamSimple, streamSimple }),
|
|
}))
|
|
|
|
import { PiAiAdapter } from '../src/adapter.ts'
|
|
import { resolveProfiles } from '../src/config.ts'
|
|
|
|
afterEach(() => { streamSimple.mockReset() })
|
|
|
|
/** A hand-declared OpenAI-compatible route with one fully described model. */
|
|
function gatewayAdapter(): PiAiAdapter {
|
|
return new PiAiAdapter({
|
|
profiles: () => resolveProfiles({
|
|
'local-gateway': {
|
|
apiKey: 'test-key',
|
|
api: 'openai-completions',
|
|
baseURL: 'http://127.0.0.1:9/v1',
|
|
models: [{ id: 'local-model', contextWindow: 8192, maxTokens: 1024 }],
|
|
},
|
|
}),
|
|
resolveApiKey: () => Promise.resolve('test-key'),
|
|
})
|
|
}
|
|
|
|
async function drain(adapter: PiAiAdapter): Promise<StreamChunk[]> {
|
|
const chunks: StreamChunk[] = []
|
|
for await (const chunk of adapter.stream({
|
|
provider: 'local-gateway',
|
|
model: 'local-model',
|
|
messages: [],
|
|
})) chunks.push(chunk)
|
|
return chunks
|
|
}
|
|
|
|
describe('pi-ai SDK retry boundary', () => {
|
|
it('pins one SDK attempt even when the installed provider currently defaults to zero retries', async () => {
|
|
streamSimple.mockImplementation(() => { throw new Error('mock SDK boundary') })
|
|
|
|
const chunks = await drain(gatewayAdapter())
|
|
|
|
expect(streamSimple).toHaveBeenCalledOnce()
|
|
expect(streamSimple.mock.calls[0]?.[2]).toMatchObject({ maxRetries: 0, apiKey: 'test-key' })
|
|
// pi-ai reports a setup failure as a terminal in-stream error rather than
|
|
// throwing, which the converter turns into the harness error finish.
|
|
expect(chunks.at(-1)).toMatchObject({
|
|
type: 'finish',
|
|
reason: { kind: 'error', failure: { message: 'mock SDK boundary' } },
|
|
})
|
|
})
|
|
|
|
it('dispatches a hand-declared route to the endpoint and model its configuration describes', async () => {
|
|
streamSimple.mockImplementation(() => { throw new Error('mock SDK boundary') })
|
|
|
|
await drain(gatewayAdapter())
|
|
|
|
expect(streamSimple.mock.calls[0]?.[0]).toMatchObject({
|
|
id: 'local-model',
|
|
provider: 'local-gateway',
|
|
api: 'openai-completions',
|
|
baseUrl: 'http://127.0.0.1:9/v1',
|
|
contextWindow: 8192,
|
|
maxTokens: 1024,
|
|
})
|
|
})
|
|
})
|