From 6d37229dc95a11dbac5b166fdf74a53ab4487637 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Thu, 22 Oct 2026 13:37:19 +0800 Subject: [PATCH 1/4] fix(ci): restore Roomen-only miaomiao preview automation --- .github/workflows/miaomiao-preview.yml | 126 ++++++++++++++++++++++++ .github/workflows/release.yml | 129 +++++++++++++++++++++++-- docs/miaomiao-preview.md | 32 ++++++ tests/miaomiaoPreview.test.ts | 68 +++++++++++++ 4 files changed, 345 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/miaomiao-preview.yml create mode 100644 docs/miaomiao-preview.md create mode 100644 tests/miaomiaoPreview.test.ts diff --git a/.github/workflows/miaomiao-preview.yml b/.github/workflows/miaomiao-preview.yml new file mode 100644 index 000000000..a6e852c48 --- /dev/null +++ b/.github/workflows/miaomiao-preview.yml @@ -0,0 +1,126 @@ +name: Miaomiao preview + +on: + workflow_run: + workflows: [Build portable releases] + types: [completed] + push: + branches: [miaomiao] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: miaomiao-${{ github.event.workflow_run.id || github.ref }} + cancel-in-progress: false + +jobs: + source: + name: Merge eligible PR and resolve preview commit + if: >- + github.repository == 'router-for-me/EasyCLIProxyAPI' + && (github.event_name == 'push' + || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/miaomiao') + || (github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.conclusion == 'success')) + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: write + pull-requests: write + outputs: + sha: ${{ steps.source.outputs.sha }} + pr: ${{ steps.source.outputs.pr }} + steps: + # Use trusted inline code only. Never execute PR source in this job. + - name: Resolve source + id: source + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + if (context.eventName === 'push' || context.eventName === 'workflow_dispatch') { + if (context.ref === 'refs/heads/miaomiao' && !context.payload.deleted) { + core.setOutput('sha', context.sha); + } + return; + } + + const { data: run } = await github.rest.actions.getWorkflowRun({ + owner, repo, run_id: context.payload.workflow_run.id, + }); + if (run.event !== 'pull_request' || run.status !== 'completed' + || run.conclusion !== 'success' || run.path !== '.github/workflows/release.yml' + || run.repository.full_name !== `${owner}/${repo}`) return; + + // Fork runs can have an empty pull_requests array; query open PRs instead. + const candidates = await github.paginate(github.rest.pulls.list, { + owner, repo, state: 'open', base: 'miaomiao', per_page: 100, + }); + for (const candidate of candidates) { + if (candidate.user.login !== 'Roomen' || candidate.head.sha !== run.head_sha) continue; + const { data: pr } = await github.rest.pulls.get({ + owner, repo, pull_number: candidate.number, + }); + if (pr.user.login !== 'Roomen' || pr.base.ref !== 'miaomiao' + || pr.base.repo.full_name !== `${owner}/${repo}` + || pr.state !== 'open' || pr.draft || pr.head.sha !== run.head_sha + || pr.head.repo?.full_name !== run.head_repository?.full_name) continue; + + const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { + owner, repo, run_id: run.id, filter: 'latest', per_page: 100, + }); + const required = ['Resolve app version', ...['linux', 'darwin', 'windows'] + .flatMap(os => ['amd64', 'aarch64'].map(arch => `Build ${os}-${arch}`))]; + if (!required.every(name => jobs.some(job => job.name === name && job.conclusion === 'success'))) { + core.info('The full six-platform PR build did not pass; skipping merge.'); + continue; + } + + // SHA locking prevents a newer, untested revision from being merged. + // GitHub still enforces conflicts and branch protection. + const { data: result } = await github.rest.pulls.merge({ + owner, repo, pull_number: pr.number, sha: pr.head.sha, merge_method: 'merge', + }); + if (!result.merged) throw new Error(`PR #${pr.number} was not merged: ${result.message}`); + core.setOutput('sha', result.sha); + core.setOutput('pr', String(pr.number)); + return; + } + + preview: + name: Build and publish merged preview + needs: source + if: needs.source.outputs.sha != '' + permissions: + contents: write + # Token merges do not trigger push workflows; explicitly build the merged SHA. + uses: ./.github/workflows/release.yml + with: + preview: true + source_sha: ${{ needs.source.outputs.sha }} + secrets: inherit + + notify: + name: Link preview on merged PR + needs: [source, preview] + if: needs.source.outputs.pr != '' + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ needs.source.outputs.pr }} + SOURCE_SHA: ${{ needs.source.outputs.sha }} + with: + script: | + const { owner, repo } = context.repo; + const { data: tag } = await github.rest.git.getRef({ owner, repo, ref: 'tags/miaomiao-preview' }); + if (tag.object.sha !== process.env.SOURCE_SHA) return; + await github.rest.issues.createComment({ + owner, repo, issue_number: Number(process.env.PR_NUMBER), + body: `已自动合并并发布 [Miaomiao Preview](https://github.com/${owner}/${repo}/releases/tag/miaomiao-preview)。\n\n构建提交:${process.env.SOURCE_SHA}\n\n下载页为滚动预览版,后续成功构建会替换安装包。`, + }); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cad797278..2e86f0afe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,18 @@ run-name: >- ${{ github.event_name == 'pull_request' && 'PR test build' || github.event_name == 'push' && github.ref_type == 'branch' && 'Warm build caches' || 'Portable release' }} ${{ github.event.pull_request.number || (github.ref_type == 'tag' && github.ref_name) || inputs.release_tag || github.ref_name }} on: + workflow_call: + inputs: + preview: + description: Build and publish the rolling miaomiao preview + required: true + type: boolean + source_sha: + description: Exact merged commit to build + required: true + type: string pull_request: - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened, ready_for_review, edited] workflow_dispatch: inputs: release_tag: @@ -24,7 +34,7 @@ on: - 'v*' concurrency: - group: portable-${{ github.ref }}-${{ github.event_name == 'pull_request' && 'pr' || github.event_name == 'push' && github.ref_type == 'branch' && 'cache' || 'release' }} + group: portable-${{ github.ref }}-${{ inputs.preview && inputs.source_sha || (github.event_name == 'pull_request' && 'pr' || github.event_name == 'push' && github.ref_type == 'branch' && 'cache' || 'release') }} cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }} permissions: @@ -34,25 +44,32 @@ jobs: version: name: Resolve app version runs-on: ubuntu-24.04 + permissions: + contents: read outputs: version: ${{ steps.resolve.outputs.version }} tag: ${{ steps.resolve.outputs.tag }} steps: - uses: actions/checkout@v7 with: - ref: ${{ github.sha }} + ref: ${{ inputs.source_sha || github.sha }} + persist-credentials: false - name: Resolve release version id: resolve shell: bash env: REQUESTED_VERSION: ${{ (github.ref_type == 'tag' && github.ref_name) || inputs.release_tag }} PR_NUMBER: ${{ github.event.pull_request.number }} + MIAOMIAO_PREVIEW: ${{ inputs.preview }} run: | set -euo pipefail source_version="$(node scripts/version.mjs)" release_tag="$REQUESTED_VERSION" - if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then + if [[ "$MIAOMIAO_PREVIEW" == "true" ]]; then + base_version="${source_version%%[-+]*}" + release_tag="v${base_version}-miaomiao.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" + elif [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then # Replace any existing prerelease/build suffix for an unambiguous test version. base_version="${source_version%%[-+]*}" release_tag="v${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" @@ -73,6 +90,8 @@ jobs: build: name: Build ${{ matrix.os }}-${{ matrix.arch }} needs: version + permissions: + contents: read strategy: fail-fast: false matrix: @@ -110,11 +129,12 @@ jobs: runs-on: ${{ matrix.runner }} timeout-minutes: 60 env: - PACKAGE_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_type == 'tag' || github.event_name == 'pull_request' }} + PACKAGE_RELEASE: ${{ inputs.preview || github.event_name == 'workflow_dispatch' || github.ref_type == 'tag' || github.event_name == 'pull_request' }} steps: - uses: actions/checkout@v7 with: - ref: ${{ github.sha }} + ref: ${{ inputs.source_sha || github.sha }} + persist-credentials: false - uses: oven-sh/setup-bun@v2 with: bun-version: 1.3.14 @@ -133,7 +153,7 @@ jobs: key: ${{ matrix.runner }} workspaces: src-tauri -> target cache-workspace-crates: true - save-if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + save-if: ${{ !inputs.preview && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} - name: Restore bundled core archive id: core-cache uses: actions/cache/restore@v5 @@ -173,13 +193,13 @@ jobs: --download true shell: bash - name: Save Bun packages - if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && steps.bun-cache.outputs.cache-hit != 'true' + if: ${{ !inputs.preview && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && steps.bun-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v5 with: path: ~/.bun/install/cache key: ${{ steps.bun-cache.outputs.cache-primary-key }} - name: Save bundled core archive - if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && steps.core-cache.outputs.cache-hit != 'true' + if: ${{ !inputs.preview && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && steps.core-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v5 with: path: cpa-core @@ -368,7 +388,7 @@ jobs: publish: name: Publish GitHub release if: >- - github.event_name != 'pull_request' + !inputs.preview && github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/') || inputs.release_tag != '') needs: - version @@ -446,3 +466,92 @@ jobs: else gh release create "$RELEASE_TAG" "${release_args[@]}" artifacts/* fi + + publish-preview: + name: Update rolling miaomiao preview + if: inputs.preview && github.repository == 'router-for-me/EasyCLIProxyAPI' + needs: [version, build] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + concurrency: + group: miaomiao-preview-publication + cancel-in-progress: false + steps: + # Publishing handles artifacts only; it never executes the merged source. + - uses: actions/download-artifact@v8 + with: + path: artifacts + merge-multiple: true + - name: Publish latest successful branch head + shell: bash + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + SOURCE_SHA: ${{ inputs.source_sha }} + APP_VERSION: ${{ needs.version.outputs.version }} + RELEASE_TAG: miaomiao-preview + run: | + set -euo pipefail + + current_sha="$(gh api "repos/$GH_REPO/git/ref/heads/miaomiao" --jq '.object.sha')" + if [[ "$current_sha" != "$SOURCE_SHA" ]]; then + echo "A newer commit is on miaomiao; skipping this outdated preview." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + # Check the complete matrix before changing the rolling release. + packages=() + for platform in Windows Linux Darwin; do + case "$platform" in + Windows) suffix=zip ;; + Linux) suffix=tar.gz ;; + Darwin) suffix=dmg ;; + esac + for arch in amd64 aarch64; do + package="artifacts/EasyCLIProxyAPI-v${APP_VERSION}-${platform}-${arch}.${suffix}" + test -s "$package" + packages+=("$package") + done + done + + notes="$RUNNER_TEMP/miaomiao-preview.md" + cat > "$notes" </dev/null 2>&1; then + # Distinct versioned filenames keep the previous packages until uploads succeed. + gh release upload "$RELEASE_TAG" "${packages[@]}" --clobber + gh api --method PATCH "repos/$GH_REPO/git/refs/tags/$RELEASE_TAG" \ + -f sha="$SOURCE_SHA" -F force=true + gh release edit "$RELEASE_TAG" --title 'Miaomiao Preview' \ + --notes-file "$notes" --prerelease --latest=false + else + gh release create "$RELEASE_TAG" "${packages[@]}" --target "$SOURCE_SHA" \ + --title 'Miaomiao Preview' --notes-file "$notes" --prerelease --latest=false + fi + + # Keep only this build's assets so the fixed download page stays small. + gh api "repos/$GH_REPO/releases/tags/$RELEASE_TAG" \ + --jq '.assets[] | [.id, .name] | @tsv' > "$RUNNER_TEMP/miaomiao-assets.tsv" + while IFS=$'\t' read -r asset_id asset_name; do + keep=false + for package in "${packages[@]}"; do + if [[ "$asset_name" == "${package#artifacts/}" ]]; then + keep=true + break + fi + done + if [[ "$keep" == false ]]; then + gh api --method DELETE "repos/$GH_REPO/releases/assets/$asset_id" + fi + done < "$RUNNER_TEMP/miaomiao-assets.tsv" + echo "[Download Miaomiao Preview](https://github.com/$GH_REPO/releases/tag/$RELEASE_TAG) — $SOURCE_SHA" >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/miaomiao-preview.md b/docs/miaomiao-preview.md new file mode 100644 index 000000000..ea9ddb3af --- /dev/null +++ b/docs/miaomiao-preview.md @@ -0,0 +1,32 @@ +# Miaomiao Preview + +Download: https://github.com/router-for-me/EasyCLIProxyAPI/releases/tag/miaomiao-preview + +Only non-draft, open PRs authored by `Roomen` and targeting `miaomiao` +are automatically merged. The `Build portable releases` PR workflow must +complete successfully for the current PR head, including all six platform +builds. The merge uses the exact tested head SHA and obeys GitHub branch +protection and conflict checks. Other authors and target branches are ignored. +Draft-to-ready and PR edits trigger another PR build so eligibility is checked +again. Fork builds may require GitHub's normal workflow approval first. + +After merging, the workflow explicitly builds the merged commit, because +merges made with GITHUB_TOKEN do not trigger a separate push workflow. +Direct pushes to `miaomiao` also build previews. To retry a preview manually, +run `Miaomiao preview` with the `miaomiao` branch selected. + +All six builds must pass before publication. Publication is skipped if the +source commit is no longer the current branch head. The fixed prerelease +`miaomiao-preview`, titled `Miaomiao Preview`, is updated with versioned +Windows, Linux and macOS packages for amd64 and aarch64. macOS previews +use the existing signing and notarization configuration. New packages are +uploaded before old assets are removed. This release never becomes Latest. +After automatic merging and successful publication, a PR comment links to +the rolling download page. Later builds replace its packages. + +Both `main` (the default branch, which receives workflow_run events) and +`miaomiao` must retain these workflow changes. Resetting either branch to +another branch without these files can disable the automation again. +No repository-wide GitHub auto-merge toggle or personal access token is needed. + +Validation: `bun test tests/miaomiaoPreview.test.ts`. diff --git a/tests/miaomiaoPreview.test.ts b/tests/miaomiaoPreview.test.ts new file mode 100644 index 000000000..c73ef5ef9 --- /dev/null +++ b/tests/miaomiaoPreview.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +const workflow = Bun.YAML.parse(readFileSync('.github/workflows/miaomiao-preview.yml', 'utf8')) as any; +const release = Bun.YAML.parse(readFileSync('.github/workflows/release.yml', 'utf8')) as any; +const execute = new (Object.getPrototypeOf(async function () {}).constructor)('github', 'context', 'core', workflow.jobs.source.steps[0].with.script); +async function run(options: any = {}) { + const repository = { full_name: 'router-for-me/EasyCLIProxyAPI' }; + const headRepo = { full_name: 'Roomen/EasyCLIProxyAPI' }; + const pr = { number: 42, user: { login: 'Roomen' }, base: { ref: 'miaomiao', repo: repository }, head: { sha: 'tested', repo: headRepo }, state: 'open', draft: false, ...options.pr }; + const build = { id: 123, event: 'pull_request', status: 'completed', conclusion: 'success', path: '.github/workflows/release.yml', repository, head_sha: 'tested', head_repository: headRepo, ...options.build }; + const jobs = ['Resolve app version', ...['linux', 'darwin', 'windows'].flatMap(os => ['amd64', 'aarch64'].map(arch => `Build ${os}-${arch}`))].map(name => ({ name, conclusion: 'success' })); + if (options.failedJob) jobs[1].conclusion = options.failedJob; + if (options.missingJob) jobs.pop(); + const merges: any[] = [], outputs: any = {}; + const listPR = () => {}, listJobs = () => {}; + const github = { rest: { actions: { getWorkflowRun: async () => ({ data: build }), listJobsForWorkflowRun: listJobs }, pulls: { list: listPR, get: async () => ({ data: pr }), merge: async (args: any) => { merges.push(args); if (options.error) throw new Error('409'); return { data: { merged: !options.rejected, sha: 'merged' } }; } } }, paginate: async (fn: any) => fn === listPR ? [pr] : jobs }; + const context = { repo: { owner: 'router-for-me', repo: 'EasyCLIProxyAPI' }, eventName: 'workflow_run', ref: 'refs/heads/main', sha: 'push-sha', payload: { workflow_run: { id: 123 } }, ...options.context }; + let error; + try { await execute(github, context, { info() {}, setOutput: (key: string, value: string) => outputs[key] = value }); } catch (e) { error = e; } + return { merges, outputs, error }; +} +describe('Roomen-only miaomiao merge', () => { + test('merges only the tested head and builds resulting commit', async () => { + const result = await run(); + expect(result.error).toBeUndefined(); + expect(result.merges).toHaveLength(1); + expect(result.merges[0].sha).toBe('tested'); + expect(result.outputs).toEqual({ sha: 'merged', pr: '42' }); + }); + for (const [name, options] of Object.entries({ + author: { pr: { user: { login: 'another-user' } } }, + draft: { pr: { draft: true } }, + closed: { pr: { state: 'closed' } }, + target: { pr: { base: { ref: 'main' } } }, + repository: { pr: { base: { ref: 'miaomiao', repo: { full_name: 'other/repo' } } } }, + stale: { pr: { head: { sha: 'new' } } }, + fork: { build: { head_repository: { full_name: 'other/repo' } } }, + failed: { build: { conclusion: 'failure' } }, + pending: { build: { status: 'in_progress' } }, + workflow: { build: { path: '.github/workflows/untrusted.yml' } }, + event: { build: { event: 'push' } }, + skipped: { failedJob: 'skipped' }, + failedPlatform: { failedJob: 'failure' }, + missingPlatform: { missingJob: true }, + })) test(`rejects ${name}`, async () => { + const result = await run(options); + expect(result.merges).toHaveLength(0); + expect(result.outputs).toEqual({}); + }); + for (const option of [{ rejected: true }, { error: true }]) test('no preview on merge failure', async () => { + const result = await run(option); + expect(result.error).toBeInstanceOf(Error); + expect(result.outputs).toEqual({}); + }); + for (const eventName of ['push', 'workflow_dispatch']) test(`${eventName} builds miaomiao only`, async () => { + expect((await run({ context: { eventName, ref: 'refs/heads/miaomiao' } })).outputs.sha).toBe('push-sha'); + expect((await run({ context: { eventName, ref: 'refs/heads/main' } })).outputs).toEqual({}); + }); + test('deletion does not build', async () => { + expect((await run({ context: { eventName: 'push', ref: 'refs/heads/miaomiao', payload: { deleted: true } } })).outputs).toEqual({}); + }); + test('preview publishes only after all builds, with read-only build jobs', () => { + expect(release.jobs['publish-preview'].needs).toEqual(['version', 'build']); + expect(release.jobs.build.permissions.contents).toBe('read'); + expect(release.jobs.version.permissions.contents).toBe('read'); + expect(release.on.workflow_call.inputs.source_sha.required).toBe(true); + }); +}); From 6197d0a152874b7987ebdb4b4603fcd0d1f7e23b Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Thu, 22 Oct 2026 14:51:06 +0800 Subject: [PATCH 2/4] fix: update Claude Desktop config and model routing --- docs/claude-desktop-compatibility.md | 112 +++++++++++++++++++ src-tauri/src/agents/backups/tests.rs | 37 ++++++ src-tauri/src/agents/configuration.rs | 57 ++++++---- src-tauri/src/agents/discovery.rs | 29 ++++- src-tauri/src/agents/transactions.rs | 1 + src-tauri/src/core_config/alias_save.rs | 14 +-- src-tauri/src/core_config/aliases.rs | 72 ++++++++---- src-tauri/src/tests/agent_configuration.rs | 60 ++++++++++ src-tauri/src/tests/alias_save.rs | 55 ++++++++- src-tauri/src/tests/core_v8_contract.rs | 25 +++++ src-tauri/src/tests/desktop_alias_routing.rs | 57 ++++++++++ 11 files changed, 465 insertions(+), 54 deletions(-) create mode 100644 docs/claude-desktop-compatibility.md diff --git a/docs/claude-desktop-compatibility.md b/docs/claude-desktop-compatibility.md new file mode 100644 index 000000000..92dd5078b --- /dev/null +++ b/docs/claude-desktop-compatibility.md @@ -0,0 +1,112 @@ +# Claude Desktop gateway configuration + +Verified on 2026-10-22 against Claude Desktop for Windows 3.1623.1.0 and the official references: + +- https://claude.com/docs/third-party/claude-desktop/configuration +- https://claude.com/docs/third-party/claude-desktop/gateway + +## Local configuration contract + +Windows stores local third-party configurations in `%LOCALAPPDATA%/Claude-3p/configLibrary/`. +macOS uses `~/Library/Application Support/Claude-3p/configLibrary/`; Linux uses its Claude-3p configuration directory. +`_meta.json` selects a saved `.json` profile through `appliedId`. +These are JSON files with native booleans and arrays, not the string-encoded values required by Windows registry policy. + +CPA owns a dedicated profile and writes the following connection settings: + +```json +{ + "disableDeploymentModeChooser": true, + "inferenceProvider": "gateway", + "inferenceCredentialKind": "static", + "inferenceGatewayAuthScheme": "bearer", + "inferenceGatewayBaseUrl": "http://127.0.0.1:", + "inferenceGatewayApiKey": "", + "modelDiscoveryEnabled": false, + "bootstrapEnabled": false, + "inferenceModels": [ + { + "name": "", + "labelOverride": "" + } + ] +} +``` + +The base URL is the proxy origin; Desktop appends `/v1/messages`. +Model IDs must match routes exposed by the proxy, not display labels. CPA's explicit list disables discovery, including resolution of bare tier aliases. +Model entries use `anthropicFamilyTier` and `isFamilyDefault` for recognized opus, sonnet, haiku, fable and mythos routes. +`supports1m` and `prefer1m` are written only for an explicitly selected 1M option. +User model extensions, including `maxEffort` and `minDesktopVersion`, remain preserved on matching entries. + +An explicit static credential kind prevents old helper or interactive sign-in settings from replacing the proxy key. +CPA disables a bootstrap overlay in its profile and removes a competing `selfHostedUrl` while managing that profile. +Other user preferences and inactive credential settings remain intact. + +In the installed 3.1623.1.0 implementation, `_meta.json.hybridPointer` takes precedence over `appliedId`. +This is an implementation detail, not a documented stable API. Applying the CPA profile clears the pointer, matching Desktop's local-apply behavior. +Integration history retains the original configuration for restoration; removing a dormant CPA integration must not disable an active hybrid deployment. + +## Atomic updates and validation + +Normal updates, template application and model-mapped backup restoration update proxy routes first. +Configuration writes retain the existing validation and rollback behavior: if writing client files fails, the proxy changes roll back. +The public model catalog is not a complete routing contract. Missing or delayed discovery entries must not block saving a valid model mapping or cause a rollback. +Saving does not poll model discovery or send an inference request. It does not validate upstream availability or model behavior. +Claude Desktop's own connection test may subsequently issue a Messages API request. + +## Operational limits + +- Fully quit and reopen Claude Desktop after changing configuration; editing a file does not establish that the running application has reloaded it. +- Managed OS policy can override the entire local configuration (with the app-behavior-only exception described by Anthropic). CPA does not rewrite enterprise policy. Use Desktop's Managed Configuration Report to diagnose policy precedence. +- A missing listener, rejected credentials, an absent model route and an upstream error are different failures. Do not infer a configuration-format change from a connection banner alone. + +## Regression coverage + +Tests cover explicit static credentials, conflicting bootstrap/session-host settings, hybrid switching and restoration, preserving user preferences, current model tiers, successful alias saves with an empty public model catalog, and rollback when configuration writes fail. +The isolated `v8_claude_desktop_and_code_share_persisted_model_routes` test exercises a real proxy executable through `CPA_V8_TEST_CORE` without changing the user's running proxy. + +## API-key routing repair and live verification (2026-10-22) + +Desktop source resolution, identity lookup and managed-route cleanup enumerate all eight +`V8_PROVIDER_FAMILIES`. The older generic reasoning-alias section list does not include +xAI, Meta, Vertex or Interactions API-key sources and must not drive Desktop routing. +A managed alias with matching model names is not a no-op when the selected source +resolves to a different API-key provider. Repair it on the selected provider before +writing the Desktop profile. Alias transactions still preserve API credentials and +roll back route changes if the client write fails. + +Live verification used Claude Desktop 3.1623.1.0 and CLIProxyAPI 8.0.36 at +`127.0.0.1:18317`, with a separate configuration and OAuth directory. Production +port 8317 was not changed. The application transaction generated the test profile +and persisted `grok-4.7-build-fast -> claude-sonnet-5-cc` on the xAI API-key source. +After restarting Desktop, the unavailable-model banner disappeared. A real message +asking `What is 2 + 3?` received `5.` in the Desktop conversation. The isolated +request log recorded HTTP 200 for `/v1/messages?beta=true`, a text response and +`message_stop`. A follow-up asking to add 7 received `12`, also with HTTP 200 and +a completed stream. The final suite passed 973 tests (12 opt-in tests ignored); +the isolated profile helper also passed explicitly. The Windows release build succeeded. +This verifies a short streamed conversation, not 1M-token capacity, +Cowork or arbitrary tool use. The old selected `grok-4.6` was unavailable in the +active provider; this test used the available `grok-4.7-build-fast` model. + +The opt-in `desktop_e2e_prepare_isolated_gateway_profile` helper requires an explicit +`CPA_DESKTOP_E2E_CONFIG` and rejects port 8317. It prepares configuration only; +Desktop UI verification is a separate manual/computer-use step. Runtime test files +contain credentials and stay in ignored `bin-work`, never in this document. + +## Review follow-up: stable routing and excluded sources + +An unchanged Desktop save retains its selected managed alias during cleanup and +keeps a valid existing API-key provider, even if another provider publishes the +same model ID. Disabled providers and exclusions are checked for both the upstream +model and client alias. A stale managed OAuth route for a configured API-key model +cannot substitute for an excluded API source without a matching OAuth channel/model +definition. Explicit legacy OAuth routes remain supported. + +Regression coverage includes the two review reproductions, wildcard exclusions, +disabled API providers, confirmed OAuth alternatives, and an excluded upstream +with an existing API-key alias. The final suite passed 977 tests with 12 opt-in +tests ignored. The isolated profile application test passed separately. After +reapplying with the updated code on port 18317, a real Desktop message asking +`8 + 9` received `17`. diff --git a/src-tauri/src/agents/backups/tests.rs b/src-tauri/src/agents/backups/tests.rs index 2237b2e38..eddb4fc2c 100644 --- a/src-tauri/src/agents/backups/tests.rs +++ b/src-tauri/src/agents/backups/tests.rs @@ -620,6 +620,43 @@ fn claude_desktop_cowork_hosts_survive_update_close_and_reconnect() { ); } +#[test] +fn claude_desktop_update_exits_hybrid_and_backup_restores_pointer() { + if !AgentClient::ClaudeDesktop.supported_platform() { return; } + let home = Home::new(); + let paths = config_paths("claude-desktop", &home.0).unwrap(); + let pointer = serde_json::json!({"bootstrapUrl":"https://config.example.test"}); + let meta = serde_json::json!({"appliedId":"other", "hybridPointer":pointer, + "entries":[{"id":"other","name":"Original"}], "custom":true}); + for path in &paths[..2] { save(path, r#"{"deploymentMode":"3p"}"#); } + save(&paths[3], meta.to_string()); + let original = config_images(&paths).unwrap(); + apply(&home.0, AgentClient::ClaudeDesktop, "gpt-one").unwrap(); + let connected: Value = serde_json::from_slice(&fs::read(&paths[3]).unwrap()).unwrap(); + assert!(connected.get("hybridPointer").is_none()); + assert_eq!(connected["appliedId"], CLAUDE_DESKTOP_PROFILE_ID); + assert_eq!(connected["custom"], true); + let restored = prepare_recorded_integration_restore(AgentClient::ClaudeDesktop, + &paths, &config_images(&paths).unwrap()).unwrap().unwrap(); + for ((path, restored), (_, original)) in restored.iter().zip(&original) { + assert_eq!(restored.is_some(), original.is_some()); + assert_eq!(parse(path, text(restored.as_deref()).unwrap()).unwrap(), + parse(path, text(original.as_deref()).unwrap()).unwrap()); + } + + // A user who switches back to a hybrid source must not lose 3P mode + // when subsequently removing the dormant CPA integration. + let mut hybrid = connected; + hybrid["hybridPointer"] = pointer.clone(); + save(&paths[3], hybrid.to_string()); + assert!(!inspect_claude_desktop_agent_config(&paths, 8317, "test-secret").unwrap().0); + clear_agent_managed_configuration(AgentClient::ClaudeDesktop, &home.0, 8317).unwrap(); + let meta: Value = serde_json::from_slice(&fs::read(&paths[3]).unwrap()).unwrap(); + assert_eq!(meta["hybridPointer"], pointer); + let settings: Value = serde_json::from_slice(&fs::read(&paths[1]).unwrap()).unwrap(); + assert_eq!(settings["deploymentMode"], "3p"); +} + #[test] fn clear_integration_preserves_claude_settings_for_other_endpoints() { let home = Home::new(); diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index 353471134..7e080731b 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -876,6 +876,21 @@ pub(crate) fn build_claude_desktop_deployment_config( render_agent_json(root, "Claude Desktop configuration") } +// Keep generation, removal and selective restoration in agreement. These keys +// belong to CPA's gateway profile; all other preferences remain user-owned. +pub(crate) const CLAUDE_DESKTOP_PROFILE_KEYS: &[&str] = &[ + "disableDeploymentModeChooser", + "inferenceGatewayApiKey", + "inferenceGatewayAuthScheme", + "inferenceGatewayBaseUrl", + "inferenceProvider", + "inferenceCredentialKind", + "inferenceModels", + "modelDiscoveryEnabled", + "bootstrapEnabled", + "selfHostedUrl", +]; + pub(crate) fn build_claude_desktop_profile( existing: Option<&str>, base_url: &str, @@ -885,6 +900,15 @@ pub(crate) fn build_claude_desktop_profile( mappings: Option<&ClaudeDesktopModelMappings>, ) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop gateway configuration")?; + // An explicit credential source prevents an old helper/SSO setting from + // taking precedence over the local proxy key. The full model IDs below + // are authoritative, so discovery must not resolve bare tier aliases. + root.insert("inferenceCredentialKind".into(), serde_json::json!("static")); + root.insert("modelDiscoveryEnabled".into(), serde_json::json!(false)); + // Preserve bootstrap details for restoration, but don't fetch an overlay + // which could replace this gateway. A session host also overrides inference. + root.insert("bootstrapEnabled".into(), serde_json::json!(false)); + root.remove("selfHostedUrl"); root.insert( "disableDeploymentModeChooser".to_string(), serde_json::json!(true), @@ -1023,6 +1047,10 @@ pub(crate) fn claude_desktop_family_tier(route_model: &str) -> Option<&'static s || route_model.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2]) { Some("haiku") + } else if lowered.starts_with("claude-fable-") || lowered.contains("-fable-") { + Some("fable") + } else if lowered.starts_with("claude-mythos-") || lowered.contains("-mythos-") { + Some("mythos") } else { None } @@ -1030,6 +1058,9 @@ pub(crate) fn claude_desktop_family_tier(route_model: &str) -> Option<&'static s pub(crate) fn build_claude_desktop_meta(existing: Option<&str>) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop configuration index")?; + // Desktop resolves a hybrid pointer before appliedId. Match its own + // "apply locally" transition; transaction snapshots retain the old pointer. + root.remove("hybridPointer"); repair_claude_desktop_meta_names(&mut root); let entries = ensure_json_array_entry(&mut root, "entries"); let mut managed_entry = None; @@ -1155,8 +1186,9 @@ pub(crate) fn prepare_claude_desktop_managed_removal(paths: &[PathBuf]) -> Resul } let meta = read_agent_json_or_empty(&paths[3], "Claude Desktop configuration index")?; let applied_id = meta.get("appliedId").and_then(serde_json::Value::as_str); - let managed_active = applied_id == Some(CLAUDE_DESKTOP_PROFILE_ID) - || (applied_id.is_none() && agent_has_managed_marker(AgentClient::ClaudeDesktop, paths)?); + let managed_active = !claude_desktop_has_hybrid_pointer(&meta) + && (applied_id == Some(CLAUDE_DESKTOP_PROFILE_ID) + || (applied_id.is_none() && agent_has_managed_marker(AgentClient::ClaudeDesktop, paths)?)); let mut changed = Vec::new(); for (path, label) in [ (&paths[0], "Claude Desktop main configuration"), @@ -1182,15 +1214,8 @@ pub(crate) fn prepare_claude_desktop_managed_removal(paths: &[PathBuf]) -> Resul if let Some(update) = prepare_agent_json_removal(&paths[2], "Claude Desktop gateway configuration", |root| { let mut updated = false; - for key in [ - "disableDeploymentModeChooser", - "inferenceGatewayApiKey", - "inferenceGatewayAuthScheme", - "inferenceGatewayBaseUrl", - "inferenceProvider", - "inferenceModels", - ] { - updated |= root.remove(key).is_some(); + for key in CLAUDE_DESKTOP_PROFILE_KEYS { + updated |= root.remove(*key).is_some(); } updated })? @@ -2018,19 +2043,13 @@ pub(crate) fn build_restored_claude_desktop_config( match index { 0 | 1 => restore_json_key(&mut root, original_root.as_ref(), "deploymentMode"), 2 => { - for key in [ - "disableDeploymentModeChooser", - "inferenceGatewayApiKey", - "inferenceGatewayAuthScheme", - "inferenceGatewayBaseUrl", - "inferenceProvider", - "inferenceModels", - ] { + for key in CLAUDE_DESKTOP_PROFILE_KEYS { restore_json_key(&mut root, original_root.as_ref(), key); } } 3 => { restore_json_key(&mut root, original_root.as_ref(), "appliedId"); + restore_json_key(&mut root, original_root.as_ref(), "hybridPointer"); let mut entries = root .remove("entries") .and_then(|value| value.as_array().cloned()) diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index e62a1d2be..a212fbcc0 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -3393,6 +3393,31 @@ pub(crate) fn claude_code_model_presentation_environment( .collect()) } +pub(crate) fn claude_desktop_has_hybrid_pointer(meta: &serde_json::Value) -> bool { + meta.get("hybridPointer") + .and_then(|pointer| pointer.get("bootstrapUrl")) + .and_then(serde_json::Value::as_str) + .is_some_and(|url| !url.trim().is_empty()) +} + +pub(crate) fn claude_desktop_profile_uses_local_gateway(profile: &serde_json::Value) -> bool { + profile.get("inferenceProvider").and_then(serde_json::Value::as_str) == Some("gateway") + && profile.get("inferenceCredentialKind") + .is_none_or(|kind| kind.as_str() == Some("static")) + && profile.get("inferenceGatewayAuthScheme") + .is_none_or(|scheme| scheme.as_str() == Some("bearer")) + && !profile.get("selfHostedUrl").and_then(serde_json::Value::as_str) + .is_some_and(|url| !url.trim().is_empty()) + && (!profile.get("bootstrapUrl").and_then(serde_json::Value::as_str) + .is_some_and(|url| !url.trim().is_empty()) + || profile.get("bootstrapEnabled").and_then(serde_json::Value::as_bool) == Some(false)) + && profile.get("inferenceModels").and_then(serde_json::Value::as_array) + .is_some_and(|models| !models.is_empty() && models.iter().all(|model| { + model.as_str().or_else(|| model.get("name").and_then(serde_json::Value::as_str)) + .is_some_and(|name| !name.trim().is_empty()) + })) +} + pub(crate) fn inspect_claude_desktop_agent_config( paths: &[PathBuf], port: u16, @@ -3406,7 +3431,9 @@ pub(crate) fn inspect_claude_desktop_agent_config( let profile = read_agent_json_or_empty(&paths[2], "Claude Desktop gateway configuration")?; let meta = read_agent_json_or_empty(&paths[3], "Claude Desktop configuration index")?; let expected_base = managed_core_loopback_origin(port); - let configured = normal + let configured = claude_desktop_profile_uses_local_gateway(&profile) + && !claude_desktop_has_hybrid_pointer(&meta) + && normal .get("deploymentMode") .and_then(serde_json::Value::as_str) == Some("3p") diff --git a/src-tauri/src/agents/transactions.rs b/src-tauri/src/agents/transactions.rs index e1442196d..1266bad27 100644 --- a/src-tauri/src/agents/transactions.rs +++ b/src-tauri/src/agents/transactions.rs @@ -1165,6 +1165,7 @@ fn restore_text( let mut root = parse(path, Some(current))?; let target = parse(path, target)?; set(&mut root, &["appliedId".into()], target.get("appliedId")); + set(&mut root, &["hybridPointer".into()], target.get("hybridPointer")); let mut entries = root .get("entries") .and_then(Value::as_array) diff --git a/src-tauri/src/core_config/alias_save.rs b/src-tauri/src/core_config/alias_save.rs index 863e1a836..b13f74bac 100644 --- a/src-tauri/src/core_config/alias_save.rs +++ b/src-tauri/src/core_config/alias_save.rs @@ -46,11 +46,7 @@ fn validate_alias_api_access_preserved(current: &str, updated: &str) -> Result<( }; let current = parse(current)?; let updated = parse(updated)?; - for section in MODEL_ALIAS_CONFIG_SECTIONS - .iter() - .copied() - .chain(["vertex-api-key", "xai-api-key", "interactions-api-key"]) - { + for (section, _) in V8_PROVIDER_FAMILIES { let without_models = |root: &serde_norway::Value| -> Result<_, String> { let mut value = root .get(section) @@ -62,11 +58,9 @@ fn validate_alias_api_access_preserved(current: &str, updated: &str) -> Result<( let providers = value .as_sequence_mut() .ok_or_else(|| format!("{section} must be an array; alias was not saved"))?; - if MODEL_ALIAS_CONFIG_SECTIONS.contains(§ion) { - for provider in providers { - if let Some(provider) = provider.as_mapping_mut() { - provider.remove(yaml_key("models")); - } + for provider in providers { + if let Some(provider) = provider.as_mapping_mut() { + provider.remove(yaml_key("models")); } } Ok(value) diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index 3d267831b..3f43146e2 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -830,7 +830,8 @@ pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_and_rou } } for alias in managed_aliases { - if !entries.iter().any(|entry| entry.source_or_alias().eq_ignore_ascii_case(&alias)) { + if !entries.iter().any(|entry| entry.source_or_alias().eq_ignore_ascii_case(&alias) + || entry.model_id().eq_ignore_ascii_case(&alias)) { remove_managed_claude_model_alias(root, &alias)?; } } @@ -955,7 +956,7 @@ fn claude_desktop_has_other_model_family(name: &str) -> bool { fn claude_desktop_configured_models(root: &serde_norway::Mapping) -> Vec<&serde_norway::Value> { let mut models = Vec::new(); - for section in MODEL_ALIAS_CONFIG_SECTIONS { + for (section, _) in V8_PROVIDER_FAMILIES { for provider in yaml_mapping_value(root, section) .and_then(serde_norway::Value::as_sequence) .into_iter() @@ -1016,7 +1017,7 @@ pub(crate) fn remove_managed_claude_model_alias( alias: &str, ) -> Result { let mut changed = false; - for section in MODEL_ALIAS_CONFIG_SECTIONS { + for (section, _) in V8_PROVIDER_FAMILIES { let Some(providers) = yaml_mapping_value_mut(root, section) else { continue; }; @@ -1047,7 +1048,7 @@ pub(crate) fn remove_existing_claude_model_alias( alias: &str, ) -> Result { let mut changed = false; - for section in MODEL_ALIAS_CONFIG_SECTIONS { + for (section, _) in V8_PROVIDER_FAMILIES { let Some(providers) = yaml_mapping_value_mut(root, section) else { continue; }; @@ -1218,7 +1219,7 @@ pub(crate) fn is_managed_claude_model_alias(model: &serde_norway::Value, alias: .is_some_and(|value| value == expected_display_name) } -pub(crate) fn configured_managed_claude_alias_matches( +fn configured_managed_claude_provider_alias_matches( root: &serde_norway::Mapping, alias: &str, source_model: &str, @@ -1228,26 +1229,19 @@ pub(crate) fn configured_managed_claude_alias_matches( && configured_model_identity(model) .is_some_and(|(source, _, _)| source.eq_ignore_ascii_case(source_model)) }; - let configured_provider_alias = MODEL_ALIAS_CONFIG_SECTIONS.iter().any(|section| { + V8_PROVIDER_FAMILIES.iter().any(|(section, _)| { yaml_mapping_value(root, section) .and_then(serde_norway::Value::as_sequence) .into_iter() .flatten() .filter_map(serde_norway::Value::as_mapping) - .filter(|provider| configured_provider_model_is_enabled(provider, alias)) + .filter(|provider| configured_provider_model_is_enabled(provider, alias) + && configured_provider_model_is_enabled(provider, source_model)) .filter_map(|provider| yaml_mapping_value(provider, "models")) .filter_map(serde_norway::Value::as_sequence) .flatten() .any(matches) - }); - configured_provider_alias - || yaml_mapping_value(root, "oauth-model-alias") - .and_then(serde_norway::Value::as_mapping) - .into_iter() - .flat_map(|channels| channels.values()) - .filter_map(serde_norway::Value::as_sequence) - .flatten() - .any(matches) + }) } pub(crate) fn ensure_claude_desktop_model_alias( @@ -1257,10 +1251,14 @@ pub(crate) fn ensure_claude_desktop_model_alias( alias: &str, oauth_model_definitions: &[OAuthModelDefinitions], ) -> Result<(), String> { - if configured_managed_claude_alias_matches(root, alias, source_model) { + // Keep a valid existing API-key route even when another provider publishes + // the same model. Provider ordering is not a user choice of credentials. + if configured_managed_claude_provider_alias_matches(root, alias, source_model) { return Ok(()); } - let source = resolve_claude_desktop_alias_source(sources, source_model, alias)?; + let source = resolve_claude_desktop_alias_source( + sources, source_model, alias, oauth_model_definitions, + )?; if let Some(source) = source { remove_existing_claude_model_alias(root, alias)?; append_claude_desktop_model_alias(root, source, alias)?; @@ -1291,7 +1289,7 @@ pub(crate) fn configured_model_client_identity( root: &serde_norway::Mapping, client_model: &str, ) -> Option<(String, String)> { - for section in MODEL_ALIAS_CONFIG_SECTIONS { + for (section, _) in V8_PROVIDER_FAMILIES { let Some(providers) = yaml_mapping_value(root, section).and_then(serde_norway::Value::as_sequence) else { @@ -1393,6 +1391,7 @@ fn resolve_claude_desktop_alias_source( root: &serde_norway::Mapping, source_model: &str, alias: &str, + oauth_model_definitions: &[OAuthModelDefinitions], ) -> Result, String> { for upstream in [false, true] { let matching_model = |model: &serde_norway::Value| { @@ -1405,7 +1404,7 @@ fn resolve_claude_desktop_alias_source( model.insert(yaml_key("name"), serde_norway::Value::String(name)); Some(model) }; - for section in MODEL_ALIAS_CONFIG_SECTIONS { + for (section, _) in V8_PROVIDER_FAMILIES { let Some(providers) = yaml_mapping_value(root, section) else { continue; }; @@ -1426,8 +1425,9 @@ fn resolve_claude_desktop_alias_source( .as_sequence() .ok_or_else(|| format!("{section}.models must be an array"))?; if let Some(model) = models.iter().find_map(|model| { - let (_, client_model, _) = configured_model_identity(model)?; - if !configured_provider_model_is_enabled(provider, &client_model) { + let (upstream_model, client_model, _) = configured_model_identity(model)?; + if !configured_provider_model_is_enabled(provider, &client_model) + || !configured_provider_model_is_enabled(provider, &upstream_model) { return None; } matching_model(model) @@ -1447,7 +1447,33 @@ fn resolve_claude_desktop_alias_source( let (Some(channel), Some(models)) = (channel.as_str(), models.as_sequence()) else { continue; }; - if let Some(model) = models.iter().find_map(matching_model) { + if let Some(model) = models.iter().find_map(|model| { + let resolved = matching_model(model)?; + let (upstream, client, _) = configured_model_identity(model)?; + // A managed OAuth mapping is not evidence of an OAuth source + // when this model belongs to an API-key provider. In particular, + // do not bypass that provider's exclusions via a stale mapping. + let api_source_exists = V8_PROVIDER_FAMILIES.iter().any(|(section, _)| { + yaml_mapping_value(root, section) + .and_then(serde_norway::Value::as_sequence) + .into_iter().flatten() + .filter_map(|provider| provider.get("models")) + .filter_map(serde_norway::Value::as_sequence) + .flatten().filter_map(configured_model_identity) + .any(|(name, id, _)| name.eq_ignore_ascii_case(&upstream) + || id.eq_ignore_ascii_case(source_model)) + }); + let confirmed_oauth_source = oauth_model_definitions.iter().any(|definition| { + definition.channel.key == channel && definition.models.iter() + .any(|entry| entry.id.eq_ignore_ascii_case(&upstream)) + }); + if is_managed_claude_model_alias(model, &client) + && api_source_exists && !confirmed_oauth_source + { + return None; + } + Some(resolved) + }) { return Ok(Some(ClaudeDesktopAliasSource::OAuth { channel: channel.to_string(), model, diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 245de4e28..f8eafed91 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -735,6 +735,9 @@ fn claude_desktop_config_builds_gateway_profile_and_index() { serde_json::json!(["*"]) ); assert_eq!(profile["inferenceGatewayApiKey"], DEFAULT_API_KEY); + assert_eq!(profile["inferenceCredentialKind"], "static"); + assert_eq!(profile["modelDiscoveryEnabled"], false); + assert_eq!(profile["bootstrapEnabled"], false); assert_eq!(profile["inferenceGatewayBaseUrl"], "http://127.0.0.1:8317"); assert_eq!( profile["inferenceModels"], @@ -757,6 +760,63 @@ fn claude_desktop_config_builds_gateway_profile_and_index() { assert_eq!(managed_entry["custom"], true); } +#[test] +fn claude_desktop_gateway_update_and_restore_handle_competing_sources() { + let original = serde_json::json!({ + "inferenceProvider": "gateway", "inferenceCredentialKind": "helper-script", + "inferenceCredentialHelper": "/user/helper", "inferenceGatewayAuthScheme": "sso", + "bootstrapUrl": "https://config.example.test/settings", "bootstrapEnabled": true, + "selfHostedUrl": "https://sessions.example.test", "modelDiscoveryEnabled": true, + "defaultModelEffort": "high", "coworkEgressAllowedHosts": ["example.test"] + }).to_string(); + let profile = build_claude_desktop_profile(Some(&original), "http://127.0.0.1:8317", + "test-key", "claude-sonnet-5", &[], None).unwrap(); + let value: serde_json::Value = serde_json::from_str(&profile).unwrap(); + assert!(claude_desktop_profile_uses_local_gateway(&value)); + assert!(value.get("selfHostedUrl").is_none()); + assert_eq!(value["inferenceCredentialHelper"], "/user/helper"); + assert_eq!(value["defaultModelEffort"], "high"); + let restored = build_restored_claude_desktop_config(2, &profile, Some(&original)).unwrap().unwrap(); + assert_eq!(serde_json::from_str::(&restored).unwrap(), + serde_json::from_str::(&original).unwrap()); +} + +#[test] +fn claude_desktop_status_rejects_overridden_and_incomplete_profiles() { + let profile: serde_json::Value = serde_json::from_str(&build_claude_desktop_profile( + None, "http://127.0.0.1:8317", "key", "claude-sonnet-5", &[], None).unwrap()).unwrap(); + for (key, value) in [ + ("inferenceProvider", serde_json::json!("anthropic")), + ("inferenceCredentialKind", serde_json::json!("interactive")), + ("inferenceGatewayAuthScheme", serde_json::json!("sso")), + ("selfHostedUrl", serde_json::json!("https://host.example.test")), + ("inferenceModels", serde_json::json!([])), + ("inferenceModels", serde_json::json!([{"labelOverride":"only a label"}])), + ] { + let mut changed = profile.clone(); + changed[key] = value; + assert!(!claude_desktop_profile_uses_local_gateway(&changed), "{key}"); + } + let mut bootstrap = profile.clone(); + bootstrap["bootstrapUrl"] = serde_json::json!("https://config.example.test"); + assert!(claude_desktop_profile_uses_local_gateway(&bootstrap)); + bootstrap["bootstrapEnabled"] = serde_json::json!(true); + assert!(!claude_desktop_profile_uses_local_gateway(&bootstrap)); + let mut legacy = profile; + legacy.as_object_mut().unwrap().remove("inferenceCredentialKind"); + assert!(claude_desktop_profile_uses_local_gateway(&legacy)); +} + +#[test] +fn claude_desktop_current_family_tiers_include_fable_and_mythos() { + for (id, tier) in [("claude-fable-5-1", "fable"), ("claude-mythos-5", "mythos"), + ("claude-sonnet-5-cc", "sonnet"), ("claude-opus-5", "opus"), ("claude-haiku-4-5", "haiku")] { + assert_eq!(claude_desktop_family_tier(id), Some(tier)); + let model = claude_desktop_inference_model(id, "source-model", false, &[]); + assert_eq!(model["anthropicFamilyTier"], tier); + } +} + #[test] fn claude_desktop_profile_keeps_non_claude_models_internal() { let mappings = ClaudeDesktopModelMappings { diff --git a/src-tauri/src/tests/alias_save.rs b/src-tauri/src/tests/alias_save.rs index a179beb50..19db117fa 100644 --- a/src-tauri/src/tests/alias_save.rs +++ b/src-tauri/src/tests/alias_save.rs @@ -252,7 +252,11 @@ impl MockCore { let request_body = &bytes[header_end..header_end + content_length]; let mut status = "200 OK"; let mut response = "{\"status\":\"ok\"}".to_string(); - if method == "GET" { + if method == "GET" && matches!(path, "/v1/models" | "/models") { + // Public discovery is not a complete routing contract. + // Keep this empty even after a successful alias write. + response = r#"{"data":[]}"#.to_string(); + } else if method == "GET" { assert_eq!(path, "/v8/management/config.yaml"); response = if v8_response { v8_yaml_from_legacy_json(&persisted) @@ -410,6 +414,28 @@ fn v8_yaml_from_legacy_json(legacy: &serde_json::Value) -> String { serde_norway::to_string(&serde_json::Value::Object(root)).unwrap() } +#[tokio::test(flavor = "current_thread")] +async fn desktop_save_does_not_require_routes_to_appear_in_public_discovery() { + let initial = "codex-api-key:\n - api-key: test-upstream\n models: [{name: model-a}]\n"; + let core = MockCore::new(initial, Failure::None); + assert!(fetch_agent_models(core.config.port, "test-client").await.unwrap().is_empty()); + let models = super::support::test_agent_models(&["model-a"]); + let mappings = ClaudeDesktopModelMappings::all("model-a"); + let profile = commit_agent_with_core(&core.config, Some(mappings), &models, |mappings| { + build_claude_desktop_profile(None, "http://127.0.0.1:8317", "test-client", + "model-a", &models, mappings) + }).await.unwrap(); + let profile: serde_json::Value = serde_json::from_str(&profile).unwrap(); + assert!(!profile["inferenceModels"].as_array().unwrap().is_empty()); + assert!(fetch_agent_models(core.config.port, "test-client").await.unwrap().is_empty()); + let (persisted, requests) = core.finish(); + assert!(persisted["codex-api-key"][0]["models"].as_array().unwrap().iter() + .any(|model| model["alias"] == CLAUDE_DESKTOP_SONNET_MODEL_ID)); + // Only the two explicit reads above; saving must not poll discovery or infer. + assert_eq!(requests.iter().filter(|request| *request == "GET /v1/models").count(), 2); + assert!(!requests.iter().any(|request| request.contains("/v1/messages"))); +} + #[tokio::test(flavor = "current_thread")] async fn alias_save_uses_only_targeted_writes_with_a_real_v8_config_view() { let core = MockCore::new_v8(CURRENT, Failure::None); @@ -1041,3 +1067,30 @@ async fn alias_transaction_checks_core_before_a_local_only_commit() { assert!(committed.load(Ordering::SeqCst)); assert_eq!(core.finish().0, yaml_json(¤t)); } + +#[tokio::test(flavor = "current_thread")] +async fn desktop_additional_api_channels_save_and_rollback_without_changing_credentials() { + for section in ["xai-api-key", "meta-api-key", "vertex-api-key", "interactions-api-key"] { + for fail in [false, true] { + let initial = format!("{section}:\n - api-key: preserved-secret\n base-url: https://provider.test\n models: [{{name: model-a}}]\n"); + let core = MockCore::new(&initial, Failure::None); + let result = commit_agent_with_core( + &core.config, + Some(ClaudeDesktopModelMappings::all("model-a")), + &super::support::test_agent_models(&["model-a"]), + |_| if fail { Err("local write rejected".into()) } else { Ok(()) }, + ).await; + let (persisted, _) = core.finish(); + if fail { + assert!(result.is_err()); + assert_eq!(persisted, yaml_json(&initial)); + } else { + result.unwrap(); + assert_eq!(persisted[section][0]["api-key"], "preserved-secret"); + assert_eq!(persisted[section][0]["base-url"], "https://provider.test"); + assert!(persisted[section][0]["models"].as_array().unwrap().iter() + .any(|entry| entry["alias"] == CLAUDE_DESKTOP_SONNET_MODEL_ID)); + } + } + } +} diff --git a/src-tauri/src/tests/core_v8_contract.rs b/src-tauri/src/tests/core_v8_contract.rs index 54749e4f8..c36afa8b7 100644 --- a/src-tauri/src/tests/core_v8_contract.rs +++ b/src-tauri/src/tests/core_v8_contract.rs @@ -11,6 +11,31 @@ struct TestCore { client: reqwest::Client, } +#[tokio::test] +#[ignore = "requires an explicitly prepared isolated CPA_DESKTOP_E2E_CONFIG; never use a production config"] +async fn desktop_e2e_prepare_isolated_gateway_profile() { + let path = PathBuf::from(std::env::var_os("CPA_DESKTOP_E2E_CONFIG").expect("set isolated config path")); + let native: serde_json::Value = serde_norway::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + let port = native["server"]["port"].as_u64().unwrap() as u16; + assert_ne!(port, 8317, "Never modify the production kernel"); + assert_eq!(native["server"]["host"], "127.0.0.1"); + let key = native["access"]["api-keys"][0].as_str().unwrap(); + let gui = GuiConfigFile { port, + management_secret_key: "claude-e2e-local-management".into(), + api_keys: vec![GuiApiKeyEntry {key:key.into(),remark:String::new()}], + ..GuiConfigFile::default() }; + let models = fetch_agent_models(port, key).await.unwrap(); + let mappings = ClaudeDesktopModelMappings { + desktop_models: Some(vec![ClaudeDesktopModelMapping { + model:"grok-4.7-build-fast".into(), alias:"claude-sonnet-5-cc".into(), context_1m:true, + }]), ..ClaudeDesktopModelMappings::all("grok-4.7-build-fast") }; + let profile = commit_agent_with_core(&gui, Some(mappings), &models, |mappings| { + build_claude_desktop_profile(None, &managed_core_loopback_origin(port), key, + "grok-4.7-build-fast", &models, mappings) + }).await.unwrap(); + fs::write(path.parent().unwrap().join("desktop-profile.json"), profile).unwrap(); +} + impl Drop for TestCore { fn drop(&mut self) { self.stop(); diff --git a/src-tauri/src/tests/desktop_alias_routing.rs b/src-tauri/src/tests/desktop_alias_routing.rs index 67c774a43..b71f846fd 100644 --- a/src-tauri/src/tests/desktop_alias_routing.rs +++ b/src-tauri/src/tests/desktop_alias_routing.rs @@ -1,6 +1,21 @@ use super::support::*; use super::*; +#[test] +fn desktop_repairs_oauth_alias_on_api_key_provider_channels() { + for section in ["xai-api-key", "meta-api-key", "vertex-api-key", "interactions-api-key"] { + let initial = format!("{section}:\n - api-key: test-key\n models: [{{name: source-model}}]\noauth-model-alias:\n xai:\n - name: source-model\n alias: claude-sonnet-5-cc\n fork: true\n display-name: EasyCLIProxyAPI managed Claude Desktop mapping\n"); + let mappings = custom_mappings(&[("source-model", "claude-sonnet-5-cc")]); + let rendered = ensure_claude_desktop_model_aliases_in_yaml(&initial, &mappings, &[]).unwrap(); + let parsed = json(&rendered); + assert!(parsed[section][0]["models"].as_array().unwrap().iter() + .any(|model| model["alias"] == "claude-sonnet-5-cc")); + assert!(!parsed["oauth-model-alias"]["xai"].as_array() + .is_some_and(|entries| entries.iter().any(|model| model["alias"] == "claude-sonnet-5-cc"))); + assert_eq!(ensure_claude_desktop_model_aliases_in_yaml(&rendered, &mappings, &[]).unwrap(), rendered); + } +} + fn json(content: &str) -> serde_json::Value { serde_json::to_value(serde_norway::from_str::(content).unwrap()).unwrap() } @@ -599,3 +614,45 @@ fn legacy_desktop_upstream_fallback_does_not_shadow_an_exact_oauth_alias() { .iter() .all(|entry| entry["name"] == "actual-upstream")); } + +#[test] +fn excluded_api_source_must_not_accept_stale_managed_oauth_route() { + let input = "xai-api-key:\n - api-key: key\n excluded-models: [grok-4.6]\n models: [{name: grok-4.6}]\noauth-model-alias:\n xai:\n - name: grok-4.6\n alias: claude-opus-4-5\n fork: true\n display-name: EasyCLIProxyAPI managed Claude Desktop mapping\n"; + let result = ensure_claude_desktop_model_aliases_in_yaml( + input, &custom_mappings(&[("grok-4.6", "claude-opus-4-5")]), &[]); + assert!(result.is_err(), "Excluded source was accepted as usable: {result:?}"); +} + +#[test] +fn valid_existing_provider_route_should_not_move_to_other_provider() { + let input = "gemini-api-key:\n - api-key: gemini-key\n models: [{name: shared-model}]\ncodex-api-key:\n - api-key: codex-key\n models:\n - name: shared-model\n - name: shared-model\n alias: claude-sonnet-5-cc\n display-name: EasyCLIProxyAPI managed Claude Desktop mapping\n"; + let result = ensure_claude_desktop_model_aliases_in_yaml( + input, &custom_mappings(&[("shared-model", "claude-sonnet-5-cc")]), &[]).unwrap(); + assert_eq!(json(&result), json(input), "A valid existing route changed provider during an unchanged save"); +} + +#[test] +fn desktop_stale_oauth_route_respects_api_exclusions_but_allows_confirmed_oauth() { + for restriction in ["disabled: true", "excluded-models: ['grok-*']"] { + let input = format!("xai-api-key:\n - {restriction}\n models: [{{name: grok-4.6}}]\noauth-model-alias:\n xai:\n - name: grok-4.6\n alias: claude-opus-4-5\n fork: true\n display-name: EasyCLIProxyAPI managed Claude Desktop mapping\n"); + let mappings = custom_mappings(&[("grok-4.6", "claude-opus-4-5")]); + assert!(ensure_claude_desktop_model_aliases_in_yaml(&input, &mappings, &[]).is_err()); + let definitions = [OAuthModelDefinitions { + channel: oauth_alias_channel("xai").unwrap(), + models: vec![CodexModelDefinition { + id: "grok-4.6".into(), display_name: None, description: None, + context_window: None, reasoning_levels: vec![], supports_tools: None, + }], + }]; + let updated = ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml( + &input, &mappings, &[], &definitions).unwrap(); + assert_eq!(json(&updated), json(&input)); + } +} + +#[test] +fn desktop_excluded_upstream_cannot_reuse_its_existing_provider_alias() { + let input = "xai-api-key:\n - excluded-models: [grok-4.6]\n models:\n - name: grok-4.6\n - name: grok-4.6\n alias: claude-opus-4-5\n display-name: EasyCLIProxyAPI managed Claude Desktop mapping\n"; + assert!(ensure_claude_desktop_model_aliases_in_yaml( + input, &custom_mappings(&[("grok-4.6", "claude-opus-4-5")]), &[]).is_err()); +} From b97587a589b497148a406175ba7061b0a622d467 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Thu, 22 Oct 2026 15:01:31 +0800 Subject: [PATCH 3/4] Add release note v0.3.51 --- docs/claude-desktop-compatibility.md | 112 --------------------------- docs/miaomiao-preview.md | 32 -------- docs/release-notes/v0.3.51/en.md | 24 ++++++ docs/release-notes/v0.3.51/ja.md | 24 ++++++ docs/release-notes/v0.3.51/zh-CN.md | 25 ++++++ docs/release-notes/v0.3.51/zh-TW.md | 24 ++++++ 6 files changed, 97 insertions(+), 144 deletions(-) delete mode 100644 docs/claude-desktop-compatibility.md delete mode 100644 docs/miaomiao-preview.md create mode 100644 docs/release-notes/v0.3.51/en.md create mode 100644 docs/release-notes/v0.3.51/ja.md create mode 100644 docs/release-notes/v0.3.51/zh-CN.md create mode 100644 docs/release-notes/v0.3.51/zh-TW.md diff --git a/docs/claude-desktop-compatibility.md b/docs/claude-desktop-compatibility.md deleted file mode 100644 index 92dd5078b..000000000 --- a/docs/claude-desktop-compatibility.md +++ /dev/null @@ -1,112 +0,0 @@ -# Claude Desktop gateway configuration - -Verified on 2026-10-22 against Claude Desktop for Windows 3.1623.1.0 and the official references: - -- https://claude.com/docs/third-party/claude-desktop/configuration -- https://claude.com/docs/third-party/claude-desktop/gateway - -## Local configuration contract - -Windows stores local third-party configurations in `%LOCALAPPDATA%/Claude-3p/configLibrary/`. -macOS uses `~/Library/Application Support/Claude-3p/configLibrary/`; Linux uses its Claude-3p configuration directory. -`_meta.json` selects a saved `.json` profile through `appliedId`. -These are JSON files with native booleans and arrays, not the string-encoded values required by Windows registry policy. - -CPA owns a dedicated profile and writes the following connection settings: - -```json -{ - "disableDeploymentModeChooser": true, - "inferenceProvider": "gateway", - "inferenceCredentialKind": "static", - "inferenceGatewayAuthScheme": "bearer", - "inferenceGatewayBaseUrl": "http://127.0.0.1:", - "inferenceGatewayApiKey": "", - "modelDiscoveryEnabled": false, - "bootstrapEnabled": false, - "inferenceModels": [ - { - "name": "", - "labelOverride": "" - } - ] -} -``` - -The base URL is the proxy origin; Desktop appends `/v1/messages`. -Model IDs must match routes exposed by the proxy, not display labels. CPA's explicit list disables discovery, including resolution of bare tier aliases. -Model entries use `anthropicFamilyTier` and `isFamilyDefault` for recognized opus, sonnet, haiku, fable and mythos routes. -`supports1m` and `prefer1m` are written only for an explicitly selected 1M option. -User model extensions, including `maxEffort` and `minDesktopVersion`, remain preserved on matching entries. - -An explicit static credential kind prevents old helper or interactive sign-in settings from replacing the proxy key. -CPA disables a bootstrap overlay in its profile and removes a competing `selfHostedUrl` while managing that profile. -Other user preferences and inactive credential settings remain intact. - -In the installed 3.1623.1.0 implementation, `_meta.json.hybridPointer` takes precedence over `appliedId`. -This is an implementation detail, not a documented stable API. Applying the CPA profile clears the pointer, matching Desktop's local-apply behavior. -Integration history retains the original configuration for restoration; removing a dormant CPA integration must not disable an active hybrid deployment. - -## Atomic updates and validation - -Normal updates, template application and model-mapped backup restoration update proxy routes first. -Configuration writes retain the existing validation and rollback behavior: if writing client files fails, the proxy changes roll back. -The public model catalog is not a complete routing contract. Missing or delayed discovery entries must not block saving a valid model mapping or cause a rollback. -Saving does not poll model discovery or send an inference request. It does not validate upstream availability or model behavior. -Claude Desktop's own connection test may subsequently issue a Messages API request. - -## Operational limits - -- Fully quit and reopen Claude Desktop after changing configuration; editing a file does not establish that the running application has reloaded it. -- Managed OS policy can override the entire local configuration (with the app-behavior-only exception described by Anthropic). CPA does not rewrite enterprise policy. Use Desktop's Managed Configuration Report to diagnose policy precedence. -- A missing listener, rejected credentials, an absent model route and an upstream error are different failures. Do not infer a configuration-format change from a connection banner alone. - -## Regression coverage - -Tests cover explicit static credentials, conflicting bootstrap/session-host settings, hybrid switching and restoration, preserving user preferences, current model tiers, successful alias saves with an empty public model catalog, and rollback when configuration writes fail. -The isolated `v8_claude_desktop_and_code_share_persisted_model_routes` test exercises a real proxy executable through `CPA_V8_TEST_CORE` without changing the user's running proxy. - -## API-key routing repair and live verification (2026-10-22) - -Desktop source resolution, identity lookup and managed-route cleanup enumerate all eight -`V8_PROVIDER_FAMILIES`. The older generic reasoning-alias section list does not include -xAI, Meta, Vertex or Interactions API-key sources and must not drive Desktop routing. -A managed alias with matching model names is not a no-op when the selected source -resolves to a different API-key provider. Repair it on the selected provider before -writing the Desktop profile. Alias transactions still preserve API credentials and -roll back route changes if the client write fails. - -Live verification used Claude Desktop 3.1623.1.0 and CLIProxyAPI 8.0.36 at -`127.0.0.1:18317`, with a separate configuration and OAuth directory. Production -port 8317 was not changed. The application transaction generated the test profile -and persisted `grok-4.7-build-fast -> claude-sonnet-5-cc` on the xAI API-key source. -After restarting Desktop, the unavailable-model banner disappeared. A real message -asking `What is 2 + 3?` received `5.` in the Desktop conversation. The isolated -request log recorded HTTP 200 for `/v1/messages?beta=true`, a text response and -`message_stop`. A follow-up asking to add 7 received `12`, also with HTTP 200 and -a completed stream. The final suite passed 973 tests (12 opt-in tests ignored); -the isolated profile helper also passed explicitly. The Windows release build succeeded. -This verifies a short streamed conversation, not 1M-token capacity, -Cowork or arbitrary tool use. The old selected `grok-4.6` was unavailable in the -active provider; this test used the available `grok-4.7-build-fast` model. - -The opt-in `desktop_e2e_prepare_isolated_gateway_profile` helper requires an explicit -`CPA_DESKTOP_E2E_CONFIG` and rejects port 8317. It prepares configuration only; -Desktop UI verification is a separate manual/computer-use step. Runtime test files -contain credentials and stay in ignored `bin-work`, never in this document. - -## Review follow-up: stable routing and excluded sources - -An unchanged Desktop save retains its selected managed alias during cleanup and -keeps a valid existing API-key provider, even if another provider publishes the -same model ID. Disabled providers and exclusions are checked for both the upstream -model and client alias. A stale managed OAuth route for a configured API-key model -cannot substitute for an excluded API source without a matching OAuth channel/model -definition. Explicit legacy OAuth routes remain supported. - -Regression coverage includes the two review reproductions, wildcard exclusions, -disabled API providers, confirmed OAuth alternatives, and an excluded upstream -with an existing API-key alias. The final suite passed 977 tests with 12 opt-in -tests ignored. The isolated profile application test passed separately. After -reapplying with the updated code on port 18317, a real Desktop message asking -`8 + 9` received `17`. diff --git a/docs/miaomiao-preview.md b/docs/miaomiao-preview.md deleted file mode 100644 index ea9ddb3af..000000000 --- a/docs/miaomiao-preview.md +++ /dev/null @@ -1,32 +0,0 @@ -# Miaomiao Preview - -Download: https://github.com/router-for-me/EasyCLIProxyAPI/releases/tag/miaomiao-preview - -Only non-draft, open PRs authored by `Roomen` and targeting `miaomiao` -are automatically merged. The `Build portable releases` PR workflow must -complete successfully for the current PR head, including all six platform -builds. The merge uses the exact tested head SHA and obeys GitHub branch -protection and conflict checks. Other authors and target branches are ignored. -Draft-to-ready and PR edits trigger another PR build so eligibility is checked -again. Fork builds may require GitHub's normal workflow approval first. - -After merging, the workflow explicitly builds the merged commit, because -merges made with GITHUB_TOKEN do not trigger a separate push workflow. -Direct pushes to `miaomiao` also build previews. To retry a preview manually, -run `Miaomiao preview` with the `miaomiao` branch selected. - -All six builds must pass before publication. Publication is skipped if the -source commit is no longer the current branch head. The fixed prerelease -`miaomiao-preview`, titled `Miaomiao Preview`, is updated with versioned -Windows, Linux and macOS packages for amd64 and aarch64. macOS previews -use the existing signing and notarization configuration. New packages are -uploaded before old assets are removed. This release never becomes Latest. -After automatic merging and successful publication, a PR comment links to -the rolling download page. Later builds replace its packages. - -Both `main` (the default branch, which receives workflow_run events) and -`miaomiao` must retain these workflow changes. Resetting either branch to -another branch without these files can disable the automation again. -No repository-wide GitHub auto-merge toggle or personal access token is needed. - -Validation: `bun test tests/miaomiaoPreview.test.ts`. diff --git a/docs/release-notes/v0.3.51/en.md b/docs/release-notes/v0.3.51/en.md new file mode 100644 index 000000000..3f4fa01bb --- /dev/null +++ b/docs/release-notes/v0.3.51/en.md @@ -0,0 +1,24 @@ +- If you have any questions or suggestions about the app, please contact us through our [QQ group](https://qm.qq.com/q/3queDaIG) or [GitHub Issues](https://github.com/router-for-me/EasyCLIProxyAPI/issues). We will respond as soon as possible. + +## Improvements + +- Updated gateway configuration for newer Claude Desktop versions. CPA now explicitly uses static credentials and a specified model list to prevent legacy authentication, automatic configuration, or session service settings from overriding the CPA connection. +- Updating Claude Desktop configuration now correctly switches to the local CPA profile. Restoring a backup also restores the previous configuration selection. +- Added recognition of the Fable and Mythos model families in Claude Desktop configuration. + +## Fixes + +- Fixed incomplete Claude Desktop model alias handling for xAI, Meta, Vertex, and Interactions API key providers. API key models are no longer incorrectly mapped to OAuth channels, which could make models unavailable. +- Fixed aliases still in use being removed when saving configuration again. Valid existing routes are preserved when multiple providers offer the same model ID, preventing unintended provider changes. +- Fixed disabled or excluded API key models being incorrectly accepted through stale OAuth mappings. +- Improved Claude Desktop configuration checks to detect authentication and configuration source conflicts, as well as incomplete model lists. +- Improved credential protection and rollback handling when saving model mappings, preserving provider keys and connection settings. + +## Release Workflow + +- Restored automatic Miaomiao preview builds and publication. Non-draft PRs authored by Roomen and targeting `miaomiao` are automatically merged after all six platform builds pass. The merged commit is then built and the rolling preview is updated. + +## Usage Notes + +- After upgrading, save the Claude Desktop configuration again in CPA, then fully quit and reopen Claude Desktop. +- Model aliases must point to an available source model. If the source model is disabled or excluded, adjust its access configuration or select another available model. diff --git a/docs/release-notes/v0.3.51/ja.md b/docs/release-notes/v0.3.51/ja.md new file mode 100644 index 000000000..581ab88ae --- /dev/null +++ b/docs/release-notes/v0.3.51/ja.md @@ -0,0 +1,24 @@ +- アプリに関するご質問やご提案は、[QQ グループ](https://qm.qq.com/q/3queDaIG) または [GitHub Issues](https://github.com/router-for-me/EasyCLIProxyAPI/issues) までお寄せください。できる限り速やかに対応します。 + +## 改善 + +- 新しい Claude Desktop のゲートウェイ設定に対応しました。静的な認証情報と指定したモデル一覧を明示的に使用し、以前の認証方式、自動設定、セッションサービスの設定によって CPA 接続が上書きされることを防ぎます。 +- Claude Desktop の設定更新時に、CPA のローカル設定へ正しく切り替わるようになりました。バックアップの復元時には、以前の設定の選択状態も復元します。 +- Claude Desktop の設定で Fable、Mythos モデル系列を認識できるようになりました。 + +## 修正 + +- xAI、Meta、Vertex、Interactions の API キー接続における Claude Desktop のモデルエイリアス処理を修正しました。API キーのモデルが誤って OAuth チャネルに割り当てられ、利用できなくなる問題を防ぎます。 +- 設定を再保存すると、使用中のモデルエイリアスが削除される問題を修正しました。複数のプロバイダーに同名のモデルがある場合も、有効な既存のルーティングを維持し、意図しないプロバイダーの切り替えを防ぎます。 +- 無効化または除外された API キーのモデルが、残存する OAuth マッピングによって誤って受け入れられる問題を修正しました。 +- Claude Desktop の設定状態の確認を改善し、認証方式や設定元の競合、モデル一覧の不備を検出できるようにしました。 +- モデルマッピングの保存時における認証情報の保護と失敗時のロールバック処理を改善し、プロバイダーのキーと接続設定を保持します。 + +## リリースフロー + +- Miaomiao プレビュー版の自動ビルドと公開を復旧しました。Roomen が作成した `miaomiao` 向けのドラフトではない PR は、全 6 プラットフォームのビルド成功後に自動マージされます。その後、マージ済みのコミットをビルドし、ローリングプレビュー版を更新します。 + +## 使用上の注意 + +- アップグレード後は、CPA で Claude Desktop の設定を再保存し、Claude Desktop を完全に終了してから再起動してください。 +- モデルエイリアスには、利用可能な元のモデルが必要です。元のモデルが無効化または除外されている場合は、該当する接続設定を調整するか、別の利用可能なモデルを選択してください。 diff --git a/docs/release-notes/v0.3.51/zh-CN.md b/docs/release-notes/v0.3.51/zh-CN.md new file mode 100644 index 000000000..b537543b5 --- /dev/null +++ b/docs/release-notes/v0.3.51/zh-CN.md @@ -0,0 +1,25 @@ +- 关于软件,如果您有任何问题或建议,请通过 [QQ群](https://qm.qq.com/q/3queDaIG) 或 [GitHub Issues](https://github.com/router-for-me/EasyCLIProxyAPI/issues) 反馈,我们会尽快处理。 + +## 优化 + +- 适配新版 Claude Desktop 的网关配置,明确使用静态凭证和指定模型列表,避免旧的认证方式、自动配置或会话服务设置覆盖 CPA 连接。 +- 更新 Claude Desktop 配置时正确切换到 CPA 本地配置,并支持在恢复备份时还原原有配置选择。 +- 补充 Claude Desktop 对 Fable、Mythos 模型系列的识别。 + +## 修复 + +- 修复 xAI、Meta、Vertex 和 Interactions API Key 通道的 Claude Desktop 模型别名处理不完整的问题,避免将 API Key 模型错误映射到 OAuth 通道,导致模型不可用。 +- 修复重复保存配置时,仍在使用的模型别名被清理的问题。同名模型存在于多个提供方时,保留有效的原有路由,避免意外切换提供方。 +- 修复被禁用或排除的 API Key 模型可能通过残留 OAuth 映射被错误接受的问题。 +- 完善 Claude Desktop 配置状态检查,识别认证方式、配置来源冲突及模型列表不完整的情况。 +- 完善模型映射保存时的凭证保护与失败回滚处理,保留提供方的密钥和连接设置。 + +## 发布流程 + +- 恢复 Miaomiao 预览版自动构建与发布:由 Roomen 提交、目标为 `miaomiao` 的非草稿 PR,在六个平台构建通过后自动合并,并构建合并后的提交、更新滚动预览版。 + +## 使用说明 + +- 升级后,请在 CPA 中重新保存 Claude Desktop 配置,并完全退出、重新打开 Claude Desktop。 +- 模型别名需要对应可用的原始模型。若原始模型已被停用或排除,请调整对应接入配置,或选择其他可用模型。 + diff --git a/docs/release-notes/v0.3.51/zh-TW.md b/docs/release-notes/v0.3.51/zh-TW.md new file mode 100644 index 000000000..547850e5a --- /dev/null +++ b/docs/release-notes/v0.3.51/zh-TW.md @@ -0,0 +1,24 @@ +- 關於軟體,如果您有任何問題或建議,請透過 [QQ群組](https://qm.qq.com/q/3queDaIG) 或 [GitHub Issues](https://github.com/router-for-me/EasyCLIProxyAPI/issues) 回饋,我們會盡快處理。 + +## 優化 + +- 適配新版 Claude Desktop 的閘道設定,明確使用靜態憑證與指定模型清單,避免舊的驗證方式、自動設定或工作階段服務設定覆寫 CPA 連線。 +- 更新 Claude Desktop 設定時正確切換至 CPA 本機設定,並支援在還原備份時恢復原有的設定選擇。 +- 補充 Claude Desktop 對 Fable、Mythos 模型系列的識別。 + +## 修復 + +- 修復 xAI、Meta、Vertex 和 Interactions API Key 通道的 Claude Desktop 模型別名處理不完整的問題,避免將 API Key 模型錯誤對應至 OAuth 通道,導致模型無法使用。 +- 修復重複儲存設定時,仍在使用的模型別名被清除的問題。同名模型存在於多個提供者時,保留有效的原有路由,避免意外切換提供者。 +- 修復被停用或排除的 API Key 模型可能透過殘留 OAuth 對應被錯誤接受的問題。 +- 完善 Claude Desktop 設定狀態檢查,識別驗證方式、設定來源衝突及模型清單不完整的情況。 +- 完善模型對應儲存時的憑證保護與失敗回復處理,保留提供者的金鑰與連線設定。 + +## 發布流程 + +- 恢復 Miaomiao 預覽版自動建置與發布:由 Roomen 提交、目標為 `miaomiao` 的非草稿 PR,在六個平台建置通過後自動合併,並建置合併後的提交、更新滾動預覽版。 + +## 使用說明 + +- 升級後,請在 CPA 中重新儲存 Claude Desktop 設定,並完全結束、重新開啟 Claude Desktop。 +- 模型別名需要對應可用的原始模型。若原始模型已被停用或排除,請調整對應連線設定,或選擇其他可用模型。 From 1d5198eb672a0e0e3257a275bd2b9e42951dbdeb Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Thu, 22 Oct 2026 15:02:11 +0800 Subject: [PATCH 4/4] Update core version to v8.0.38 --- core-version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core-version.txt b/core-version.txt index 41cda36c0..7c973d6f1 100644 --- a/core-version.txt +++ b/core-version.txt @@ -1 +1 @@ -8.0.36 +8.0.38