Merge remote-tracking branch 'origin/master' into worktree/default-model-persistence

Carries two edits beyond conflict resolution, both forced by what master
brought in:

- `CustomProviderCard`: master added front-end key validation and a
  component-level `keyValue` (already trimmed) while still writing
  `apiKeyEnv` unconditionally. Kept this branch's blank-key rule and its
  committed-profile retry gate, and adopted master's single `keyValue` so
  the component has one spelling of the key rather than two.
- `docs/user/guide/providers`: master merged #1810, whose default-model
  section still taught overriding the `api-gateway` row in
  `$DSH_HOME/config.yaml` — the behavior this branch replaced. Rewritten
  for the settings section the picker now writes, plus the review fix from
  #1810 replacing the colloquial 挂着 in the opener.
This commit is contained in:
Yichen Jiang
2026-08-07 15:44:17 +08:00
91 changed files with 1940 additions and 117 deletions
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { 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/)
})
})
+46 -1
View File
@@ -1,6 +1,6 @@
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { userAgent } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
@@ -12,6 +12,9 @@ const servers: Server[] = []
const touchedEnv: string[] = []
afterEach(async () => {
// A no-op when the test never stubbed `fetch`; only 'probe key format'
// below installs one.
vi.unstubAllGlobals()
for (const name of touchedEnv.splice(0)) Reflect.deleteProperty(process.env, name)
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
})
@@ -311,3 +314,45 @@ describe('draft-provider model discovery', () => {
.rejects.toMatchObject({ code: 'NO_DISCOVERY' })
})
})
describe('probe key format', () => {
it('reports an illegal probe key as a credential fault, not an unreachable endpoint', async () => {
await expect(discoverModels({
baseURL: 'https://acme.test',
api: 'openai-completions',
apiKey: 'sk-\u{1F600}',
})).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' })
})
it('reports a blank probe key as a credential fault too', async () => {
// The Models page omits `apiKey` entirely for a cleared field rather than
// sending '', so this pins the contract for every other caller: a supplied
// key is judged, and only an absent one probes unauthenticated. '' means
// "I have a key" and is answered as the empty key it is.
await expect(discoverModels({
baseURL: 'https://acme.test',
api: 'openai-completions',
apiKey: '',
})).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' })
})
it('leaves a probe with no key unauthenticated', async () => {
// The file's other cases capture headers through a real local HTTP server
// (`listingServer`); this one has no route or stored key to resolve, so
// the smallest real double is a `fetch` stub, scoped to this test and
// unstubbed by the shared `afterEach` above.
const requests: RequestInit[] = []
vi.stubGlobal('fetch', async (_url: string | URL, init?: RequestInit) => {
requests.push(init ?? {})
return new Response(JSON.stringify({ data: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
})
await discoverModels({ baseURL: 'https://acme.test', api: 'openai-completions' })
const headers = new Headers(requests[0]?.headers)
expect(headers.has('authorization')).toBe(false)
})
})