diff --git a/.github/workflows/live.yml b/.github/workflows/live.yml index 99b6031..4261fca 100644 --- a/.github/workflows/live.yml +++ b/.github/workflows/live.yml @@ -1,25 +1,37 @@ name: live canary -# The real-boundary lanes: pack the artifact, install it into a throwaway -# profile next to the published base/web-app bundles, boot a REAL DeepSeek -# Harness from a fresh checkout, and drive the page in a real browser — -# integration (module served) and functional (a capability write persists to -# settings.yaml). Too heavy for the push pipeline, so this lane canaries once -# a week (plus on manual dispatch); the hermetic `ci` workflow stays the fast -# gate. +# The real-boundary lanes, run once per published harness channel: pack the +# artifact, install it into a throwaway profile next to the published +# base/web-app bundles, boot a REAL DeepSeek Harness from the git tag the +# channel's npm dist-tag currently names, and drive the page in a real +# browser — integration (module served) and functional (a capability write +# persists to settings.yaml). Resolving the dist-tag each run makes this a +# radar: when upstream publishes a new rc or alpha, THIS lane is what tests +# us against it first, and a red run opens one tracking issue instead of +# anyone discovering the break by hand. Daily, plus on manual dispatch; the +# hermetic `ci` workflow stays the fast gate. on: schedule: - - cron: '17 21 * * 1' + - cron: '17 21 * * *' workflow_dispatch: permissions: contents: read + # The escalation step opens one issue per red channel instead of letting a + # silent cron turn stale. + issues: write jobs: live: runs-on: ubuntu-latest timeout-minutes: 90 + # One red channel must not cancel the other: each is a verdict on its own. + strategy: + fail-fast: false + matrix: + # `tag` is the npm dist-tag naming that channel's current public build. + channel: [{ key: rc, label: rc }, { key: alpha, label: alpha }] env: BMP_DSH_DIR: ${{ github.workspace }}/dsh # Chrome is preinstalled on the runner; puppeteer-core just needs the path. @@ -34,29 +46,69 @@ jobs: cache: npm - run: npm ci + # Channel → harness git tag, resolved fresh: the dist-tag names the + # published version, and upstream tags every release `dsh-v`. + # A channel whose dist-tag vanished (a closed line) skips cleanly — + # loud failure is reserved for a line that exists but broke us. + - name: resolve channel tag + id: resolve + run: | + v=$(npm view @deepseek-ai/dsh "dist-tags.${{ matrix.channel.key }}" 2>/dev/null || true) + if [ -z "$v" ]; then + echo "::warning::dist-tag ${{ matrix.channel.key }} is absent; channel skipped" + else + echo "version=$v" >> "$GITHUB_OUTPUT" + echo "ref=dsh-v$v" >> "$GITHUB_OUTPUT" + fi + # The harness is consumed as a source checkout: liveBoot runs the real # CLI through tsx out of it. Its lockfile pins the wire line the plugin # is tested against; a harness regression fails this lane, not a PR. - uses: actions/checkout@v4 + if: steps.resolve.outputs.ref with: repository: deepseek-ai/deepseek-harness path: dsh + ref: ${{ steps.resolve.outputs.ref }} - uses: pnpm/action-setup@v4 + if: steps.resolve.outputs.ref with: # The action would otherwise read OUR package.json (no version → # "No pnpm version is specified"); point it at the harness's one, # exactly like the harness's own CI resolves it. package_json_file: dsh/package.json - uses: actions/setup-node@v4 + if: steps.resolve.outputs.ref with: node-version: 22 - run: pnpm install + if: steps.resolve.outputs.ref working-directory: dsh # The checkout's loader entries import workspace sources through # node_modules junctions — lib/ exists only after the harness builds # itself (local checkouts quietly carry those artifacts; CI's does not). - run: pnpm build + if: steps.resolve.outputs.ref working-directory: dsh - run: npm run test:live + if: steps.resolve.outputs.ref - run: npm run test:functional + if: steps.resolve.outputs.ref + + # Escalate a red run into ONE open issue per channel per harness version: + # never a daily duplicate, never a silent canary. + - name: escalate on failure + if: failure() && steps.resolve.outputs.ref + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + title="live canary red on ${{ matrix.channel.label }} (${{ steps.resolve.outputs.version }})" + run_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + existing=$(gh issue list --search "\"$title\" in:title" --state open --json number --jq '.[0].number') + if [ -z "$existing" ]; then + gh issue create --title "$title" \ + --body "Harness ${{ matrix.channel.label }} ${{ steps.resolve.outputs.version }} breaks the live lanes. CI run: $run_url" + else + gh issue comment "$existing" --body "Still red: $run_url" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index dab2fe0..de1a608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ Notable changes to better-model-provider. Versions track the published git tags (npm artifact when it ships); the verification matrix each release was held to lives in `CONTRIBUTING.md`. +## [0.0.4] - 2026-08-31 + +- **dsh 0.1.2-alpha.2 support.** The alpha folded owner failures into a + shared `RemoteError` class and rebadged the hyphenated wire codes into + slash namespaces — `settings-conflict` → `settings/conflict`, + `settings-rejected` → `settings/rejected`, `model-discovery-failed` → + `llm/model-discovery-rejected` — with every details payload unchanged. + The adapter now folds the rename back at the failure arm, so the CAS + conflict keeps its localized, actionable message; the copy is fieldwise + because the alpha's real `RemoteError` instance carries `message` as an + Error-inherited non-enumerable a spread would silently drop. All four + face methods are untouched; both live lanes pass against the alpha.2 + checkout (and 0.1.2-alpha.1 and the published rc line). + +- **The CI canary is now a two-channel radar.** The weekly live lane + becomes a daily matrix over both published harness channels (rc and + alpha), each resolved from its npm dist-tag to the matching + `dsh-v` git tag, so an upstream publish is tested against us + on the next run — and a red channel opens ONE tracking issue instead of + waiting to be noticed by hand. + ## [0.0.3] - 2026-08-29 - **dsh 0.1.2-alpha.1 (source master) support, without dropping the npm line.** diff --git a/README.md b/README.md index 0ec5dd3..f7923f9 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Dedicated-adapter apps (built-in DeepSeek / OpenAI Codex) declare their capabili ## Compatibility -We declare compatibility with the whole **dsh 0.1.x line**: contract `@deepseek-ai/dsh-api-remotes >=0.1.0-rc.7 <0.2.0`, real-harness lanes verified on rc.7, rc.8, 0.1.1-rc.2, and the 0.1.2-alpha.1 source master. Surfaces outside the contract degrade silently. Development gates, live lanes, and invariants: see [CONTRIBUTING.md](CONTRIBUTING.md). +We declare compatibility with the whole **dsh 0.1.x line**: contract `@deepseek-ai/dsh-api-remotes >=0.1.0-rc.7 <0.2.0`, real-harness lanes verified on rc.7, rc.8, 0.1.1-rc.2, and 0.1.2-alpha.1/alpha.2 — a daily CI canary re-proves both published channels (rc and alpha) against their current npm dist-tags. Surfaces outside the contract degrade silently. Development gates, live lanes, and invariants: see [CONTRIBUTING.md](CONTRIBUTING.md). ## License diff --git a/README.zh.md b/README.zh.md index 99b3fa0..e3a24c3 100644 --- a/README.zh.md +++ b/README.zh.md @@ -34,7 +34,7 @@ CI 在 main 每次全绿后自动重建并发布成品到 `master` 分支,此 ## 兼容性 -我们声明兼容**整个 dsh 0.1.x 系**:契约 `@deepseek-ai/dsh-api-remotes >=0.1.0-rc.7 <0.2.0`,live 车道已在 rc.7、rc.8、0.1.1-rc.2 及 0.1.2-alpha.1 源树 master 上实证。契约之外的能力面静默降级。开发门禁、live 车道与不变式见 [CONTRIBUTING.md](CONTRIBUTING.md)。 +我们声明兼容**整个 dsh 0.1.x 系**:契约 `@deepseek-ai/dsh-api-remotes >=0.1.0-rc.7 <0.2.0`,live 车道已在 rc.7、rc.8、0.1.1-rc.2 及 0.1.2-alpha.1 / alpha.2 上实证,并有一条按日运行的 CI 金丝雀对 rc 与 alpha 两个已发布频道按各自 npm dist-tag 的当前版本持续复验。契约之外的能力面静默降级。开发门禁、live 车道与不变式见 [CONTRIBUTING.md](CONTRIBUTING.md)。 ## 许可证 diff --git a/package.json b/package.json index 3ce2296..3e7f385 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "better-model-provider", - "version": "0.0.3", + "version": "0.0.4", "description": "Per-model capability declaration for DeepSeek Harness: reasoning-effort levels (with wire spellings) and request modalities (text/image) for OpenAI-compatible providers — one settings section, no YAML hand-editing.", "license": "MIT", "repository": { diff --git a/src/client/wire.ts b/src/client/wire.ts index fea8770..c5359ca 100644 --- a/src/client/wire.ts +++ b/src/client/wire.ts @@ -1,13 +1,14 @@ /** * Dual-generation Remote adapter. dsh ≤0.1.1-rc.2 carries the settings/llm - * namespaces on the `connection.api` face; dsh 0.1.2-alpha.1 (the current - * harness master) installs them as traced `remote.` Cordis services whose - * generated Typert proxies take positional arguments, rename `providers` to - * `listConfigurableProviders`, and resolve the slimmer `RemoteResult` - * envelope (no outer `rpcId`/`result` wrapper, values unboxed from their - * `{providers}`/`{models}` carriers). The page logic keeps speaking ONE face - * — {@link IRemoteApi}, the legacy published contract — so this module is the - * only place in the plugin that knows both generations. + * namespaces on the `connection.api` face; dsh 0.1.2-alpha.1 installs them as + * traced `remote.` Cordis services whose generated Typert proxies take + * positional arguments, rename `providers` to `listConfigurableProviders`, + * and resolve the slimmer `RemoteResult` envelope (no outer `rpcId`/`result` + * wrapper, values unboxed from their `{providers}`/`{models}` carriers); + * 0.1.2-alpha.2 then wraps owner failures in `RemoteError` and rebadges the + * hyphen codes into slash namespaces. The page logic keeps speaking ONE face + * — {@link IRemoteApi}, the legacy published contract, with those renames + * folded back — so this module is the only place that knows both generations. * * The alpha-generation METHOD SIGNATURES are hand-projected here from the * generated clients in the harness checkout: the npm-published @@ -27,10 +28,9 @@ import type { } from './types.ts' /** - * Slim Remote failure carried by the alpha.1 generation's error branch. Its - * `code` is an open string upstream (the closed union lives in the carrier - * package, which would invert the dependency edge), but the VALUES are the - * same wire vocabulary the legacy RpcError union closed over. + * Slim Remote failure carried by the alpha generation's error branch: a + * code-discriminated `RemoteError` union instance since alpha.2, wire-cardinal + * `code`/`message`/`details` fields throughout both alphas. */ interface AlphaRemoteFailure { readonly code: string @@ -94,20 +94,38 @@ interface AlphaLlmRemote { */ const ALPHA_RPC_ID = 'bmp-alpha' as RpcId +/** + * alpha.2 rebadged the hyphen wire codes into slash namespaces + * (`settings-conflict` → `settings/conflict`) with details unchanged; the + * page branches on the legacy union, so the adapter folds the renames back. + */ +const ALPHA_TO_LEGACY_CODES: Readonly> = { + 'settings/conflict': 'settings-conflict', +} + +/** Fold an alpha failure into the legacy RpcError frame: translate the renamed codes, carry message/details. */ +function toLegacyError(error: AlphaRemoteFailure): RpcError { + // Fieldwise, never a spread: alpha.2's RemoteError instance has its `message` + // Error-inherited and non-enumerable, so `{...error}` would silently drop it. + return { + code: ALPHA_TO_LEGACY_CODES[error.code] ?? error.code, + message: error.message, + details: error.details, + } as RpcError +} + /** * Re-wrap the slim alpha envelope as the legacy carrier frame the page's * `unwrap` reads: stamp the shared `rpcId` echo the alpha no longer - * carries, box the value arm, and forward the failure arm. The single cast - * at the `error` slot pins exactly the documented claim that the alpha's - * open `code` string and the legacy closed RpcError union spell the same - * wire vocabulary — `settings-conflict` keeps its `{ns, expected, actual}` - * details, so the CAS branch in the store stays put. + * carries, box the value arm, and translate the failure arm. The CAS details + * (`{ns, expected, actual}`) survive the code rename untouched, so the + * store's conflict branch stays put. */ function toLegacyEnvelope(call: Promise>): Promise> { return call.then(result => result.ok ? { rpcId: ALPHA_RPC_ID, result } - : { rpcId: ALPHA_RPC_ID, result: { ok: false, error: result.error as RpcError } }, + : { rpcId: ALPHA_RPC_ID, result: { ok: false, error: toLegacyError(result.error) } }, ) } diff --git a/tests/wire.client.spec.ts b/tests/wire.client.spec.ts index d78df26..48e6019 100644 --- a/tests/wire.client.spec.ts +++ b/tests/wire.client.spec.ts @@ -221,6 +221,31 @@ describe('resolveRemoteApiGently — alpha generation (dsh 0.1.2-alpha.1)', () = expect(caught).toMatchObject({ name: 'HarnessRpcError', code: 'settings-conflict', message }) }) + test('an alpha.2 RemoteError-shaped failure folds its slash code back to the legacy spelling', async () => { + // alpha.2 hands a real RemoteError INSTANCE: `message` is Error-inherited + // (non-enumerable, so a spread would drop it) and the code is slash-spelled. + class FakeRemoteError extends Error { + readonly code = 'settings/conflict' + readonly details = { ns: 'llm-pi-ai', expected: 1, actual: 2 } + } + const fake = new FakeRemoteError('expected revision 1, actual 2') + expect(Object.keys(fake)).not.toContain('message') + const { services } = alphaRemotes({ mutate: () => Promise.resolve({ ok: false, error: fake }) }) + const resolved = resolveGently(services) + + const response = await resolved.settings.mutate({ + ns: 'llm-pi-ai', + ops: [{ op: 'set', path: ['providers', 'ksyun', 'models'], value: [] }], + expectedRevision: 1, + }) + if (response.result.ok) throw new Error('expected the failure branch') + expect(response.result.error).toEqual({ + code: 'settings-conflict', + message: 'expected revision 1, actual 2', + details: { ns: 'llm-pi-ai', expected: 1, actual: 2 }, + }) + }) + test('a carrier-folded failure rides the value-mapping arm untouched', async () => { // Upstream folds carrier failures into the same error branch // (transportError's 'internal' catch-all) — the boxing helpers must not