diff --git a/docs/compatibility.md b/docs/compatibility.md index 2f540e9..55b9b45 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -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 diff --git a/src/host/balance.ts b/src/host/balance.ts index c08befe..5af5128 100644 --- a/src/host/balance.ts +++ b/src/host/balance.ts @@ -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 | 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 | 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 { - 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 diff --git a/tests/baselines.ts b/tests/baselines.ts index cdfad0f..3a65213 100644 --- a/tests/baselines.ts +++ b/tests/baselines.ts @@ -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(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(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'"], diff --git a/tests/compat/matrix.spec.ts b/tests/compat/matrix.spec.ts index 11bd1bd..477a2f9 100644 --- a/tests/compat/matrix.spec.ts +++ b/tests/compat/matrix.spec.ts @@ -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) diff --git a/tests/host/balance.spec.ts b/tests/host/balance.spec.ts index 32dadaa..ea5eeef 100644 --- a/tests/host/balance.spec.ts +++ b/tests/host/balance.spec.ts @@ -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({