catalog.spec.ts 53 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194
  1. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  5. import { Context } from '@deepseek-ai/cordis'
  6. import LlmRuntime, { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  7. import type { StreamChunk } from '@deepseek-ai/dsh-llm'
  8. import FileSettingsProvider from '@deepseek-ai/dsh-settings-file'
  9. import { settingsNamespace } from '@deepseek-ai/dsh-settings'
  10. import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
  11. import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
  12. import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
  13. import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai'
  14. import type { Api, Model, OpenAICompletionsCompat, Provider } from '@earendil-works/pi-ai'
  15. import { resolveProfiles } from '../src/config.ts'
  16. import { buildProvider, supportedProtocols } from '../src/provider.ts'
  17. import { assemble } from './assemble.ts'
  18. import { memoryAuth } from './auth-double.ts'
  19. import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
  20. const homes: string[] = []
  21. // Routes name their credential by reference; the value lives in the
  22. // environment, which is the layer the adapter falls back to without a
  23. // mounted credentials seam.
  24. const KEY_ENV = 'PI_TEST_KEY'
  25. beforeEach(() => {
  26. vi.stubEnv(KEY_ENV, 'test-key')
  27. })
  28. afterEach(async () => {
  29. vi.unstubAllEnvs()
  30. await closeMockServers()
  31. await Promise.all(homes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
  32. })
  33. /** A throwaway $DSH_HOME with an empty settings document. */
  34. async function home(): Promise<string> {
  35. const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-catalog-'))
  36. homes.push(dir)
  37. await writeFile(join(dir, 'settings.yaml'), '')
  38. return dir
  39. }
  40. /** The dormant composition plus a real settings service, as the product mounts it. */
  41. async function bootWithSettings(dir: string, config: LlmPiAi.Config): Promise<Context> {
  42. const ctx = new Context()
  43. await ctx.plugin(LlmRuntime)
  44. await ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false })
  45. await ctx.plugin(LlmPiAi, config)
  46. return ctx
  47. }
  48. /** A complete hand-declared route: nothing about it exists in pi-ai's catalog. */
  49. function gateway(baseURL: string, overrides: Record<string, unknown> = {}): LlmPiAi.Config {
  50. return {
  51. providers: {
  52. 'acme-gateway': {
  53. apiKeyEnv: KEY_ENV,
  54. displayName: 'Acme Gateway',
  55. api: 'openai-completions',
  56. baseURL,
  57. models: [{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 }],
  58. ...overrides,
  59. },
  60. },
  61. }
  62. }
  63. async function harness(config: LlmPiAi.Config): Promise<Context> {
  64. const ctx = new Context()
  65. await ctx.plugin(LlmRuntime)
  66. await ctx.plugin(LlmPiAi, config)
  67. return ctx
  68. }
  69. describe('hand-declared providers', () => {
  70. it('serves a route pi-ai has never heard of from its own declaration', async () => {
  71. const server = await mockServer([{ events: textEvents }])
  72. const ctx = await harness(gateway(`${server.url}/v1`))
  73. const result = await assemble(ctx, {
  74. provider: 'acme-gateway',
  75. model: 'acme-large',
  76. messages: [createUserMessage({
  77. content: [{ type: 'text', text: 'hi' }],
  78. source: { kind: 'plugin', plugin: 'test' },
  79. })],
  80. })
  81. expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
  82. expect(result.finish).toEqual({ kind: 'stop' })
  83. expect(server.paths).toEqual(['/v1/chat/completions'])
  84. // The reference resolved through the environment and reached the wire.
  85. expect(server.headers[0]?.authorization).toBe('Bearer test-key')
  86. })
  87. it('lists and resolves the declared models rather than a catalog', async () => {
  88. const server = await mockServer([])
  89. const ctx = await harness(gateway(`${server.url}/v1`))
  90. expect(await ctx.llm.listModels('acme-gateway')).toEqual([
  91. { provider: 'acme-gateway', id: 'acme-large', name: 'Acme Large', inputModalities: ['text'] },
  92. ])
  93. const info = await ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')
  94. expect(info).toMatchObject({
  95. provider: 'acme-gateway',
  96. id: 'acme-large',
  97. name: 'Acme Large',
  98. context: { contextWindow: 65_536 },
  99. defaultMaxTokens: 4096,
  100. })
  101. })
  102. it('offers no reasoning control it could not honour', async () => {
  103. const server = await mockServer([])
  104. const ctx = await harness(gateway(`${server.url}/v1`))
  105. // pi-ai reports a model with no reasoning metadata as supporting the single
  106. // level `off`, but `off` is translated to *omitting* the reasoning option —
  107. // byte-for-byte the same request as naming no effort — so a provider whose
  108. // own default is to think would keep thinking with `off` selected. The
  109. // capability is reported unavailable instead of offering that control.
  110. expect((await ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).reasoning).toBeUndefined()
  111. // A catalog route is unaffected: its models carry the metadata that makes
  112. // `off` actually disable thinking.
  113. const withCatalog = await harness({ providers: { deepseek: { baseURL: server.url } } })
  114. const [catalogModel] = getBuiltinModels('deepseek')
  115. if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
  116. expect((await withCatalog.llm.resolveModelInfo('deepseek', catalogModel.id)).reasoning?.efforts.map(e => e.id))
  117. .toContain('off')
  118. })
  119. it('joins the configurable-provider directory so a settings surface can reach it', async () => {
  120. const server = await mockServer([])
  121. const ctx = await harness(gateway(`${server.url}/v1`))
  122. const directory = ctx.llm.listConfigurableProviders()
  123. expect(directory).toContainEqual({
  124. provider: 'acme-gateway',
  125. displayName: 'Acme Gateway',
  126. settingsNs: 'llm-pi-ai',
  127. settingsPath: ['providers', 'acme-gateway'],
  128. // Nothing in the installed catalog answers for this route, which is what
  129. // configuration surfaces mark as a route this deployment declared.
  130. declared: true,
  131. })
  132. // Membership of the catalog, not of the settings document: a shipped
  133. // provider carries a stored profile the moment anyone corrects it.
  134. expect(directory.filter(entry => entry.declared).map(entry => entry.provider))
  135. .toEqual(['acme-gateway'])
  136. expect(directory.find(entry => entry.provider === 'deepseek')?.declared).toBe(false)
  137. })
  138. it('sizes a model the catalog cannot describe from the route\u2019s own fallbacks', () => {
  139. const resolved = resolveProfiles({
  140. 'acme-gateway': {
  141. api: 'openai-completions',
  142. baseURL: 'https://acme.test',
  143. // A listing endpoint that discloses nothing but ids still yields a
  144. // serviceable route.
  145. models: [{ id: 'bare' }, { id: 'sized', contextWindow: 8192, maxTokens: 512 }],
  146. },
  147. 'tuned-gateway': {
  148. api: 'openai-completions',
  149. baseURL: 'https://tuned.test',
  150. defaultContextWindow: 4096,
  151. defaultMaxTokens: 256,
  152. models: [{ id: 'bare' }],
  153. },
  154. })
  155. const modelsOf = (route: string): readonly { id: string; contextWindow: number; maxTokens: number }[] =>
  156. resolved.get(route)?.piProvider.getModels() ?? []
  157. expect(modelsOf('acme-gateway')).toMatchObject([
  158. { id: 'bare', contextWindow: 262_144, maxTokens: 32_768 },
  159. { id: 'sized', contextWindow: 8192, maxTokens: 512 },
  160. ])
  161. // The fallback is a guess, so a deployment whose gateway serves smaller
  162. // models corrects it once for the whole route.
  163. expect(modelsOf('tuned-gateway')).toMatchObject([{ id: 'bare', contextWindow: 4096, maxTokens: 256 }])
  164. // Only an explicitly configured cap is a request default; a fallback is
  165. // the model's capability and stops there.
  166. expect(resolved.get('acme-gateway')?.configuredMaxTokens.get('bare')).toBeUndefined()
  167. expect(resolved.get('acme-gateway')?.configuredMaxTokens.get('sized')).toBe(512)
  168. })
  169. it('takes a model’s declared modalities, then the catalog’s, then the route’s', () => {
  170. const vision = getBuiltinModels('anthropic').find(model => model.input.includes('image'))
  171. if (vision === undefined) throw new Error('the installed catalog ships no anthropic vision model')
  172. const resolved = resolveProfiles({
  173. 'acme-gateway': {
  174. api: 'openai-completions',
  175. baseURL: 'https://acme.test',
  176. // One route, two modality sets: the entry field is what says so.
  177. models: [{ id: 'bare' }, { id: 'seeing', input: ['text', 'image'] }, { id: 'deaf', input: ['text'] }],
  178. },
  179. 'seeing-gateway': {
  180. api: 'openai-completions',
  181. baseURL: 'https://seeing.test',
  182. // A gateway whose undescribed models all take images says so once
  183. // rather than on every entry; an entry still outranks it.
  184. defaultInput: ['text', 'image'],
  185. models: [{ id: 'bare' }, { id: 'deaf', input: ['text'] }],
  186. },
  187. // The route value is a fallback, never an override: a catalog model
  188. // keeps what the catalog records even under a narrower route default,
  189. // exactly as it keeps its own contextWindow.
  190. 'anthropic': { defaultInput: ['text'] },
  191. })
  192. const inputOf = (route: string, id: string): readonly string[] | undefined =>
  193. resolved.get(route)?.piProvider.getModels().find(model => model.id === id)?.input
  194. expect(inputOf('acme-gateway', 'bare')).toEqual(['text'])
  195. expect(inputOf('acme-gateway', 'seeing')).toEqual(['text', 'image'])
  196. expect(inputOf('acme-gateway', 'deaf')).toEqual(['text'])
  197. expect(inputOf('seeing-gateway', 'bare')).toEqual(['text', 'image'])
  198. expect(inputOf('seeing-gateway', 'deaf')).toEqual(['text'])
  199. expect(inputOf('anthropic', vision.id)).toEqual(vision.input)
  200. })
  201. it('carries a written modality declaration all the way to the seam’s model metadata', async () => {
  202. // The resolver-level cases above cannot see a break between the settings
  203. // document and `LlmModelInfo`, so each rung is asserted once more through
  204. // a written section, the plugin's own registration, and `ctx.llm`.
  205. const dir = await home()
  206. const ctx = await bootWithSettings(dir, {})
  207. await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
  208. providers: {
  209. 'acme-gateway': {
  210. api: 'openai-completions',
  211. baseURL: 'https://acme.test/v1',
  212. models: [{ id: 'bare' }, { id: 'seeing', input: ['text', 'image'] }],
  213. },
  214. 'vision-gateway': {
  215. api: 'openai-completions',
  216. baseURL: 'https://vision.test/v1',
  217. defaultInput: ['text', 'image'],
  218. models: [{ id: 'bare' }, { id: 'deaf', input: ['text'] }],
  219. },
  220. 'anthropic': { defaultInput: ['text'] },
  221. },
  222. })
  223. const listed = async (provider: string): Promise<Record<string, readonly string[] | undefined>> =>
  224. Object.fromEntries((await ctx.llm.listModels(provider)).map(model => [model.id, model.inputModalities]))
  225. expect(await listed('acme-gateway')).toEqual({ bare: ['text'], seeing: ['text', 'image'] })
  226. expect(await listed('vision-gateway')).toEqual({ bare: ['text', 'image'], deaf: ['text'] })
  227. expect((await ctx.llm.resolveModelInfo('acme-gateway', 'seeing')).inputModalities).toEqual(['text', 'image'])
  228. // A catalog vision model keeps what the catalog records even under a
  229. // narrower route default: the route value is a fallback, not an override.
  230. const vision = getBuiltinModels('anthropic').find(model => model.input.includes('image'))
  231. if (vision === undefined) throw new Error('the installed catalog ships no anthropic vision model')
  232. expect((await ctx.llm.resolveModelInfo('anthropic', vision.id)).inputModalities).toEqual(vision.input)
  233. })
  234. it('reads an entry’s empty modality list as no answer, and the route’s as unserviceable', () => {
  235. // Absent and empty are the same request on an entry, exactly as they are
  236. // for the route's `models` list — which matters because the config schema
  237. // materializes `[]` for an absent array, so an entry naming a catalog
  238. // model without declaring modalities must keep the catalog's rather than
  239. // describe a model that accepts nothing.
  240. const [catalogModel] = getBuiltinModels('deepseek')
  241. if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
  242. const resolved = resolveProfiles({
  243. 'deepseek': { baseURL: 'https://catalog.test', models: [{ id: catalogModel.id, input: [] }] },
  244. 'acme-gateway': {
  245. api: 'openai-completions',
  246. baseURL: 'https://acme.test',
  247. models: [{ id: 'bare', input: [] }],
  248. },
  249. })
  250. expect(resolved.get('acme-gateway')?.piProvider.getModels()[0]?.input).toEqual(['text'])
  251. expect(resolved.get('deepseek')?.piProvider.getModels()[0]?.input).toEqual(catalogModel.input)
  252. // Nothing sits below the route value, so its empty list states no answer
  253. // anything could take, and is refused where it is written.
  254. expect(() => resolveProfiles({
  255. 'acme-gateway': {
  256. api: 'openai-completions',
  257. baseURL: 'https://acme.test',
  258. defaultInput: [],
  259. models: [{ id: 'bare' }],
  260. },
  261. })).toThrow(/defaultInput must name at least one modality/)
  262. })
  263. it('rejects a model the route cannot identify', () => {
  264. const declare = (model: LlmPiAi.PiAiModelProfile): (() => unknown) =>
  265. () => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models: [model] } })
  266. expect(declare({ id: '' })).toThrow(/empty id/)
  267. expect(() => resolveProfiles({
  268. 'acme-gateway': {
  269. api: 'openai-completions',
  270. baseURL: 'https://acme.test',
  271. models: [{ id: 'dup', contextWindow: 1, maxTokens: 1 }, { id: 'dup', contextWindow: 2, maxTokens: 2 }],
  272. },
  273. })).toThrow(/more than once/)
  274. })
  275. it('rejects a declaration that names no wire protocol or endpoint', () => {
  276. expect(() => resolveProfiles({
  277. 'acme-gateway': { baseURL: 'https://acme.test', models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }] },
  278. })).toThrow(/needs an api/)
  279. expect(() => resolveProfiles({
  280. 'acme-gateway': { api: 'openai-completions', models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }] },
  281. })).toThrow(/needs a baseURL/)
  282. })
  283. it.each(['bedrock-converse-stream', 'google-vertex', 'azure-openai-responses', 'openai-codex-responses'])(
  284. 'refuses %s, whose authentication a profile cannot express',
  285. (api) => {
  286. // These need SigV4 credentials and a region, a project plus ADC, provider
  287. // environment and an api-version, or OAuth — none of which a key, an
  288. // endpoint, and headers can carry, so a route naming one would be built
  289. // unable to authenticate.
  290. expect(supportedProtocols()).not.toContain(api)
  291. expect(() => buildProvider({ provider: 'acme-gateway', displayName: 'Acme', api, models: [], namesCredential: true }))
  292. .toThrow(/cannot serve; supported protocols are/)
  293. },
  294. )
  295. it('rejects a protocol this build cannot serve, and a route that names none', () => {
  296. const spec = { provider: 'acme-gateway', displayName: 'Acme Gateway', models: [], namesCredential: true }
  297. expect(() => buildProvider({ ...spec, api: 'quantum-telepathy' }))
  298. .toThrow(/cannot serve; supported protocols are/)
  299. expect(() => buildProvider(spec)).toThrow(/cannot serve; supported protocols are/)
  300. })
  301. it('leaves an unauthenticated route to its protocol rather than inventing a credential', async () => {
  302. const server = await mockServer([{ events: textEvents }])
  303. // Naming no credential is the deliberately unauthenticated posture — a
  304. // named reference that resolved to nothing would have failed with
  305. // MISSING_CREDENTIAL long before this point. The route resolves as
  306. // configured and the protocol decides: pi-ai's OpenAI-compatible
  307. // implementation wants a key or an Authorization header of its own, and
  308. // says so instead of the harness guessing a placeholder.
  309. const ctx = await harness({
  310. providers: {
  311. 'local-llm': {
  312. api: 'openai-completions',
  313. baseURL: `${server.url}/v1`,
  314. models: [{ id: 'qwen3', contextWindow: 32_768, maxTokens: 2048 }],
  315. },
  316. },
  317. })
  318. const result = await assemble(ctx, { provider: 'local-llm', model: 'qwen3', messages: [] })
  319. expect(result.finish).toMatchObject({
  320. kind: 'error',
  321. failure: { message: 'No API key for provider: local-llm' },
  322. })
  323. expect(server.requests).toHaveLength(0)
  324. })
  325. it('authenticates an unauthenticated route through a configured header', async () => {
  326. const server = await mockServer([{ events: textEvents }])
  327. const ctx = await harness({
  328. providers: {
  329. 'local-llm': {
  330. api: 'openai-completions',
  331. baseURL: `${server.url}/v1`,
  332. headers: { Authorization: 'Bearer local' },
  333. models: [{ id: 'qwen3', contextWindow: 32_768, maxTokens: 2048 }],
  334. },
  335. },
  336. })
  337. const result = await assemble(ctx, { provider: 'local-llm', model: 'qwen3', messages: [] })
  338. expect(result.finish).toEqual({ kind: 'stop' })
  339. expect(server.headers[0]?.authorization).toBe('Bearer local')
  340. })
  341. it('rejects a capacity that is not a positive integer', () => {
  342. const declare = (model: LlmPiAi.PiAiModelProfile): (() => unknown) =>
  343. () => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models: [model] } })
  344. expect(declare({ id: 'm', contextWindow: 0, maxTokens: 1 })).toThrow(/contextWindow must be a positive integer/)
  345. expect(declare({ id: 'm', contextWindow: 1.5, maxTokens: 1 })).toThrow(/contextWindow must be a positive integer/)
  346. expect(declare({ id: 'm', contextWindow: 1, maxTokens: 0 })).toThrow(/maxTokens must be a positive integer/)
  347. expect(declare({ id: 'm', contextWindow: 1, maxTokens: 1.5 })).toThrow(/maxTokens must be a positive integer/)
  348. })
  349. it('names the route key when no displayName is configured', () => {
  350. const resolved = resolveProfiles({
  351. 'acme-gateway': {
  352. api: 'openai-completions',
  353. baseURL: 'https://acme.test',
  354. models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }],
  355. },
  356. })
  357. expect(resolved.get('acme-gateway')?.displayName).toBe('acme-gateway')
  358. expect(() => resolveProfiles({ 'acme-gateway': { displayName: '' } })).toThrow(/empty displayName/)
  359. })
  360. })
  361. describe('catalog routes with per-model configuration', () => {
  362. it('serves the installed catalog untouched when the profile lists no models', async () => {
  363. const server = await mockServer([])
  364. const ctx = await harness({ providers: { deepseek: { baseURL: server.url } } })
  365. const listed = await ctx.llm.listModels('deepseek')
  366. expect(listed.map(model => model.id).sort())
  367. .toEqual(getBuiltinModels('deepseek').map(model => model.id).sort())
  368. })
  369. it('overrides one catalog model field and defaults the rest from the catalog', async () => {
  370. const server = await mockServer([])
  371. const [catalogModel] = getBuiltinModels('deepseek')
  372. if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
  373. const ctx = await harness({
  374. providers: {
  375. deepseek: {
  376. baseURL: server.url,
  377. models: [{ id: catalogModel.id, contextWindow: 4096 }],
  378. },
  379. },
  380. })
  381. const info = await ctx.llm.resolveModelInfo('deepseek', catalogModel.id)
  382. // The configured field wins and the name still comes from the catalog. The
  383. // catalog's own output cap is the model's capability, not a cap anyone
  384. // chose, so it must not arrive as the request default.
  385. expect(info.context).toEqual({ contextWindow: 4096 })
  386. expect(info.name).toBe(catalogModel.name)
  387. expect(info.defaultMaxTokens).toBeUndefined()
  388. // An explicit list replaces the catalog rather than adding to it.
  389. expect((await ctx.llm.listModels('deepseek')).map(model => model.id)).toEqual([catalogModel.id])
  390. })
  391. it('materializes a request default only from a configured output cap', async () => {
  392. const server = await mockServer([])
  393. const [catalogModel] = getBuiltinModels('deepseek')
  394. if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
  395. const ctx = await harness({
  396. providers: {
  397. deepseek: {
  398. baseURL: server.url,
  399. models: [{ id: catalogModel.id, maxTokens: 4096 }],
  400. },
  401. },
  402. })
  403. // Configuring the cap is the deployment choosing one, so it becomes the
  404. // default the seam materializes into requests that name none.
  405. expect((await ctx.llm.resolveModelInfo('deepseek', catalogModel.id)).defaultMaxTokens).toBe(4096)
  406. })
  407. it('adds a model the installed catalog does not describe to a catalog route', async () => {
  408. const server = await mockServer([{ events: textEvents }])
  409. const ctx = await harness({
  410. providers: {
  411. deepseek: {
  412. apiKeyEnv: KEY_ENV,
  413. baseURL: `${server.url}/v1`,
  414. models: [{ id: 'deepseek-preview', contextWindow: 200_000, maxTokens: 8192 }],
  415. },
  416. },
  417. })
  418. const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-preview', messages: [] })
  419. expect(result.finish).toEqual({ kind: 'stop' })
  420. // The catalog route keeps its catalog protocol, so the new model reaches
  421. // the same endpoint shape the shipped models use.
  422. expect(server.paths).toEqual(['/v1/chat/completions'])
  423. })
  424. it('fails an unconfigured model id before any provider request', async () => {
  425. const server = await mockServer([])
  426. const ctx = await harness({
  427. providers: {
  428. deepseek: { baseURL: server.url, models: [{ id: 'deepseek-preview', contextWindow: 1, maxTokens: 1 }] },
  429. },
  430. })
  431. const result = await assemble(ctx, { provider: 'deepseek', model: 'not-configured', messages: [] })
  432. expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'UNKNOWN_MODEL' } })
  433. expect(server.requests).toHaveLength(0)
  434. })
  435. it('preserves catalog-only model metadata the profile cannot express', () => {
  436. // Some catalog models carry provider-required request headers; overriding a
  437. // capacity must not drop them, because configuration has no way to restate
  438. // them.
  439. const headered = (getBuiltinModels('nvidia') as { id: string; headers?: unknown }[])
  440. .find(model => model.headers !== undefined)
  441. if (headered === undefined) throw new Error('the installed catalog ships no nvidia model with headers')
  442. const resolved = resolveProfiles({
  443. nvidia: { models: [{ id: headered.id, contextWindow: 4096 }] },
  444. })
  445. const [model] = resolved.get('nvidia')?.piProvider.getModels() ?? []
  446. expect(model?.headers).toEqual(headered.headers)
  447. expect(model?.contextWindow).toBe(4096)
  448. })
  449. it('delegates both stream methods back to the reused catalog provider', async () => {
  450. const server = await mockServer([{ events: textEvents }, { events: textEvents }])
  451. const resolved = resolveProfiles({ deepseek: { baseURL: `${server.url}/v1` } })
  452. const built = resolved.get('deepseek')?.piProvider
  453. if (built === undefined) throw new Error('the deepseek route built no provider')
  454. const [model] = built.getModels()
  455. if (model === undefined) throw new Error('the deepseek route resolved no models')
  456. const context = { messages: [{ role: 'user' as const, content: 'hi', timestamp: 0 }] }
  457. // `stream` is interface-required and unused by the harness adapter, which
  458. // only calls `streamSimple`; both must still reach the catalog provider.
  459. for await (const _event of built.stream(model, context, { apiKey: 'k' })) { /* drain */ }
  460. for await (const _event of built.streamSimple(model, context, { apiKey: 'k' })) { /* drain */ }
  461. expect(server.paths).toEqual(['/v1/chat/completions', '/v1/chat/completions'])
  462. })
  463. it('keeps each model its own endpoint when the catalog route declares none', () => {
  464. // `opencode` ships no provider-level endpoint: the address lives on every
  465. // catalog model, so the route resolves without any configured baseURL.
  466. const resolved = resolveProfiles({ opencode: {} })
  467. const models = resolved.get('opencode')?.piProvider.getModels() ?? []
  468. expect(models.length).toBeGreaterThan(0)
  469. expect(models.every(model => model.baseUrl.length > 0)).toBe(true)
  470. expect(resolved.get('opencode')?.piProvider.baseUrl).toBeUndefined()
  471. })
  472. it('repoints a catalog route at another wire protocol without restating its endpoint', () => {
  473. const resolved = resolveProfiles({ openai: { api: 'openai-completions' } })
  474. const models = resolved.get('openai')?.piProvider.getModels() ?? []
  475. // The protocol changes for the whole route; each model keeps the catalog
  476. // endpoint it already had.
  477. expect(models.every(model => model.api === 'openai-completions')).toBe(true)
  478. expect(models.every(model => model.baseUrl === 'https://api.openai.com/v1')).toBe(true)
  479. })
  480. it('repoints a catalog route at another wire protocol', async () => {
  481. const server = await mockServer([{ events: textEvents }])
  482. const ctx = await harness({
  483. providers: {
  484. // openai's catalog models speak the Responses API; naming the protocol
  485. // explicitly moves the whole route onto Chat Completions.
  486. openai: {
  487. apiKeyEnv: KEY_ENV,
  488. api: 'openai-completions',
  489. baseURL: `${server.url}/v1`,
  490. models: [{ id: 'gpt-4.1', contextWindow: 100_000, maxTokens: 4096 }],
  491. },
  492. },
  493. })
  494. await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
  495. expect(server.paths).toEqual(['/v1/chat/completions'])
  496. })
  497. it('keeps the catalog provider’s own auth when the route repoints its protocol', () => {
  498. // Which environment a provider reads is a property of the provider, not of
  499. // the wire format its models speak: naming an api must not cost a profile
  500. // its provider-native discovery.
  501. const resolved = resolveProfiles({ openai: { api: 'openai-completions' } })
  502. expect(resolved.get('openai')?.piProvider.auth.apiKey?.name).toBe('OpenAI API key')
  503. })
  504. it('lets an OAuth-only catalog route authenticate with the key its profile names', async () => {
  505. // pi-ai honours a request's `apiKey` override only when the provider
  506. // declares an api-key method. `openai-codex` ships OAuth alone, so without
  507. // the harness method beside it the route refuses its own configured key as
  508. // `Provider is not configured` before any request goes out.
  509. const resolved = resolveProfiles({ 'openai-codex': { apiKeyEnv: 'CODEX_TOKEN' } })
  510. const provider = resolved.get('openai-codex')?.piProvider
  511. expect(provider?.auth.oauth).toBeDefined()
  512. const models = createModels()
  513. models.setProvider(provider as Provider)
  514. const model = provider?.getModels()[0] as Model<Api>
  515. const auth = await models.getAuth(model, { apiKey: 'codex-token' })
  516. expect(auth?.auth.apiKey).toBe('codex-token')
  517. })
  518. it('leaves an OAuth-only catalog route unconfigured when its profile names no key', () => {
  519. // Nothing to add: this adapter resolves credentials through its own seam
  520. // and holds no OAuth store, so declaring the provider configured would
  521. // trade a truthful refusal for an endpoint's 401.
  522. const resolved = resolveProfiles({ 'openai-codex': {} })
  523. expect(resolved.get('openai-codex')?.piProvider.auth.apiKey).toBeUndefined()
  524. })
  525. })
  526. describe('per-model reasoning efforts', () => {
  527. /** One hand-declared route holding exactly the given models. */
  528. function declared(models: LlmPiAi.PiAiModelProfile[]): Record<string, LlmPiAi.PiAiProviderProfile> {
  529. return { 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models } }
  530. }
  531. /** The first materialized model of one route, or throw. */
  532. function modelOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>, route = 'acme-gateway'): Model<Api> {
  533. const [model] = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? []
  534. if (model === undefined) throw new Error(`route "${route}" resolved no models`)
  535. return model
  536. }
  537. it('declares selectable levels with their wire spellings on a hand-declared model', () => {
  538. const model = modelOf(declared([{
  539. id: 'acme-think',
  540. reasoningEfforts: { off: null, low: 'low', high: 'high', max: 'ultra' },
  541. }]))
  542. expect(model.reasoning).toBe(true)
  543. // Undeclared levels are pinned null rather than left to pi-ai's own
  544. // defaulting, which is asymmetric: an absent key means "supported" for the
  545. // five base levels but "unsupported" for xhigh/max. A profile author
  546. // should not need to know that. Declared `off` with no value stays absent
  547. // from the map — supported, send nothing.
  548. expect(model.thinkingLevelMap).toEqual({
  549. minimal: null,
  550. medium: null,
  551. xhigh: null,
  552. low: 'low',
  553. high: 'high',
  554. max: 'ultra',
  555. })
  556. expect(getSupportedThinkingLevels(model)).toEqual(['off', 'low', 'high', 'max'])
  557. })
  558. it('keeps a declared off value in the map for dispatch to send', () => {
  559. const model = modelOf(declared([{ id: 'm', reasoningEfforts: { off: 'none', high: 'high' } }]))
  560. expect(model.thinkingLevelMap?.off).toBe('none')
  561. expect(getSupportedThinkingLevels(model)).toEqual(['off', 'high'])
  562. })
  563. it('offers exactly the declared keys: leaving off out makes thinking mandatory', () => {
  564. const model = modelOf(declared([{ id: 'm', reasoningEfforts: { high: 'high' } }]))
  565. expect(getSupportedThinkingLevels(model)).toEqual(['high'])
  566. })
  567. it('narrows a catalog model’s levels in place', () => {
  568. const [catalogModel] = getBuiltinModels('deepseek')
  569. if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
  570. expect(getSupportedThinkingLevels(catalogModel as Model<Api>)).toEqual(['off', 'high', 'max'])
  571. const model = modelOf({
  572. deepseek: { models: [{ id: catalogModel.id, reasoningEfforts: { off: null, high: 'high' } }] },
  573. }, 'deepseek')
  574. expect(getSupportedThinkingLevels(model)).toEqual(['off', 'high'])
  575. // Only the reasoning fields change; identity and capacities stay catalog.
  576. expect(model.name).toBe(catalogModel.name)
  577. expect(model.contextWindow).toBe(catalogModel.contextWindow)
  578. })
  579. it('strips reasoning from a catalog model with false', () => {
  580. const [catalogModel] = getBuiltinModels('deepseek')
  581. if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
  582. expect(catalogModel.reasoning).toBe(true)
  583. const model = modelOf({ deepseek: { models: [{ id: catalogModel.id, reasoningEfforts: false }] } }, 'deepseek')
  584. expect(model.reasoning).toBe(false)
  585. expect(getSupportedThinkingLevels(model)).toEqual(['off'])
  586. })
  587. it('inherits the catalog capability when the field is absent', () => {
  588. const [catalogModel] = getBuiltinModels('deepseek')
  589. if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
  590. const model = modelOf({ deepseek: { models: [{ id: catalogModel.id }] } }, 'deepseek')
  591. expect(model.reasoning).toBe(catalogModel.reasoning)
  592. expect(model.thinkingLevelMap).toEqual(catalogModel.thinkingLevelMap)
  593. })
  594. it('rejects a declaration that offers nothing or spells a level it cannot send', () => {
  595. const declare = (efforts: NonNullable<LlmPiAi.PiAiModelProfile['reasoningEfforts']>): (() => unknown) =>
  596. () => resolveProfiles(declared([{ id: 'm', reasoningEfforts: efforts }]))
  597. expect(declare({})).toThrow(/empty reasoningEfforts/)
  598. // A YAML `reasoningEfforts:` left valueless arrives as null through the
  599. // schema union; it declares nothing and is not a spelling of "inherit".
  600. expect(declare(null as never)).toThrow(/empty reasoningEfforts/)
  601. expect(declare({ off: null })).toThrow(/offers no level beyond "off"/)
  602. expect(declare({ off: 'none' })).toThrow(/offers no level beyond "off"/)
  603. expect(declare({ high: null })).toThrow(/only "off" may leave it empty/)
  604. expect(declare({ high: '' })).toThrow(/must not be an empty string/)
  605. })
  606. })
  607. describe('modelOverrides', () => {
  608. const deepseekModel = (): Model<Api> => {
  609. const [model] = getBuiltinModels('deepseek')
  610. if (model === undefined) throw new Error('the installed catalog ships no deepseek model')
  611. return model
  612. }
  613. it('reshapes one catalog model while the rest of the catalog keeps serving', () => {
  614. const catalogSize = getBuiltinModels('deepseek').length
  615. const target = deepseekModel()
  616. const resolved = resolveProfiles({
  617. deepseek: {
  618. modelOverrides: {
  619. [target.id]: {
  620. name: 'DeepSeek (proxied)',
  621. maxTokens: 4096,
  622. reasoningEfforts: { off: null, high: 'high' },
  623. },
  624. },
  625. },
  626. })
  627. const models = resolved.get('deepseek')?.piProvider.getModels() ?? []
  628. const reshaped = models.find(model => model.id === target.id)
  629. if (reshaped === undefined) throw new Error('the overridden model vanished from the route')
  630. // The whole catalog still serves — that is the difference from `models`,
  631. // which replaces it.
  632. expect(models).toHaveLength(catalogSize)
  633. expect(reshaped.name).toBe('DeepSeek (proxied)')
  634. expect(getSupportedThinkingLevels(reshaped)).toEqual(['off', 'high'])
  635. // An override's cap is explicit configuration, so it becomes the request
  636. // default exactly as a models entry's would.
  637. expect(resolved.get('deepseek')?.configuredMaxTokens.get(target.id)).toBe(4096)
  638. // A sibling the overrides do not name is byte-identical to the catalog.
  639. const sibling = models.find(model => model.id !== target.id)
  640. expect(sibling?.maxTokens).toBe(getBuiltinModels('deepseek').find(model => model.id === sibling?.id)?.maxTokens)
  641. })
  642. it('refuses every override that lands nowhere instead of skipping it', () => {
  643. expect(() => resolveProfiles({
  644. deepseek: { modelOverrides: { 'no-such-model': { name: 'ghost' } } },
  645. })).toThrow(/which the installed catalog does not describe/)
  646. expect(() => resolveProfiles({
  647. 'acme-gateway': {
  648. api: 'openai-completions',
  649. baseURL: 'https://acme.test',
  650. models: [{ id: 'm' }],
  651. modelOverrides: { m: { name: 'renamed' } },
  652. },
  653. })).toThrow(/a declared route spells every model out/)
  654. const declaredOnly = deepseekModel()
  655. expect(() => resolveProfiles({
  656. deepseek: {
  657. models: [{ id: declaredOnly.id }],
  658. modelOverrides: { [declaredOnly.id]: { name: 'renamed' } },
  659. },
  660. })).toThrow(/models already replaces the served catalog/)
  661. expect(() => resolveProfiles({
  662. deepseek: { modelOverrides: { '': { name: 'nameless' } } },
  663. })).toThrow(/empty model id/)
  664. // The dict key is the id; a value smuggling its own would quietly rename
  665. // the model it meant to customize. The schema passes unknown keys
  666. // through, so resolution is the boundary that refuses it — the variable
  667. // indirection mirrors that boundary by sidestepping the literal check.
  668. const smuggled = { name: 'x', id: 'other' }
  669. expect(() => resolveProfiles({
  670. deepseek: { modelOverrides: { [deepseekModel().id]: smuggled } },
  671. })).toThrow(/sets "id", which is the dict key/)
  672. })
  673. })
  674. describe('compat switches', () => {
  675. /** The materialized models of one route, keyed by id. */
  676. function modelsOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>, route: string): Map<string, Model<Api>> {
  677. const models = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? []
  678. return new Map(models.map(model => [model.id, model]))
  679. }
  680. it('applies route switches to every openai-completions model, entries winning per field', () => {
  681. const models = modelsOf({
  682. 'acme-gateway': {
  683. api: 'openai-completions',
  684. baseURL: 'https://acme.test',
  685. compat: { thinkingFormat: 'deepseek' },
  686. models: [
  687. { id: 'dialect-default', reasoningEfforts: { off: null, high: 'high' } },
  688. { id: 'dialect-odd', compat: { thinkingFormat: 'openai', supportsReasoningEffort: false } },
  689. ],
  690. },
  691. }, 'acme-gateway')
  692. expect(models.get('dialect-default')?.compat).toEqual({ thinkingFormat: 'deepseek' })
  693. expect(models.get('dialect-odd')?.compat).toEqual({ thinkingFormat: 'openai', supportsReasoningEffort: false })
  694. })
  695. it('merges the switches over the catalog entry’s own compat instead of replacing it', () => {
  696. const [catalogModel] = getBuiltinModels('deepseek')
  697. if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
  698. const inherited = catalogModel.compat as OpenAICompletionsCompat
  699. expect(inherited.requiresReasoningContentOnAssistantMessages).toBe(true)
  700. const models = modelsOf({
  701. deepseek: { models: [{ id: catalogModel.id, compat: { thinkingFormat: 'openai' } }] },
  702. }, 'deepseek')
  703. // The one switched field changes; the catalog's other quirks survive,
  704. // because configuration has no way to restate them.
  705. expect(models.get(catalogModel.id)?.compat).toEqual({ ...inherited, thinkingFormat: 'openai' })
  706. })
  707. it('skips models of other protocols on a mixed route instead of failing them', () => {
  708. // xai ships both completions and responses models, so a route-level switch
  709. // must land on the former without invalidating the latter.
  710. const catalog = getBuiltinModels('xai') as readonly Model<Api>[]
  711. const completions = catalog.find(model => model.api === 'openai-completions')
  712. const responses = catalog.find(model => model.api === 'openai-responses')
  713. if (completions === undefined || responses === undefined) throw new Error('xai no longer ships a mixed catalog')
  714. const models = modelsOf({
  715. xai: {
  716. compat: { supportsReasoningEffort: false },
  717. models: [{ id: completions.id }, { id: responses.id }],
  718. },
  719. }, 'xai')
  720. expect((models.get(completions.id)?.compat as OpenAICompletionsCompat).supportsReasoningEffort).toBe(false)
  721. expect(models.get(responses.id)?.compat).toEqual(responses.compat)
  722. })
  723. it('rejects a model-level switch on a protocol that has no such field, naming what it offers', () => {
  724. expect(() => resolveProfiles({
  725. anthropic: {
  726. models: [{ id: 'claude-sonnet-4-5', compat: { thinkingFormat: 'openai' } }],
  727. },
  728. })).toThrow(/its api is "anthropic-messages", which does not take it.*exists on openai-completions/s)
  729. })
  730. it('rejects route switches no model on the route can take', () => {
  731. expect(() => resolveProfiles({
  732. anthropic: { compat: { thinkingFormat: 'openai' } },
  733. })).toThrow(/no model on the route speaks a protocol that takes it/)
  734. })
  735. it('carries the developer-role switch onto a hand-declared reasoning model', () => {
  736. // pi-ai reads this switch only for a reasoning model, and detects it from
  737. // the endpoint URL — which for a private gateway answers as though it were
  738. // OpenAI itself, so the route must be able to say otherwise.
  739. const models = modelsOf({
  740. 'acme-gateway': {
  741. api: 'openai-completions',
  742. baseURL: 'https://acme.test',
  743. compat: { supportsDeveloperRole: false, maxTokensField: 'max_tokens' },
  744. models: [{ id: 'acme-think', reasoningEfforts: { off: null, high: 'high' } }],
  745. },
  746. }, 'acme-gateway')
  747. expect(models.get('acme-think')?.compat).toEqual({
  748. supportsDeveloperRole: false,
  749. maxTokensField: 'max_tokens',
  750. })
  751. })
  752. it('carries a switch both OpenAI protocols declare onto an openai-responses route', () => {
  753. const models = modelsOf({
  754. 'acme-responses': {
  755. api: 'openai-responses',
  756. baseURL: 'https://acme.test',
  757. compat: { supportsDeveloperRole: false },
  758. models: [{ id: 'acme-r', reasoningEfforts: { off: null, high: 'high' } }],
  759. },
  760. }, 'acme-responses')
  761. expect(models.get('acme-r')?.compat).toEqual({ supportsDeveloperRole: false })
  762. })
  763. it('carries an anthropic-only switch onto an anthropic-messages route', () => {
  764. const models = modelsOf({
  765. 'acme-claude': {
  766. api: 'anthropic-messages',
  767. baseURL: 'https://acme.test',
  768. compat: { supportsTemperature: false, supportsCacheControlOnTools: false },
  769. models: [{ id: 'acme-opus' }],
  770. },
  771. }, 'acme-claude')
  772. expect(models.get('acme-opus')?.compat).toEqual({
  773. supportsTemperature: false,
  774. supportsCacheControlOnTools: false,
  775. })
  776. })
  777. it('lands each route switch only on the models whose protocol declares it', () => {
  778. const catalog = getBuiltinModels('xai') as readonly Model<Api>[]
  779. const completions = catalog.find(model => model.api === 'openai-completions')
  780. const responses = catalog.find(model => model.api === 'openai-responses')
  781. if (completions === undefined || responses === undefined) throw new Error('xai no longer ships a mixed catalog')
  782. const models = modelsOf({
  783. xai: {
  784. // Both protocols take the first switch; only completions takes the second.
  785. compat: { supportsDeveloperRole: false, thinkingFormat: 'openai' },
  786. models: [{ id: completions.id }, { id: responses.id }],
  787. },
  788. }, 'xai')
  789. const onCompletions = models.get(completions.id)?.compat as OpenAICompletionsCompat
  790. expect(onCompletions.supportsDeveloperRole).toBe(false)
  791. expect(onCompletions.thinkingFormat).toBe('openai')
  792. const onResponses = models.get(responses.id)?.compat as { supportsDeveloperRole?: boolean; thinkingFormat?: string }
  793. expect(onResponses.supportsDeveloperRole).toBe(false)
  794. expect(onResponses.thinkingFormat).toBeUndefined()
  795. })
  796. it('carries chat-template kwargs beside the thinking format that dispatches through them', () => {
  797. const models = modelsOf({
  798. 'acme-qwen': {
  799. api: 'openai-completions',
  800. baseURL: 'https://acme.test',
  801. models: [{
  802. id: 'qwen-local',
  803. reasoningEfforts: { off: null, medium: 'medium' },
  804. compat: {
  805. thinkingFormat: 'qwen-chat-template',
  806. chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' } },
  807. },
  808. }],
  809. },
  810. }, 'acme-qwen')
  811. expect(models.get('qwen-local')?.compat).toEqual({
  812. thinkingFormat: 'qwen-chat-template',
  813. chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' } },
  814. })
  815. })
  816. it('rejects a model switch on an unrecognized protocol as having no configurable compat', () => {
  817. expect(() => resolveProfiles({
  818. 'acme-gateway': {
  819. api: 'acme-chat',
  820. baseURL: 'https://acme.test',
  821. models: [{ id: 'acme-a', compat: { supportsStore: false } }],
  822. },
  823. })).toThrow(/its api is "acme-chat", which does not take it.*"acme-chat" offers no configurable compat/s)
  824. })
  825. it('refuses a valueless compat key written through the composed settings path', async () => {
  826. // The write path an operator reaches: a section resolved by schemastery,
  827. // judged by this adapter's section validator before it is stored.
  828. // schemastery keeps the null, so nothing but that check stands between it
  829. // and `Model.compat`.
  830. const dir = await home()
  831. const ctx = await bootWithSettings(dir, {})
  832. await expect(ctx.settings.update(settingsNamespace('llm-pi-ai'), {
  833. providers: {
  834. 'acme-gateway': {
  835. api: 'openai-completions',
  836. baseURL: 'https://acme.test/v1',
  837. compat: { supportsDeveloperRole: null },
  838. models: [{ id: 'acme-a' }],
  839. },
  840. },
  841. })).rejects.toThrow(/compat "supportsDeveloperRole" with no value/)
  842. })
  843. it('carries a compat switch from a written settings section onto the wire', async () => {
  844. // End to end for the reported gap: the switch enters as configuration and
  845. // changes the request the provider receives, not merely the resolved model.
  846. vi.stubEnv(KEY_ENV, 'test-key')
  847. const server = await mockServer([{ events: textEvents }])
  848. const dir = await home()
  849. const ctx = await bootWithSettings(dir, {})
  850. await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
  851. providers: {
  852. 'acme-gateway': {
  853. apiKeyEnv: KEY_ENV,
  854. api: 'openai-completions',
  855. baseURL: `${server.url}/v1`,
  856. compat: { supportsDeveloperRole: false },
  857. models: [{ id: 'acme-think', reasoningEfforts: { off: null, high: 'high' } }],
  858. },
  859. },
  860. })
  861. await assemble(ctx, {
  862. provider: 'acme-gateway',
  863. model: 'acme-think',
  864. reasoningEffort: ReasoningEffortId('high'),
  865. system: 'you are a harness',
  866. messages: [],
  867. })
  868. const request = server.requests[0] as { messages: { role: string }[] }
  869. expect(request.messages.map(message => message.role)).toEqual(['system'])
  870. })
  871. it('refuses a valueless compat key rather than writing null over the catalog', () => {
  872. // schemastery passes a YAML bare key through as null. Carried forward it
  873. // would replace the installed entry's value, and pi-ai's `??` would then
  874. // reach for its baseURL detection — the "written but not applied" outcome.
  875. expect(() => resolveProfiles({
  876. 'acme-gateway': {
  877. api: 'openai-completions',
  878. baseURL: 'https://acme.test',
  879. compat: { supportsDeveloperRole: null } as never,
  880. models: [{ id: 'acme-a' }],
  881. },
  882. })).toThrow(/compat "supportsDeveloperRole" with no value/)
  883. })
  884. it('refuses a compat key whose value is undefined, as a cordis.yml entry can write', () => {
  885. // `!!js undefined` reaches the same state as a YAML bare key, and
  886. // schemastery keeps the key either way, so both are refused together.
  887. expect(() => resolveProfiles({
  888. 'acme-gateway': {
  889. api: 'openai-completions',
  890. baseURL: 'https://acme.test',
  891. compat: { supportsDeveloperRole: undefined } as never,
  892. models: [{ id: 'acme-a' }],
  893. },
  894. })).toThrow(/compat "supportsDeveloperRole" with no value/)
  895. })
  896. it('refuses a valueless compat key on a model entry too', () => {
  897. expect(() => resolveProfiles({
  898. deepseek: {
  899. modelOverrides: { 'deepseek-v4-flash': { compat: { requiresReasoningContentOnAssistantMessages: null } } as never },
  900. },
  901. })).toThrow(/model "deepseek-v4-flash" sets compat "requiresReasoningContentOnAssistantMessages" with no value/)
  902. })
  903. it('serves the Responses compat type on every protocol pi-ai gives it to', () => {
  904. // pi-ai types azure-openai-responses and openai-codex-responses with the
  905. // same OpenAIResponsesCompat, so a switch settable on one is settable on all.
  906. for (const route of ['azure-openai-responses', 'openai-codex']) {
  907. const models = modelsOf({ [route]: { compat: { supportsDeveloperRole: false } } }, route)
  908. const [first] = [...models.values()]
  909. expect((first?.compat as { supportsDeveloperRole?: boolean }).supportsDeveloperRole).toBe(false)
  910. }
  911. })
  912. it('serves the Bedrock compat type on its own protocol', () => {
  913. const models = modelsOf({ 'amazon-bedrock': { compat: { supportsStrictMode: false } } }, 'amazon-bedrock')
  914. const [first] = [...models.values()]
  915. expect((first?.compat as { supportsStrictMode?: boolean }).supportsStrictMode).toBe(false)
  916. })
  917. it('refuses a compat key no wire protocol declares instead of dropping it', () => {
  918. // Schemastery passes unknown keys through, so silently dropping one would
  919. // make an unreadable switch look applied; the resolver must refuse it.
  920. expect(() => resolveProfiles({
  921. 'acme-gateway': {
  922. api: 'openai-completions',
  923. baseURL: 'https://acme.test',
  924. compat: { supportsDevelperRole: false } as never,
  925. models: [{ id: 'acme-a' }],
  926. },
  927. })).toThrow(/compat "supportsDevelperRole", which no wire protocol declares; the configurable switches are .*\bsupportsDeveloperRole\b/)
  928. })
  929. it('refuses a compat key pi-ai’s catalog owns, pointing at the catalog route', () => {
  930. expect(() => resolveProfiles({
  931. 'acme-gateway': {
  932. api: 'openai-completions',
  933. baseURL: 'https://acme.test',
  934. models: [{ id: 'acme-a', compat: { openRouterRouting: {} } as never }],
  935. },
  936. })).toThrow(/compat "openRouterRouting", which is not configurable here/)
  937. })
  938. })
  939. describe('resolution snapshots', () => {
  940. it('finishes an in-flight request under the configuration it started with', async () => {
  941. const server = await mockServer([{ events: textEvents }])
  942. let current = resolveProfiles({ deepseek: { baseURL: `${server.url}/v1` } })
  943. let release: () => void = () => {}
  944. const held = new Promise<void>((resolve) => { release = resolve })
  945. const adapter = new PiAiAdapter({
  946. profiles: () => current,
  947. // Credential resolution is the real await inside a stream call, and the
  948. // window a configuration change has to land in.
  949. resolveApiKey: async () => { await held; return 'k' },
  950. auth: memoryAuth(),
  951. })
  952. const chunks: StreamChunk[] = []
  953. const inFlight = (async () => {
  954. for await (const chunk of adapter.stream({
  955. provider: 'deepseek',
  956. model: 'deepseek-v4-flash',
  957. messages: [],
  958. })) chunks.push(chunk)
  959. })()
  960. // The route set changes while the request waits, and something else reads
  961. // the adapter meanwhile, which is what would rebuild a shared collection.
  962. current = resolveProfiles({ openai: { baseURL: `${server.url}/v1` } })
  963. await expect(adapter.listModels('openai')).resolves.not.toHaveLength(0)
  964. release()
  965. await inFlight
  966. // The in-flight request keeps its own snapshot: it reaches the endpoint it
  967. // resolved against instead of failing on a provider that no longer exists.
  968. expect(chunks.at(-1)).toMatchObject({ type: 'finish', reason: { kind: 'stop' } })
  969. expect(server.paths).toEqual(['/v1/chat/completions'])
  970. })
  971. it('serves the next request from the new configuration', async () => {
  972. const first = await mockServer([{ events: textEvents }])
  973. const second = await mockServer([{ events: textEvents }])
  974. let current = resolveProfiles({ deepseek: { baseURL: `${first.url}/v1` } })
  975. const adapter = new PiAiAdapter({
  976. profiles: () => current,
  977. resolveApiKey: () => Promise.resolve('k'),
  978. auth: memoryAuth(),
  979. })
  980. const drain = async (): Promise<void> => {
  981. for await (const _chunk of adapter.stream({
  982. provider: 'deepseek', model: 'deepseek-v4-flash', messages: [],
  983. })) { /* drain */ }
  984. }
  985. await drain()
  986. current = resolveProfiles({ deepseek: { baseURL: `${second.url}/v1` } })
  987. await drain()
  988. expect(first.paths).toHaveLength(1)
  989. expect(second.paths).toHaveLength(1)
  990. })
  991. })
  992. describe('configurable-provider directory', () => {
  993. it('keeps the previous directory when a route collides with another adapter family', async () => {
  994. const dir = await home()
  995. const ctx = await bootWithSettings(dir, {})
  996. ctx.llm.registerConfigurableProviders([
  997. { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
  998. ])
  999. const before = ctx.llm.listConfigurableProviders().length
  1000. expect(before).toBeGreaterThan(30)
  1001. await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
  1002. providers: {
  1003. 'deepseek-official': {
  1004. api: 'openai-completions',
  1005. baseURL: 'https://acme.test/v1',
  1006. models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }],
  1007. },
  1008. },
  1009. })
  1010. // The refused swap costs a diagnostic, not the directory: every entry the
  1011. // page needs is still declared.
  1012. expect(ctx.llm.listConfigurableProviders()).toHaveLength(before)
  1013. expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'deepseek-official')?.settingsNs)
  1014. .toBe('llm-deepseek')
  1015. })
  1016. it('replaces its entries atomically as declared routes come and go', async () => {
  1017. const dir = await home()
  1018. const ctx = await bootWithSettings(dir, {})
  1019. const catalogOnly = ctx.llm.listConfigurableProviders().length
  1020. await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
  1021. providers: {
  1022. 'acme-gateway': {
  1023. displayName: 'Acme Gateway',
  1024. api: 'openai-completions',
  1025. baseURL: 'https://acme.test/v1',
  1026. models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }],
  1027. },
  1028. },
  1029. })
  1030. expect(ctx.llm.listConfigurableProviders()).toHaveLength(catalogOnly + 1)
  1031. expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'acme-gateway')?.displayName)
  1032. .toBe('Acme Gateway')
  1033. await ctx.settings.replace(settingsNamespace('llm-pi-ai'), {})
  1034. expect(ctx.llm.listConfigurableProviders()).toHaveLength(catalogOnly)
  1035. })
  1036. it('offers every installed catalog route, including one that only signs in', async () => {
  1037. const ctx = await harness({})
  1038. const offered = ctx.llm.listConfigurableProviders().map(entry => entry.provider)
  1039. // `openai-codex` is the one installed provider that authenticates through
  1040. // OAuth alone. It is offered like any other because the collection now
  1041. // carries a durable credential store and a login flow writes into it, so
  1042. // the route has a posture that works rather than only one that fails.
  1043. expect(offered).toContain('openai-codex')
  1044. expect(offered).toContain('anthropic')
  1045. expect(offered).toContain('openai')
  1046. })
  1047. it('lists a route a stored profile names as a catalog route, not a declared one', async () => {
  1048. // `declared` answers catalog membership, so a profile stored against a
  1049. // route pi-ai ships is not mislabelled as one this deployment invented.
  1050. const ctx = await harness({ providers: { 'openai-codex': { apiKeyEnv: KEY_ENV } } })
  1051. expect(ctx.llm.listConfigurableProviders()).toContainEqual({
  1052. provider: 'openai-codex',
  1053. displayName: 'openai-codex',
  1054. settingsNs: 'llm-pi-ai',
  1055. settingsPath: ['providers', 'openai-codex'],
  1056. declared: false,
  1057. })
  1058. })
  1059. })