fix(host): read the balance facts through both settings generations
The 0.1.7 line's SettingsForms service retired the `get(ns)` section read, so the balance route never resolved the DeepSeek API key on V4+ deployments and the capsule stayed hidden. deepseekSectionOf now folds over whichever face the running line serves: the registered `llm-deepseek` section through V3, and on V4+ the Config-form `describe()` projection, matching the provider's row by its own apiKeyEnv-bearing section shape and preferring the settings ids the generations have served it as. Rows fold in isolation, and a describe that throws is no section — the capsule still arms nowhere it cannot. The per-baseline `balance` probe pins the settings face and the provider row's sources against each tag's real sources.
This commit is contained in:
1 parent
7ce259384f
commit
423ea4f2ac
5 files changed
+277
-13
No files matched your search
@@ -73,7 +73,7 @@ The File Activity card's file-name affordance is an optional-column story: on a
|
||||
|
||||
The session jump — a Context Dashboard session card or an Agent network node bringing its session forward — rides the harness's own selection verb (`openSessionVia` in `src/client/services.ts`): `uiWorkspace.openSession`, the verb the built-in sidebar's row click uses on every supported line, with the sessions service's retired `open(id)` spelling kept as the degradation path for a composition without the workspace module. Both faces are re-proved per call and a deployment serving neither (or a hostile face) swallows silently — the panel still closes, the jump just does not land. Issue #90 was exactly the retired-spelling trap: the plugin called `sessions.open` alone, which the 0.1.6 selection refactor removed from the service (the click then did nothing on `0.1.7-rc.2`), so the jump now follows the view owner first and the per-baseline `sessionNav` probe pins both spellings presence-as-declared.
|
||||
|
||||
The Context Dashboard's DeepSeek balance capsule is an all-optional stack, so it arms nowhere it cannot: the host reaches the `settings` and `credentials` services only inside a deferred `ctx.inject` (and re-reads them per request, so a key added while the host runs is picked up on the next open), mounts `/api/dsh-context/balance` through the same Connection exact-route registry as the detail and backfill routes, and the outbound `/user/balance` read carries the key only inside the host process. The capsule appears only on a live, well-formed answer; a deployment whose llm-deepseek provider is not composed (no `llm-deepseek` settings section), whose credentials service cannot resolve the key, whose connection service lacks the exact-route registry, or whose platform read fails (network, non-2xx, malformed payload, a third-party endpoint that does not serve the balance path) answers a typed `null` and the browser renders nothing — no pill, no spinner, no error state.
|
||||
The Context Dashboard's DeepSeek balance capsule is an all-optional stack, so it arms nowhere it cannot: the host reaches the `settings` and `credentials` services only inside a deferred `ctx.inject` (and re-reads them per request, so a key added while the host runs is picked up on the next open), mounts `/api/dsh-context/balance` through the same Connection exact-route registry as the detail and backfill routes, and the outbound `/user/balance` read carries the key only inside the host process. The provider's settings row is read through whichever face the running line serves: the `get(ns)` registered-section read through V3 (the `llm-deepseek` namespace), the Config-form `describe()` projection on V4+ — there the row is the entry whose served value declares the top-level `apiKeyEnv` credential ref, preferred under the settings ids the generations have served it as (`llm-deepseek` on product profiles, `llm-deepseek-api-key` elsewhere). The capsule appears only on a live, well-formed answer; a deployment whose DeepSeek API-key provider is not composed (no settings row on either face), whose credentials service cannot resolve the key, whose connection service lacks the exact-route registry, or whose platform read fails (network, non-2xx, malformed payload, a third-party endpoint that does not serve the balance path) answers a typed `null` and the browser renders nothing — no pill, no spinner, no error state. The `balance` probe pins the settings face and the provider row's sources per baseline.
|
||||
|
||||
## What each check means
|
||||
|
||||
|
||||
+58
-12
@@ -5,13 +5,13 @@
|
||||
* The platform exposes exactly one account endpoint (`GET /user/balance`);
|
||||
* reaching it needs the API key, which by design never rides to the browser,
|
||||
* so the HOST reads it and serves the redacted figures. The connection facts
|
||||
* resolve per request, exactly as llm-deepseek serves its own requests: its
|
||||
* settings section (`llm-deepseek`, installed only when that provider
|
||||
* composes) carries the credential ref and the optional endpoint override,
|
||||
* and the credentials service resolves the ref to the key. Any missing fact
|
||||
* — the provider absent, no settings service, no key — answers a typed
|
||||
* `null`, as does a failed or malformed platform read: the capsule renders
|
||||
* nothing rather than a stale figure.
|
||||
* resolve per request, exactly as the DeepSeek provider serves its own
|
||||
* requests: its settings row carries the credential ref and the optional
|
||||
* endpoint override, read through whichever face the running line serves
|
||||
* (deepseekSectionOf), and the credentials service resolves the ref to the
|
||||
* key. Any missing fact — the provider absent, no settings service, no key —
|
||||
* answers a typed `null`, as does a failed or malformed platform read: the
|
||||
* capsule renders nothing rather than a stale figure.
|
||||
*
|
||||
* The transport is Connection's fetch-route registry (the same authenticated
|
||||
* `/api` fence host/detail.ts mounts), registered through a deferred inject
|
||||
@@ -28,7 +28,9 @@ import type { PlatformBalance, PlatformBalanceEntry } from '../shared/types'
|
||||
/** The plugin's balance route, under the authenticated `/api` fence. */
|
||||
export const BALANCE_ROUTE = '/api/dsh-context/balance'
|
||||
|
||||
/** The settings namespace llm-deepseek installs its connection section under. */
|
||||
/** The settings id the DeepSeek API-key provider has served its connection
|
||||
* section under: the V3 registered namespace, kept as the entry id on V4+
|
||||
* product profiles. */
|
||||
const DEEPSEEK_SETTINGS_NS = 'llm-deepseek'
|
||||
|
||||
/** llm-deepseek's default credential ref (the env-var name its section resolves). */
|
||||
@@ -40,9 +42,15 @@ const PUBLIC_BASE_URL = 'https://api.deepseek.com'
|
||||
/** One platform read's whole budget — never worth blocking the route longer. */
|
||||
const FETCH_TIMEOUT_MS = 10_000
|
||||
|
||||
/** The harness `settings` service, as far as the route consumes it. */
|
||||
/**
|
||||
* The harness `settings` service, as far as the route consumes it. The face
|
||||
* is generation-specific: V3 reads a registered section back (`get`), while
|
||||
* V4+ retired that face and projects every configurable entry instead
|
||||
* (`describe`) — the route folds over whichever the running line serves.
|
||||
*/
|
||||
interface SettingsHostFace {
|
||||
get?(ns: string): unknown
|
||||
describe?(): unknown
|
||||
}
|
||||
|
||||
/** The harness `credentials` service, as far as the route consumes it. */
|
||||
@@ -78,15 +86,53 @@ function amountOf(value: unknown): number | null {
|
||||
/** The connection facts one read needs, as far as runtime can prove them. */
|
||||
interface DeepSeekFacts { baseUrl: string; apiKey: string }
|
||||
|
||||
/**
|
||||
* The DeepSeek API-key provider's settings section, read through whichever
|
||||
* face the running line serves. V3's `get` reads the registered
|
||||
* `llm-deepseek` section directly; V4+ retired that face, so its `describe()`
|
||||
* projection is folded instead — the provider's row is the one whose served
|
||||
* value declares the top-level `apiKeyEnv` credential ref (the volatile shape
|
||||
* only that provider declares at the section root), preferred under the entry
|
||||
* ids the generations have served it as (`llm-deepseek` on product profiles,
|
||||
* `llm-deepseek-api-key` elsewhere). Rows fold in isolation — a hostile row
|
||||
* drops whole, valid siblings keep serving — and a describe that throws is no
|
||||
* section at all.
|
||||
*/
|
||||
function deepseekSectionOf(ctx: Context): Record<string, unknown> | null {
|
||||
const settings = ctx.get('settings') as SettingsHostFace | undefined
|
||||
const section = typeof settings?.get === 'function' ? asRecord(settings.get(DEEPSEEK_SETTINGS_NS)) : null
|
||||
if (section !== null) return section
|
||||
if (typeof settings?.describe !== 'function') return null
|
||||
try {
|
||||
const rows = settings.describe()
|
||||
if (!Array.isArray(rows)) return null
|
||||
let shaped: Record<string, unknown> | null = null
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const record = asRecord(row)
|
||||
if (record === null) continue
|
||||
const value = asRecord(record.value)
|
||||
if (value === null || typeof value.apiKeyEnv !== 'string' || value.apiKeyEnv === '') continue
|
||||
if (record.ns === DEEPSEEK_SETTINGS_NS || record.ns === 'llm-deepseek-api-key') return value
|
||||
shaped ??= value
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return shaped
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the DeepSeek connection facts exactly as llm-deepseek serves its
|
||||
* requests: its settings section for the credential ref and endpoint, the
|
||||
* requests: its settings row for the credential ref and endpoint, the
|
||||
* credentials service for the key. `null` whenever any fact is missing —
|
||||
* the platform is simply not configured for this deployment.
|
||||
*/
|
||||
async function resolveFacts(ctx: Context): Promise<DeepSeekFacts | null> {
|
||||
const settings = ctx.get('settings') as SettingsHostFace | undefined
|
||||
const section = typeof settings?.get === 'function' ? asRecord(settings.get(DEEPSEEK_SETTINGS_NS)) : null
|
||||
const section = deepseekSectionOf(ctx)
|
||||
if (section === null) return null
|
||||
const apiKeyEnv = typeof section.apiKeyEnv === 'string' && section.apiKeyEnv !== ''
|
||||
? section.apiKeyEnv
|
||||
|
||||
@@ -170,6 +170,26 @@ export interface Baseline {
|
||||
transportFile: string
|
||||
transportPresent: boolean
|
||||
}
|
||||
/**
|
||||
* The balance capsule's host seam (src/host/balance.ts). The facts fold
|
||||
* reads the DeepSeek API-key provider's settings row through whichever face
|
||||
* the line serves: the `get(ns)` registered-section read through V3, the
|
||||
* Config-form `describe()` projection on V4+ — where the provider row is
|
||||
* the entry whose served value declares the top-level `apiKeyEnv`
|
||||
* credential ref, preferred under the settings ids the generations have
|
||||
* served it as. Each face is probed against the tag's real sources.
|
||||
*/
|
||||
balance: {
|
||||
/** The settings service source the face needles are asserted in. */
|
||||
settingsFile: string
|
||||
/** The `get(ns)` section read — served through V3, retired on V4+. */
|
||||
settingsGetNeedle: string
|
||||
settingsGetPresent: boolean
|
||||
/** The Config-form projection needle, when this line serves one the fold reads. */
|
||||
settingsDescribeNeedle: string | undefined
|
||||
/** The provider sources carrying the plugin id and the section shape the fold matches. */
|
||||
providerFiles: readonly { file: string; needles: readonly string[] }[]
|
||||
}
|
||||
}
|
||||
|
||||
export const BASELINES: readonly Baseline[] = [
|
||||
@@ -245,6 +265,18 @@ export const BASELINES: readonly Baseline[] = [
|
||||
transportFile: 'packages/client/ui-settings/src/client/settings-scope.ts',
|
||||
transportPresent: true,
|
||||
},
|
||||
balance: {
|
||||
settingsFile: 'packages/settings/settings/src/index.ts',
|
||||
settingsGetNeedle: 'get<const Namespace extends string>(ns:',
|
||||
settingsGetPresent: true,
|
||||
settingsDescribeNeedle: undefined,
|
||||
providerFiles: [
|
||||
{
|
||||
file: 'packages/llm/llm-deepseek/src/index.ts',
|
||||
needles: ["export const name = 'llm-deepseek'", "apiKeyEnv: z.string().role('credential-ref')"],
|
||||
},
|
||||
],
|
||||
},
|
||||
stepGuard: {
|
||||
loopFile: 'packages/core/agent-loop/src/agent.ts',
|
||||
loopNeedles: ["'agent/pre-step'", "append('user/message'"],
|
||||
@@ -331,6 +363,26 @@ export const BASELINES: readonly Baseline[] = [
|
||||
transportFile: 'packages/client/ui-settings/src/client/config-form.ts',
|
||||
transportPresent: true,
|
||||
},
|
||||
balance: {
|
||||
settingsFile: 'packages/settings/settings/src/index.ts',
|
||||
settingsGetNeedle: 'get<const Namespace extends string>(ns:',
|
||||
settingsGetPresent: false,
|
||||
settingsDescribeNeedle: 'describe(options?: SettingsDescribeOptions)',
|
||||
providerFiles: [
|
||||
{
|
||||
file: 'packages/llm/llm-deepseek-api-key/src/index.ts',
|
||||
needles: ["export const name = 'llm-deepseek-api-key'"],
|
||||
},
|
||||
{
|
||||
file: 'packages/llm/llm-deepseek-api-key/src/config.ts',
|
||||
needles: ["apiKeyEnv: z.string().role('credential-ref').default('DEEPSEEK_API_KEY').volatile()"],
|
||||
},
|
||||
{
|
||||
file: 'packages/llm/llm-deepseek/src/config.ts',
|
||||
needles: ['baseURL: z.string().volatile()'],
|
||||
},
|
||||
],
|
||||
},
|
||||
stepGuard: {
|
||||
loopFile: 'packages/core/agent-loop/src/agent.ts',
|
||||
loopNeedles: ["'agent/pre-step'", "append('user/message'"],
|
||||
|
||||
@@ -115,6 +115,33 @@ describe.skipIf(reasons.length > 0)('compat matrix — real dsh sources per base
|
||||
assert.equal(pattern.test('dsh-context'), true)
|
||||
})
|
||||
|
||||
test('balance: the settings face and the DeepSeek provider row the facts fold reads', () => {
|
||||
// The balance route resolves the provider's settings row through the
|
||||
// face this generation serves — the `get(ns)` section read through V3,
|
||||
// the Config-form `describe()` projection on V4+ (the row matched by
|
||||
// its own apiKeyEnv-bearing section shape). Presence asserted both
|
||||
// ways, so a moved seam names itself instead of silently hiding the
|
||||
// capsule.
|
||||
const balance = baseline.balance
|
||||
assert.equal(
|
||||
staging.dshHasString(baseline.tag, balance.settingsGetNeedle, balance.settingsFile),
|
||||
balance.settingsGetPresent,
|
||||
'the settings service get face',
|
||||
)
|
||||
if (balance.settingsDescribeNeedle !== undefined) {
|
||||
assert.equal(
|
||||
staging.dshHasString(baseline.tag, balance.settingsDescribeNeedle, balance.settingsFile),
|
||||
true,
|
||||
'the settings service describe face',
|
||||
)
|
||||
}
|
||||
for (const provider of balance.providerFiles) {
|
||||
for (const needle of provider.needles) {
|
||||
assert.equal(staging.dshHasString(baseline.tag, needle, provider.file), true, `provider seam: ${needle}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for (const slot of staging.SLOT_SEAMS) {
|
||||
test(`client: slot "${slot}" exists`, () => {
|
||||
assert.equal(staging.dshHasString(baseline.tag, slot, 'packages/client/*/src/**'), true)
|
||||
|
||||
@@ -69,6 +69,16 @@ function settingsOf(section: unknown): { get(ns: string): unknown } {
|
||||
return { get: (ns: string) => (ns === 'llm-deepseek' ? section : undefined) }
|
||||
}
|
||||
|
||||
/** The V4+ settings face projecting one row per configurable entry (`throws` fails the read). */
|
||||
function settingsDescribeOf(rows: unknown, throws = false): { describe(): unknown } {
|
||||
return {
|
||||
describe() {
|
||||
if (throws) throw new Error('hostile describe')
|
||||
return rows
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The harness credentials face serving one resolved value. */
|
||||
function credentialsOf(value: unknown, calls?: string[]): { resolve(ref: string): Promise<{ value: unknown } | undefined> } {
|
||||
return {
|
||||
@@ -282,6 +292,135 @@ describe('balance route outcomes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings faces across generations', () => {
|
||||
test('the V4+ describe face serves the facts by the provider row\'s own shape', async () => {
|
||||
const refs: string[] = []
|
||||
const { ctx, captured } = ctxOf({
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
settings: settingsDescribeOf([
|
||||
{ ns: 'ui-theme', value: { preference: 'light' } },
|
||||
{ ns: 'llm-pi-ai', value: { providers: { tokener: { apiKeyEnv: 'TOKENER_API_KEY' } } } },
|
||||
{ ns: 'llm-deepseek-api-key', value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://proxy.test///', thinking: 'enabled' } },
|
||||
]),
|
||||
credentials: credentialsOf('sk-v4', refs),
|
||||
})
|
||||
watchBalanceChannel(ctx)
|
||||
const { log } = stubPlatform(CNY_BALANCE)
|
||||
await serve(captured)
|
||||
assert.deepEqual(refs, ['DEEPSEEK_API_KEY'])
|
||||
assert.equal(log[0]?.url, 'https://proxy.test/user/balance')
|
||||
assert.equal(log[0]?.auth, 'Bearer sk-v4')
|
||||
})
|
||||
|
||||
test('a provider row under an unknown entry id serves by shape; the first shape wins', async () => {
|
||||
const refs: string[] = []
|
||||
const { ctx, captured } = ctxOf({
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
settings: settingsDescribeOf([
|
||||
{ ns: 'renamed-a', value: { apiKeyEnv: 'FIRST_KEY' } },
|
||||
{ ns: 'renamed-b', value: { apiKeyEnv: 'SECOND_KEY' } },
|
||||
]),
|
||||
credentials: credentialsOf('sk-shape', refs),
|
||||
})
|
||||
watchBalanceChannel(ctx)
|
||||
const { log } = stubPlatform(CNY_BALANCE)
|
||||
await serve(captured)
|
||||
assert.deepEqual(refs, ['FIRST_KEY'])
|
||||
assert.equal(log[0]?.url, 'https://api.deepseek.com/user/balance')
|
||||
})
|
||||
|
||||
test('the served entry ids win over an earlier shape-only row', async () => {
|
||||
const refs: string[] = []
|
||||
const { ctx, captured } = ctxOf({
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
settings: settingsDescribeOf([
|
||||
{ ns: 'some-provider', value: { apiKeyEnv: 'OTHER_KEY' } },
|
||||
{ ns: 'llm-deepseek', value: { apiKeyEnv: 'PRODUCT_KEY' } },
|
||||
]),
|
||||
credentials: credentialsOf('sk-product', refs),
|
||||
})
|
||||
watchBalanceChannel(ctx)
|
||||
stubPlatform(CNY_BALANCE)
|
||||
await serve(captured)
|
||||
assert.deepEqual(refs, ['PRODUCT_KEY'])
|
||||
})
|
||||
|
||||
test('a V3 section that reads absent falls through to the describe face', async () => {
|
||||
const refs: string[] = []
|
||||
const { ctx, captured } = ctxOf({
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
settings: {
|
||||
get: () => undefined,
|
||||
describe: () => [{ ns: 'llm-deepseek-api-key', value: { apiKeyEnv: 'FALLBACK_KEY' } }],
|
||||
},
|
||||
credentials: credentialsOf('sk-fallback', refs),
|
||||
})
|
||||
watchBalanceChannel(ctx)
|
||||
stubPlatform(CNY_BALANCE)
|
||||
await serve(captured)
|
||||
assert.deepEqual(refs, ['FALLBACK_KEY'])
|
||||
})
|
||||
|
||||
test('the V3 section wins when both faces are served', async () => {
|
||||
const refs: string[] = []
|
||||
const { ctx, captured } = ctxOf({
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
settings: {
|
||||
get: (ns: string) => (ns === 'llm-deepseek' ? { apiKeyEnv: 'V3_KEY' } : undefined),
|
||||
describe: () => [{ ns: 'llm-deepseek-api-key', value: { apiKeyEnv: 'V4_KEY' } }],
|
||||
},
|
||||
credentials: credentialsOf('sk-both', refs),
|
||||
})
|
||||
watchBalanceChannel(ctx)
|
||||
stubPlatform(CNY_BALANCE)
|
||||
await serve(captured)
|
||||
assert.deepEqual(refs, ['V3_KEY'])
|
||||
})
|
||||
|
||||
test('every broken describe shape serves a typed null without touching the platform', async () => {
|
||||
const faces: unknown[] = [
|
||||
settingsDescribeOf('not an array'),
|
||||
settingsDescribeOf(null),
|
||||
settingsDescribeOf([]),
|
||||
settingsDescribeOf([null, 'row', 42, [], {}]),
|
||||
settingsDescribeOf([{ value: null }, { ns: 'llm-deepseek-api-key' }, { value: 'section' }]),
|
||||
settingsDescribeOf([{ value: {} }, { value: { apiKeyEnv: '' } }, { value: { apiKeyEnv: 42 } }]),
|
||||
settingsDescribeOf([], true),
|
||||
]
|
||||
for (const settings of faces) {
|
||||
resetBalance()
|
||||
const { ctx, captured } = ctxOf({
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
settings,
|
||||
credentials: credentialsOf('sk-test'),
|
||||
})
|
||||
watchBalanceChannel(ctx)
|
||||
const { calls } = stubPlatform(CNY_BALANCE)
|
||||
const reply = await serve(captured)
|
||||
assert.deepEqual(reply, { ok: true, value: null })
|
||||
assert.equal(calls(), 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('a hostile row throwing on property access drops whole, valid siblings survive', async () => {
|
||||
const refs: string[] = []
|
||||
const hostile = new Proxy({}, { get() { throw new Error('hostile') } })
|
||||
const { ctx, captured } = ctxOf({
|
||||
connection: { fetch: { register: () => () => {} } },
|
||||
settings: settingsDescribeOf([hostile, { ns: 'llm-deepseek-api-key', value: { apiKeyEnv: 'DEEPSEEK_API_KEY' } }]),
|
||||
credentials: credentialsOf('sk-test', refs),
|
||||
})
|
||||
watchBalanceChannel(ctx)
|
||||
stubPlatform(CNY_BALANCE)
|
||||
const reply = await serve(captured)
|
||||
assert.deepEqual(refs, ['DEEPSEEK_API_KEY'])
|
||||
assert.deepEqual(reply, {
|
||||
ok: true,
|
||||
value: { isAvailable: true, balances: [{ currency: 'CNY', total: 110, granted: 10, toppedUp: 100 }] },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('balanceOfPayload (direct)', () => {
|
||||
test('multiple currencies keep their entries in order', () => {
|
||||
const balance = balanceOfPayload({
|
||||
|
||||
Reference in new issue
Block a user