|
@@ -171,7 +171,7 @@ describe('hand-declared providers', () => {
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
|
const modelsOf = (route: string): readonly { id: string; contextWindow: number; maxTokens: number }[] =>
|
|
const modelsOf = (route: string): readonly { id: string; contextWindow: number; maxTokens: number }[] =>
|
|
|
- resolved.get(route)?.piProvider.getModels() ?? []
|
|
|
|
|
|
|
+ resolved.get(route)?.piProvider?.getModels() ?? []
|
|
|
|
|
|
|
|
expect(modelsOf('acme-gateway')).toMatchObject([
|
|
expect(modelsOf('acme-gateway')).toMatchObject([
|
|
|
{ id: 'bare', contextWindow: 262_144, maxTokens: 32_768 },
|
|
{ id: 'bare', contextWindow: 262_144, maxTokens: 32_768 },
|
|
@@ -210,7 +210,7 @@ describe('hand-declared providers', () => {
|
|
|
'anthropic': { defaultInput: ['text'] },
|
|
'anthropic': { defaultInput: ['text'] },
|
|
|
})
|
|
})
|
|
|
const inputOf = (route: string, id: string): readonly string[] | undefined =>
|
|
const inputOf = (route: string, id: string): readonly string[] | undefined =>
|
|
|
- resolved.get(route)?.piProvider.getModels().find(model => model.id === id)?.input
|
|
|
|
|
|
|
+ resolved.get(route)?.piProvider?.getModels().find(model => model.id === id)?.input
|
|
|
|
|
|
|
|
expect(inputOf('acme-gateway', 'bare')).toEqual(['text'])
|
|
expect(inputOf('acme-gateway', 'bare')).toEqual(['text'])
|
|
|
expect(inputOf('acme-gateway', 'seeing')).toEqual(['text', 'image'])
|
|
expect(inputOf('acme-gateway', 'seeing')).toEqual(['text', 'image'])
|
|
@@ -273,8 +273,8 @@ describe('hand-declared providers', () => {
|
|
|
models: [{ id: 'bare', input: [] }],
|
|
models: [{ id: 'bare', input: [] }],
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
|
- expect(resolved.get('acme-gateway')?.piProvider.getModels()[0]?.input).toEqual(['text'])
|
|
|
|
|
- expect(resolved.get('deepseek')?.piProvider.getModels()[0]?.input).toEqual(catalogModel.input)
|
|
|
|
|
|
|
+ expect(resolved.get('acme-gateway')?.piProvider?.getModels()[0]?.input).toEqual(['text'])
|
|
|
|
|
+ expect(resolved.get('deepseek')?.piProvider?.getModels()[0]?.input).toEqual(catalogModel.input)
|
|
|
|
|
|
|
|
// Nothing sits below the route value, so its empty list states no answer
|
|
// Nothing sits below the route value, so its empty list states no answer
|
|
|
// anything could take, and is refused where it is written.
|
|
// anything could take, and is refused where it is written.
|
|
@@ -302,6 +302,18 @@ describe('hand-declared providers', () => {
|
|
|
})).toThrow(/more than once/)
|
|
})).toThrow(/more than once/)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+ it('retains duplicate-id diagnostics without offering the ambiguous model after loading', () => {
|
|
|
|
|
+ const profile = resolveProfiles({
|
|
|
|
|
+ 'acme-gateway': {
|
|
|
|
|
+ api: 'openai-completions',
|
|
|
|
|
+ baseURL: 'https://acme.test',
|
|
|
|
|
+ models: [{ id: 'dup' }, { id: 'valid' }, { id: 'dup' }],
|
|
|
|
|
+ },
|
|
|
|
|
+ }, 'deferred').get('acme-gateway')
|
|
|
|
|
+ expect(profile?.modelErrors.get('dup')).toContain('lists model "dup" more than once')
|
|
|
|
|
+ expect(profile?.piProvider?.getModels().map(model => model.id)).toEqual(['valid'])
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
it('rejects a declaration that names no wire protocol or endpoint', () => {
|
|
it('rejects a declaration that names no wire protocol or endpoint', () => {
|
|
|
expect(() => resolveProfiles({
|
|
expect(() => resolveProfiles({
|
|
|
'acme-gateway': { baseURL: 'https://acme.test', models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }] },
|
|
'acme-gateway': { baseURL: 'https://acme.test', models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }] },
|
|
@@ -311,6 +323,18 @@ describe('hand-declared providers', () => {
|
|
|
})).toThrow(/needs a baseURL/)
|
|
})).toThrow(/needs a baseURL/)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+ it('retains the missing-api model diagnostic when a stored custom provider cannot be built', () => {
|
|
|
|
|
+ const profile = resolveProfiles({
|
|
|
|
|
+ 'acme-gateway': { baseURL: 'https://acme.test', models: [{ id: '111' }] },
|
|
|
|
|
+ }, 'deferred').get('acme-gateway')!
|
|
|
|
|
+ const failure = 'llm-pi-ai: provider "acme-gateway" model "111" needs an api; '
|
|
|
|
|
+ + 'the installed catalog does not describe it, so set the route\'s api to the wire protocol its endpoint speaks'
|
|
|
|
|
+
|
|
|
|
|
+ expect(profile.catalogError).toBe(failure)
|
|
|
|
|
+ expect(profile.modelErrors.get('111')).toBe(failure)
|
|
|
|
|
+ expect(profile.piProvider).toBeUndefined()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
it.each(['bedrock-converse-stream', 'google-vertex', 'azure-openai-responses', 'openai-codex-responses'])(
|
|
it.each(['bedrock-converse-stream', 'google-vertex', 'azure-openai-responses', 'openai-codex-responses'])(
|
|
|
'refuses %s, whose authentication a profile cannot express',
|
|
'refuses %s, whose authentication a profile cannot express',
|
|
|
(api) => {
|
|
(api) => {
|
|
@@ -494,7 +518,7 @@ describe('catalog routes with per-model configuration', () => {
|
|
|
const resolved = resolveProfiles({
|
|
const resolved = resolveProfiles({
|
|
|
nvidia: { models: [{ id: headered.id, contextWindow: 4096 }] },
|
|
nvidia: { models: [{ id: headered.id, contextWindow: 4096 }] },
|
|
|
})
|
|
})
|
|
|
- const [model] = resolved.get('nvidia')?.piProvider.getModels() ?? []
|
|
|
|
|
|
|
+ const [model] = resolved.get('nvidia')?.piProvider?.getModels() ?? []
|
|
|
expect(model?.headers).toEqual(headered.headers)
|
|
expect(model?.headers).toEqual(headered.headers)
|
|
|
expect(model?.contextWindow).toBe(4096)
|
|
expect(model?.contextWindow).toBe(4096)
|
|
|
})
|
|
})
|
|
@@ -520,15 +544,15 @@ describe('catalog routes with per-model configuration', () => {
|
|
|
// `opencode` ships no provider-level endpoint: the address lives on every
|
|
// `opencode` ships no provider-level endpoint: the address lives on every
|
|
|
// catalog model, so the route resolves without any configured baseURL.
|
|
// catalog model, so the route resolves without any configured baseURL.
|
|
|
const resolved = resolveProfiles({ opencode: {} })
|
|
const resolved = resolveProfiles({ opencode: {} })
|
|
|
- const models = resolved.get('opencode')?.piProvider.getModels() ?? []
|
|
|
|
|
|
|
+ const models = resolved.get('opencode')?.piProvider?.getModels() ?? []
|
|
|
expect(models.length).toBeGreaterThan(0)
|
|
expect(models.length).toBeGreaterThan(0)
|
|
|
expect(models.every(model => model.baseUrl.length > 0)).toBe(true)
|
|
expect(models.every(model => model.baseUrl.length > 0)).toBe(true)
|
|
|
- expect(resolved.get('opencode')?.piProvider.baseUrl).toBeUndefined()
|
|
|
|
|
|
|
+ expect(resolved.get('opencode')?.piProvider?.baseUrl).toBeUndefined()
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
it('repoints a catalog route at another wire protocol without restating its endpoint', () => {
|
|
it('repoints a catalog route at another wire protocol without restating its endpoint', () => {
|
|
|
const resolved = resolveProfiles({ openai: { api: 'openai-completions' } })
|
|
const resolved = resolveProfiles({ openai: { api: 'openai-completions' } })
|
|
|
- const models = resolved.get('openai')?.piProvider.getModels() ?? []
|
|
|
|
|
|
|
+ const models = resolved.get('openai')?.piProvider?.getModels() ?? []
|
|
|
// The protocol changes for the whole route; each model keeps the catalog
|
|
// The protocol changes for the whole route; each model keeps the catalog
|
|
|
// endpoint it already had.
|
|
// endpoint it already had.
|
|
|
expect(models.every(model => model.api === 'openai-completions')).toBe(true)
|
|
expect(models.every(model => model.api === 'openai-completions')).toBe(true)
|
|
@@ -559,7 +583,7 @@ describe('catalog routes with per-model configuration', () => {
|
|
|
// the wire format its models speak: naming an api must not cost a profile
|
|
// the wire format its models speak: naming an api must not cost a profile
|
|
|
// its provider-native discovery.
|
|
// its provider-native discovery.
|
|
|
const resolved = resolveProfiles({ openai: { api: 'openai-completions' } })
|
|
const resolved = resolveProfiles({ openai: { api: 'openai-completions' } })
|
|
|
- expect(resolved.get('openai')?.piProvider.auth.apiKey?.name).toBe('OpenAI API key')
|
|
|
|
|
|
|
+ expect(resolved.get('openai')?.piProvider?.auth.apiKey?.name).toBe('OpenAI API key')
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
it('lets an OAuth-only catalog route authenticate with the key its profile names', async () => {
|
|
it('lets an OAuth-only catalog route authenticate with the key its profile names', async () => {
|
|
@@ -582,7 +606,7 @@ describe('catalog routes with per-model configuration', () => {
|
|
|
// and holds no OAuth store, so declaring the provider configured would
|
|
// and holds no OAuth store, so declaring the provider configured would
|
|
|
// trade a truthful refusal for an endpoint's 401.
|
|
// trade a truthful refusal for an endpoint's 401.
|
|
|
const resolved = resolveProfiles({ 'openai-codex': {} })
|
|
const resolved = resolveProfiles({ 'openai-codex': {} })
|
|
|
- expect(resolved.get('openai-codex')?.piProvider.auth.apiKey).toBeUndefined()
|
|
|
|
|
|
|
+ expect(resolved.get('openai-codex')?.piProvider?.auth.apiKey).toBeUndefined()
|
|
|
})
|
|
})
|
|
|
})
|
|
})
|
|
|
|
|
|
|
@@ -594,7 +618,7 @@ describe('per-model reasoning efforts', () => {
|
|
|
|
|
|
|
|
/** The first materialized model of one route, or throw. */
|
|
/** The first materialized model of one route, or throw. */
|
|
|
function modelOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>, route = 'acme-gateway'): Model<Api> {
|
|
function modelOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>, route = 'acme-gateway'): Model<Api> {
|
|
|
- const [model] = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? []
|
|
|
|
|
|
|
+ const [model] = resolveProfiles(providers).get(route)?.piProvider?.getModels() ?? []
|
|
|
if (model === undefined) throw new Error(`route "${route}" resolved no models`)
|
|
if (model === undefined) throw new Error(`route "${route}" resolved no models`)
|
|
|
return model
|
|
return model
|
|
|
}
|
|
}
|
|
@@ -705,7 +729,7 @@ describe('modelOverrides', () => {
|
|
|
},
|
|
},
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
|
- const models = resolved.get('deepseek')?.piProvider.getModels() ?? []
|
|
|
|
|
|
|
+ const models = resolved.get('deepseek')?.piProvider?.getModels() ?? []
|
|
|
const reshaped = models.find(model => model.id === target.id)
|
|
const reshaped = models.find(model => model.id === target.id)
|
|
|
if (reshaped === undefined) throw new Error('the overridden model vanished from the route')
|
|
if (reshaped === undefined) throw new Error('the overridden model vanished from the route')
|
|
|
|
|
|
|
@@ -758,7 +782,7 @@ describe('modelOverrides', () => {
|
|
|
describe('compat switches', () => {
|
|
describe('compat switches', () => {
|
|
|
/** The materialized models of one route, keyed by id. */
|
|
/** The materialized models of one route, keyed by id. */
|
|
|
function modelsOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>, route: string): Map<string, Model<Api>> {
|
|
function modelsOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>, route: string): Map<string, Model<Api>> {
|
|
|
- const models = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? []
|
|
|
|
|
|
|
+ const models = resolveProfiles(providers).get(route)?.piProvider?.getModels() ?? []
|
|
|
return new Map(models.map(model => [model.id, model]))
|
|
return new Map(models.map(model => [model.id, model]))
|
|
|
}
|
|
}
|
|
|
|
|
|