Merge remote-tracking branch 'origin/master' into worktree/pi-ai-per-model-reasoning
# Conflicts: # docs/config-catalog.md # docs/user/guide/providers.i18n.yaml # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/README.zh.md # packages/llm/llm-pi-ai/tests/config.spec.ts
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
@@ -15,22 +15,32 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
async function harness(baseURL: string, overrides: Record<string, unknown> = {}): Promise<Context> {
|
||||
vi.stubEnv('PI_TEST_KEY', 'test-key')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: { deepseek: { apiKey: 'test-key', baseURL, ...overrides } },
|
||||
providers: { deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL, ...overrides } },
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Direct adapter over the real profile resolver, with literal-key resolution. */
|
||||
function adapterOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>): PiAiAdapter {
|
||||
/** Direct adapter over the real profile resolver, with a fixed key per call. */
|
||||
function adapterOf(
|
||||
providers: Record<string, LlmPiAi.PiAiProviderProfile>,
|
||||
apiKey: string | undefined = 'test-key',
|
||||
): PiAiAdapter {
|
||||
return new PiAiAdapter({
|
||||
profiles: () => resolveProfiles(providers),
|
||||
resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey),
|
||||
resolveApiKey: () => Promise.resolve(apiKey),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Configuration carries only the reference; these mounts resolve it from
|
||||
// the environment, which is the whole credential plane without a seam.
|
||||
vi.stubEnv('PI_TEST_KEY', 'test-key')
|
||||
})
|
||||
|
||||
describe('PiAiAdapter provider routing', () => {
|
||||
it('resolves a catalog model dynamically and uses a private endpoint', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
@@ -121,7 +131,7 @@ describe('PiAiAdapter provider routing', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['deepseek'], adapterOf({
|
||||
deepseek: { apiKey: 'test-key', baseURL: server.url },
|
||||
deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL: server.url },
|
||||
}))
|
||||
|
||||
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
@@ -131,7 +141,6 @@ describe('PiAiAdapter provider routing', () => {
|
||||
|
||||
it('names a route by its displayName, and by its own key once the profiles drop it', () => {
|
||||
const adapter = adapterOf({ 'acme-gateway': {
|
||||
apiKey: 'k',
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://acme.test/v1',
|
||||
@@ -167,7 +176,7 @@ describe('PiAiAdapter provider routing', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } },
|
||||
providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } },
|
||||
})
|
||||
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
|
||||
expect(result.finish.kind).toBe('error')
|
||||
@@ -187,7 +196,7 @@ describe('PiAiAdapter provider routing', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } },
|
||||
providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } },
|
||||
})
|
||||
|
||||
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
|
||||
@@ -203,7 +212,7 @@ describe('PiAiAdapter provider routing', () => {
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: 'test-key',
|
||||
apiKeyEnv: 'PI_TEST_KEY',
|
||||
baseURL: `${server.url}/api/projects/openai/openai/v1`,
|
||||
headers: { 'api-key': 'test-key', Authorization: '' },
|
||||
},
|
||||
@@ -406,7 +415,7 @@ describe('provider profile lifecycle', () => {
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKey: 'gw-key',
|
||||
apiKeyEnv: 'PI_TEST_KEY',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://acme.test/v1',
|
||||
reasoning: 'high',
|
||||
@@ -435,13 +444,14 @@ describe('provider profile lifecycle', () => {
|
||||
})
|
||||
|
||||
it('sends the declared wire spelling and refuses undeclared levels before network I/O', async () => {
|
||||
vi.stubEnv('PI_TEST_KEY', 'test-key')
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKey: 'gw-key',
|
||||
apiKeyEnv: 'PI_TEST_KEY',
|
||||
api: 'openai-completions',
|
||||
baseURL: `${server.url}/v1`,
|
||||
models: [{
|
||||
@@ -477,13 +487,14 @@ describe('provider profile lifecycle', () => {
|
||||
})
|
||||
|
||||
it('dispatches the compat-switched dialect on a declared route', async () => {
|
||||
vi.stubEnv('PI_TEST_KEY', 'test-key')
|
||||
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKey: 'gw-key',
|
||||
apiKeyEnv: 'PI_TEST_KEY',
|
||||
api: 'openai-completions',
|
||||
baseURL: `${server.url}/v1`,
|
||||
// Without the switch pi-ai guesses the dialect from the endpoint
|
||||
@@ -514,13 +525,14 @@ describe('provider profile lifecycle', () => {
|
||||
})
|
||||
|
||||
it('holds back reasoning_effort when the endpoint cannot take it', async () => {
|
||||
vi.stubEnv('PI_TEST_KEY', 'test-key')
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKey: 'gw-key',
|
||||
apiKeyEnv: 'PI_TEST_KEY',
|
||||
api: 'openai-completions',
|
||||
baseURL: `${server.url}/v1`,
|
||||
compat: { supportsReasoningEffort: false },
|
||||
@@ -546,7 +558,9 @@ describe('provider profile lifecycle', () => {
|
||||
it('accepts absent credentials for pi-ai ambient authentication', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url, { apiKey: undefined })
|
||||
// A profile that names no reference at all is the one case that defers to
|
||||
// pi-ai's own provider-native discovery.
|
||||
const ctx = await harness(server.url, { apiKeyEnv: undefined })
|
||||
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer ambient-key')
|
||||
})
|
||||
@@ -588,8 +602,6 @@ describe('provider profile lifecycle', () => {
|
||||
// loud with migration directions instead of half-working.
|
||||
expect(() => resolveProfiles([{ provider: 'openai' }] as never)).toThrow(/dict keyed by provider/)
|
||||
expect(() => resolveProfiles({ openai: { provider: 'openai' } as never })).toThrow(/moved to the providers dict key/)
|
||||
expect(() => resolveProfiles({ openai: { apiKey: '' } })).toThrow(/empty apiKey/)
|
||||
expect(() => resolveProfiles({ openai: { apiKey: ' ' } })).toThrow(/empty apiKey/)
|
||||
expect(() => resolveProfiles({ openai: { baseURL: '' } })).toThrow(/empty baseURL/)
|
||||
expect(() => resolveProfiles({ openai: { apiKeyEnv: 'not-a-var!' } })).toThrow(/must match/)
|
||||
})
|
||||
@@ -664,7 +676,7 @@ describe('abort wiring', () => {
|
||||
const message = Object.defineProperty({}, 'role', {
|
||||
get() { throw original },
|
||||
})
|
||||
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
|
||||
const adapter = adapterOf({ deepseek: {} })
|
||||
const drain = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({
|
||||
provider: 'deepseek',
|
||||
@@ -685,7 +697,7 @@ describe('abort wiring', () => {
|
||||
throw original
|
||||
},
|
||||
})
|
||||
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
|
||||
const adapter = adapterOf({ deepseek: {} })
|
||||
const drain = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({
|
||||
provider: 'deepseek',
|
||||
@@ -699,7 +711,7 @@ describe('abort wiring', () => {
|
||||
})
|
||||
|
||||
it('resolves catalog endpoints without an override before honoring pre-abort', async () => {
|
||||
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
|
||||
const adapter = adapterOf({ deepseek: {} })
|
||||
const controller = new AbortController()
|
||||
controller.abort('already stopped')
|
||||
const chunks = []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
@@ -19,7 +19,17 @@ import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
|
||||
|
||||
const homes: string[] = []
|
||||
|
||||
// Routes name their credential by reference; the value lives in the
|
||||
// environment, which is the layer the adapter falls back to without a
|
||||
// mounted credentials seam.
|
||||
const KEY_ENV = 'PI_TEST_KEY'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv(KEY_ENV, 'test-key')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
await closeMockServers()
|
||||
await Promise.all(homes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
@@ -46,7 +56,7 @@ function gateway(baseURL: string, overrides: Record<string, unknown> = {}): LlmP
|
||||
return {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKey: 'gw-key',
|
||||
apiKeyEnv: KEY_ENV,
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL,
|
||||
@@ -81,7 +91,8 @@ describe('hand-declared providers', () => {
|
||||
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
expect(result.finish).toEqual({ kind: 'stop' })
|
||||
expect(server.paths).toEqual(['/v1/chat/completions'])
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer gw-key')
|
||||
// The reference resolved through the environment and reached the wire.
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer test-key')
|
||||
})
|
||||
|
||||
it('lists and resolves the declared models rather than a catalog', async () => {
|
||||
@@ -114,7 +125,7 @@ describe('hand-declared providers', () => {
|
||||
|
||||
// A catalog route is unaffected: its models carry the metadata that makes
|
||||
// `off` actually disable thinking.
|
||||
const withCatalog = await harness({ providers: { deepseek: { apiKey: 'k', baseURL: server.url } } })
|
||||
const withCatalog = await harness({ providers: { deepseek: { baseURL: server.url } } })
|
||||
const [catalogModel] = getBuiltinModels('deepseek')
|
||||
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
||||
expect((await withCatalog.llm.resolveModelInfo('deepseek', catalogModel.id)).reasoning?.efforts.map(e => e.id))
|
||||
@@ -288,7 +299,7 @@ describe('hand-declared providers', () => {
|
||||
describe('catalog routes with per-model configuration', () => {
|
||||
it('serves the installed catalog untouched when the profile lists no models', async () => {
|
||||
const server = await mockServer([])
|
||||
const ctx = await harness({ providers: { deepseek: { apiKey: 'k', baseURL: server.url } } })
|
||||
const ctx = await harness({ providers: { deepseek: { baseURL: server.url } } })
|
||||
|
||||
const listed = await ctx.llm.listModels('deepseek')
|
||||
expect(listed.map(model => model.id).sort())
|
||||
@@ -302,7 +313,6 @@ describe('catalog routes with per-model configuration', () => {
|
||||
const ctx = await harness({
|
||||
providers: {
|
||||
deepseek: {
|
||||
apiKey: 'k',
|
||||
baseURL: server.url,
|
||||
models: [{ id: catalogModel.id, contextWindow: 4096 }],
|
||||
},
|
||||
@@ -327,7 +337,6 @@ describe('catalog routes with per-model configuration', () => {
|
||||
const ctx = await harness({
|
||||
providers: {
|
||||
deepseek: {
|
||||
apiKey: 'k',
|
||||
baseURL: server.url,
|
||||
models: [{ id: catalogModel.id, maxTokens: 4096 }],
|
||||
},
|
||||
@@ -344,7 +353,7 @@ describe('catalog routes with per-model configuration', () => {
|
||||
const ctx = await harness({
|
||||
providers: {
|
||||
deepseek: {
|
||||
apiKey: 'k',
|
||||
apiKeyEnv: KEY_ENV,
|
||||
baseURL: `${server.url}/v1`,
|
||||
models: [{ id: 'deepseek-preview', contextWindow: 200_000, maxTokens: 8192 }],
|
||||
},
|
||||
@@ -362,7 +371,7 @@ describe('catalog routes with per-model configuration', () => {
|
||||
const server = await mockServer([])
|
||||
const ctx = await harness({
|
||||
providers: {
|
||||
deepseek: { apiKey: 'k', baseURL: server.url, models: [{ id: 'deepseek-preview', contextWindow: 1, maxTokens: 1 }] },
|
||||
deepseek: { baseURL: server.url, models: [{ id: 'deepseek-preview', contextWindow: 1, maxTokens: 1 }] },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -390,7 +399,7 @@ describe('catalog routes with per-model configuration', () => {
|
||||
|
||||
it('delegates both stream methods back to the reused catalog provider', async () => {
|
||||
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
|
||||
const resolved = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${server.url}/v1` } })
|
||||
const resolved = resolveProfiles({ deepseek: { baseURL: `${server.url}/v1` } })
|
||||
const built = resolved.get('deepseek')?.piProvider
|
||||
if (built === undefined) throw new Error('the deepseek route built no provider')
|
||||
const [model] = built.getModels()
|
||||
@@ -431,7 +440,7 @@ describe('catalog routes with per-model configuration', () => {
|
||||
// openai's catalog models speak the Responses API; naming the protocol
|
||||
// explicitly moves the whole route onto Chat Completions.
|
||||
openai: {
|
||||
apiKey: 'k',
|
||||
apiKeyEnv: KEY_ENV,
|
||||
api: 'openai-completions',
|
||||
baseURL: `${server.url}/v1`,
|
||||
models: [{ id: 'gpt-4.1', contextWindow: 100_000, maxTokens: 4096 }],
|
||||
@@ -456,7 +465,7 @@ describe('catalog routes with per-model configuration', () => {
|
||||
// declares an api-key method. `openai-codex` ships OAuth alone, so without
|
||||
// the harness method beside it the route refuses its own configured key as
|
||||
// `Provider is not configured` before any request goes out.
|
||||
const resolved = resolveProfiles({ 'openai-codex': { apiKey: 'codex-token' } })
|
||||
const resolved = resolveProfiles({ 'openai-codex': { apiKeyEnv: 'CODEX_TOKEN' } })
|
||||
const provider = resolved.get('openai-codex')?.piProvider
|
||||
expect(provider?.auth.oauth).toBeDefined()
|
||||
const models = createModels()
|
||||
@@ -720,7 +729,7 @@ describe('reasoning-dispatch compat switches', () => {
|
||||
describe('resolution snapshots', () => {
|
||||
it('finishes an in-flight request under the configuration it started with', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
let current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${server.url}/v1` } })
|
||||
let current = resolveProfiles({ deepseek: { baseURL: `${server.url}/v1` } })
|
||||
let release: () => void = () => {}
|
||||
const held = new Promise<void>((resolve) => { release = resolve })
|
||||
const adapter = new PiAiAdapter({
|
||||
@@ -741,7 +750,7 @@ describe('resolution snapshots', () => {
|
||||
|
||||
// The route set changes while the request waits, and something else reads
|
||||
// the adapter meanwhile, which is what would rebuild a shared collection.
|
||||
current = resolveProfiles({ openai: { apiKey: 'k', baseURL: `${server.url}/v1` } })
|
||||
current = resolveProfiles({ openai: { baseURL: `${server.url}/v1` } })
|
||||
await expect(adapter.listModels('openai')).resolves.not.toHaveLength(0)
|
||||
release()
|
||||
await inFlight
|
||||
@@ -755,7 +764,7 @@ describe('resolution snapshots', () => {
|
||||
it('serves the next request from the new configuration', async () => {
|
||||
const first = await mockServer([{ events: textEvents }])
|
||||
const second = await mockServer([{ events: textEvents }])
|
||||
let current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${first.url}/v1` } })
|
||||
let current = resolveProfiles({ deepseek: { baseURL: `${first.url}/v1` } })
|
||||
const adapter = new PiAiAdapter({ profiles: () => current, resolveApiKey: () => Promise.resolve('k') })
|
||||
const drain = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({
|
||||
@@ -764,7 +773,7 @@ describe('resolution snapshots', () => {
|
||||
}
|
||||
|
||||
await drain()
|
||||
current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${second.url}/v1` } })
|
||||
current = resolveProfiles({ deepseek: { baseURL: `${second.url}/v1` } })
|
||||
await drain()
|
||||
|
||||
expect(first.paths).toHaveLength(1)
|
||||
@@ -786,7 +795,6 @@ describe('configurable-provider directory', () => {
|
||||
await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
|
||||
providers: {
|
||||
'deepseek-official': {
|
||||
apiKey: 'k',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://acme.test/v1',
|
||||
models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }],
|
||||
@@ -809,7 +817,6 @@ describe('configurable-provider directory', () => {
|
||||
await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKey: 'k',
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://acme.test/v1',
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Config, resolveProfiles } from '../src/config.ts'
|
||||
|
||||
describe('API key format', () => {
|
||||
it('trims a padded literal apiKey into the resolved profile', () => {
|
||||
const resolved = resolveProfiles({ openai: { apiKey: ' sk-abc ', baseURL: 'https://acme.test' } })
|
||||
expect(resolved.get('openai')?.apiKey).toBe('sk-abc')
|
||||
})
|
||||
|
||||
it('keeps an omitted apiKey absent so ambient authentication still applies', () => {
|
||||
const resolved = resolveProfiles({ openai: { baseURL: 'https://acme.test' } })
|
||||
expect(resolved.get('openai')?.apiKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still tells an empty apiKey to omit itself', () => {
|
||||
expect(() => resolveProfiles({ openai: { apiKey: ' ', baseURL: 'https://acme.test' } }))
|
||||
.toThrow(/omit it to use ambient authentication/)
|
||||
})
|
||||
|
||||
it('rejects an apiKey no header can carry', () => {
|
||||
expect(() => resolveProfiles({ openai: { apiKey: 'sk-\u{1F600}', baseURL: 'https://acme.test' } }))
|
||||
.toThrow(/no HTTP header can carry/)
|
||||
})
|
||||
})
|
||||
import { Config } from '../src/config.ts'
|
||||
|
||||
describe('reasoning schema boundary', () => {
|
||||
const configWith = (model: Record<string, unknown>): (() => unknown) =>
|
||||
|
||||
@@ -44,7 +44,7 @@ async function boot(dir: string, config: LlmPiAi.Config): Promise<Context> {
|
||||
})
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false })
|
||||
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
|
||||
await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
|
||||
await ctx.plugin(LlmPiAi, config)
|
||||
return ctx
|
||||
}
|
||||
@@ -53,7 +53,11 @@ describe('request-level dynamic profiles', () => {
|
||||
it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => {
|
||||
vi.stubEnv('PI_DYNAMIC_KEY', '')
|
||||
const dir = await home()
|
||||
await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-from-settings\n')
|
||||
await writeFile(
|
||||
join(dir, '.credentials.yaml'),
|
||||
'PI_DYNAMIC_KEY: pk-from-settings\nPI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
// The exact product posture: `- id: llm-pi-ai` with no config at all.
|
||||
const ctx = await boot(dir, {})
|
||||
@@ -87,14 +91,19 @@ describe('request-level dynamic profiles', () => {
|
||||
|
||||
it('adds a provider route from settings and drops it when the user layer resets', async () => {
|
||||
const dir = await home()
|
||||
await writeFile(
|
||||
join(dir, '.credentials.yaml'),
|
||||
'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await boot(dir, {
|
||||
providers: { openai: { apiKey: 'k', baseURL: 'http://127.0.0.1:1/v1' } },
|
||||
providers: { openai: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: 'http://127.0.0.1:1/v1' } },
|
||||
})
|
||||
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
|
||||
await ctx.settings.update(NS, {
|
||||
providers: { deepseek: { apiKey: 'live-key', baseURL: server.url } },
|
||||
providers: { deepseek: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: server.url } },
|
||||
})
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek'])
|
||||
|
||||
@@ -113,7 +122,7 @@ describe('request-level dynamic profiles', () => {
|
||||
it('rotates the per-request credential referenced by apiKeyEnv', async () => {
|
||||
vi.stubEnv('PI_DYNAMIC_KEY', '')
|
||||
const dir = await home()
|
||||
await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-one\n')
|
||||
await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n', { mode: 0o600 })
|
||||
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
|
||||
const ctx = await boot(dir, {
|
||||
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
|
||||
@@ -162,15 +171,20 @@ describe('request-level dynamic profiles', () => {
|
||||
|
||||
it('keeps serving its routes when a settings-born route collides with another adapter', async () => {
|
||||
const dir = await home()
|
||||
await writeFile(
|
||||
join(dir, '.credentials.yaml'),
|
||||
'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
|
||||
const ctx = await boot(dir, { providers: { openai: { apiKey: 'pk', baseURL: `${server.url}/v1` } } })
|
||||
const ctx = await boot(dir, { providers: { openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` } } })
|
||||
// Another adapter owns `anthropic`; the registry must refuse to hand it over.
|
||||
ctx.llm.registerAdapter(['anthropic'], new StubAdapter())
|
||||
|
||||
await ctx.settings.update(NS, {
|
||||
providers: {
|
||||
openai: { apiKey: 'pk', baseURL: `${server.url}/v1` },
|
||||
anthropic: { apiKey: 'other' },
|
||||
openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` },
|
||||
anthropic: { apiKeyEnv: 'PI_OTHER_KEY' },
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* settings-local, credentials-local, and a bare `llm-pi-ai` row boot from a
|
||||
* test-only cordis.yml through the actual Loader + Include path, an external
|
||||
* edit of settings.yaml registers the route live, and the next request
|
||||
* carries the credential the .env supplies. A hand-mounted `ctx.plugin` cannot
|
||||
* carries the credential the credentials document supplies. A hand-mounted `ctx.plugin` cannot
|
||||
* catch Loader export-shape failures, which is why the twin adapter has the
|
||||
* same guard.
|
||||
*/
|
||||
@@ -40,7 +40,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-'))
|
||||
const settingsPath = join(root, 'settings.yaml')
|
||||
await writeFile(settingsPath, '# personal settings\n')
|
||||
await writeFile(join(root, '.env'), 'PI_COMPOSITION_KEY=key-from-store\n')
|
||||
await writeFile(join(root, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 })
|
||||
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
@@ -54,7 +54,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }
|
||||
'- id: credentials',
|
||||
" name: '@deepseek-ai/dsh-credentials-local'",
|
||||
' config:',
|
||||
` path: ${JSON.stringify(join(root, '.env'))}`,
|
||||
` path: ${JSON.stringify(join(root, '.credentials.yaml'))}`,
|
||||
' debounceMs: 10',
|
||||
'- id: llm-pi-ai',
|
||||
" name: '@deepseek-ai/dsh-llm-pi-ai'",
|
||||
|
||||
@@ -21,7 +21,6 @@ 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 }],
|
||||
|
||||
Reference in New Issue
Block a user