diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 4bd53a72..9efe9afb 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -16,8 +16,11 @@ permissions: contents: read jobs: + # Deliberately not a dependency of native-arm64. These finish in about a minute; making the + # macOS job wait for them added that minute to every run's critical path and gated the suite + # that actually covers the shipping app behind the one that does not. shared-tests: - name: Shared JavaScript and Rust tests + name: Shared JavaScript tests runs-on: ubuntu-22.04 steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 @@ -28,25 +31,13 @@ jobs: with: node-version: 24.19.0 cache: npm - - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c - with: - toolchain: 1.98.0 - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - with: - workspaces: './src-tauri -> target' - name: Install test dependencies - run: | - npm ci --ignore-scripts --no-audit --no-fund - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libssl-dev + run: npm ci --ignore-scripts --no-audit --no-fund - name: JavaScript tests run: npm test - - name: Rust tests - run: cargo test --locked --lib --manifest-path src-tauri/Cargo.toml native-arm64: name: Native tests and unsigned universal package - needs: shared-tests runs-on: macos-26 env: DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer @@ -110,36 +101,6 @@ jobs: cp "$codex" native/Vendor/test-tools/codex cp "$claude" native/Vendor/test-tools/claude chmod 755 native/Vendor/test-tools/codex native/Vendor/test-tools/claude - # Fail quickly on the large-reader regression, then still run both complete suites. - # Its separate result bundle is never included in the full-suite membership counts. - - name: Preflight the twelve-thousand-message reader - shell: bash - run: | - set -euo pipefail - result="$RUNNER_TEMP/CCBuddyLongTranscriptPreflight.xcresult" - summary="$RUNNER_TEMP/CCBuddyLongTranscriptPreflight-summary.json" - tests="$RUNNER_TEMP/CCBuddyLongTranscriptPreflight-tests.json" - test ! -e "$result" - xcodebuild test \ - -project native/CCBuddy.xcodeproj \ - -scheme CCBuddy \ - -configuration Debug \ - -destination 'platform=macOS,arch=arm64' \ - -only-testing:CCBuddyUITests/NativeSearchExperienceUITests/testTwelveThousandMessageTranscriptCanFindItsTailAndReturnToSmallSession \ - -parallel-testing-enabled NO \ - -maximum-parallel-testing-workers 1 \ - -resultBundlePath "$result" \ - ARCHS=arm64 ONLY_ACTIVE_ARCH=YES CODE_SIGN_IDENTITY=- \ - ENABLE_HARDENED_RUNTIME=NO \ - CCBUD_PRODUCT_BUNDLE_IDENTIFIER="dev.ccbud.gateway.preflight.${GITHUB_RUN_ID}.${GITHUB_RUN_ATTEMPT}" - xcrun xcresulttool get test-results summary --path "$result" --compact > "$summary" - xcrun xcresulttool get test-results tests --path "$result" --compact > "$tests" - jq -e '.result == "Passed" and .totalTestCount == 1 and .passedTests == 1 - and .failedTests == 0 and .skippedTests == 0 and .expectedFailures == 0' "$summary" - jq -e '[.. | objects | select(.nodeType == "Test Case")] as $cases - | ($cases | length) == 1 - and $cases[0].nodeIdentifier == "NativeSearchExperienceUITests/testTwelveThousandMessageTranscriptCanFindItsTailAndReturnToSmallSession()" - and $cases[0].result == "Passed"' "$tests" - name: Run native unit tests with all real E2Es shell: bash env: @@ -242,7 +203,7 @@ jobs: set -euo pipefail evidence="$RUNNER_TEMP/ccbud-test-evidence" mkdir -p "$evidence" - for suite in CCBuddyLongTranscriptPreflight CCBuddyTests CCBuddyUITests; do + for suite in CCBuddyTests CCBuddyUITests; do result="$RUNNER_TEMP/$suite.xcresult" if [[ -d "$result" ]]; then xcrun xcresulttool get test-results summary --path "$result" \ @@ -251,11 +212,6 @@ jobs: --compact > "$evidence/$suite-tests.json" fi done - if [[ -d "$RUNNER_TEMP/CCBuddyLongTranscriptPreflight.xcresult" ]]; then - xcrun xcresulttool export attachments \ - --path "$RUNNER_TEMP/CCBuddyLongTranscriptPreflight.xcresult" \ - --output-path "$evidence/preflight-attachments" - fi if [[ -d "$RUNNER_TEMP/CCBuddyUITests.xcresult" ]]; then xcrun xcresulttool export attachments \ --path "$RUNNER_TEMP/CCBuddyUITests.xcresult" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c98b7a48..63e67c40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -121,30 +121,15 @@ jobs: RELEASE_COMMIT: ${{ needs.prepare.outputs.commit }} RELEASE_SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }} run: node scripts/prepare-main-release.js --verify "$RELEASE_COMMIT" - - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c - with: - toolchain: 1.98.0 - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - with: - workspaces: './src-tauri -> target' - name: Verify synchronized release version run: node scripts/release-version.js check "${{ steps.tag.outputs.version }}" - - name: Run legacy updater validator suites + - name: Validate the published updater manifest shell: bash - run: | - set -euo pipefail - node native/Scripts/tests/verify-legacy-tauri-manifest.test.js - node --test native/Scripts/tests/generate-latest-json.test.js - bash native/Scripts/tests/verify-legacy-tauri-update-contract.test.sh + run: node --test native/Scripts/tests/generate-latest-json.test.js - name: Install shared test dependencies - run: | - npm ci --ignore-scripts --no-audit --no-fund - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libssl-dev + run: npm ci --ignore-scripts --no-audit --no-fund - name: JavaScript tests run: npm test - - name: Rust tests - run: cargo test --locked --lib --manifest-path src-tauri/Cargo.toml native-test: name: Native tests and unsigned packaged checks @@ -498,15 +483,6 @@ jobs: cleanup_credentials trap - EXIT INT TERM HUP - - name: Validate the legacy Tauri 1.3.9 updater input contract - shell: bash - run: | - set -euo pipefail - native/Scripts/verify-legacy-tauri-update-contract.sh \ - "$RUNNER_TEMP/ccbud-native/artifacts/CC.Buddy_universal.app.tar.gz" \ - "$RUNNER_TEMP/ccbud-native/artifacts/CC.Buddy_universal.app.tar.gz.sig" \ - "$RUNNER_TEMP/ccbud-native/artifacts/latest.json" \ - "${{ needs.verify.outputs.version }}" "${{ github.repository }}" - name: Upload immutable release inputs uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f with: diff --git a/README.md b/README.md index 76e95ed8..23b9b7d6 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,12 @@ The redesign follows a macOS 27 design direction using available macOS 26 APIs b ## Included: local API gateway -As a companion feature, the gateway accepts **Anthropic Messages**, **OpenAI Chat Completions**, and **OpenAI Responses** on both client and provider sides, passing through matching protocols or translating between them. It configures **Claude Code and Codex** with one click; other compatible clients can use the local endpoint manually. Around seventy provider presets ship built in, alongside custom and plugin-backed providers, with switching and model mapping. +As a companion feature, the gateway accepts **Anthropic Messages**, **OpenAI Chat Completions**, and **OpenAI Responses** from clients, whichever of those three you configure as upstreams. + +- **Configure all three** and every client is handed to the upstream that already speaks its protocol, untouched. +- **Configure one or two** and a client speaking a protocol you did have still passes through, while one speaking a protocol you do not is converted for you. + +Either way, if an upstream fails the rest of your queue takes over in order. It configures **Claude Code and Codex** with one click; other compatible clients can use the local endpoint manually. Around seventy provider presets ship built in, alongside custom and plugin-backed providers, with switching and model mapping. A provider that publishes `/v1/models` or `/models` can fill in its own model bindings from the editor; one that does not leaves the control disabled rather than failing. The gateway binds to `127.0.0.1`; inference requests still go to the provider you select. diff --git a/README.zh-CN.md b/README.zh-CN.md index ff5edebf..2a4bc0d6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -46,7 +46,12 @@ CLI 会话记录 ──▶ CC Buddy ──▶ 浏览 · 搜索 · 追踪 · 导 ## 附带能力:本地 API 网关 -作为附带能力,网关的客户端和上游均支持 **Anthropic Messages、OpenAI Chat Completions 和 OpenAI Responses**:协议相同则直通,不同则相互转换。它能一键配置 **Claude Code 和 Codex**;其他兼容客户端可手动使用本地端点。内置约七十个服务商预设,也支持自定义与插件服务,均可切换并映射模型。 +作为附带能力,网关始终接受客户端发来的 **Anthropic Messages、OpenAI Chat Completions 和 OpenAI Responses**,而上游由你在这三种协议中自行选配。 + +- **三种都配**:每个客户端都会被交给协议相同的那个上游,原样直通,不做任何转换。 +- **只配一种或两种**:协议已配置的客户端照样直通;协议未配置的客户端由网关完成协议转换后再发出。 + +两种情况下,若某个上游失败,队列中的其余上游都会按顺序接管。它能一键配置 **Claude Code 和 Codex**;其他兼容客户端可手动使用本地端点。内置约七十个服务商预设,也支持自定义与插件服务,均可切换并映射模型。若服务商提供 `/v1/models` 或 `/models`,可在编辑器中一键拉取并自动建立模型绑定;没有该接口时按钮会置灰而不是报错。 网关只监听 `127.0.0.1`;推理请求仍会发送给所选服务商。 diff --git a/native/CCBuddy.xcodeproj/project.pbxproj b/native/CCBuddy.xcodeproj/project.pbxproj index f2f67c0d..c3723722 100644 --- a/native/CCBuddy.xcodeproj/project.pbxproj +++ b/native/CCBuddy.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 07874D8AD8FF549B4A62724D /* MenuBarUsageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBC515C889C13A4BBA92CAEC /* MenuBarUsageTests.swift */; }; 07BCE24A86CBEB4145EA04BA /* ConversationMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1587054CDF99625CCC80A719 /* ConversationMessageView.swift */; }; 07D32A4555CCB5EFCECCD0F8 /* valid-plugin.json in Resources */ = {isa = PBXBuildFile; fileRef = 4C147226666B366D21D68FEC /* valid-plugin.json */; }; + 09214113C7454374FED6339B /* BoundedMemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B86E383FA74D79F7B49F0A58 /* BoundedMemoryCache.swift */; }; 096F2EABDC65CC0751F91A9D /* SkillsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F2377F1AA19C0445033222 /* SkillsServiceTests.swift */; }; 098696F7CF49006E2AB3EF8D /* ProvidersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E351EB3332316BC4119C10 /* ProvidersView.swift */; }; 0A108D6974FC24F74EFDB5DD /* CLIConnectionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F438077993F7B9F080464A8A /* CLIConnectionManagerTests.swift */; }; @@ -30,6 +31,7 @@ 0E01DA42D16BAD29E34ABA2E /* PluginManifestValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65EE8CF8C7046B6CEF034B84 /* PluginManifestValidation.swift */; }; 0E689EBE6CF428800AEB57D1 /* UpdateModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25E570DC57688B345A2BAB50 /* UpdateModels.swift */; }; 0E82961F0627E8A2739BEEE2 /* SkillsViewOperations.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF066709364C3E043291739E /* SkillsViewOperations.swift */; }; + 0FFA6571280C1AE51068EE09 /* ProviderModelDiscoveryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D698E95C47A4A499973A75 /* ProviderModelDiscoveryService.swift */; }; 11A9256E1DB63ED0BAC4AA1A /* SkillsInstallOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25257F9E5EC8888A219E540C /* SkillsInstallOptionsView.swift */; }; 12D0CFD70FF1E55783ADC16F /* CodexMessageNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65356F6F7F99947876BFF04D /* CodexMessageNormalizer.swift */; }; 139B2014D80FBF68A0EEAF4E /* HistoryAdvancedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E420832A1781B39297AD612 /* HistoryAdvancedTests.swift */; }; @@ -58,6 +60,7 @@ 25634D435C4E8CD41541DB13 /* ConversationHistoryProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCE012B87DAB25511EAE84F3 /* ConversationHistoryProviding.swift */; }; 265E56D43BFF72C92322AC85 /* ConversationFileCatalogSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94A1BA8DD76661007670800B /* ConversationFileCatalogSearchTests.swift */; }; 2704E3B136A22ED92ED0A682 /* ConversationArchiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC18182779BA0AE2600D40A2 /* ConversationArchiveTests.swift */; }; + 27B763EB7896CCC978B963DB /* GatewayProtocolRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DB696DF1740177CDD4AD164 /* GatewayProtocolRoutingTests.swift */; }; 29CC805D0000B120B55DDC82 /* zhipu.svg in Resources */ = {isa = PBXBuildFile; fileRef = EC0FDFCB6E518A3B299066DE /* zhipu.svg */; }; 2B469A3B8E0A68A5D9B2AE14 /* MonitorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54D6D1F39E624E7BA851331B /* MonitorStore.swift */; }; 2D107A736D6A4B1F1EF12AFF /* MonitorDetailDrawer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B7F265DEB955C5388A16E5 /* MonitorDetailDrawer.swift */; }; @@ -104,6 +107,7 @@ 53128D85D37947AEE1102BED /* UpdateCryptography.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FBB4D910E85764DBAE4761 /* UpdateCryptography.swift */; }; 53A4F83A31C5E92758B45357 /* ConversationHTMLExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F208AD38A97D0A372930BEA /* ConversationHTMLExporterTests.swift */; }; 53ADAEA860D6DBF34FACB688 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AC0A8FF88300F4634C330E2 /* SettingsView.swift */; }; + 54BC86191766B5C93434EE52 /* GatewayUpstreamURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373C3017BCA0B96F4E2E9254 /* GatewayUpstreamURL.swift */; }; 559F79F90D82C05405D9DFF1 /* BifrostManagementClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5390E75BB1C5743700E7FC /* BifrostManagementClient.swift */; }; 55F51C6CCFDEA2625A1700D9 /* MiniLM-LICENSE.txt in Resources */ = {isa = PBXBuildFile; fileRef = 061340969C3D76CF90825EAF /* MiniLM-LICENSE.txt */; }; 5601B056F12CF28584449D1D /* ConversationIndexScannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A8DA25160A01825760F2EC /* ConversationIndexScannerTests.swift */; }; @@ -156,10 +160,12 @@ 77813510F9B7CC98EB698E34 /* ConversationLiteralSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A31BAB58B93D3FAC16ABA4C /* ConversationLiteralSearch.swift */; }; 787D097CD8AC54C49B04D09F /* PluginManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9222EAC972B6255EBAE7EB /* PluginManagement.swift */; }; 78BB7DC753EAE680029B3A65 /* SkillsIndexRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CD0079A2C4C29FC6A0B796 /* SkillsIndexRepository.swift */; }; + 78F13A22A373BD1C73D49B60 /* ProviderModelDiscoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 688BC01CE3B98FA99ED5E8A0 /* ProviderModelDiscoveryTests.swift */; }; 798BC6E5EC9925147160641C /* MenuBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4189171E1EF948DE19DA8F /* MenuBarController.swift */; }; 7AADB8A4CC4C3DBED9F99B6B /* antigravity.png in Resources */ = {isa = PBXBuildFile; fileRef = 850C2A27D4B7560C4CB1A115 /* antigravity.png */; }; 7BA83D0506B5FD54085689D3 /* LegacyGatewayProxyLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12781A0CE9D687750CB274B4 /* LegacyGatewayProxyLifecycleTests.swift */; }; 7C1FAEE1923B69074D92E871 /* zenmux.svg in Resources */ = {isa = PBXBuildFile; fileRef = B6A50DDDDBA95B4560234BB9 /* zenmux.svg */; }; + 7C2A8C589A0F412BC068C959 /* MemoryPressureMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBBEC678473EC4F3CDD6EA21 /* MemoryPressureMonitor.swift */; }; 7EA6BD187E4132DA3753C2B1 /* SkillsTagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A694870A90C320C264A827E3 /* SkillsTagsView.swift */; }; 7F8AB587F041F6C6E4D7B250 /* ProviderProbeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91DF39EB3A3F102BFA028424 /* ProviderProbeService.swift */; }; 7FD70CD8DC5EBE2CE961E7F0 /* HistoryDirectoryDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = D737B871E1FA575B014CE7BE /* HistoryDirectoryDiscovery.swift */; }; @@ -191,11 +197,13 @@ 9036F5A0DAD1B3CB759C3CF4 /* LocationsSettingsPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = A24A198BCEC25FDA7672106D /* LocationsSettingsPane.swift */; }; 90DD6CAD76B56880C1E31B78 /* ConversationBlockRenderCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 751BFF3773DBDAA2EECE26B0 /* ConversationBlockRenderCacheTests.swift */; }; 91BE317F9AE916C7612CF63E /* ForeignHistoryParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C6331BD246BB06F57E7199F /* ForeignHistoryParserTests.swift */; }; + 92A55F57D4EA819A15E2408B /* GatewayUpstreamURLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9F76DD27441FB8244170AE /* GatewayUpstreamURLTests.swift */; }; 92CB12A8A03D0314EABE9E40 /* deepseek.svg in Resources */ = {isa = PBXBuildFile; fileRef = 7B5D03DE556298B172BF7F2D /* deepseek.svg */; }; 9637D5F8FD7BC09227201365 /* HistorySQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = C56BB3100926DB743FD07BDC /* HistorySQLite.swift */; }; 96C341DBEA09B0CD367A9CE9 /* SkillsTagEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3264E7534FA455DC70F5F8D7 /* SkillsTagEditor.swift */; }; 96CCF0A03757CE533F5088F0 /* SingleInstanceLockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B47F550C13733CF5D11BF17C /* SingleInstanceLockTests.swift */; }; 970723D93DDF560AD5ABC8EB /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16346A87926DF03A2B3404E6 /* Components.swift */; }; + 9715D19E24970DFB94D262C0 /* GatewayProtocolRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A8568C60635DC434D1A9140 /* GatewayProtocolRouting.swift */; }; 977A6288EBE3BDB8B2996289 /* minilm-vocab.txt in Resources */ = {isa = PBXBuildFile; fileRef = A1AAFBAB1925F9B64A728117 /* minilm-vocab.txt */; }; 993DD0479D39C0ACE547EAA0 /* ProviderEditorSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBB5099CE0E8163F18F4797B /* ProviderEditorSupport.swift */; }; 99C1D8C7670509C8AF0AABCC /* BifrostManagementCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = C198442E0684851873228DC0 /* BifrostManagementCredentials.swift */; }; @@ -271,6 +279,7 @@ D910466BA5E0042175CD837F /* PluginGitServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81674625F330DD20D2EC7F8 /* PluginGitServiceTests.swift */; }; DAEC99B6E334B481139E9881 /* THIRD_PARTY_NOTICES.md in Resources */ = {isa = PBXBuildFile; fileRef = 5E6E710138388AA65B0D5B92 /* THIRD_PARTY_NOTICES.md */; }; DBCC46AB587D47EC471CE227 /* ConversationResumeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474EA02B6568E99E4AC92F83 /* ConversationResumeTests.swift */; }; + DC08CE9C7D704AA1B2643D04 /* BoundedMemoryCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7D8DC793108737036206F63 /* BoundedMemoryCacheTests.swift */; }; DF3B7A1DCC967FBF7FC3D6FE /* CodexToolMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA7E5E37F81A908528B5009 /* CodexToolMapper.swift */; }; DFFFD99D5A7F8AA7691D6D65 /* CodexResponsesHistoryStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95A61364990801EFB42B09D /* CodexResponsesHistoryStoreTests.swift */; }; E0E25DB307AB335283DB96FD /* ConversationSourceSearchCoverage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CD2538DA7C8A8C839DFEE8 /* ConversationSourceSearchCoverage.swift */; }; @@ -444,6 +453,7 @@ 326D3888C64242B02A6EDEF8 /* MonitorSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitorSupport.swift; sourceTree = ""; }; 340E54A278A79109DD5537BE /* UsageHistoryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageHistoryService.swift; sourceTree = ""; }; 35C1FE7A7B34C8527ABECA25 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; + 373C3017BCA0B96F4E2E9254 /* GatewayUpstreamURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GatewayUpstreamURL.swift; sourceTree = ""; }; 377203344050906107EB2B01 /* NativeWorkbenchStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeWorkbenchStateTests.swift; sourceTree = ""; }; 3843426FFCFAAB585E5E8E04 /* grok-light.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "grok-light.png"; sourceTree = ""; }; 38654E16EF32896C44805730 /* SubagentRolloutNestingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubagentRolloutNestingTests.swift; sourceTree = ""; }; @@ -455,6 +465,8 @@ 3A69ADB27289A105969A2ED3 /* HistoryModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryModels.swift; sourceTree = ""; }; 3A9D226B253DB3C17C51BD02 /* CCBuddyUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CCBuddyUITests.swift; sourceTree = ""; }; 3B555CCD94EB0411866880E8 /* PluginRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginRepositoryTests.swift; sourceTree = ""; }; + 3D9F76DD27441FB8244170AE /* GatewayUpstreamURLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GatewayUpstreamURLTests.swift; sourceTree = ""; }; + 3DB696DF1740177CDD4AD164 /* GatewayProtocolRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GatewayProtocolRoutingTests.swift; sourceTree = ""; }; 3E420832A1781B39297AD612 /* HistoryAdvancedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryAdvancedTests.swift; sourceTree = ""; }; 409331E776EE56FEC5FD19AF /* copilot.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = copilot.png; sourceTree = ""; }; 474EA02B6568E99E4AC92F83 /* ConversationResumeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationResumeTests.swift; sourceTree = ""; }; @@ -497,6 +509,7 @@ 661F0799E28AA2309386C04B /* Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Provider.swift; sourceTree = ""; }; 66FAD00D4672338CD9969A81 /* GatewaySettingsPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GatewaySettingsPane.swift; sourceTree = ""; }; 6794FB56FA4DCBC6F2BF6322 /* ConversationIndexScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationIndexScanner.swift; sourceTree = ""; }; + 688BC01CE3B98FA99ED5E8A0 /* ProviderModelDiscoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderModelDiscoveryTests.swift; sourceTree = ""; }; 69B7F265DEB955C5388A16E5 /* MonitorDetailDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitorDetailDrawer.swift; sourceTree = ""; }; 6A5C20FC1425184E7E870DB6 /* SkillsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkillsStoreTests.swift; sourceTree = ""; }; 6A9CC77C414AB1F96B6385EB /* MonitorPayloadTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitorPayloadTextView.swift; sourceTree = ""; }; @@ -547,6 +560,7 @@ 89CC4B74490C5543D3441B91 /* SkillsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkillsModels.swift; sourceTree = ""; }; 89CD2538DA7C8A8C839DFEE8 /* ConversationSourceSearchCoverage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSourceSearchCoverage.swift; sourceTree = ""; }; 8A2D89C9263BB315B380BD08 /* Bifrost-LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Bifrost-LICENSE.txt"; sourceTree = ""; }; + 8A8568C60635DC434D1A9140 /* GatewayProtocolRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GatewayProtocolRouting.swift; sourceTree = ""; }; 8AB9ED7546466148F6DBF019 /* ConversationSourceSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSourceSearch.swift; sourceTree = ""; }; 8AC0A8FF88300F4634C330E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 8B411852001A8FC12788D65A /* ConversationActivityFeedbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationActivityFeedbackTests.swift; sourceTree = ""; }; @@ -600,6 +614,7 @@ B47F550C13733CF5D11BF17C /* SingleInstanceLockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleInstanceLockTests.swift; sourceTree = ""; }; B6A50DDDDBA95B4560234BB9 /* zenmux.svg */ = {isa = PBXFileReference; path = zenmux.svg; sourceTree = ""; }; B86A012CAC94F60A10FA0EFF /* libccbuddy_tgrep.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libccbuddy_tgrep.dylib; path = Vendor/libccbuddy_tgrep.dylib; sourceTree = ""; }; + B86E383FA74D79F7B49F0A58 /* BoundedMemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoundedMemoryCache.swift; sourceTree = ""; }; B92FAC36C68E696553DB5337 /* UsageAggregation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageAggregation.swift; sourceTree = ""; }; B99C4680DC8CA44B76C0BBBC /* PluginGitService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginGitService.swift; sourceTree = ""; }; BA01561F25586269C344EE6B /* MonitorInspectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitorInspectorTests.swift; sourceTree = ""; }; @@ -620,12 +635,14 @@ C74300BD2B0351DBB2DEDE97 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; C771C9D53B365956839BD1D7 /* ConversationListPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationListPane.swift; sourceTree = ""; }; C7D79207DFFABC08E99B355F /* UsageHistoryScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageHistoryScanner.swift; sourceTree = ""; }; + C7D8DC793108737036206F63 /* BoundedMemoryCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoundedMemoryCacheTests.swift; sourceTree = ""; }; C868D705375B4681A1CF7D56 /* cursor-light.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "cursor-light.png"; sourceTree = ""; }; C94821FF506683DCE813D2D5 /* HistoryPaths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryPaths.swift; sourceTree = ""; }; C9701E567C1E06181199B0FB /* ConversationActivityFeedback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationActivityFeedback.swift; sourceTree = ""; }; C99F1E7BA04E3A9AD0219746 /* claude.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = claude.png; sourceTree = ""; }; CA66EF298C2017C150B71F5D /* codex.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = codex.png; sourceTree = ""; }; CBB5099CE0E8163F18F4797B /* ProviderEditorSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderEditorSupport.swift; sourceTree = ""; }; + CBBEC678473EC4F3CDD6EA21 /* MemoryPressureMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryPressureMonitor.swift; sourceTree = ""; }; CBD3A871CD7822D40262A1D9 /* ConversationExportAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationExportAssets.swift; sourceTree = ""; }; CC18182779BA0AE2600D40A2 /* ConversationArchiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationArchiveTests.swift; sourceTree = ""; }; CDBD37601C3D88C5D8F5DB39 /* gemini.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = gemini.png; sourceTree = ""; }; @@ -638,6 +655,7 @@ D191884E015AB1C41EDADED7 /* HistoryRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryRepositoryTests.swift; sourceTree = ""; }; D2548DDBF33AFC1E1B524FF9 /* CodexConfigDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexConfigDocument.swift; sourceTree = ""; }; D26567473B3EBD19C6B103F1 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; + D2D698E95C47A4A499973A75 /* ProviderModelDiscoveryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderModelDiscoveryService.swift; sourceTree = ""; }; D48170C2963C9DC0808C55FC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; D51939CE474C9B42EDED502F /* claude.svg */ = {isa = PBXFileReference; path = claude.svg; sourceTree = ""; }; D5B0B515BD5C866AD86E6985 /* SkillsLibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkillsLibraryView.swift; sourceTree = ""; }; @@ -730,6 +748,7 @@ children = ( A7AE2892CC23D59A25F0F82D /* AppModel.swift */, EA5390E75BB1C5743700E7FC /* BifrostManagementClient.swift */, + B86E383FA74D79F7B49F0A58 /* BoundedMemoryCache.swift */, 15A318324E84209F17060DD5 /* CLIConnectionManager.swift */, D2548DDBF33AFC1E1B524FF9 /* CodexConfigDocument.swift */, F8D25BE5AD3BC5DDF1BD8061 /* ConfigRepository.swift */, @@ -743,7 +762,9 @@ A41F9376F5624B2C44C92266 /* ConversationStore.swift */, 7BE2FAC40EF9640B4A2935F2 /* LaunchAtLoginController.swift */, 131F90AEC59ABFBF6C610860 /* LocalSemanticSearch.swift */, + CBBEC678473EC4F3CDD6EA21 /* MemoryPressureMonitor.swift */, 54D6D1F39E624E7BA851331B /* MonitorStore.swift */, + D2D698E95C47A4A499973A75 /* ProviderModelDiscoveryService.swift */, 91DF39EB3A3F102BFA028424 /* ProviderProbeService.swift */, EE748BCA298BB911C90F8339 /* SecureAtomicFile.swift */, 9F46A9C08A32FCFBC4EAE3BA /* SelfCheckRunner.swift */, @@ -813,6 +834,8 @@ 31F565B3107A54342E2277E9 /* BifrostConfiguration.swift */, C198442E0684851873228DC0 /* BifrostManagementCredentials.swift */, 0503EB0BCAE1DADCD23027AA /* BifrostSupervisor.swift */, + 8A8568C60635DC434D1A9140 /* GatewayProtocolRouting.swift */, + 373C3017BCA0B96F4E2E9254 /* GatewayUpstreamURL.swift */, ); path = Gateway; sourceTree = ""; @@ -1138,6 +1161,7 @@ D0DD890556DE650191999B7E /* BifrostResponsesProtocolIntegrationTests.swift */, 5058B808BB1AEC0E8014344F /* BifrostSupervisorIntegrationTests.swift */, 81F29AC97FAD28FBB5DB62CE /* BifrostSupervisorTests.swift */, + C7D8DC793108737036206F63 /* BoundedMemoryCacheTests.swift */, E91668807E82572F5D91EA6B /* ClaudeCLIBifrostE2ETests.swift */, 476A4D77504FB1F4EC470009 /* ClaudeCLIE2ETestSupport.swift */, 7C3E985C7DFAFE1BAE19EFD7 /* ClaudeHistoryParserTests.swift */, @@ -1179,6 +1203,8 @@ A4DE8FA9EF70BFD05664E7E1 /* DesignProofSheetTests.swift */, 1C6331BD246BB06F57E7199F /* ForeignHistoryParserTests.swift */, AC03880370CADE575C561932 /* GatewayFailoverQueueTests.swift */, + 3DB696DF1740177CDD4AD164 /* GatewayProtocolRoutingTests.swift */, + 3D9F76DD27441FB8244170AE /* GatewayUpstreamURLTests.swift */, 3E420832A1781B39297AD612 /* HistoryAdvancedTests.swift */, FB3C79387F26509C72739F80 /* HistoryDirectoryDiscoveryTests.swift */, AC0492776CF8545CB07BED43 /* HistoryJSONLReaderTests.swift */, @@ -1205,6 +1231,7 @@ 74F27577A203A7BFFBB426A7 /* PluginSidecarSupervisorTests.swift */, 6F5033E1EAE8F7AE2FD44F17 /* PluginTestSupport.swift */, A1F951E5B51A601EB3E2DCF3 /* ProviderHeroUsageTests.swift */, + 688BC01CE3B98FA99ED5E8A0 /* ProviderModelDiscoveryTests.swift */, A9548560006C87E291F73BB7 /* ProviderPresetCatalogTests.swift */, 003BA3D38BDE1FE03B72EE48 /* ProviderProbeServiceTests.swift */, DA825F10B3E4AA7055095EA9 /* QoderFileReaderTests.swift */, @@ -1492,6 +1519,7 @@ 559F79F90D82C05405D9DFF1 /* BifrostManagementClient.swift in Sources */, 99C1D8C7670509C8AF0AABCC /* BifrostManagementCredentials.swift in Sources */, 775E1261F4E30E0C09608C66 /* BifrostSupervisor.swift in Sources */, + 09214113C7454374FED6339B /* BoundedMemoryCache.swift in Sources */, C33AF948ACA5DBDC1B477AD3 /* CCBuddyApp.swift in Sources */, 74920949F86DBE9E3E512671 /* CLIConnectionManager.swift in Sources */, 51942784934E67434036714A /* ClaudeHistoryParser.swift in Sources */, @@ -1546,7 +1574,9 @@ 4D63C4B03937A8BE0FC87282 /* DataSettingsPane.swift in Sources */, 3019F08C80340D433FD3FB1D /* DesignSystem.swift in Sources */, 88CD229DA81D507ACD474399 /* ForeignHistorySupport.swift in Sources */, + 9715D19E24970DFB94D262C0 /* GatewayProtocolRouting.swift in Sources */, B3BF90B1CBE009190D20D0C5 /* GatewaySettingsPane.swift in Sources */, + 54BC86191766B5C93434EE52 /* GatewayUpstreamURL.swift in Sources */, C49771CA00E9C79A3131B20C /* GeneralSettingsPane.swift in Sources */, 6FF2E606C11A8C9DF9258B9D /* GrokHistoryParser.swift in Sources */, F02A3093FB79A8B01F135E95 /* HistoryCatalogProjection.swift in Sources */, @@ -1565,6 +1595,7 @@ E220B25200410A8478ECB6EA /* LaunchAtLoginController.swift in Sources */, 71C115685BCDDCBE341F383C /* LocalSemanticSearch.swift in Sources */, 9036F5A0DAD1B3CB759C3CF4 /* LocationsSettingsPane.swift in Sources */, + 7C2A8C589A0F412BC068C959 /* MemoryPressureMonitor.swift in Sources */, 798BC6E5EC9925147160641C /* MenuBarController.swift in Sources */, A93910C931522B7CC309F143 /* MenuBarPanel.swift in Sources */, ACBC705927A8C8446CB252B6 /* MenuBarView.swift in Sources */, @@ -1595,6 +1626,7 @@ 993DD0479D39C0ACE547EAA0 /* ProviderEditorSupport.swift in Sources */, 6E982031EDF8F93BD4FFE711 /* ProviderEditorView.swift in Sources */, 72DDF08189E6A4A493244327 /* ProviderHeroView.swift in Sources */, + 0FFA6571280C1AE51068EE09 /* ProviderModelDiscoveryService.swift in Sources */, 0510B7087A3FF17C4613E0EA /* ProviderPresetCatalog.swift in Sources */, 7F8AB587F041F6C6E4D7B250 /* ProviderProbeService.swift in Sources */, C9517BC32CA10A7F02914F11 /* ProviderRow.swift in Sources */, @@ -1669,6 +1701,7 @@ F1E34F22B95122BBFEE97F5C /* BifrostResponsesProtocolIntegrationTests.swift in Sources */, C0BBEDE12B6CD25131BC9F30 /* BifrostSupervisorIntegrationTests.swift in Sources */, 40AEF43A8142B1A0610EA858 /* BifrostSupervisorTests.swift in Sources */, + DC08CE9C7D704AA1B2643D04 /* BoundedMemoryCacheTests.swift in Sources */, 0A108D6974FC24F74EFDB5DD /* CLIConnectionManagerTests.swift in Sources */, EB352B94E7EE2C19BB8F4B0F /* ClaudeCLIBifrostE2ETests.swift in Sources */, 20B698F987406C199A73F619 /* ClaudeCLIE2ETestSupport.swift in Sources */, @@ -1710,6 +1743,8 @@ 6953099919213A0F6856512D /* DesignProofSheetTests.swift in Sources */, 91BE317F9AE916C7612CF63E /* ForeignHistoryParserTests.swift in Sources */, 1F5202A91CE62CC272605249 /* GatewayFailoverQueueTests.swift in Sources */, + 27B763EB7896CCC978B963DB /* GatewayProtocolRoutingTests.swift in Sources */, + 92A55F57D4EA819A15E2408B /* GatewayUpstreamURLTests.swift in Sources */, 139B2014D80FBF68A0EEAF4E /* HistoryAdvancedTests.swift in Sources */, E7E0D2A00F472BBFBB7010F9 /* HistoryDirectoryDiscoveryTests.swift in Sources */, 5A1F5D62E3D7C64653E1B0B0 /* HistoryJSONLReaderTests.swift in Sources */, @@ -1736,6 +1771,7 @@ CACB6B82197CF3B70E1708BF /* PluginSidecarSupervisorTests.swift in Sources */, 49777CE3B639D8CACE0A661D /* PluginTestSupport.swift in Sources */, 2506DC2DF588BD09613B9021 /* ProviderHeroUsageTests.swift in Sources */, + 78F13A22A373BD1C73D49B60 /* ProviderModelDiscoveryTests.swift in Sources */, ADABC856DD0AADBDCCA7B02D /* ProviderPresetCatalogTests.swift in Sources */, 51ED5824F5ADFB72AFFC02B5 /* ProviderProbeServiceTests.swift in Sources */, 0125B64FE14034CA71A1FBF6 /* QoderFileReaderTests.swift in Sources */, diff --git a/native/Resources/en.lproj/Localizable.strings b/native/Resources/en.lproj/Localizable.strings index a2d70804..30519eb4 100644 --- a/native/Resources/en.lproj/Localizable.strings +++ b/native/Resources/en.lproj/Localizable.strings @@ -162,6 +162,7 @@ "从 Git 仓库添加 Skill 后可在此检查更新。" = "Add a Skill from a Git repository to check for updates here."; "从 Git 添加" = "Add from Git"; "从仓库读取 SKILL.md,可一次导入一个或多个 Skills。" = "Read SKILL.md files from a repository and import one or more Skills at once."; +"从接口获取模型" = "Fetch models from API"; "错误" = "Error"; "错误,真实上游 HTTP 状态码 {code}" = "Error, actual upstream HTTP status {code}"; "错误结果" = "Error result"; @@ -212,6 +213,7 @@ "点击查看完整请求 / 响应" = "Click to view the full request / response"; "点击自定义图标" = "Click to customize"; "点选切换 · 拖动排序" = "Click to switch · drag to reorder"; +"调用 /v1/models 或 /models 并自动创建模型绑定" = "Call /v1/models or /models and create model bindings automatically"; "调整故障转移顺序失败" = "Could not reorder the failover queue"; "调整会话页消息正文的字体大小。" = "Adjust the message text size on the Sessions page."; "调整面板宽度" = "Resize panel"; @@ -242,6 +244,8 @@ "分支" = "Branch"; "峰值" = "Peak"; "服务" = "Services"; +"服务地址为空" = "The service address is empty"; +"服务地址无效" = "The service address is not valid"; "服务商" = "Services"; "服务商配置已更新" = "Provider configuration updated"; "符号链接" = "Symbolic link"; @@ -265,6 +269,8 @@ "覆盖现有 Skill?" = "Replace the existing Skill?"; "该对话将被隐藏并移入回收站,之后可以在回收站中恢复。" = "This conversation will be hidden and moved to the recycle bin. You can restore it later."; "该服务的 API 协议;非 Anthropic 请求会被自动转换。" = "The provider's wire protocol; non-Anthropic requests are auto-translated."; +"该服务未提供 /v1/models 接口" = "This service has no /v1/models endpoint"; +"该服务未提供模型列表接口" = "This service has no model listing endpoint"; "该请求的详情已不可用(仅保留最近 100 条转发的完整报文)。" = "This request’s detail is no longer available (only the last 100 forwards keep full payloads)."; "概览" = "Overview"; "刚刚" = "just now"; @@ -421,6 +427,7 @@ "开发插件" = "Build a plugin"; "开关会立即、原子地写入或恢复各 CLI 的配置文件。" = "Each switch immediately and atomically writes or restores the CLI configuration file."; "开机自启动" = "Launch at login"; +"开启后队列即是网关的上游集合:客户端请求的协议如果队列里有,就直接透传给那个供应商;没有就转换协议后交给队首。任一上游失败时按队列顺序自动改用下一个。关闭时只使用当前启用的供应商。" = "With this on, the queue is the gateway's set of upstreams: a client speaking a protocol the queue already offers is passed straight through to that provider, and one speaking a protocol none of them offers is converted and sent to the head of the queue. If an upstream fails, the next one in the queue takes over. With it off, only the enabled provider is used."; "开启后网关按队列顺序发送请求,上游失败时自动改用下一个供应商;关闭时只使用当前启用的供应商。" = "With this on, the gateway sends requests down the queue in order and moves to the next provider when one fails. With it off, only the active provider is used."; "开始时间" = "Started"; "开始于 {date}" = "Started {date}"; @@ -471,6 +478,7 @@ "模式" = "Mode"; "模型" = "Model"; "模型厂商" = "Model vendors"; +"模型列表为空或无法解析" = "The model list was empty or could not be read"; "默认" = "Default"; "默认位置不可移除" = "The default location can't be removed"; "目标工具" = "Target tools"; @@ -865,6 +873,7 @@ "已导入 {n} 个" = "Imported {n}"; "已登录" = "Signed in"; "已登录 · {account}" = "Signed in · {account}"; +"已发现模型" = "Models found"; "已复制" = "Copied"; "已复制 ✓" = "Copied ✓"; "已复制会话路径" = "Conversation path copied"; diff --git a/native/Resources/ja.lproj/Localizable.strings b/native/Resources/ja.lproj/Localizable.strings index a5cab9cd..a80f5e13 100644 --- a/native/Resources/ja.lproj/Localizable.strings +++ b/native/Resources/ja.lproj/Localizable.strings @@ -162,6 +162,7 @@ "从 Git 仓库添加 Skill 后可在此检查更新。" = "Git リポジトリから Skill を追加すると、ここで更新を確認できます。"; "从 Git 添加" = "Git から追加"; "从仓库读取 SKILL.md,可一次导入一个或多个 Skills。" = "リポジトリの SKILL.md を読み込み、1 件以上の Skills をまとめてインポートします。"; +"从接口获取模型" = "APIからモデルを取得"; "错误" = "エラー"; "错误,真实上游 HTTP 状态码 {code}" = "エラー、実際の上流 HTTP ステータス {code}"; "错误结果" = "エラー結果"; @@ -212,6 +213,7 @@ "点击查看完整请求 / 响应" = "クリックでリクエスト / レスポンス全体を表示"; "点击自定义图标" = "クリックしてカスタマイズ"; "点选切换 · 拖动排序" = "クリックで切替 · ドラッグで並べ替え"; +"调用 /v1/models 或 /models 并自动创建模型绑定" = "/v1/models または /models を呼び出してモデル割り当てを自動作成"; "调整故障转移顺序失败" = "フェイルオーバーの順序を変更できませんでした"; "调整会话页消息正文的字体大小。" = "セッションページのメッセージ本文の文字サイズを調整します。"; "调整面板宽度" = "パネル幅を調整"; @@ -242,6 +244,8 @@ "分支" = "ブランチ"; "峰值" = "ピーク"; "服务" = "サービス"; +"服务地址为空" = "サービスアドレスが空です"; +"服务地址无效" = "サービスアドレスが無効です"; "服务商" = "サービス"; "服务商配置已更新" = "プロバイダー設定を更新しました"; "符号链接" = "シンボリックリンク"; @@ -265,6 +269,8 @@ "覆盖现有 Skill?" = "既存の Skill を上書きしますか?"; "该对话将被隐藏并移入回收站,之后可以在回收站中恢复。" = "この会話は非表示になり、ゴミ箱に移動します。あとで復元できます。"; "该服务的 API 协议;非 Anthropic 请求会被自动转换。" = "このプロバイダーの API プロトコル。Anthropic 以外は自動変換されます。"; +"该服务未提供 /v1/models 接口" = "このサービスに /v1/models エンドポイントはありません"; +"该服务未提供模型列表接口" = "このサービスにモデル一覧のエンドポイントはありません"; "该请求的详情已不可用(仅保留最近 100 条转发的完整报文)。" = "このリクエストの詳細は利用できません(直近 100 件の転送のみ全文を保持)。"; "概览" = "概要"; "刚刚" = "たった今"; @@ -421,6 +427,7 @@ "开发插件" = "プラグインを開発"; "开关会立即、原子地写入或恢复各 CLI 的配置文件。" = "スイッチは各 CLI の設定ファイルを即座かつアトミックに書き込み、または復元します。"; "开机自启动" = "ログイン時に起動"; +"开启后队列即是网关的上游集合:客户端请求的协议如果队列里有,就直接透传给那个供应商;没有就转换协议后交给队首。任一上游失败时按队列顺序自动改用下一个。关闭时只使用当前启用的供应商。" = "オンにすると、キューがゲートウェイの上流集合になります。クライアントのプロトコルがキューにあればそのプロバイダーへそのまま渡し、なければ変換して先頭へ送ります。上流が失敗した場合はキュー順に次へ切り替わります。オフの場合は有効なプロバイダーのみを使用します。"; "开启后网关按队列顺序发送请求,上游失败时自动改用下一个供应商;关闭时只使用当前启用的供应商。" = "オンにするとゲートウェイはキューの順に送信し、失敗した場合は次のプロバイダーに切り替えます。オフのときは現在有効なプロバイダーだけを使います。"; "开始时间" = "開始"; "开始于 {date}" = "開始:{date}"; @@ -471,6 +478,7 @@ "模式" = "モード"; "模型" = "モデル"; "模型厂商" = "モデルベンダー"; +"模型列表为空或无法解析" = "モデル一覧が空か、解析できませんでした"; "默认" = "標準"; "默认位置不可移除" = "既定の場所は削除できません"; "目标工具" = "対象ツール"; @@ -865,6 +873,7 @@ "已导入 {n} 个" = "{n} 件をインポート"; "已登录" = "ログイン済み"; "已登录 · {account}" = "ログイン済み · {account}"; +"已发现模型" = "モデルを検出"; "已复制" = "コピー済み"; "已复制 ✓" = "コピー済み ✓"; "已复制会话路径" = "会話のパスをコピーしました"; diff --git a/native/Resources/ko.lproj/Localizable.strings b/native/Resources/ko.lproj/Localizable.strings index 3c635c28..0a744c34 100644 --- a/native/Resources/ko.lproj/Localizable.strings +++ b/native/Resources/ko.lproj/Localizable.strings @@ -162,6 +162,7 @@ "从 Git 仓库添加 Skill 后可在此检查更新。" = "Git 저장소에서 Skill을 추가하면 여기에서 업데이트를 확인할 수 있습니다."; "从 Git 添加" = "Git에서 추가"; "从仓库读取 SKILL.md,可一次导入一个或多个 Skills。" = "저장소에서 SKILL.md를 읽어 하나 이상의 Skills를 한 번에 가져옵니다."; +"从接口获取模型" = "API에서 모델 가져오기"; "错误" = "오류"; "错误,真实上游 HTTP 状态码 {code}" = "오류, 실제 업스트림 HTTP 상태 {code}"; "错误结果" = "오류 결과"; @@ -212,6 +213,7 @@ "点击查看完整请求 / 响应" = "클릭해 전체 요청 / 응답 보기"; "点击自定义图标" = "클릭하여 사용자 지정"; "点选切换 · 拖动排序" = "클릭해 전환 · 드래그해 정렬"; +"调用 /v1/models 或 /models 并自动创建模型绑定" = "/v1/models 또는 /models를 호출해 모델 바인딩을 자동 생성"; "调整故障转移顺序失败" = "장애 조치 순서를 변경하지 못했습니다"; "调整会话页消息正文的字体大小。" = "세션 페이지의 메시지 본문 글꼴 크기를 조절합니다."; "调整面板宽度" = "패널 너비 조절"; @@ -242,6 +244,8 @@ "分支" = "브랜치"; "峰值" = "피크"; "服务" = "서비스"; +"服务地址为空" = "서비스 주소가 비어 있습니다"; +"服务地址无效" = "서비스 주소가 올바르지 않습니다"; "服务商" = "서비스"; "服务商配置已更新" = "공급자 구성 업데이트됨"; "符号链接" = "심볼릭 링크"; @@ -265,6 +269,8 @@ "覆盖现有 Skill?" = "기존 Skill을 덮어쓸까요?"; "该对话将被隐藏并移入回收站,之后可以在回收站中恢复。" = "이 대화는 숨겨지고 휴지통으로 이동합니다. 나중에 복원할 수 있습니다."; "该服务的 API 协议;非 Anthropic 请求会被自动转换。" = "이 공급자의 API 프로토콜. Anthropic이 아니면 자동 변환됩니다."; +"该服务未提供 /v1/models 接口" = "이 서비스에는 /v1/models 엔드포인트가 없습니다"; +"该服务未提供模型列表接口" = "이 서비스에는 모델 목록 엔드포인트가 없습니다"; "该请求的详情已不可用(仅保留最近 100 条转发的完整报文)。" = "이 요청의 상세 정보를 더 이상 볼 수 없습니다 (최근 100건의 전달만 전체 내용을 보관)."; "概览" = "개요"; "刚刚" = "방금"; @@ -421,6 +427,7 @@ "开发插件" = "플러그인 개발"; "开关会立即、原子地写入或恢复各 CLI 的配置文件。" = "스위치는 각 CLI 구성 파일을 즉시 원자적으로 기록하거나 복원합니다."; "开机自启动" = "로그인 시 실행"; +"开启后队列即是网关的上游集合:客户端请求的协议如果队列里有,就直接透传给那个供应商;没有就转换协议后交给队首。任一上游失败时按队列顺序自动改用下一个。关闭时只使用当前启用的供应商。" = "켜면 큐가 게이트웨이의 업스트림 집합이 됩니다. 클라이언트가 사용하는 프로토콜이 큐에 있으면 해당 공급자로 그대로 전달하고, 없으면 변환해 큐의 첫 번째로 보냅니다. 업스트림이 실패하면 큐 순서대로 다음으로 전환합니다. 끄면 활성화된 공급자만 사용합니다."; "开启后网关按队列顺序发送请求,上游失败时自动改用下一个供应商;关闭时只使用当前启用的供应商。" = "켜면 게이트웨이가 대기열 순서대로 요청을 보내고 실패하면 다음 공급자로 넘어갑니다. 끄면 현재 사용 중인 공급자만 사용합니다。"; "开始时间" = "시작"; "开始于 {date}" = "시작: {date}"; @@ -471,6 +478,7 @@ "模式" = "모드"; "模型" = "모델"; "模型厂商" = "모델 제공사"; +"模型列表为空或无法解析" = "모델 목록이 비어 있거나 읽을 수 없습니다"; "默认" = "기본"; "默认位置不可移除" = "기본 위치는 제거할 수 없습니다"; "目标工具" = "대상 도구"; @@ -865,6 +873,7 @@ "已导入 {n} 个" = "{n}개 가져옴"; "已登录" = "로그인됨"; "已登录 · {account}" = "로그인됨 · {account}"; +"已发现模型" = "모델 발견"; "已复制" = "복사됨"; "已复制 ✓" = "복사됨 ✓"; "已复制会话路径" = "대화 경로를 복사했습니다"; diff --git a/native/Resources/zh-Hans.lproj/Localizable.strings b/native/Resources/zh-Hans.lproj/Localizable.strings index 012ea434..3997936d 100644 --- a/native/Resources/zh-Hans.lproj/Localizable.strings +++ b/native/Resources/zh-Hans.lproj/Localizable.strings @@ -162,6 +162,7 @@ "从 Git 仓库添加 Skill 后可在此检查更新。" = "从 Git 仓库添加 Skill 后可在此检查更新。"; "从 Git 添加" = "从 Git 添加"; "从仓库读取 SKILL.md,可一次导入一个或多个 Skills。" = "从仓库读取 SKILL.md,可一次导入一个或多个 Skills。"; +"从接口获取模型" = "从接口获取模型"; "错误" = "错误"; "错误,真实上游 HTTP 状态码 {code}" = "错误,真实上游 HTTP 状态码 {code}"; "错误结果" = "错误结果"; @@ -212,6 +213,7 @@ "点击查看完整请求 / 响应" = "点击查看完整请求 / 响应"; "点击自定义图标" = "点击自定义图标"; "点选切换 · 拖动排序" = "点选切换 · 拖动排序"; +"调用 /v1/models 或 /models 并自动创建模型绑定" = "调用 /v1/models 或 /models 并自动创建模型绑定"; "调整故障转移顺序失败" = "调整故障转移顺序失败"; "调整会话页消息正文的字体大小。" = "调整会话页消息正文的字体大小。"; "调整面板宽度" = "调整面板宽度"; @@ -242,6 +244,8 @@ "分支" = "分支"; "峰值" = "峰值"; "服务" = "服务"; +"服务地址为空" = "服务地址为空"; +"服务地址无效" = "服务地址无效"; "服务商" = "服务商"; "服务商配置已更新" = "服务商配置已更新"; "符号链接" = "符号链接"; @@ -265,6 +269,8 @@ "覆盖现有 Skill?" = "覆盖现有 Skill?"; "该对话将被隐藏并移入回收站,之后可以在回收站中恢复。" = "该对话将被隐藏并移入回收站,之后可以在回收站中恢复。"; "该服务的 API 协议;非 Anthropic 请求会被自动转换。" = "该服务的 API 协议;非 Anthropic 请求会被自动转换。"; +"该服务未提供 /v1/models 接口" = "该服务未提供 /v1/models 接口"; +"该服务未提供模型列表接口" = "该服务未提供模型列表接口"; "该请求的详情已不可用(仅保留最近 100 条转发的完整报文)。" = "该请求的详情已不可用(仅保留最近 100 条转发的完整报文)。"; "概览" = "概览"; "刚刚" = "刚刚"; @@ -421,6 +427,7 @@ "开发插件" = "开发插件"; "开关会立即、原子地写入或恢复各 CLI 的配置文件。" = "开关会立即、原子地写入或恢复各 CLI 的配置文件。"; "开机自启动" = "开机自启动"; +"开启后队列即是网关的上游集合:客户端请求的协议如果队列里有,就直接透传给那个供应商;没有就转换协议后交给队首。任一上游失败时按队列顺序自动改用下一个。关闭时只使用当前启用的供应商。" = "开启后队列即是网关的上游集合:客户端请求的协议如果队列里有,就直接透传给那个供应商;没有就转换协议后交给队首。任一上游失败时按队列顺序自动改用下一个。关闭时只使用当前启用的供应商。"; "开启后网关按队列顺序发送请求,上游失败时自动改用下一个供应商;关闭时只使用当前启用的供应商。" = "开启后网关按队列顺序发送请求,上游失败时自动改用下一个供应商;关闭时只使用当前启用的供应商。"; "开始时间" = "开始时间"; "开始于 {date}" = "开始于 {date}"; @@ -471,6 +478,7 @@ "模式" = "模式"; "模型" = "模型"; "模型厂商" = "模型厂商"; +"模型列表为空或无法解析" = "模型列表为空或无法解析"; "默认" = "默认"; "默认位置不可移除" = "默认位置不可移除"; "目标工具" = "目标工具"; @@ -865,6 +873,7 @@ "已导入 {n} 个" = "已导入 {n} 个"; "已登录" = "已登录"; "已登录 · {account}" = "已登录 · {account}"; +"已发现模型" = "已发现模型"; "已复制" = "已复制"; "已复制 ✓" = "已复制 ✓"; "已复制会话路径" = "已复制会话路径"; diff --git a/native/Resources/zh-Hant.lproj/Localizable.strings b/native/Resources/zh-Hant.lproj/Localizable.strings index da9ecc18..e72d245e 100644 --- a/native/Resources/zh-Hant.lproj/Localizable.strings +++ b/native/Resources/zh-Hant.lproj/Localizable.strings @@ -162,6 +162,7 @@ "从 Git 仓库添加 Skill 后可在此检查更新。" = "從 Git 儲存庫新增 Skill 後,即可在此檢查更新。"; "从 Git 添加" = "從 Git 新增"; "从仓库读取 SKILL.md,可一次导入一个或多个 Skills。" = "從儲存庫讀取 SKILL.md,可一次匯入一個或多個 Skills。"; +"从接口获取模型" = "從介面取得模型"; "错误" = "錯誤"; "错误,真实上游 HTTP 状态码 {code}" = "錯誤,實際上游 HTTP 狀態碼 {code}"; "错误结果" = "錯誤結果"; @@ -212,6 +213,7 @@ "点击查看完整请求 / 响应" = "點擊檢視完整請求 / 回應"; "点击自定义图标" = "點擊自訂圖示"; "点选切换 · 拖动排序" = "點選切換 · 拖曳排序"; +"调用 /v1/models 或 /models 并自动创建模型绑定" = "呼叫 /v1/models 或 /models 並自動建立模型繫結"; "调整故障转移顺序失败" = "調整故障轉移順序失敗"; "调整会话页消息正文的字体大小。" = "調整會話頁訊息內文的字體大小。"; "调整面板宽度" = "調整面板寬度"; @@ -242,6 +244,8 @@ "分支" = "分支"; "峰值" = "尖峰"; "服务" = "服務"; +"服务地址为空" = "服務位址為空"; +"服务地址无效" = "服務位址無效"; "服务商" = "服務商"; "服务商配置已更新" = "服務商設定已更新"; "符号链接" = "符號連結"; @@ -265,6 +269,8 @@ "覆盖现有 Skill?" = "覆蓋現有 Skill?"; "该对话将被隐藏并移入回收站,之后可以在回收站中恢复。" = "該對話將被隱藏並移至回收筒,之後可以在回收筒中還原。"; "该服务的 API 协议;非 Anthropic 请求会被自动转换。" = "該服務的 API 協定;非 Anthropic 請求會被自動轉換。"; +"该服务未提供 /v1/models 接口" = "此服務未提供 /v1/models 介面"; +"该服务未提供模型列表接口" = "此服務未提供模型清單介面"; "该请求的详情已不可用(仅保留最近 100 条转发的完整报文)。" = "該請求的詳情已無法取得(僅保留最近 100 筆轉發的完整內容)。"; "概览" = "總覽"; "刚刚" = "剛剛"; @@ -421,6 +427,7 @@ "开发插件" = "開發外掛"; "开关会立即、原子地写入或恢复各 CLI 的配置文件。" = "開關會立即且以原子方式寫入或還原各 CLI 的設定檔。"; "开机自启动" = "開機自動啟動"; +"开启后队列即是网关的上游集合:客户端请求的协议如果队列里有,就直接透传给那个供应商;没有就转换协议后交给队首。任一上游失败时按队列顺序自动改用下一个。关闭时只使用当前启用的供应商。" = "開啟後佇列即是閘道的上游集合:用戶端請求的協定如果佇列裡有,就直接透傳給該供應商;沒有就轉換協定後交給佇列首位。任一上游失敗時按佇列順序自動改用下一個。關閉時只使用目前啟用的供應商。"; "开启后网关按队列顺序发送请求,上游失败时自动改用下一个供应商;关闭时只使用当前启用的供应商。" = "開啟後閘道會依佇列順序送出請求,上游失敗時自動改用下一個供應商;關閉時只使用目前啟用的供應商。"; "开始时间" = "開始時間"; "开始于 {date}" = "開始於 {date}"; @@ -471,6 +478,7 @@ "模式" = "模式"; "模型" = "模型"; "模型厂商" = "模型廠商"; +"模型列表为空或无法解析" = "模型清單為空或無法解析"; "默认" = "預設"; "默认位置不可移除" = "預設位置無法移除"; "目标工具" = "目標工具"; @@ -865,6 +873,7 @@ "已导入 {n} 个" = "已匯入 {n} 個"; "已登录" = "已登入"; "已登录 · {account}" = "已登入 · {account}"; +"已发现模型" = "已發現模型"; "已复制" = "已複製"; "已复制 ✓" = "已複製 ✓"; "已复制会话路径" = "已複製會話路徑"; diff --git a/native/Scripts/fetch-bifrost.sh b/native/Scripts/fetch-bifrost.sh index 0f155018..3652a8c6 100755 --- a/native/Scripts/fetch-bifrost.sh +++ b/native/Scripts/fetch-bifrost.sh @@ -1,11 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -readonly BIFROST_VERSION="v1.6.11" -readonly BIFROST_SHA256_ARM64="422eea68b860dd069d1b9989ff494a7bc566b7e11920632624cb6e85ca2c5263" -readonly BIFROST_SHA256_AMD64_UPSTREAM="50523247a6e5016bd3da29aeb0efea11e8ec6a01edd8b2b8b14bf4b6344afc07" -readonly BIFROST_SHA256_AMD64_NORMALIZED="cff62f56fc2bb8274f0b5eb97e663d6d1db953fcd710bb9ef9add1b7d27f75b3" -VENDOR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/Vendor" +readonly BIFROST_VERSION="v2.1.1" +readonly BIFROST_SHA256_ARM64="9a5dc7f02c28e2ec23207bfd6c54a3ee91c7a930b68ff6d2abddd738239ea971" +readonly BIFROST_SHA256_AMD64_UPSTREAM="540bf9f708de17a2d6b0c279a7f8c1498cd1bb23a5dbab8e6a357ce5072ca96c" +readonly BIFROST_SHA256_AMD64_NORMALIZED="76dc2cced55cef20485124d6a253d4753b614b1a6317c0fdd8d169e33137bc60" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +VENDOR_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/Vendor" readonly VENDOR_DIR readonly OUTPUT_PATH="$VENDOR_DIR/bifrost-http" @@ -33,13 +35,17 @@ fetch_slice() { # command incorrectly declares SDK 10.4. Apple rejects every notarization whose nested x86_64 # executable declares an SDK older than 10.9. Correct that SDK metadata to the actual 12.3 before # Xcode signs it, while pinning both the downloaded bytes and the deterministic normalized result. +# +# The correction used to run through `xcrun vtool`, which re-emits the whole Mach-O and so could +# only ever be pinned by a digest produced on a Mac. normalize-macho-sdk.py edits the single SDK +# field in place instead: two bytes change, the result is reproducible on any platform, and +# verify-bifrost.sh still confirms the outcome independently with `vtool -show-build`. normalize_intel_slice() { local upstream="$1" local destination="$2" local actual - xcrun vtool -set-version-min macos 10.4 12.3 -replace \ - -output "$destination" "$upstream" + python3 "$SCRIPT_DIR/normalize-macho-sdk.py" --sdk 12.3 "$upstream" "$destination" chmod 0755 "$destination" actual="$(shasum -a 256 "$destination" | awk '{print $1}')" [[ "$actual" == "$BIFROST_SHA256_AMD64_NORMALIZED" ]] \ @@ -61,7 +67,7 @@ readonly STAGED="$WORK_DIR/bifrost-http" # A release ships one helper that runs on both kinds of Mac, so the two published slices are # joined here rather than choosing between them. Building the arm64 slice alone would leave every -# Intel Mac still on 1.3.9 with an updater that only knows how to fail. +# Intel Mac on the legacy build with an updater that only knows how to fail. if [[ "$REQUESTED" == "universal" ]]; then fetch_slice arm64 "$BIFROST_SHA256_ARM64" "$WORK_DIR/arm64" fetch_slice amd64 "$BIFROST_SHA256_AMD64_UPSTREAM" "$WORK_DIR/amd64-upstream" diff --git a/native/Scripts/native-localization-overrides.json b/native/Scripts/native-localization-overrides.json index e0a478cb..ec9db074 100644 --- a/native/Scripts/native-localization-overrides.json +++ b/native/Scripts/native-localization-overrides.json @@ -4765,5 +4765,68 @@ "ko": "동기화 실패", "zh-Hans": "同步失败", "zh-Hant": "同步失敗" + }, + "从接口获取模型": { + "en": "Fetch models from API", + "ja": "APIからモデルを取得", + "ko": "API에서 모델 가져오기", + "zh-Hans": "从接口获取模型", + "zh-Hant": "從介面取得模型" + }, + "调用 /v1/models 或 /models 并自动创建模型绑定": { + "en": "Call /v1/models or /models and create model bindings automatically", + "ja": "/v1/models または /models を呼び出してモデル割り当てを自動作成", + "ko": "/v1/models 또는 /models를 호출해 모델 바인딩을 자동 생성", + "zh-Hans": "调用 /v1/models 或 /models 并自动创建模型绑定", + "zh-Hant": "呼叫 /v1/models 或 /models 並自動建立模型繫結" + }, + "该服务未提供 /v1/models 接口": { + "en": "This service has no /v1/models endpoint", + "ja": "このサービスに /v1/models エンドポイントはありません", + "ko": "이 서비스에는 /v1/models 엔드포인트가 없습니다", + "zh-Hans": "该服务未提供 /v1/models 接口", + "zh-Hant": "此服務未提供 /v1/models 介面" + }, + "该服务未提供模型列表接口": { + "en": "This service has no model listing endpoint", + "ja": "このサービスにモデル一覧のエンドポイントはありません", + "ko": "이 서비스에는 모델 목록 엔드포인트가 없습니다", + "zh-Hans": "该服务未提供模型列表接口", + "zh-Hant": "此服務未提供模型清單介面" + }, + "模型列表为空或无法解析": { + "en": "The model list was empty or could not be read", + "ja": "モデル一覧が空か、解析できませんでした", + "ko": "모델 목록이 비어 있거나 읽을 수 없습니다", + "zh-Hans": "模型列表为空或无法解析", + "zh-Hant": "模型清單為空或無法解析" + }, + "服务地址为空": { + "en": "The service address is empty", + "ja": "サービスアドレスが空です", + "ko": "서비스 주소가 비어 있습니다", + "zh-Hans": "服务地址为空", + "zh-Hant": "服務位址為空" + }, + "服务地址无效": { + "en": "The service address is not valid", + "ja": "サービスアドレスが無効です", + "ko": "서비스 주소가 올바르지 않습니다", + "zh-Hans": "服务地址无效", + "zh-Hant": "服務位址無效" + }, + "已发现模型": { + "en": "Models found", + "ja": "モデルを検出", + "ko": "모델 발견", + "zh-Hans": "已发现模型", + "zh-Hant": "已發現模型" + }, + "开启后队列即是网关的上游集合:客户端请求的协议如果队列里有,就直接透传给那个供应商;没有就转换协议后交给队首。任一上游失败时按队列顺序自动改用下一个。关闭时只使用当前启用的供应商。": { + "en": "With this on, the queue is the gateway's set of upstreams: a client speaking a protocol the queue already offers is passed straight through to that provider, and one speaking a protocol none of them offers is converted and sent to the head of the queue. If an upstream fails, the next one in the queue takes over. With it off, only the enabled provider is used.", + "ja": "オンにすると、キューがゲートウェイの上流集合になります。クライアントのプロトコルがキューにあればそのプロバイダーへそのまま渡し、なければ変換して先頭へ送ります。上流が失敗した場合はキュー順に次へ切り替わります。オフの場合は有効なプロバイダーのみを使用します。", + "ko": "켜면 큐가 게이트웨이의 업스트림 집합이 됩니다. 클라이언트가 사용하는 프로토콜이 큐에 있으면 해당 공급자로 그대로 전달하고, 없으면 변환해 큐의 첫 번째로 보냅니다. 업스트림이 실패하면 큐 순서대로 다음으로 전환합니다. 끄면 활성화된 공급자만 사용합니다.", + "zh-Hans": "开启后队列即是网关的上游集合:客户端请求的协议如果队列里有,就直接透传给那个供应商;没有就转换协议后交给队首。任一上游失败时按队列顺序自动改用下一个。关闭时只使用当前启用的供应商。", + "zh-Hant": "開啟後佇列即是閘道的上游集合:用戶端請求的協定如果佇列裡有,就直接透傳給該供應商;沒有就轉換協定後交給佇列首位。任一上游失敗時按佇列順序自動改用下一個。關閉時只使用目前啟用的供應商。" } } diff --git a/native/Scripts/normalize-macho-sdk.py b/native/Scripts/normalize-macho-sdk.py new file mode 100755 index 00000000..bb800a79 --- /dev/null +++ b/native/Scripts/normalize-macho-sdk.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Raise the macOS SDK a thin Mach-O declares, so Apple will notarize it. + +Upstream cross-compiles Bifrost's Intel slice on Ubuntu against MacOSX12.3.sdk, but the emitted +`LC_VERSION_MIN_MACOSX` load command records SDK 10.4. Apple rejects every notarization whose +nested x86_64 executable declares an SDK older than 10.9, so the field has to be corrected before +Xcode signs the helper. + +This used to be `xcrun vtool -set-version-min macos 10.4 12.3 -replace`. vtool re-emits the whole +Mach-O, so its output could only be pinned by a digest produced on a Mac — which meant the pin +could not be computed or checked anywhere else, including in review. Editing the one field in +place is the same correction expressed as a transformation anybody can reproduce: four bytes +change, the digest is deterministic on every platform, and `verify-bifrost.sh` still independently +confirms the result with `vtool -show-build` on macOS. + +Only the SDK version is touched. The minimum OS version (the deployment target) is deliberately +left alone: Apple gates notarization on the SDK, and raising the deployment target would claim +support the binary was never built for. +""" + +from __future__ import annotations + +import argparse +import shutil +import struct +import sys +from pathlib import Path + +MH_MAGIC_64 = 0xFEEDFACF +MH_CIGAM_64 = 0xCFFAEDFE +MH_MAGIC_32 = 0xFEEDFACE +MH_CIGAM_32 = 0xCEFAEDFE +FAT_MAGICS = {0xCAFEBABE, 0xBEBAFECA, 0xCAFEBABF, 0xBFBAFECA} + +LC_VERSION_MIN_MACOSX = 0x24 +LC_BUILD_VERSION = 0x32 +PLATFORM_MACOS = 1 + + +def encode_version(text: str) -> int: + """`12.3` / `12.3.0` -> the packed X.Y.Z nibble form Mach-O stores.""" + parts = [int(p) for p in text.split(".")] + while len(parts) < 3: + parts.append(0) + if len(parts) != 3: + raise ValueError(f"not a version: {text}") + major, minor, patch = parts + if not (0 <= major <= 0xFFFF and 0 <= minor <= 0xFF and 0 <= patch <= 0xFF): + raise ValueError(f"version out of range: {text}") + return (major << 16) | (minor << 8) | patch + + +def decode_version(value: int) -> str: + return f"{value >> 16}.{(value >> 8) & 0xFF}.{value & 0xFF}" + + +def patch_slice(data: bytearray, offset: int, sdk: int) -> list[str]: + """Rewrite the SDK field of every macOS version load command in one thin Mach-O.""" + magic = struct.unpack_from(" {decode_version(sdk)}" + ) + elif cmd == LC_BUILD_VERSION: + platform = struct.unpack_from(endian + "I", data, cursor + 8)[0] + if platform == PLATFORM_MACOS: + field = cursor + 16 # cmd, cmdsize, platform, minos, then sdk + current = struct.unpack_from(endian + "I", data, field)[0] + if current != sdk: + struct.pack_into(endian + "I", data, field, sdk) + changes.append( + f"LC_BUILD_VERSION sdk {decode_version(current)} -> {decode_version(sdk)}" + ) + cursor += size + return changes + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source") + parser.add_argument("destination") + parser.add_argument( + "--sdk", default="12.3", help="macOS SDK version to declare (default: 12.3)" + ) + args = parser.parse_args() + + sdk = encode_version(args.sdk) + data = bytearray(Path(args.source).read_bytes()) + if len(data) < 32: + print("input is too small to be a Mach-O", file=sys.stderr) + return 1 + + magic = struct.unpack_from(">I", data, 0)[0] + if magic in FAT_MAGICS: + print( + "refusing to edit a universal binary: normalize each slice before lipo joins them", + file=sys.stderr, + ) + return 1 + + try: + changes = patch_slice(data, 0, sdk) + except ValueError as error: + print(str(error), file=sys.stderr) + return 1 + + destination = Path(args.destination) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(bytes(data)) + shutil.copymode(args.source, destination) + for change in changes: + print(change) + if not changes: + print(f"already declares macOS SDK {args.sdk}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/native/Scripts/tests/verify-legacy-tauri-manifest.test.js b/native/Scripts/tests/verify-legacy-tauri-manifest.test.js deleted file mode 100644 index 568ecadc..00000000 --- a/native/Scripts/tests/verify-legacy-tauri-manifest.test.js +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const assert = require('assert/strict'); -const crypto = require('crypto'); -const { - LEGACY_CONTRACT, - decodeCanonicalBase64, - isStrictRfc3339, - validateLegacyConfig, - verifyLegacyManifestContract, -} = require('../verify-legacy-tauri-manifest.js'); - -const ARTIFACT = Buffer.from('test'); -const VERSION = '2.0.0'; -const REPOSITORY = 'ccbud/ccbud'; -const ARTIFACT_NAME = 'CC.Buddy_universal.app.tar.gz'; -const PUBLIC_KEY_PAYLOAD = 'RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3'; -const PUBLIC_KEY = Buffer.from([ - 'untrusted comment: minisign public key: E7620F1842B4E81F', - PUBLIC_KEY_PAYLOAD, - '', -].join('\n')).toString('base64'); -const SIGNATURE_TEXT = [ - 'untrusted comment: signature from minisign secret key', - 'RUQf6LRCGA9i559r3g7V1qNyJDApGip8MfqcadIgT9CuhV3EMhHoN1mGTkUidF/z7SrlQgXdy8ofjb7bNJJylDOocrCo8KLzZwo=', - 'trusted comment: timestamp:1556193335\tfile:test', - 'y/rUw2y8/hOUYjZU71eHp/Wo1KZ40fGy2VJEDl34XMJM+TX48Ss/17u3IvIfbVR1FkZZSNCisQbuQY+bHwhEBg==', - '', -].join('\n'); -const SIGNATURE = Buffer.from(SIGNATURE_TEXT).toString('base64'); -const TEST_CONTRACT = Object.freeze({ ...LEGACY_CONTRACT, publicKey: PUBLIC_KEY }); - -function configWith(endpoints = [TEST_CONTRACT.endpoint]) { - return { - identifier: TEST_CONTRACT.bundleIdentifier, - plugins: { updater: { pubkey: TEST_CONTRACT.publicKey, endpoints } }, - }; -} - -function manifestFor(artifact = ARTIFACT) { - const url = `https://github.com/${REPOSITORY}/releases/download/v${VERSION}/${ARTIFACT_NAME}`; - const sha256 = crypto.createHash('sha256').update(artifact).digest('hex'); - const platform = { url, signature: SIGNATURE, sha256 }; - return { - version: VERSION, - pub_date: '2026-08-22T12:34:56.123456789+08:00', - platforms: { - 'darwin-aarch64-app': platform, - 'darwin-aarch64': { ...platform }, - 'darwin-x86_64-app': { ...platform }, - 'darwin-x86_64': { ...platform }, - }, - }; -} - -function inputFor(artifact = ARTIFACT) { - return { - version: VERSION, - repository: REPOSITORY, - artifact, - artifactName: ARTIFACT_NAME, - signatureAsset: `${SIGNATURE}\r\n`, - manifest: manifestFor(artifact), - config: configWith(), - }; -} - -let passed = 0; -function test(name, callback) { - callback(); - passed += 1; - console.log(`ok ${passed} - ${name}`); -} - -test('canonical Base64 requires exact padding and rejects whitespace', () => { - assert.deepEqual(decodeCanonicalBase64('Zg=='), Buffer.from('f')); - for (const malformed of ['Zg', 'Zg=', 'Zg===', ' Zg==', 'Zg==\n']) { - assert.throws(() => decodeCanonicalBase64(malformed), /not canonical Base64/); - } -}); - -test('strict RFC 3339 accepts Z, offsets, leap dates, and up to nine fractional digits', () => { - for (const value of [ - '2026-08-22T01:02:03Z', - '2000-02-29T23:59:59.123456789Z', - '2026-08-22T01:02:03.1-00:30', - '2026-08-22T01:02:03+23:59', - ]) assert.equal(isStrictRfc3339(value), true, value); -}); - -test('strict RFC 3339 rejects loose syntax and invalid calendar or clock fields', () => { - for (const value of [ - 'August 22, 2026', '2026-08-22', '1900-02-29T00:00:00Z', - '2026-02-30T00:00:00Z', '2026-00-01T00:00:00Z', '2026-01-00T00:00:00Z', - '2026-08-22T24:00:00Z', '2026-08-22T00:60:00Z', '2026-08-22T00:00:60Z', - '2026-08-22T00:00:00.1234567890Z', '2026-08-22t00:00:00z', - '2026-08-22T00:00:00+24:00', '2026-08-22T00:00:00+00:60', - ]) assert.equal(isStrictRfc3339(value), false, value); -}); - -test('updater config requires exactly the frozen one-element endpoint array', () => { - assert.doesNotThrow(() => validateLegacyConfig(configWith(), TEST_CONTRACT)); - for (const endpoints of [ - [], - ['https://example.invalid/latest.json'], - [TEST_CONTRACT.endpoint, 'https://example.invalid/latest.json'], - ['https://example.invalid/latest.json', TEST_CONTRACT.endpoint], - TEST_CONTRACT.endpoint, - ]) { - assert.throws(() => validateLegacyConfig(configWith(endpoints), TEST_CONTRACT), /endpoint differs/); - } -}); - -test('valid manifest selects the preferred target and verifies both Minisign signatures', () => { - const result = verifyLegacyManifestContract(inputFor(), TEST_CONTRACT); - assert.equal(result.selectedKey, 'darwin-aarch64-app'); - assert.equal(result.digest, crypto.createHash('sha256').update(ARTIFACT).digest('hex')); -}); - -test('manifest rejects loose dates and missing platform data', () => { - const looseDate = inputFor(); - looseDate.manifest.pub_date = 'August 22, 2026'; - assert.throws(() => verifyLegacyManifestContract(looseDate, TEST_CONTRACT), /strict RFC 3339/); - - const missingPlatforms = inputFor(); - delete missingPlatforms.manifest.platforms; - assert.throws(() => verifyLegacyManifestContract(missingPlatforms, TEST_CONTRACT), /platforms are missing/); -}); - -test('manifest requires matching preferred and fallback targets', () => { - const missingPreferred = inputFor(); - delete missingPreferred.manifest.platforms['darwin-aarch64-app']; - assert.throws(() => verifyLegacyManifestContract(missingPreferred, TEST_CONTRACT), /preferred app target/); - - const missingFallback = inputFor(); - delete missingFallback.manifest.platforms['darwin-aarch64']; - assert.throws( - () => verifyLegacyManifestContract(missingFallback, TEST_CONTRACT), - /target is missing: darwin-aarch64/, - ); - - const mismatchedFallback = inputFor(); - mismatchedFallback.manifest.platforms['darwin-aarch64'].url += '?other'; - assert.throws(() => verifyLegacyManifestContract(mismatchedFallback, TEST_CONTRACT), /same artifact/); -}); - -// An Intel Mac running 1.3.9 reads darwin-x86_64 and nothing else. Dropping the key does not make -// the release arm64-only for those users, it makes their update check fail with no explanation, so -// the omission has to fail here instead. -test('manifest requires the Intel targets the last 1.x release also published', () => { - for (const target of ['darwin-x86_64-app', 'darwin-x86_64']) { - const missing = inputFor(); - delete missing.manifest.platforms[target]; - assert.throws( - () => verifyLegacyManifestContract(missing, TEST_CONTRACT), - new RegExp(`target is missing: ${target}`), - ); - - const divergent = inputFor(); - divergent.manifest.platforms[target].sha256 = 'f'.repeat(64); - assert.throws( - () => verifyLegacyManifestContract(divergent, TEST_CONTRACT), - new RegExp(`same artifact: ${target}`), - ); - } -}); - -test('tampered artifact with a matching SHA-256 still fails Minisign verification', () => { - const tampered = inputFor(Buffer.from('tampered')); - assert.throws(() => verifyLegacyManifestContract(tampered, TEST_CONTRACT), /artifact signature verification failed/); -}); - -test('signature asset permits only one terminal LF or CRLF', () => { - const oneLf = inputFor(); - oneLf.signatureAsset = `${SIGNATURE}\n`; - assert.doesNotThrow(() => verifyLegacyManifestContract(oneLf, TEST_CONTRACT)); - - const repeated = inputFor(); - repeated.signatureAsset = `${SIGNATURE}\n\n`; - assert.throws(() => verifyLegacyManifestContract(repeated, TEST_CONTRACT), /signature differs/); -}); - -console.log(`verified ${passed} legacy Tauri manifest validator cases`); diff --git a/native/Scripts/tests/verify-legacy-tauri-update-contract.test.sh b/native/Scripts/tests/verify-legacy-tauri-update-contract.test.sh deleted file mode 100644 index 320e37c6..00000000 --- a/native/Scripts/tests/verify-legacy-tauri-update-contract.test.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -readonly SCRIPT_DIR -# The path is anchored to this test, not the caller's working directory. -# shellcheck disable=SC1091 -source "$SCRIPT_DIR/../verify-legacy-tauri-update-contract.sh" - -TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ccbud-legacy-tauri-tests.XXXXXX")" -readonly TEST_ROOT -[[ -d "$TEST_ROOT" && "$TEST_ROOT" == */ccbud-legacy-tauri-tests.* ]] \ - || { echo "failed to create a safe test directory" >&2; exit 1; } -trap 'rm -rf -- "$TEST_ROOT"' EXIT - -PASSED=0 - -test_fail() { - echo "not ok - $*" >&2 - exit 1 -} - -pass() { - PASSED=$((PASSED + 1)) - echo "ok $PASSED - $1" -} - -make_archive() { - local name="$1" - shift - local staging="$TEST_ROOT/staging-$name" - local directory - - mkdir -p "$staging" - for directory in "$@"; do - mkdir -p "$staging/$directory" - done - /usr/bin/tar -czf "$TEST_ROOT/$name.tar.gz" -C "$staging" "$@" -} - -assert_valid() { - local name="$1" - local work_root="$TEST_ROOT/work-$name" - - mkdir "$work_root" - if ! (validate_legacy_tauri_archive_layout "$TEST_ROOT/$name.tar.gz" "$work_root"); then - test_fail "$name should satisfy the legacy extraction layout" - fi - [[ -d "$work_root/tauri_updated_app/Contents" ]] \ - || test_fail "$name did not extract to Contents" - pass "$name archive layout accepted" -} - -assert_invalid() { - local name="$1" - local work_root="$TEST_ROOT/work-$name" - - mkdir "$work_root" - if (validate_legacy_tauri_archive_layout \ - "$TEST_ROOT/$name.tar.gz" "$work_root") >/dev/null 2>&1; then - test_fail "$name should not satisfy the legacy extraction layout" - fi - pass "$name archive layout rejected" -} - -make_archive valid 'CC Buddy.app/Contents/MacOS' -make_archive wrong-root 'Wrong.app/Contents' -make_archive two-roots 'CC Buddy.app/Contents' 'Other.app/Contents' -make_archive extra-stripped-root 'CC Buddy.app/Contents' 'CC Buddy.app/Resources' - -assert_valid valid -assert_invalid wrong-root -assert_invalid two-roots -assert_invalid extra-stripped-root - -echo "verified $PASSED legacy Tauri archive-layout cases" diff --git a/native/Scripts/verify-bifrost.sh b/native/Scripts/verify-bifrost.sh index d2a64da1..64032986 100755 --- a/native/Scripts/verify-bifrost.sh +++ b/native/Scripts/verify-bifrost.sh @@ -3,8 +3,8 @@ set -euo pipefail readonly BINARY_PATH="${1:-}" readonly REQUESTED_ARCHS="${2:-}" -readonly SHA256_ARM64="422eea68b860dd069d1b9989ff494a7bc566b7e11920632624cb6e85ca2c5263" -readonly SHA256_X86_64="cff62f56fc2bb8274f0b5eb97e663d6d1db953fcd710bb9ef9add1b7d27f75b3" +readonly SHA256_ARM64="9a5dc7f02c28e2ec23207bfd6c54a3ee91c7a930b68ff6d2abddd738239ea971" +readonly SHA256_X86_64="76dc2cced55cef20485124d6a253d4753b614b1a6317c0fdd8d169e33137bc60" fail() { echo "$*" >&2; exit 1; } diff --git a/native/Scripts/verify-legacy-tauri-manifest.js b/native/Scripts/verify-legacy-tauri-manifest.js deleted file mode 100644 index c8d39fdb..00000000 --- a/native/Scripts/verify-legacy-tauri-manifest.js +++ /dev/null @@ -1,229 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const crypto = require('crypto'); -const fs = require('fs'); -const path = require('path'); -const { isDeepStrictEqual } = require('util'); - -// Frozen from tag v1.3.9 (4b65286). The shipped Tauri application has this endpoint, -// public key, bundle identifier, and platform lookup order compiled into it. -const LEGACY_CONTRACT = Object.freeze({ - endpoint: 'https://github.com/ccbud/ccbud/releases/latest/download/latest.json', - publicKey: 'dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEZCMTMwRjI5MDhCNjE1NzUKUldSMUZiWUlLUThUK3kybFBUU3ljMWUyenAwR3U1NjdPZm1jM25ocndIclhLYUFGTU92KzJXRFQK', - bundleIdentifier: 'dev.ccbud.gateway', - version: Object.freeze([1, 3, 9]), - platformCandidates: Object.freeze(['darwin-aarch64-app', 'darwin-aarch64']), - // Every macOS key a 1.3.9 updater may ask for. It resolves its own platform by name and treats - // an absent key as a failed check, so an Intel Mac needs darwin-x86_64 present even though the - // release is one universal archive that both kinds of Mac download. - requiredTargets: Object.freeze([ - 'darwin-aarch64-app', - 'darwin-aarch64', - 'darwin-x86_64-app', - 'darwin-x86_64', - ]), -}); - -function fail(message) { - throw new Error(message); -} - -function parseArgs(argv) { - const result = {}; - for (let index = 0; index < argv.length; index += 2) { - const key = argv[index]; - const value = argv[index + 1]; - if (!key?.startsWith('--') || value === undefined) fail(`invalid argument: ${key || ''}`); - result[key.slice(2)] = value; - } - return result; -} - -function parseVersion(value) { - if (!/^\d+\.\d+\.\d+$/.test(value)) fail(`invalid version: ${value}`); - return value.split('.').map(Number); -} - -function compareVersions(left, right) { - for (let index = 0; index < 3; index += 1) { - if (left[index] !== right[index]) return left[index] - right[index]; - } - return 0; -} - -function removeOptionalTerminalLineEnding(value) { - if (value.endsWith('\r\n')) return value.slice(0, -2); - if (value.endsWith('\n')) return value.slice(0, -1); - return value; -} - -function decodeCanonicalBase64(value, label = 'value') { - if (typeof value !== 'string' || !value.length || /\s/.test(value)) { - fail(`${label} is not canonical Base64`); - } - const decoded = Buffer.from(value, 'base64'); - if (!decoded.length || decoded.toString('base64') !== value) { - fail(`${label} is not canonical Base64`); - } - return decoded; -} - -function isStrictRfc3339(value) { - if (typeof value !== 'string') return false; - const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|[+-](\d{2}):(\d{2}))$/.exec(value); - if (!match) return false; - const [, yearText, monthText, dayText, hourText, minuteText, secondText, - offsetHourText, offsetMinuteText] = match; - const year = Number(yearText); - const month = Number(monthText); - const day = Number(dayText); - const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); - const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; - if (month < 1 || month > 12 || day < 1 || day > daysInMonth[month - 1]) return false; - if (Number(hourText) > 23 || Number(minuteText) > 59 || Number(secondText) > 59) return false; - return offsetHourText === undefined || - (Number(offsetHourText) <= 23 && Number(offsetMinuteText) <= 59); -} - -function decodeMinisignPublicKey(encodedPublicKey) { - const text = decodeCanonicalBase64(encodedPublicKey, 'legacy updater public key').toString('utf8'); - const lines = removeOptionalTerminalLineEnding(text).split(/\r?\n/); - if (lines.length !== 2 || !lines[0].startsWith('untrusted comment: minisign public key: ')) { - fail('legacy updater public key has an unexpected envelope'); - } - const binary = decodeCanonicalBase64(lines[1], 'minisign public key payload'); - if (binary.length !== 42 || binary[0] !== 0x45 || (binary[1] !== 0x64 && binary[1] !== 0x44)) { - fail('legacy updater public key payload is invalid'); - } - return { keyID: binary.subarray(2, 10), rawKey: binary.subarray(10, 42) }; -} - -function verifyMinisign(artifact, encodedSignature, encodedPublicKey) { - const { keyID, rawKey } = decodeMinisignPublicKey(encodedPublicKey); - const signatureText = decodeCanonicalBase64(encodedSignature, 'updater signature').toString('utf8'); - const lines = removeOptionalTerminalLineEnding(signatureText).split(/\r?\n/); - if (lines.length !== 4 || !lines[0].startsWith('untrusted comment: ') || - !lines[2].startsWith('trusted comment: ')) { - fail('updater signature is not the four-line Tauri minisign format'); - } - - const envelope = decodeCanonicalBase64(lines[1], 'minisign signature payload'); - const globalSignature = decodeCanonicalBase64(lines[3], 'minisign global signature'); - if (envelope.length !== 74 || envelope[0] !== 0x45 || envelope[1] !== 0x44 || - !envelope.subarray(2, 10).equals(keyID) || globalSignature.length !== 64) { - fail('updater signature does not use the legacy production updater key'); - } - - const signature = envelope.subarray(10, 74); - const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex'); - const publicKey = crypto.createPublicKey({ - key: Buffer.concat([spkiPrefix, rawKey]), - format: 'der', - type: 'spki', - }); - const digest = crypto.createHash('blake2b512').update(artifact).digest(); - if (!crypto.verify(null, digest, publicKey, signature)) { - fail('updater artifact signature verification failed'); - } - - const trustedComment = lines[2].slice('trusted comment: '.length); - const globalPayload = Buffer.concat([signature, Buffer.from(trustedComment, 'utf8')]); - if (!crypto.verify(null, globalPayload, publicKey, globalSignature)) { - fail('updater trusted-comment signature verification failed'); - } -} - -function selectLegacyPlatform(platforms, candidates) { - for (const candidate of candidates) { - const value = platforms[candidate]; - if (value && typeof value === 'object' && !Array.isArray(value)) return { key: candidate, value }; - } - fail(`manifest has none of the legacy targets: ${candidates.join(', ')}`); -} - -function validateLegacyConfig(config, contract = LEGACY_CONTRACT) { - if (config?.identifier !== contract.bundleIdentifier) fail('Tauri/native bundle identifier contract drifted'); - if (config?.plugins?.updater?.pubkey !== contract.publicKey) fail('configured updater key differs from v1.3.9'); - const endpoints = config?.plugins?.updater?.endpoints; - if (!Array.isArray(endpoints) || endpoints.length !== 1 || endpoints[0] !== contract.endpoint) { - fail('configured updater endpoint differs from v1.3.9'); - } -} - -function verifyLegacyManifestContract(input, contract = LEGACY_CONTRACT) { - const { version, repository, artifact, artifactName, signatureAsset, manifest, config } = input || {}; - if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) fail('invalid repository'); - if (compareVersions(parseVersion(version), contract.version) <= 0) { - fail('release must be newer than legacy 1.3.9'); - } - if (!Buffer.isBuffer(artifact)) fail('updater artifact must be a Buffer'); - if (typeof artifactName !== 'string' || !artifactName.length || path.basename(artifactName) !== artifactName) { - fail('updater artifact name is invalid'); - } - if (typeof signatureAsset !== 'string') fail('updater signature asset is invalid'); - const encodedSignature = removeOptionalTerminalLineEnding(signatureAsset); - validateLegacyConfig(config, contract); - - if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) fail('manifest is invalid'); - if (manifest.version !== version) fail(`manifest version is ${manifest.version || ''}`); - if (!isStrictRfc3339(manifest.pub_date)) fail('manifest pub_date is not strict RFC 3339'); - if (!manifest.platforms || typeof manifest.platforms !== 'object' || Array.isArray(manifest.platforms)) { - fail('manifest platforms are missing'); - } - - const selected = selectLegacyPlatform(manifest.platforms, contract.platformCandidates); - const [preferredKey] = contract.platformCandidates; - if (selected.key !== preferredKey) fail('legacy preferred app target is missing'); - for (const target of contract.requiredTargets) { - const entry = manifest.platforms[target]; - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { - fail(`legacy target is missing: ${target}`); - } - if (!isDeepStrictEqual(selected.value, entry)) { - fail(`legacy target does not describe the same artifact: ${target}`); - } - } - - const expectedURL = `https://github.com/${repository}/releases/download/v${version}/${encodeURIComponent(artifactName)}`; - if (selected.value.url !== expectedURL) fail(`selected updater URL is ${selected.value.url || ''}`); - if (selected.value.signature !== encodedSignature) fail('manifest signature differs from the signature asset'); - const digest = crypto.createHash('sha256').update(artifact).digest('hex'); - if (selected.value.sha256 !== digest) fail('manifest SHA-256 differs from the updater artifact'); - verifyMinisign(artifact, encodedSignature, contract.publicKey); - return { selectedKey: selected.key, digest }; -} - -function main() { - const args = parseArgs(process.argv.slice(2)); - for (const name of ['version', 'repository', 'artifact', 'signature', 'manifest', 'config']) { - if (!args[name]) fail(`missing --${name}`); - } - const artifactPath = path.resolve(args.artifact); - const result = verifyLegacyManifestContract({ - version: args.version, - repository: args.repository, - artifact: fs.readFileSync(artifactPath), - artifactName: path.basename(artifactPath), - signatureAsset: fs.readFileSync(path.resolve(args.signature), 'utf8'), - manifest: JSON.parse(fs.readFileSync(path.resolve(args.manifest), 'utf8')), - config: JSON.parse(fs.readFileSync(path.resolve(args.config), 'utf8')), - }); - console.log(`verified legacy Tauri manifest selection and production signature: ${result.selectedKey} (${result.digest})`); -} - -module.exports = { - LEGACY_CONTRACT, - decodeCanonicalBase64, - isStrictRfc3339, - validateLegacyConfig, - verifyLegacyManifestContract, -}; - -if (require.main === module) { - try { main(); } - catch (error) { - console.error(`legacy Tauri updater manifest verification failed: ${error.message}`); - process.exit(1); - } -} diff --git a/native/Scripts/verify-legacy-tauri-update-contract.sh b/native/Scripts/verify-legacy-tauri-update-contract.sh deleted file mode 100755 index 5f073059..00000000 --- a/native/Scripts/verify-legacy-tauri-update-contract.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -readonly ROOT - -fail() { echo "legacy Tauri updater contract verification failed: $*" >&2; exit 1; } - -# tauri-plugin-updater 2.10.1's macOS installer strips the first component from every -# tar entry, then renames that extraction directory onto the running .app path. Exercise -# that archive contract directly without claiming to launch or drive the legacy app. -validate_legacy_tauri_archive_layout() { - local archive="${1:-}" - local work_root="${2:-}" - local archive_roots - local tauri_extracted - local top_level - - [[ -f "$archive" ]] || fail "updater archive is missing: $archive" - [[ -d "$work_root" ]] || fail "archive validation work directory is missing: $work_root" - archive_roots="$(/usr/bin/tar -tzf "$archive" \ - | sed 's#^\./##' | awk -F/ 'NF {print $1}' | LC_ALL=C sort -u)" - [[ "$archive_roots" == 'CC Buddy.app' ]] \ - || fail "archive must have the single CC Buddy.app root" - if /usr/bin/tar -tzf "$archive" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then - fail "archive contains a path that the legacy updater must not install" - fi - - tauri_extracted="$work_root/tauri_updated_app" - mkdir "$tauri_extracted" - /usr/bin/tar -xzf "$archive" --strip-components 1 -C "$tauri_extracted" - top_level="$(find "$tauri_extracted" -mindepth 1 -maxdepth 1 -print \ - | sed 's#^.*/##' | LC_ALL=C sort)" - [[ "$top_level" == Contents ]] \ - || fail "stripped archive must become one Contents directory" -} - -main() { - local archive="${1:-}" - local signature="${2:-}" - local manifest="${3:-}" - local version="${4:-}" - local repository="${5:-ccbud/ccbud}" - local executable_name - - [[ "$(uname -s)" == Darwin ]] || fail "macOS is required" - [[ -f "$archive" ]] || fail "updater archive is missing: $archive" - [[ -f "$signature" ]] || fail "updater signature is missing: $signature" - [[ -f "$manifest" ]] || fail "updater manifest is missing: $manifest" - [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "version must be x.y.z" - [[ "$repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || fail "invalid repository" - - node "$ROOT/native/Scripts/verify-legacy-tauri-manifest.js" \ - --version "$version" --repository "$repository" \ - --artifact "$archive" --signature "$signature" --manifest "$manifest" \ - --config "$ROOT/src-tauri/tauri.conf.json" - - WORK_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/ccbud-legacy-tauri-contract.XXXXXX")" - readonly WORK_ROOT - trap 'rm -rf -- "$WORK_ROOT"' EXIT - validate_legacy_tauri_archive_layout "$archive" "$WORK_ROOT" - - readonly TAURI_EXTRACTED="$WORK_ROOT/tauri_updated_app" - readonly REPLACEMENT_APP="$WORK_ROOT/CC Buddy.app" - mv "$TAURI_EXTRACTED" "$REPLACEMENT_APP" - [[ -f "$REPLACEMENT_APP/Contents/Info.plist" ]] || fail "replacement Info.plist is missing" - executable_name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \ - "$REPLACEMENT_APP/Contents/Info.plist")" - readonly executable_name - [[ -n "$executable_name" ]] || fail "replacement CFBundleExecutable is empty" - [[ -x "$REPLACEMENT_APP/Contents/MacOS/$executable_name" ]] \ - || fail "replacement executable does not match CFBundleExecutable" - - "$ROOT/native/Scripts/verify-release-app.sh" "$REPLACEMENT_APP" "$version" stapled - echo "verified signed native archive at the legacy Tauri 1.3.9 install boundary" -} - -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - main "$@" -fi diff --git a/native/Sources/Gateway/BifrostConfiguration.swift b/native/Sources/Gateway/BifrostConfiguration.swift index f0528bf9..46b72b07 100644 --- a/native/Sources/Gateway/BifrostConfiguration.swift +++ b/native/Sources/Gateway/BifrostConfiguration.swift @@ -3,7 +3,7 @@ import Foundation struct BifrostConfiguration: Codable, Equatable { /// Bifrost accepts SecretVar values as either a scalar string or its management-API - /// object form. Config schema v1.6.11 documents the scalar form, so generated config + /// object form. Config schema v2.1.1 documents the scalar form, so generated config /// uses it while decoding both representations for round-trip compatibility. struct SecretVar: Codable, Equatable { var value: String @@ -44,7 +44,7 @@ struct BifrostConfiguration: Codable, Equatable { case virtualKeys = "virtual_keys" } } - /// Only fields deliberately owned by CC Buddy are encoded. Bifrost v1.6.11 fills all other + /// Only fields deliberately owned by CC Buddy are encoded. Bifrost v2.1.1 fills all other /// ClientConfig defaults after decoding this partial object, including its compatibility /// conversion defaults. An explicit empty origin list is preserved while Bifrost's built-in /// localhost exception continues to allow the native app and local CLIs. @@ -140,7 +140,7 @@ struct BifrostConfiguration: Codable, Equatable { case retryBackoffMax = "retry_backoff_max" } } - /// Mirrors Bifrost v1.6.11's `AllowedRequests`. A non-nil object is an + /// Mirrors Bifrost v2.1.1's `AllowedRequests`. A non-nil object is an /// allow-list: every omitted or false operation is rejected. Encoding the /// complete shape makes the selected upstream wire protocol authoritative /// instead of silently granting newly added Bifrost operations. @@ -269,9 +269,13 @@ struct BifrostConfiguration: Codable, Equatable { struct CustomProvider: Codable, Equatable { var baseProviderType: String var allowedRequests: AllowedRequests + /// Paths Bifrost must use verbatim instead of prefixing its own `/v1`. Empty for the + /// common case; see `GatewayUpstreamURL` for when a base URL needs it. + var requestPathOverrides: [String: String]? enum CodingKeys: String, CodingKey { case baseProviderType = "base_provider_type" case allowedRequests = "allowed_requests" + case requestPathOverrides = "request_path_overrides" } } struct ProviderConfig: Codable, Equatable { @@ -312,7 +316,7 @@ enum BifrostConfigBuilder { static let providerName = "ccbud-active" static let virtualKeyID = "ccbud-gateway" static let virtualKeyName = "CC Buddy Gateway" - /// Bifrost v1.6.11's SQLite cleaner reads `client.log_retention_days`; the similarly named + /// Bifrost v2.1.1's SQLite cleaner reads `client.log_retention_days`; the similarly named /// `logs_store.retention_days` is consumed only by the ClickHouse store. Raw provider payloads /// exist solely for the local monitor inspector, so keep them for Bifrost's minimum interval. static let logRetentionDays = 1 @@ -513,10 +517,17 @@ enum BifrostConfigBuilder { models: ["*"], aliases: aliases(for: provider) ) + // Bifrost owns the whole upstream path and inserts its own `/v1` segment. Every provider + // preset, and every base URL a provider's own documentation gives, already ends where its + // version segment begins, so handing one over untouched produced `…/v1/v1/messages` and a + // 404 from every upstream. `GatewayUpstreamURL` reconciles the two. + let overrides = GatewayUpstreamURL.requestPathOverrides( + for: provider.protocol, baseURL: provider.baseUrl + ) return .init( keys: [key], networkConfig: .init( - baseURL: provider.baseUrl, + baseURL: GatewayUpstreamURL.bifrostBaseURL(for: provider.baseUrl), insecureSkipVerify: config.insecureSkipVerify, maxRetries: retryCount, retryBackoffInitial: retryInitial, @@ -524,7 +535,8 @@ enum BifrostConfigBuilder { ), customProviderConfig: .init( baseProviderType: baseType, - allowedRequests: allowedRequests(for: provider.protocol) + allowedRequests: allowedRequests(for: provider.protocol), + requestPathOverrides: overrides.isEmpty ? nil : overrides ), // Raw bodies stay local and power the native monitor inspector. storeRawRequestResponse: true @@ -588,15 +600,15 @@ enum BifrostConfigBuilder { enforceAuthOnInference: config.requireToken, allowedOrigins: [], logRetentionDays: logRetentionDays, - // Bifrost's compat plugin converts Chat callers only when the - // selected model is catalogued as Responses-only. Enabling the - // feature is safe for mixed catalogs and lets Responses-native - // providers serve compatible Chat clients where Bifrost has a - // verified conversion path. + // Chat -> Responses is the one conversion Bifrost gates behind a client flag; + // Responses -> Chat and both Anthropic directions are chosen from the provider's + // own `allowed_requests`. Turn it on whenever a Responses upstream is configured, + // because that upstream may have to serve a Chat caller. It used to additionally + // require a non-empty generated model catalog, which silently disabled the + // conversion for any provider whose models were not enumerated in advance. compat: .init( convertChatToResponses: routedProviders.contains { $0.provider.protocol == .openAIResponses - && !modelCatalogModels(for: $0.provider).isEmpty } ) ), diff --git a/native/Sources/Gateway/BifrostSupervisor.swift b/native/Sources/Gateway/BifrostSupervisor.swift index d8a3fec1..98f09328 100644 --- a/native/Sources/Gateway/BifrostSupervisor.swift +++ b/native/Sources/Gateway/BifrostSupervisor.swift @@ -163,7 +163,7 @@ actor BifrostSupervisor { logByteLimitPerStream: Int = 32 * 1_024, // A first launch runs both config-store and log-store migrations before // the HTTP listener opens. On slower external/APFS volumes the pinned - // v1.6.11 helper can legitimately need more than 20 seconds, so keep a + // v2.1.1 helper can legitimately need more than 20 seconds, so keep a // one-minute startup envelope while retaining the 100 ms responsive poll. healthCheckAttempts: Int = 600, healthCheckIntervalNanoseconds: UInt64 = 100_000_000, @@ -211,7 +211,7 @@ actor BifrostSupervisor { // Bifrost creates SQLite files with the process umask (commonly 0644). Keeping the // store directory private prevents prompts, responses, and provider configuration // from being readable by other local accounts even before individual files exist. - // v1.6.11 loads any persisted model-parameter rows before refreshing the configured + // v2.1.1 loads any persisted model-parameter rows before refreshing the configured // catalog in the background. Clear only that derived cache while the helper is // stopped, forcing startup to synchronously load the just-generated local catalog. // Provider/governance settings remain available for config.json reconciliation. @@ -826,14 +826,56 @@ struct LegacyModelRoute: Equatable, Sendable { /// Explicit configured aliases are deliberately left for Bifrost's native alias resolver so /// they retain precedence and Bifrost records its ordinary `alias` field. let usesNativeAlias: Bool + /// The Bifrost provider this request is pinned to, or nil when the configured set is + /// unambiguous and the request can stay byte-identical. + /// + /// Bifrost resolves a bare model name through its model catalog, which knows nothing about + /// which protocol the caller spoke. With several providers configured — each advertising the + /// same `["*"]` model list — that resolution collapses onto whichever provider is indexed + /// first, so a Responses caller could be answered by an Anthropic upstream even when a + /// Responses upstream was configured. Naming the provider in the model string removes the + /// guess: Bifrost honours a `provider/model` prefix ahead of catalog resolution. + let pinnedProviderName: String? + /// The rest of the configured queue, already provider-prefixed, handed to Bifrost as the + /// request's `fallbacks` so pinning a provider does not also disable automatic failover. + let fallbackModels: [String] + + init( + requestedModel: String?, + outgoingModel: String?, + usesNativeAlias: Bool, + pinnedProviderName: String? = nil, + fallbackModels: [String] = [] + ) { + self.requestedModel = requestedModel + self.outgoingModel = outgoingModel + self.usesNativeAlias = usesNativeAlias + self.pinnedProviderName = pinnedProviderName + self.fallbackModels = fallbackModels + } var needsResponseRestoration: Bool { guard let requestedModel, let outgoingModel else { return false } - return requestedModel != outgoingModel + return requestedModel != outgoingModel || pinnedProviderName != nil } var needsRequestBodyRewrite: Bool { - needsResponseRestoration && !usesNativeAlias + guard requestedModel != nil, outgoingModel != nil else { return false } + if pinnedProviderName != nil { return true } + return needsResponseRestoration && !usesNativeAlias + } + + /// The exact string written into the outgoing request body. + /// + /// A configured alias keeps its caller spelling even when pinned, so Bifrost's own alias + /// resolver still runs and still records the alias it applied; only the provider prefix is + /// added in front of it. + var wireModel: String? { + guard let outgoingModel else { return nil } + let bare = usesNativeAlias ? (requestedModel ?? outgoingModel) : outgoingModel + guard let pinnedProviderName, !pinnedProviderName.isEmpty else { return bare } + if bare.hasPrefix("\(pinnedProviderName)/") { return bare } + return "\(pinnedProviderName)/\(bare)" } } @@ -873,8 +915,15 @@ struct LegacyModelListCompatibility: Equatable, Sendable { let aliases: [String] let configuredFallbacks: [String] let tierModels: [String] + /// Used only to strip CC Buddy's internal Bifrost provider names off upstream identifiers. + let router: GatewayProtocolRouter - init(provider: Provider?, aliases: [String]? = nil, isCodex: Bool) { + init( + provider: Provider?, + aliases: [String]? = nil, + isCodex: Bool, + router: GatewayProtocolRouter = GatewayProtocolRouter(routes: []) + ) { self.aliases = Self.unique(aliases ?? provider?.models.compactMap { mapping in mapping.alias.isEmpty ? nil : mapping.alias } ?? []) @@ -883,6 +932,7 @@ struct LegacyModelListCompatibility: Equatable, Sendable { provider?.smallFastModel ?? "", ].filter { !$0.isEmpty }) tierModels = isCodex ? Self.codexTierModels : Self.claudeTierModels + self.router = router } /// Returns a merged legacy list and the original upstream identifiers. Invalid/error model @@ -893,7 +943,20 @@ struct LegacyModelListCompatibility: Equatable, Sendable { ) -> (body: Data, upstreamModels: Set) { if upstreamSucceeded, var object = (try? JSONSerialization.jsonObject(with: upstreamBody)) as? [String: Any], - let upstreamEntries = object["data"] as? [Any] { + let rawEntries = object["data"] as? [Any] { + // Bifrost answers with `"/"` whenever a request names a provider, and + // CC Buddy's provider names are an internal routing detail. A client that copied one + // of those identifiers back into a request would be pinning itself to a provider that + // disappears the moment the gateway is reconfigured, so strip them here. + let upstreamEntries: [Any] = rawEntries.map { entry in + guard var item = entry as? [String: Any], + let identifier = item["id"] as? String else { return entry } + let stripped = router.strippingProviderPrefix(identifier) + guard stripped != identifier else { return entry } + item["id"] = stripped + if item["display_name"] as? String == identifier { item["display_name"] = stripped } + return item + } let upstreamModels = Set(upstreamEntries.compactMap { entry -> String? in (entry as? [String: Any])?["id"] as? String }) @@ -1027,11 +1090,14 @@ enum LegacyCountTokensEstimator { } /// A literal port of `src-tauri/src/gateway/routing.rs`. Keeping this decision outside Bifrost is -/// necessary because v1.6.11 aliases are exact-only, while CC Buddy historically accepted whole +/// necessary because v2.1.1 aliases are exact-only, while CC Buddy historically accepted whole /// Claude/Codex model families and a primary-tier `-ccbud` sentinel. struct LegacyModelRoutingCompatibility: Sendable { private let provider: Provider? private let modelListAliases: [String] + /// Decides which configured upstream serves each caller protocol. See + /// `GatewayProtocolRouter` for the one-of-three / three-of-three contract it implements. + let router: GatewayProtocolRouter let knownModelStore: LegacyKnownModelStore init(provider: Provider?, knownModels: Set = []) { @@ -1039,11 +1105,19 @@ struct LegacyModelRoutingCompatibility: Sendable { modelListAliases = provider?.models.compactMap { $0.alias.isEmpty ? nil : $0.alias } ?? [] + router = GatewayProtocolRouter( + routes: provider.map { + [GatewayUpstreamRoute( + bifrostName: BifrostConfigBuilder.providerName, provider: $0 + )] + } ?? [] + ) knownModelStore = LegacyKnownModelStore(knownModels) } init(config: AppConfig, knownModels: Set = []) { - provider = config.activeProvider + router = GatewayProtocolRouter(config: config) + provider = router.primaryRoute?.provider ?? config.activeProvider modelListAliases = config.providers.flatMap { provider in provider.models.compactMap { $0.alias.isEmpty ? nil : $0.alias } } @@ -1054,25 +1128,75 @@ struct LegacyModelRoutingCompatibility: Sendable { LegacyModelListCompatibility( provider: provider, aliases: modelListAliases, - isCodex: isCodex + isCodex: isCodex, + router: router ) } - func resolve(_ requestedModel: String?) -> LegacyModelRoute? { - guard let provider else { return nil } + /// Resolves a caller's model against the upstream that will actually serve it. + /// + /// Model mapping is a property of the selected upstream, not of whichever provider happens to + /// be marked active: with several configured, a Responses caller answered by the Responses + /// upstream must be mapped through *that* provider's aliases and default models. + func resolve( + _ requestedModel: String?, + clientProtocol: GatewayClientProtocol? = nil + ) -> LegacyModelRoute? { + let fallbackRoute = provider.map { + GatewayUpstreamRoute(bifrostName: BifrostConfigBuilder.providerName, provider: $0) + } + guard let upstream = router.route(for: clientProtocol) ?? fallbackRoute else { return nil } + let pinnedProviderName = router.pinsProviderName ? upstream.bifrostName : nil guard let requestedModel else { - return .init(requestedModel: nil, outgoingModel: nil, usesNativeAlias: false) + return .init( + requestedModel: nil, outgoingModel: nil, usesNativeAlias: false, + pinnedProviderName: pinnedProviderName + ) } + let mapped = map(requestedModel, for: upstream.provider) + return .init( + requestedModel: requestedModel, + outgoingModel: mapped.model, + usesNativeAlias: mapped.usesNativeAlias, + pinnedProviderName: pinnedProviderName, + fallbackModels: fallbackModels(for: requestedModel, excluding: upstream) + ) + } + + /// The remaining configured upstreams, in queue order, spelled the way Bifrost wants them in + /// a request's `fallbacks`. + /// + /// Pinning a provider takes the choice away from Bifrost's virtual-key load balancer, which + /// is also what used to provide automatic failover. Handing the rest of the queue back as + /// explicit fallbacks keeps that behaviour: the protocol-matched upstream is tried first, and + /// the queue still takes over when it fails. Each fallback is mapped through *its own* + /// provider's aliases and default models, because a model name that means something on one + /// upstream frequently means nothing on the next. + private func fallbackModels( + for requestedModel: String, + excluding pinned: GatewayUpstreamRoute + ) -> [String] { + guard router.pinsProviderName else { return [] } + return router.routes.compactMap { route in + guard route.bifrostName != pinned.bifrostName else { return nil } + let mapped = map(requestedModel, for: route.provider) + let bare = mapped.usesNativeAlias ? requestedModel : mapped.model + guard !bare.isEmpty else { return nil } + return "\(route.bifrostName)/\(bare)" + } + } + + /// A literal port of `src-tauri/src/gateway/routing.rs`, applied to one provider. + private func map( + _ requestedModel: String, + for provider: Provider + ) -> (model: String, usesNativeAlias: Bool) { for mapping in provider.models where !mapping.alias.isEmpty && mapping.alias == requestedModel && !mapping.upstream.isEmpty { - return .init( - requestedModel: requestedModel, - outgoingModel: mapping.upstream, - usesNativeAlias: true - ) + return (mapping.upstream, true) } let primary = provider.defaultModel @@ -1080,31 +1204,15 @@ struct LegacyModelRoutingCompatibility: Sendable { if requestedModel == primary || requestedModel == fast || provider.models.contains(where: { $0.upstream == requestedModel }) || knownModelStore.contains(requestedModel) { - return .init( - requestedModel: requestedModel, - outgoingModel: requestedModel, - usesNativeAlias: false - ) + return (requestedModel, false) } if requestedModel.hasSuffix("-ccbud") { let target = primary.isEmpty ? fast : primary - if !target.isEmpty { - return .init( - requestedModel: requestedModel, - outgoingModel: target, - usesNativeAlias: false - ) - } + if !target.isEmpty { return (target, false) } } - guard provider.mapDefaultModels else { - return .init( - requestedModel: requestedModel, - outgoingModel: requestedModel, - usesNativeAlias: false - ) - } + guard provider.mapDefaultModels else { return (requestedModel, false) } let big = primary.isEmpty ? fast : primary let small = fast.isEmpty ? primary : fast @@ -1125,18 +1233,7 @@ struct LegacyModelRoutingCompatibility: Sendable { target = small } - guard !target.isEmpty else { - return .init( - requestedModel: requestedModel, - outgoingModel: requestedModel, - usesNativeAlias: false - ) - } - return .init( - requestedModel: requestedModel, - outgoingModel: target, - usesNativeAlias: false - ) + return target.isEmpty ? (requestedModel, false) : (target, false) } } @@ -1153,6 +1250,9 @@ enum LegacyGatewayRouteCompatibility { // This route was handled specially by the legacy gateway even though wire.rs excludes // it from provider-endpoint rebasing. "/v1/messages/count_tokens": "/anthropic/v1/messages/count_tokens", + // Bifrost serves its management UI from the root, so an unversioned model listing would + // answer a client with HTML instead of a catalog. Send it to the real listing route. + "/models": "/v1/models", ] static var legacyRoutes: [String] { destinations.keys.sorted() } @@ -1182,7 +1282,14 @@ enum LegacyGatewayRouteCompatibility { let normalized = path.count > 1 && path.hasSuffix("/") ? String(path.dropLast()) : path - return normalized.hasSuffix("/v1/models") + return normalized.hasSuffix("/v1/models") || normalized == "/models" + } + + /// The wire protocol the caller spoke, recovered before the request is rewritten onto a + /// Bifrost integration route. + static func clientProtocol(for requestTarget: String) -> GatewayClientProtocol? { + guard let path = requestPath(for: requestTarget) else { return nil } + return GatewayClientProtocol.classify(path: path) } static func isHeadRoot(method: String, requestTarget: String) -> Bool { @@ -1409,6 +1516,10 @@ fileprivate struct LegacyHTTPRequestHeaderPlan { let isHeadRoot: Bool let isCountTokens: Bool let countTokensRequestIsJSON: Bool + /// The wire protocol this caller spoke. Derived from the request line, which by this point + /// has already been rewritten onto its Bifrost integration route — both spellings classify + /// identically, so the value is the same either way. + let clientProtocol: GatewayClientProtocol? init( header: Data, @@ -1438,6 +1549,7 @@ fileprivate struct LegacyHTTPRequestHeaderPlan { self.isCountTokens = isCountTokens ?? LegacyGatewayRouteCompatibility.isCountTokens( method: method, requestTarget: requestTarget ) + clientProtocol = LegacyGatewayRouteCompatibility.clientProtocol(for: requestTarget) countTokensRequestIsJSON = lines.dropFirst().contains { line in guard let colon = line.firstIndex(of: ":") else { return false } let name = line[.. GatewayClientProtocol? { + var normalized = path.split(separator: "?", maxSplits: 1).first.map(String.init) ?? path + while normalized.count > 1 && normalized.hasSuffix("/") { normalized.removeLast() } + guard !normalized.isEmpty else { return nil } + + // Longest-suffix first: `/v1/responses/compact` must not be read as `/compact`, and + // `/v1/messages/count_tokens` must not fall through to the bare `/count_tokens`. + if normalized.hasSuffix("/messages/count_tokens") { return .anthropic } + if normalized.hasSuffix("/chat/completions") { return .openAIChat } + if normalized.hasSuffix("/responses/compact") { return .openAIResponses } + if normalized.hasSuffix("/v1/messages") || normalized == "/messages" { return .anthropic } + if normalized.hasSuffix("/v1/complete") || normalized == "/complete" { return .anthropic } + // Bifrost publishes the Responses endpoint under three spellings on each prefix — + // `/v1/responses`, `/responses` and `/openai/responses` — so match the trailing segment + // rather than enumerating them. + if normalized.hasSuffix("/responses") { return .openAIResponses } + // Responses object lifecycle: retrieve / delete / cancel / input_items all hang off an + // opaque response id, so match the segment rather than the whole path. + if normalized.contains("/responses/") { return .openAIResponses } + return nil + } +} + +/// One configured upstream paired with the Bifrost provider name that addresses it. +struct GatewayUpstreamRoute: Equatable, Sendable { + let bifrostName: String + let provider: Provider + + var wireProtocol: Provider.WireProtocol { provider.protocol } + + init(bifrostName: String, provider: Provider) { + self.bifrostName = bifrostName + self.provider = provider + } +} + +/// Decides which configured upstream serves a caller, and whether Bifrost has to convert. +/// +/// The rule the gateway promises: +/// +/// * **Three of three.** Every caller protocol has an upstream that speaks it, so every request +/// is handed to that upstream untouched. Nothing is converted. +/// * **One or two of three.** A caller whose protocol *is* configured still passes through. A +/// caller whose protocol is not configured is routed to the primary upstream (the head of the +/// failover queue, or the active provider when no queue is enabled) and Bifrost performs the +/// conversion. +/// +/// Bifrost resolves an unprefixed model name through its model catalog, which has no notion of +/// the caller's protocol and — when several providers advertise the same wildcard model list — +/// collapses onto whichever provider it happens to index first. That is why the decision is made +/// here and then *pinned* by rewriting the request model to `"/"`, which +/// Bifrost honours ahead of catalog resolution. +struct GatewayProtocolRouter: Equatable, Sendable { + let routes: [GatewayUpstreamRoute] + + init(routes: [GatewayUpstreamRoute]) { + self.routes = routes + } + + init(config: AppConfig) { + routes = BifrostConfigBuilder.routedProviders(from: config).map { + GatewayUpstreamRoute(bifrostName: $0.bifrostName, provider: $0.provider) + } + } + + /// A single configured upstream is already unambiguous to Bifrost, so its requests keep the + /// byte-identical passthrough path instead of being rewritten to carry a provider prefix. + var pinsProviderName: Bool { routes.count > 1 } + + var primaryRoute: GatewayUpstreamRoute? { routes.first } + + /// The caller protocols that reach an upstream speaking the same protocol. + var passthroughProtocols: Set { + Set(routes.map { GatewayClientProtocol($0.wireProtocol) }) + } + + /// True when every caller shape has a matching upstream — the "three of three" case, where + /// the gateway is a pure pass-through. + var servesEveryProtocolDirectly: Bool { + passthroughProtocols.count == GatewayClientProtocol.allCases.count + } + + /// The upstream that serves this caller. An exact protocol match always wins; otherwise the + /// primary upstream takes the request and Bifrost converts it. + func route(for clientProtocol: GatewayClientProtocol?) -> GatewayUpstreamRoute? { + guard let clientProtocol else { return primaryRoute } + if let direct = routes.first(where: { $0.wireProtocol == clientProtocol.passthroughProtocol }) { + return direct + } + return primaryRoute + } + + /// Whether serving this caller requires Bifrost to translate between protocols. Used to keep + /// the generated Bifrost configuration's conversion hooks in step with the configured set. + func requiresConversion(for clientProtocol: GatewayClientProtocol) -> Bool { + guard let route = route(for: clientProtocol) else { return false } + return route.wireProtocol != clientProtocol.passthroughProtocol + } + + /// Removes a `"/"` prefix that belongs to one of this gateway's own + /// providers. Bifrost echoes prefixed identifiers in `/v1/models`, and those names are an + /// internal detail that must never reach a client. + func strippingProviderPrefix(_ identifier: String) -> String { + for route in routes { + let prefix = "\(route.bifrostName)/" + if identifier.hasPrefix(prefix) { + return String(identifier.dropFirst(prefix.count)) + } + } + return identifier + } +} diff --git a/native/Sources/Gateway/GatewayUpstreamURL.swift b/native/Sources/Gateway/GatewayUpstreamURL.swift new file mode 100644 index 00000000..7a697b98 --- /dev/null +++ b/native/Sources/Gateway/GatewayUpstreamURL.swift @@ -0,0 +1,134 @@ +import Foundation + +/// Reconciles the base URL a user types with the path Bifrost builds on top of it. +/// +/// Bifrost owns the whole upstream path. Its Anthropic transport posts to +/// `/v1/messages`, its OpenAI transport to `/v1/chat/completions`, and so on: the +/// `/v1` is Bifrost's, not the caller's. Every provider, meanwhile, documents a base URL that +/// already ends where its own version segment begins — `https://api.anthropic.com/v1`, +/// `https://api.moonshot.cn/anthropic/v1`, `https://generativelanguage.googleapis.com/v1beta/openai`. +/// +/// Handing one of those straight to Bifrost produces `…/v1/v1/messages`, which every provider +/// answers with 404. That is a single character of path and it breaks the gateway completely, so +/// the reconciliation lives here, in one place, used both when generating Bifrost's configuration +/// and when probing a provider — otherwise the connection test passes against a URL the gateway +/// will never call. +enum GatewayUpstreamURL { + /// How a base URL relates to the version segment Bifrost inserts. + enum Versioning: Equatable, Sendable { + /// No version segment. Bifrost's own `/v1/...` paths are already correct. + case none + /// Ends in exactly `/v1`, the segment Bifrost adds back. Dropping it is a complete fix: + /// every endpoint, including the ones whose paths carry a response id, lands correctly. + case trailingV1 + /// Carries a version Bifrost cannot reproduce, such as `/v4` or `/v1beta/openai`. The + /// base has to be kept and Bifrost told not to prepend its own. + case foreign + } + + /// Detects a path segment that names an API version: `v1`, `v4`, `v1beta`, `v2alpha`. + private static func isVersionSegment(_ segment: Substring) -> Bool { + guard segment.first == "v" || segment.first == "V" else { return false } + let rest = segment.dropFirst() + guard let first = rest.first, first.isNumber else { return false } + return rest.allSatisfy { $0.isNumber || $0.isLetter } + } + + static func versioning(of baseURL: String) -> Versioning { + let segments = pathSegments(of: baseURL) + guard let last = segments.last else { return .none } + if last == "v1" || last == "V1" { return .trailingV1 } + return segments.contains(where: isVersionSegment) ? .foreign : .none + } + + /// The base URL to write into Bifrost's `network_config.base_url`. + /// + /// A trailing `/v1` is removed because Bifrost puts it back. Anything else is preserved + /// exactly as the user typed it; `requestPathOverrides` handles the rest. + static func bifrostBaseURL(for baseURL: String) -> String { + let trimmed = trimmedBase(baseURL) + guard versioning(of: trimmed) == .trailingV1 else { return trimmed } + guard let range = trimmed.range(of: "/", options: .backwards) else { return trimmed } + let shortened = String(trimmed[trimmed.startIndex.. [String: String] { + guard versioning(of: baseURL) == .foreign else { return [:] } + switch wireProtocol { + case .anthropic: + return [ + "list_models": "/models", + "chat_completion": "/messages", + "chat_completion_stream": "/messages", + "responses": "/messages", + "responses_stream": "/messages", + "count_tokens": "/messages/count_tokens", + ] + case .openAIChat, .openAIResponses: + return [ + "list_models": "/models", + "chat_completion": "/chat/completions", + "chat_completion_stream": "/chat/completions", + "responses": "/responses", + "responses_stream": "/responses", + "count_tokens": "/responses/input_tokens", + "compaction": "/responses/compact", + ] + } + } + + /// The inference path for a protocol, relative to a base URL that already carries its version. + static func inferencePath(for wireProtocol: Provider.WireProtocol) -> String { + switch wireProtocol { + case .anthropic: "/messages" + case .openAIChat: "/chat/completions" + case .openAIResponses: "/responses" + } + } + + /// The absolute URL an inference request will actually reach, for the base URL as typed. + /// + /// This is what a connection test has to call. Computing it from the same rules the generated + /// configuration uses is the point: a test that probes a different URL than the gateway can + /// report success on a provider the gateway cannot reach. + static func endpointURL( + baseURL: String, + wireProtocol: Provider.WireProtocol + ) -> URL? { + let base = trimmedBase(baseURL) + guard !base.isEmpty else { return nil } + let path = inferencePath(for: wireProtocol) + switch versioning(of: base) { + case .none: + // Bifrost inserts its own version segment for this base, so the probe must too. + return URL(string: base + "/v1" + path) + case .trailingV1, .foreign: + return URL(string: base + path) + } + } + + private static func trimmedBase(_ baseURL: String) -> String { + var value = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + while value.count > 1 && value.hasSuffix("/") { value.removeLast() } + return value + } + + private static func pathSegments(of baseURL: String) -> [Substring] { + let base = trimmedBase(baseURL) + guard let components = URLComponents(string: base) else { return [] } + // A bare "host/v1" with no scheme parses as a path, which is still worth classifying. + let path = components.host == nil && components.scheme == nil ? base : components.path + return path.split(separator: "/") + } +} diff --git a/native/Sources/History/ConversationFileCatalog.swift b/native/Sources/History/ConversationFileCatalog.swift index cd754c82..4d4fe300 100644 --- a/native/Sources/History/ConversationFileCatalog.swift +++ b/native/Sources/History/ConversationFileCatalog.swift @@ -88,6 +88,30 @@ final class ConversationFileCatalog: @unchecked Sendable { var documents: [Document] } + /// A cached record with its documents left out. Everything here is needed by the listing + /// paths that run on every refresh. + private struct CachedHeader { + var checksum: String + var entry: ConversationIndexEntry + var pack: String? + } + + /// Roughly sixty-four bytes per span plus per-chunk bookkeeping; an estimate, used only to + /// keep the cache's shape sane rather than to account for memory exactly. + private static let documentCacheCostLimit = 24 * 1_024 * 1_024 + private static let documentCacheCountLimit = 4_096 + + private static func documentsCost(_ documents: [Document]) -> Int { + var cost = 0 + for document in documents { + cost += 128 + for chunk in document.chunks { + cost += 160 + chunk.spans.count * 64 + } + } + return cost + } + private struct Prepared { var temporaryPack: String var finalPack: String @@ -129,7 +153,22 @@ final class ConversationFileCatalog: @unchecked Sendable { private let stateLock = NSLock() private var cachedManifest: Manifest? private var cachedManifestIdentity: FileIdentity? - private var cachedRecords: [String: (checksum: String, record: Record)] = [:] + /// The light half of a record, one row per catalog object. + /// + /// Listing, scope queries and fingerprint comparison all want only this, and the app walks + /// every object on each refresh, so these stay resident: a few hundred bytes per session. + private var cachedHeaders: [String: CachedHeader] = [:] + /// The heavy half: per-message spans for every block of every document in a session. + /// + /// This is where a large library's memory actually went. Spans are roughly sixty bytes each, + /// one per message per thirty-two-kilobyte block, and the old single cache kept them for + /// every session the process had ever touched — hundreds of megabytes across a library with + /// tens of thousands of sessions, never returned. Only search reads them, a miss costs one + /// small header re-read, so they are bounded and evicted least-recently-used. + private var cachedDocuments = BoundedMemoryCache( + costLimit: documentCacheCostLimit, countLimit: documentCacheCountLimit + ) + private var memoryPressureRegistration: MemoryPressureMonitor.Registration? private var corruptRecordNames = Set() private var corruptPackNames = Set() private let maintenanceLock = NSLock() @@ -170,11 +209,32 @@ final class ConversationFileCatalog: @unchecked Sendable { } catch { close(root); throw error } // All stored properties are now initialized. Swift runs deinit if this throws; // manually closing here would double-close FDs another concurrent opener can reuse. + memoryPressureRegistration = MemoryPressureMonitor.shared.register { [weak self] level in + self?.releaseMemory(for: level) + } try withAccess(exclusive: true) { manifest in if manifest == nil { try publish(Manifest()) } } } + /// Hands memory back when the system asks for it. + /// + /// Everything dropped here is rebuilt by re-reading a small header, so the app stays correct + /// and the machine stops paying for a cache it needs more than we do. Under critical pressure + /// the light header rows go too: they are the larger of the two once a library is big enough + /// for the system to be complaining in the first place. + private func releaseMemory(for level: MemoryPressureLevel) { + stateLock.lock() + defer { stateLock.unlock() } + switch level { + case .warning: + cachedDocuments.shrink(toFraction: 0.25) + case .critical: + cachedDocuments.removeAll() + cachedHeaders.removeAll(keepingCapacity: false) + } + } + convenience init(url: URL) throws { try self.init(file: url) } deinit { @@ -302,7 +362,8 @@ final class ConversationFileCatalog: @unchecked Sendable { throw Self.posixError() } manifest.objects[header.path] = header.reference - cachedRecords[header.reference.name] = (header.reference.checksum, header.record) + cache(header.record, name: header.reference.name, + checksum: header.reference.checksum) } guard fsync(objectsDescriptor) == 0 else { throw Self.posixError() } return generation @@ -337,7 +398,7 @@ final class ConversationFileCatalog: @unchecked Sendable { return try mutate { manifest in var removed: [String] = [] for (path, object) in manifest.objects where !seen.contains(path) { - if try usableRecord(object, expectedPath: path)?.entry.scope == scope { removed.append(path) } + if try usableEntry(object, expectedPath: path)?.scope == scope { removed.append(path) } } for path in removed { manifest.objects.removeValue(forKey: path) } if !removed.isEmpty { _ = try advance(&manifest) } @@ -349,7 +410,7 @@ final class ConversationFileCatalog: @unchecked Sendable { try withManifest { manifest in var result: [String: ConversationIndexFingerprint] = [:] for (path, object) in manifest.objects { - guard let entry = try usableRecord(object, expectedPath: path)?.entry else { continue } + guard let entry = try usableEntry(object, expectedPath: path) else { continue } if scope == nil || entry.scope == scope { result[path] = entry.fingerprint } } return result @@ -361,7 +422,7 @@ final class ConversationFileCatalog: @unchecked Sendable { func entry(forPath path: String) throws -> ConversationIndexEntry? { let normalized = Self.normalizedPath(path) return try withManifest { manifest in - try manifest.objects[normalized].flatMap { try usableRecord($0, expectedPath: normalized)?.entry } + try manifest.objects[normalized].flatMap { try usableEntry($0, expectedPath: normalized) } } } @@ -378,7 +439,7 @@ final class ConversationFileCatalog: @unchecked Sendable { guard limit > 0, offset >= 0 else { return [] } let entries = try withManifest { manifest in try manifest.objects.compactMap { path, object -> ConversationIndexEntry? in - guard let entry = try usableRecord(object, expectedPath: path)?.entry else { return nil } + guard let entry = try usableEntry(object, expectedPath: path) else { return nil } return matches(entry, scope: scope, source: source, deleted: deleted) ? entry : nil } }.sorted { @@ -975,7 +1036,8 @@ final class ConversationFileCatalog: @unchecked Sendable { throw Failure.corrupt("manifest identities") } let retained = Set(manifest.objects.values.map(\.name)) - cachedRecords = cachedRecords.filter { retained.contains($0.key) } + cachedHeaders = cachedHeaders.filter { retained.contains($0.key) } + cachedDocuments.removeAll { !retained.contains($0) } corruptRecordNames.formIntersection(retained) cachedManifest = manifest cachedManifestIdentity = identity @@ -1007,17 +1069,59 @@ final class ConversationFileCatalog: @unchecked Sendable { let bytes = try encoder.encode(record) try Self.atomicWrite(bytes, name: name, parent: objectsDescriptor) let checksum = Self.checksum(bytes) - cachedRecords[name] = (checksum, record) + cache(record, name: name, checksum: checksum) return ObjectReference(name: name, checksum: checksum) } + private func cache(_ record: Record, name: String, checksum: String) { + cachedHeaders[name] = CachedHeader( + checksum: checksum, entry: record.entry, pack: record.pack + ) + cachedDocuments.setValue( + record.documents, forKey: name, cost: Self.documentsCost(record.documents) + ) + } + + /// The cheap read: everything except the per-message spans. + /// + /// Kept separate from `readRecord` so the listing walks — which run over every session in the + /// library every few seconds — never fault a session's spans into memory just to read its + /// title and timestamps. + private func readEntry( + _ object: ObjectReference, + expectedPath: String + ) throws -> ConversationIndexEntry { + guard Self.isObjectName(object.name, suffix: ".header") else { throw Failure.unsafeFile } + if let cached = cachedHeaders[object.name], cached.checksum == object.checksum { + guard cached.entry.sourcePath == expectedPath else { + throw Failure.corrupt("session identity") + } + if let pack = cached.pack, corruptPackNames.contains(pack) { + throw Failure.corrupt("damaged pack") + } + return cached.entry + } + return try readRecord(object, expectedPath: expectedPath).entry + } + + private func usableEntry( + _ object: ObjectReference, + expectedPath: String + ) throws -> ConversationIndexEntry? { + do { return try readEntry(object, expectedPath: expectedPath) } + catch Failure.corrupt { + corruptRecordNames.insert(object.name) + return nil + } + } + private func readRecord(_ object: ObjectReference, expectedPath: String) throws -> Record { guard Self.isObjectName(object.name, suffix: ".header") else { throw Failure.unsafeFile } - if let cached = cachedRecords[object.name], cached.checksum == object.checksum { - let record = cached.record - guard record.entry.sourcePath == expectedPath else { throw Failure.corrupt("session identity") } - if let pack = record.pack, corruptPackNames.contains(pack) { throw Failure.corrupt("damaged pack") } - return record + if let cached = cachedHeaders[object.name], cached.checksum == object.checksum, + let documents = cachedDocuments.value(forKey: object.name) { + guard cached.entry.sourcePath == expectedPath else { throw Failure.corrupt("session identity") } + if let pack = cached.pack, corruptPackNames.contains(pack) { throw Failure.corrupt("damaged pack") } + return Record(entry: cached.entry, pack: cached.pack, documents: documents) } let descriptor = try openObject(object.name) defer { close(descriptor) } @@ -1053,7 +1157,7 @@ final class ConversationFileCatalog: @unchecked Sendable { location += chunk.utf16Length } } - cachedRecords[object.name] = (object.checksum, record) + cache(record, name: object.name, checksum: object.checksum) return record } @@ -1235,7 +1339,8 @@ final class ConversationFileCatalog: @unchecked Sendable { // this exclusive lock. Unlink cannot invalidate those FDs; subsequent // reads must validate the new generation before opening another pack. guard unlinkat(objectsDescriptor, name, 0) == 0 else { throw Self.posixError() } - cachedRecords.removeValue(forKey: name) + cachedHeaders.removeValue(forKey: name) + cachedDocuments.removeValue(forKey: name) } guard fsync(objectsDescriptor) == 0 else { throw Self.posixError() } } diff --git a/native/Sources/History/IndexedHistoryRepository.swift b/native/Sources/History/IndexedHistoryRepository.swift index 31dd5ec4..6ad60821 100644 --- a/native/Sources/History/IndexedHistoryRepository.swift +++ b/native/Sources/History/IndexedHistoryRepository.swift @@ -775,19 +775,26 @@ struct IndexedHistoryRepository: ConversationIndexedHistoryProviding, Conversati func conversationScopeSnapshot() -> ConversationScopeSnapshot? { do { + // One walk over the catalog, split afterwards, rather than two full walks that each + // build and discard a library-sized metadata array. This runs on every list refresh, + // so on a library with tens of thousands of sessions the second walk was pure churn. + var liveMetadata: [HistorySessionMetadata] = [] + var trashMetadata: [HistorySessionMetadata] = [] + for entry in try database.listEntries(deleted: nil, limit: .max) { + let metadata = entry.metadata + guard allowedScopeIDs.contains(metadata.dirID) else { continue } + if metadata.deleted { trashMetadata.append(metadata) } + else { liveMetadata.append(metadata) } + } let live = HistoryCatalogProjection.nestingSubagentRollouts( HistoryCatalogProjection.canonicalizedCodexSessions( - try database.listEntries(deleted: false, limit: .max) - .map(\.metadata) - .filter { allowedScopeIDs.contains($0.dirID) }, + liveMetadata, homeDirectory: configuration.homeDirectory ) ) let trash = HistoryCatalogProjection.nestingSubagentRollouts( HistoryCatalogProjection.canonicalizedCodexSessions( - try database.listEntries(deleted: true, limit: .max) - .map(\.metadata) - .filter { allowedScopeIDs.contains($0.dirID) }, + trashMetadata, homeDirectory: configuration.homeDirectory ) ) diff --git a/native/Sources/History/QoderFileReader.swift b/native/Sources/History/QoderFileReader.swift index 360d9b38..8c8a3b83 100644 --- a/native/Sources/History/QoderFileReader.swift +++ b/native/Sources/History/QoderFileReader.swift @@ -339,9 +339,23 @@ final class QoderHelperCache: @unchecked Sendable { private let lock = NSLock() private var entries: [String: Entry] = [:] private var byteCount = 0 + private var memoryPressureRegistration: MemoryPressureMonitor.Registration? init(budget: Int = QoderFileReader.helperCacheBudget) { self.budget = max(0, budget) + memoryPressureRegistration = MemoryPressureMonitor.shared.register { [weak self] _ in + self?.releaseMemory() + } + } + + /// Drops every cached file when the system is short on memory. A quarter of a gigabyte of + /// raw transcript bytes is worth holding while the machine has room and worth nothing at all + /// while it does not: each entry is one re-read of a file that is still on disk. + func releaseMemory() { + lock.lock() + defer { lock.unlock() } + entries.removeAll(keepingCapacity: false) + byteCount = 0 } func data(for file: URL, stamp: QoderFileStamp) -> Data? { diff --git a/native/Sources/Models/BifrostLog.swift b/native/Sources/Models/BifrostLog.swift index 57cb103d..e638ca53 100644 --- a/native/Sources/Models/BifrostLog.swift +++ b/native/Sources/Models/BifrostLog.swift @@ -133,7 +133,7 @@ struct BifrostLog: Codable, Identifiable, Equatable { var rawRequest: String? var rawResponse: String? - // Pinned Bifrost v1.6.11 large-payload and passthrough fields. A large-payload flag means the + // Pinned Bifrost v2.1.1 large-payload and passthrough fields. A large-payload flag means the // served content is only a preview; Bifrost does not report the original byte count. var isLargePayloadRequest: Bool var isLargePayloadResponse: Bool diff --git a/native/Sources/Services/BoundedMemoryCache.swift b/native/Sources/Services/BoundedMemoryCache.swift new file mode 100644 index 00000000..ba0917a5 --- /dev/null +++ b/native/Sources/Services/BoundedMemoryCache.swift @@ -0,0 +1,116 @@ +import Foundation + +/// A least-recently-used cache with a byte budget. +/// +/// Several caches in the app were keyed by session or file path and grew for the life of the +/// process: correct, and fine for a handful of sessions, but a library with tens of thousands of +/// them turns "remember everything we ever parsed" into hundreds of megabytes that are never +/// handed back. Each of those caches fronts something cheap to recompute — a header re-read, a +/// JSON decode — so a bound costs a little latency on a miss and nothing else. +/// +/// Not thread-safe on its own; every current owner already serializes access under its own lock. +struct BoundedMemoryCache { + private struct Entry { + var value: Value + var cost: Int + var clock: UInt64 + } + + /// Byte budget across all entries. Costs are estimates, so this is a shape control rather + /// than an exact ceiling. + let costLimit: Int + /// Hard ceiling on entry count, so a cache of many tiny entries cannot grow without limit + /// either. + let countLimit: Int + + private var entries: [Key: Entry] = [:] + private var clock: UInt64 = 0 + private(set) var totalCost = 0 + + init(costLimit: Int, countLimit: Int = Int.max) { + self.costLimit = max(0, costLimit) + self.countLimit = max(0, countLimit) + } + + var count: Int { entries.count } + var isEmpty: Bool { entries.isEmpty } + + /// Reads a value and marks it most-recently-used. + mutating func value(forKey key: Key) -> Value? { + guard var entry = entries[key] else { return nil } + clock &+= 1 + entry.clock = clock + entries[key] = entry + return entry.value + } + + /// Reads without disturbing recency. Used where a lookup is bookkeeping rather than use. + func peek(forKey key: Key) -> Value? { entries[key]?.value } + + mutating func setValue(_ value: Value, forKey key: Key, cost: Int) { + let cost = max(0, cost) + // An entry larger than the whole budget would evict everything and then sit there alone. + guard cost <= costLimit, countLimit > 0 else { + removeValue(forKey: key) + return + } + clock &+= 1 + if let existing = entries[key] { totalCost -= existing.cost } + entries[key] = Entry(value: value, cost: cost, clock: clock) + totalCost += cost + evictIfNeeded() + } + + @discardableResult + mutating func removeValue(forKey key: Key) -> Value? { + guard let entry = entries.removeValue(forKey: key) else { return nil } + totalCost -= entry.cost + return entry.value + } + + mutating func removeAll() { + entries.removeAll(keepingCapacity: false) + totalCost = 0 + } + + /// Drops every entry whose key the predicate rejects. Used to follow an authoritative list + /// (a manifest, the set of files still on disk) rather than to reclaim memory. + mutating func removeAll(where shouldRemove: (Key) -> Bool) { + // Snapshot first: the loop mutates the dictionary it is reading. + for key in Array(entries.keys) where shouldRemove(key) { + removeValue(forKey: key) + } + } + + /// Sheds entries until the cache holds at most `fraction` of its budget. Used to answer + /// system memory pressure, where giving memory back promptly matters more than hit rate. + mutating func shrink(toFraction fraction: Double) { + let target = Int(Double(costLimit) * max(0, min(1, fraction))) + evict(untilCostAtMost: target, countAtMost: max(1, countLimit / 4)) + } + + /// Eviction orders entries by recency, so shedding exactly one entry per insert would pay + /// for that ordering on every insert once the cache is full — which is precisely what a walk + /// over a whole library does. Shedding down to a low water mark instead amortises the cost + /// across many inserts. A cache small enough that a tenth rounds to nothing sheds only what + /// it must, so the bound stays exact for small limits. + private var costLowWaterMark: Int { costLimit - costLimit / 10 } + private var countLowWaterMark: Int { countLimit - countLimit / 10 } + + private mutating func evictIfNeeded() { + guard totalCost > costLimit || entries.count > countLimit else { return } + evict(untilCostAtMost: costLowWaterMark, countAtMost: countLowWaterMark) + } + + private mutating func evict(untilCostAtMost cost: Int, countAtMost count: Int) { + guard totalCost > cost || entries.count > count else { return } + // Sorting once beats repeatedly scanning for a minimum: eviction runs when a budget is + // already exceeded, which in a full-library walk is every insert. + let ordered = entries.sorted { $0.value.clock < $1.value.clock } + var index = 0 + while (totalCost > cost || entries.count > count) && index < ordered.count { + removeValue(forKey: ordered[index].key) + index += 1 + } + } +} diff --git a/native/Sources/Services/MemoryPressureMonitor.swift b/native/Sources/Services/MemoryPressureMonitor.swift new file mode 100644 index 00000000..220e22a5 --- /dev/null +++ b/native/Sources/Services/MemoryPressureMonitor.swift @@ -0,0 +1,89 @@ +import Dispatch +import Foundation + +/// How urgently the system wants memory back. +enum MemoryPressureLevel: Equatable, Sendable { + /// The system is short on memory. Shed what is cheap to rebuild. + case warning + /// The system is about to start killing processes. Shed everything optional. + case critical +} + +/// Relays macOS memory-pressure notifications to the app's caches. +/// +/// Without this, a large history library degrades the whole machine rather than just the app: +/// every cache holds what it holds, the app never gives anything back, and the system pages other +/// applications out to keep serving a cache that would happily have been rebuilt from disk. The +/// monitor is the one place that hears the system and the one place caches subscribe to it. +/// +/// Handlers run on a background queue and must be safe to call at any time. +final class MemoryPressureMonitor: @unchecked Sendable { + static let shared = MemoryPressureMonitor() + + /// Cancels a registration. Deinit is enough; holding it is only needed to unsubscribe early. + final class Registration { + private let cancel: () -> Void + private var isCancelled = false + + fileprivate init(cancel: @escaping () -> Void) { self.cancel = cancel } + + func invalidate() { + guard !isCancelled else { return } + isCancelled = true + cancel() + } + + deinit { invalidate() } + } + + private let lock = NSLock() + private let queue = DispatchQueue( + label: "dev.ccbud.memory-pressure", qos: .utility + ) + private var handlers: [UUID: @Sendable (MemoryPressureLevel) -> Void] = [:] + private var source: DispatchSourceMemoryPressure? + + private init() {} + + @discardableResult + func register( + _ handler: @escaping @Sendable (MemoryPressureLevel) -> Void + ) -> Registration { + let id = UUID() + lock.lock() + handlers[id] = handler + startIfNeededLocked() + lock.unlock() + return Registration { [weak self] in + guard let self else { return } + self.lock.lock() + self.handlers.removeValue(forKey: id) + self.lock.unlock() + } + } + + /// Delivers a level to every handler. Exposed so tests can exercise the response to pressure + /// without waiting on the system to actually run out of memory. + func dispatch(_ level: MemoryPressureLevel) { + lock.lock() + let handlers = Array(self.handlers.values) + lock.unlock() + for handler in handlers { handler(level) } + } + + private func startIfNeededLocked() { + guard source == nil else { return } + let source = DispatchSource.makeMemoryPressureSource( + eventMask: [.warning, .critical], queue: queue + ) + source.setEventHandler { [weak self] in + guard let self else { return } + let data = source.data + let level: MemoryPressureLevel = + data.contains(.critical) ? .critical : .warning + self.dispatch(level) + } + source.resume() + self.source = source + } +} diff --git a/native/Sources/Services/ProviderModelDiscoveryService.swift b/native/Sources/Services/ProviderModelDiscoveryService.swift new file mode 100644 index 00000000..3f4e8244 --- /dev/null +++ b/native/Sources/Services/ProviderModelDiscoveryService.swift @@ -0,0 +1,261 @@ +import Foundation + +/// What a provider's model-listing endpoint turned out to be. +/// +/// `/v1/models` (and its unversioned `/models` sibling) is a convention, not a guarantee: plenty +/// of Anthropic-compatible relays and self-hosted gateways expose inference only. So discovery +/// reports three distinct outcomes rather than a bare optional, and the UI greys its refresh +/// control out for `.unsupported` instead of offering an action that can only fail. +enum ProviderModelCatalogAvailability: String, Equatable, Sendable { + /// Never probed, or the probe could not reach the host at all. Refresh stays available. + case unknown + /// A listing endpoint answered with a parseable catalog. + case available + /// The host answered, and every candidate path said the endpoint is not there. + case unsupported +} + +struct ProviderModelCatalog: Equatable, Sendable { + var availability: ProviderModelCatalogAvailability = .unknown + /// Upstream model identifiers, in the order the provider returned them. + var models: [String] = [] + /// The absolute URL that answered, kept so the UI can show what was probed. + var endpoint: String? + var statusCode: Int? + var message: String? + + var canRefresh: Bool { availability != .unsupported } + + static let unknown = ProviderModelCatalog() +} + +/// Sniffs the conventional model-listing endpoints and turns whatever comes back into model +/// bindings. +/// +/// Ordering matters. A base URL that already carries a version segment is asked for `/models` +/// first, because appending another `/v1` to it is how `…/v1/v1/models` 404s get produced. A base +/// URL without one is asked for `/v1/models` first, since that is what the overwhelming majority +/// of OpenAI-compatible hosts publish. +struct ProviderModelDiscoveryService: Sendable { + private let injectedSession: URLSession? + + init(session: URLSession? = nil) { + injectedSession = session + } + + func discover( + _ provider: Provider, + insecureSkipVerify: Bool = false + ) async -> ProviderModelCatalog { + let baseURL = provider.baseUrl.trimmingCharacters(in: .whitespacesAndNewlines) + guard !baseURL.isEmpty else { + return .init(availability: .unknown, message: "服务地址为空") + } + let candidates = Self.candidateURLs(baseURL: baseURL) + guard !candidates.isEmpty else { + return .init(availability: .unknown, message: "服务地址无效") + } + + let session = injectedSession ?? Self.makeSession(insecureSkipVerify: insecureSkipVerify) + var sawDefinitiveMiss = false + var lastStatus: Int? + var lastMessage: String? + + for url in candidates { + do { + let response = try await send(to: url, provider: provider, session: session) + lastStatus = response.statusCode + if (200..<300).contains(response.statusCode) { + let models = Self.parseModels(from: response.data) + if models.isEmpty { + // A 200 that carries no recognisable catalog is not proof the endpoint is + // missing, so keep probing and leave refresh enabled. + lastMessage = "模型列表为空或无法解析" + continue + } + return .init( + availability: .available, + models: models, + endpoint: url.absoluteString, + statusCode: response.statusCode + ) + } + // 404/405/501 are the honest "no such endpoint" answers. Anything else (401, 403, + // 429, 5xx) says the endpoint may well exist and the request was simply rejected. + if [404, 405, 501].contains(response.statusCode) { + sawDefinitiveMiss = true + } else { + lastMessage = Self.errorMessage(from: response.data, status: response.statusCode) + } + } catch { + lastMessage = error.localizedDescription + } + } + + if sawDefinitiveMiss && lastMessage == nil { + return .init( + availability: .unsupported, + statusCode: lastStatus, + message: "该服务未提供模型列表接口" + ) + } + return .init(availability: .unknown, statusCode: lastStatus, message: lastMessage) + } + + /// Folds discovered identifiers into a provider's bindings without disturbing anything the + /// user wrote. Existing rows win on both sides of the arrow: an alias the user already mapped + /// keeps its upstream, and a discovered model that some row already targets is not duplicated. + static func merging( + discovered models: [String], + into existing: [ModelMapping] + ) -> [ModelMapping] { + let kept = existing.filter { + !$0.alias.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !$0.upstream.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + var claimedAliases = Set(kept.map { $0.alias.trimmingCharacters(in: .whitespacesAndNewlines) }) + var claimedUpstreams = Set( + kept.map { $0.upstream.trimmingCharacters(in: .whitespacesAndNewlines) } + ) + var result = kept + for model in models { + let identifier = model.trimmingCharacters(in: .whitespacesAndNewlines) + guard !identifier.isEmpty, identifier != "*" else { continue } + guard !claimedAliases.contains(identifier), + !claimedUpstreams.contains(identifier) else { continue } + claimedAliases.insert(identifier) + claimedUpstreams.insert(identifier) + result.append(ModelMapping(alias: identifier, upstream: identifier)) + } + return result + } + + private func send( + to url: URL, + provider: Provider, + session: URLSession + ) async throws -> (statusCode: Int, data: Data) { + var request = URLRequest(url: url, timeoutInterval: 20) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + // Compressed bodies are decoded by URLSession, but asking for identity keeps the + // occasional relay that mis-advertises its encoding from handing back bytes we cannot read. + request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") + let token = provider.authToken.trimmingCharacters(in: .whitespacesAndNewlines) + if !token.isEmpty { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + if provider.protocol == .anthropic { + request.setValue(token, forHTTPHeaderField: "x-api-key") + } + } + if provider.protocol == .anthropic { + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + } + let (data, response) = try await session.data(for: request) + guard let response = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + return (response.statusCode, data) + } + + /// The conventional listing paths, ordered so the likelier one for this base URL goes first. + static func candidateURLs(baseURL: String) -> [URL] { + let base = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let components = URLComponents(string: base), + ["http", "https"].contains(components.scheme?.lowercased() ?? ""), + components.host != nil else { return [] } + // One shared definition of "does this base already carry its version segment". A second, + // slightly different copy of that question living here is exactly how the gateway and the + // connection test came to disagree about it. + let carriesVersion = GatewayUpstreamURL.versioning(of: base) != .none + let paths = carriesVersion ? ["/models", "/v1/models"] : ["/v1/models", "/models"] + var seen = Set() + return paths.compactMap { path in + guard let url = URL(string: base + path), seen.insert(url.absoluteString).inserted + else { return nil } + return url + } + } + + /// Accepts every catalog shape these endpoints are served in: OpenAI's and Anthropic's + /// `{"data":[…]}`, the `{"models":[…]}` used by several self-hosted relays, and a bare array + /// of either objects or plain strings. + static func parseModels(from data: Data) -> [String] { + guard let root = try? JSONSerialization.jsonObject( + with: data, options: [.fragmentsAllowed] + ) else { return [] } + let entries: [Any] + if let array = root as? [Any] { + entries = array + } else if let object = root as? [String: Any] { + if let list = object["data"] as? [Any] { + entries = list + } else if let list = object["models"] as? [Any] { + entries = list + } else { + return [] + } + } else { + return [] + } + + var seen = Set() + var models: [String] = [] + for entry in entries { + let identifier: String? + if let text = entry as? String { + identifier = text + } else if let object = entry as? [String: Any] { + identifier = (object["id"] as? String) + ?? (object["name"] as? String) + ?? (object["model"] as? String) + } else { + identifier = nil + } + guard let raw = identifier else { continue } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { continue } + models.append(trimmed) + } + return models + } + + private static func errorMessage(from data: Data, status: Int) -> String { + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = object["error"] as? [String: Any], + let message = error["message"] as? String, !message.isEmpty { + return message + } + let text = String(decoding: data.prefix(200), as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? "HTTP \(status)" : text + } + + private static func makeSession(insecureSkipVerify: Bool) -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 20 + configuration.timeoutIntervalForResource = 20 + guard insecureSkipVerify else { return URLSession(configuration: configuration) } + return URLSession( + configuration: configuration, + delegate: ProviderModelDiscoveryTLSDelegate(), + delegateQueue: nil + ) + } +} + +private final class ProviderModelDiscoveryTLSDelegate: NSObject, URLSessionDelegate, @unchecked Sendable { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust else { + completionHandler(.performDefaultHandling, nil) + return + } + completionHandler(.useCredential, URLCredential(trust: trust)) + } +} diff --git a/native/Sources/Services/ProviderProbeService.swift b/native/Sources/Services/ProviderProbeService.swift index a1087c2f..1759124e 100644 --- a/native/Sources/Services/ProviderProbeService.swift +++ b/native/Sources/Services/ProviderProbeService.swift @@ -12,7 +12,6 @@ struct ProviderProbeResult: Equatable, Sendable { var model: String? var message: String? var reason: FailureReason? - var migratedBaseURL: String? } struct ProviderProbeService: Sendable { @@ -38,31 +37,21 @@ struct ProviderProbeService: Sendable { let session = injectedSession ?? Self.makeSession(insecureSkipVerify: insecureSkipVerify) let body = requestBody(for: provider) do { - var response = try await send( + // Exactly one request, to exactly the URL the gateway will call. The probe used to + // try a second spelling and quietly rewrite the user's base URL when it answered, + // which is how a provider could test healthy against an address the gateway never + // used. `GatewayUpstreamURL` now maps each base URL to a single upstream URL, so + // there is no second spelling to try. + let response = try await send( to: primaryURL, provider: provider, body: body, session: session ) - var migratedBaseURL: String? - if [400, 404, 405].contains(response.statusCode), - let fallbackURL = fallbackEndpointURL(baseURL: baseURL, protocol: provider.protocol), - let fallback = try? await send( - to: fallbackURL, - provider: provider, - body: body, - session: session - ), - (200..<300).contains(fallback.statusCode) { - response = fallback - migratedBaseURL = baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - + "/v1" - } return decode( response, protocol: provider.protocol, - requestedModel: selectedModel(for: provider), - migratedBaseURL: migratedBaseURL + requestedModel: selectedModel(for: provider) ) } catch let error as URLError where error.code == .timedOut { return .init(succeeded: false, message: error.localizedDescription, reason: .timeout) @@ -119,8 +108,7 @@ struct ProviderProbeService: Sendable { private func decode( _ response: (statusCode: Int, data: Data), protocol wireProtocol: Provider.WireProtocol, - requestedModel: String, - migratedBaseURL: String? + requestedModel: String ) -> ProviderProbeResult { let object = try? JSONSerialization.jsonObject(with: response.data) as? [String: Any] let shapeIsValid: Bool @@ -136,8 +124,7 @@ struct ProviderProbeService: Sendable { return .init( succeeded: true, statusCode: response.statusCode, - model: object?["model"] as? String ?? requestedModel, - migratedBaseURL: migratedBaseURL + model: object?["model"] as? String ?? requestedModel ) } @@ -149,43 +136,20 @@ struct ProviderProbeService: Sendable { return .init( succeeded: false, statusCode: response.statusCode, - message: message, - migratedBaseURL: migratedBaseURL + message: message ) } + /// The URL a real inference request will reach. + /// + /// Shared with the Bifrost configuration builder on purpose. When the probe computed its own + /// URL, it could report a healthy provider the gateway then answered with 404, because the + /// two disagreed about whether the base URL already carried its version segment. private func endpointURL( baseURL: String, protocol wireProtocol: Provider.WireProtocol ) -> URL? { - URL(string: baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + endpointPath(wireProtocol)) - } - - private func fallbackEndpointURL( - baseURL: String, - protocol wireProtocol: Provider.WireProtocol - ) -> URL? { - let base = baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - guard !hasVersionSuffix(base), - !(wireProtocol == .openAIChat && base.lowercased().hasSuffix("/openai")) - else { return nil } - return URL(string: base + "/v1" + endpointPath(wireProtocol)) - } - - private func endpointPath(_ wireProtocol: Provider.WireProtocol) -> String { - switch wireProtocol { - case .anthropic: "/messages" - case .openAIChat: "/chat/completions" - case .openAIResponses: "/responses" - } - } - - private func hasVersionSuffix(_ baseURL: String) -> Bool { - guard let components = URLComponents(string: baseURL) else { return false } - guard let segment = components.path.split(separator: "/").last else { return false } - let lower = segment.lowercased() - guard lower.first == "v" else { return false } - return lower.dropFirst().first?.isNumber == true + GatewayUpstreamURL.endpointURL(baseURL: baseURL, wireProtocol: wireProtocol) } private static func makeSession(insecureSkipVerify: Bool) -> URLSession { diff --git a/native/Sources/Services/SelfCheckRunner.swift b/native/Sources/Services/SelfCheckRunner.swift index d4072ff5..582e61a9 100644 --- a/native/Sources/Services/SelfCheckRunner.swift +++ b/native/Sources/Services/SelfCheckRunner.swift @@ -443,8 +443,8 @@ struct SelfCheckRunner { /// The pinned digest of each shipped slice, matching `native/Scripts/verify-bifrost.sh`. /// Intel is the deterministic, notarization-compatible normalization of the upstream binary. nonisolated static let expectedBifrostSliceSHA256: [String: String] = [ - "arm64": "422eea68b860dd069d1b9989ff494a7bc566b7e11920632624cb6e85ca2c5263", - "x86_64": "cff62f56fc2bb8274f0b5eb97e663d6d1db953fcd710bb9ef9add1b7d27f75b3", + "arm64": "9a5dc7f02c28e2ec23207bfd6c54a3ee91c7a930b68ff6d2abddd738239ea971", + "x86_64": "76dc2cced55cef20485124d6a253d4753b614b1a6317c0fdd8d169e33137bc60", ] var dependencies: SelfCheckDependencies diff --git a/native/Sources/Usage/UsageHistoryRecordCache.swift b/native/Sources/Usage/UsageHistoryRecordCache.swift index 25db9043..b7883120 100644 --- a/native/Sources/Usage/UsageHistoryRecordCache.swift +++ b/native/Sources/Usage/UsageHistoryRecordCache.swift @@ -109,6 +109,7 @@ final class UsageHistoryRecordCache: @unchecked Sendable { private var loaded = false private var dirty = false private var lastPersistedAt: Date? + private var memoryPressureRegistration: MemoryPressureMonitor.Registration? init( file: URL, @@ -118,6 +119,28 @@ final class UsageHistoryRecordCache: @unchecked Sendable { self.file = file self.fileManager = fileManager self.minimumPersistInterval = max(0, minimumPersistInterval) + memoryPressureRegistration = MemoryPressureMonitor.shared.register { [weak self] _ in + self?.releaseMemory() + } + } + + /// Writes the cache out and drops the in-memory copy when the system is short on memory. + /// + /// This map holds every assistant turn's usage record for every transcript in the library — + /// a few hundred bytes each, and nothing ever removed it while the app ran. Because the same + /// data is already on disk, handing it back costs one reload on the next scan and loses + /// nothing: `loadIfNeededLocked` reads the file again the first time anything asks. + private func releaseMemory() { + lock.lock() + defer { lock.unlock() } + guard loaded else { return } + if dirty { + persistLocked() + dirty = false + lastPersistedAt = Date() + } + files.removeAll(keepingCapacity: false) + loaded = false } convenience init?(applicationDataRoot: URL?, fileManager: FileManager = .default) { diff --git a/native/Sources/Usage/UsageHistoryScanner.swift b/native/Sources/Usage/UsageHistoryScanner.swift index 8c65fbcc..b39701c8 100644 --- a/native/Sources/Usage/UsageHistoryScanner.swift +++ b/native/Sources/Usage/UsageHistoryScanner.swift @@ -41,17 +41,8 @@ struct UsageHistoryScanner: UsageHistoryScanning, Sendable { } var visited = Set() qoderReader.prefetch(claudeFiles) - var claudeDeduplicator = ClaudeDeduplicator() - for file in claudeFiles { - guard !Task.isCancelled else { return days } - for record in claudeRecords(in: file, visited: &visited) { - claudeDeduplicator.append(record) - } - } - for record in claudeDeduplicator.kept { - guard !Task.isCancelled else { return days } - bump(record.event, into: &days) - } + foldClaudeUsage(in: claudeFiles, visited: &visited, into: &days) + guard !Task.isCancelled else { return days } var seenCodex = Set() for root in roots { @@ -85,6 +76,33 @@ struct UsageHistoryScanner: UsageHistoryScanning, Sendable { return days } + /// Deduplicates and folds the Claude side of a scan, then lets go of everything it used. + /// + /// Deduplication has to see the whole library before it can say which copy of an assistant + /// message wins, so its table really does hold one row per assistant turn — the scan's single + /// largest allocation. Doing that work inside its own function is what makes the table's + /// lifetime end here instead of at the end of `scan`: the Codex pass that follows can take a + /// while on a large library, and it used to run with all of this still resident while the UI + /// was live. + private func foldClaudeUsage( + in files: [URL], + visited: inout Set, + into days: inout [String: UsageHistoryDay] + ) { + var deduplicator = ClaudeDeduplicator() + for file in files { + guard !Task.isCancelled else { return } + for record in claudeRecords(in: file, visited: &visited) { + deduplicator.append(record) + } + } + // Releases the two lookup tables before the fold allocates day buckets. + for record in deduplicator.finish() { + guard !Task.isCancelled else { return } + bump(record.event, into: &days) + } + } + private func bump(_ event: UsageHistoryEvent, into days: inout [String: UsageHistoryDay]) { // One calendar query per record. This fold visits every cached record on every scan, so a // second `calendar.component(.hour, ...)` call doubled the dominant per-record cost. @@ -532,6 +550,18 @@ private extension UsageHistoryScanner { kept.append(candidate) } } + + /// Hands back the deduplicated records and drops the lookup tables that produced them. + /// + /// Both tables are keyed by message and request ids, so on a large library they are + /// comparable in size to the records themselves and are dead the moment the last record + /// has been appended. + mutating func finish() -> [ClaudeRecord] { + byExact.removeAll(keepingCapacity: false) + byID.removeAll(keepingCapacity: false) + defer { kept = [] } + return kept + } } static func isDegenerateClaudeID(_ id: String) -> Bool { diff --git a/native/Sources/Views/Monitor/MonitorInspectorSupport.swift b/native/Sources/Views/Monitor/MonitorInspectorSupport.swift index ddff9745..1b4a1319 100644 --- a/native/Sources/Views/Monitor/MonitorInspectorSupport.swift +++ b/native/Sources/Views/Monitor/MonitorInspectorSupport.swift @@ -97,7 +97,7 @@ enum MonitorProtocolDisposition: Equatable { } /// Converts Bifrost's detail record into the legacy inspector's two-sided/four-sided mental model. -/// Pinned Bifrost v1.6.11 has no `translated` field: normalized and raw payloads coexist for normal +/// Pinned Bifrost v2.1.1 has no `translated` field: normalized and raw payloads coexist for normal /// passthrough traffic too. Four sides are therefore shown only when the route request type and the /// configured upstream wire protocol prove a conversion. Ambiguous records remain generic two-sided /// documents rather than being labelled as translated. @@ -226,7 +226,7 @@ struct MonitorInspectorDocument: Equatable { // `object` is populated from Bifrost's route RequestType. Chat routes are unambiguous; // Anthropic Messages and OpenAI Responses both intentionally normalize to `responses`, so - // those records cannot be distinguished without route metadata that v1.6.11 does not emit. + // those records cannot be distinguished without route metadata that v2.1.1 does not emit. let object = log.object? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() diff --git a/native/Sources/Views/Providers/ProviderEditorView.swift b/native/Sources/Views/Providers/ProviderEditorView.swift index e9c21d5f..21d89ac1 100644 --- a/native/Sources/Views/Providers/ProviderEditorView.swift +++ b/native/Sources/Views/Providers/ProviderEditorView.swift @@ -15,6 +15,11 @@ struct ProviderEditorView: View { @State private var testing = false @State private var testMessage: String? @State private var testSucceeded = false + @State private var discovering = false + /// What the last probe of `/v1/models` found. `.unsupported` is what greys the refresh + /// control out: the provider answered and said it has no listing endpoint, so offering to + /// refresh again would only produce the same answer. + @State private var modelCatalog = ProviderModelCatalog.unknown let onSave: (Provider) -> Void @@ -66,6 +71,11 @@ struct ProviderEditorView: View { height: ProviderEditorLayout.sheetSize.height ) .background(Theme.surface) + // A listing endpoint belongs to one address; pointing the provider somewhere else makes + // the previous "this host has no /v1/models" answer meaningless, so the control comes + // back enabled rather than staying greyed out against a host never probed. + .onChange(of: draft.baseUrl) { _ in modelCatalog = .unknown } + .onChange(of: draft.protocol) { _ in modelCatalog = .unknown } .overlay(alignment: .topLeading) { Rectangle() .fill(Color.clear) @@ -168,6 +178,8 @@ struct ProviderEditorView: View { selectedPreset = preset.id preset.apply(to: &draft) testMessage = nil + // Availability was measured against the previous address. + modelCatalog = .unknown } .buttonStyle(ProviderPresetButtonStyle(selected: selectedPreset == preset.id)) .help(preset.baseURL.isEmpty ? preset.name : preset.baseURL) @@ -274,12 +286,69 @@ struct ProviderEditorView: View { } } + /// Refreshes the mapping list from the provider's own `/v1/models`. + /// + /// The button is disabled while a probe is in flight, while the address is unusable, and — + /// the point of tracking availability at all — once a probe has established that this + /// provider has no listing endpoint, so the control reads as unavailable rather than broken. + private var discoverModelsControl: some View { + HStack(spacing: 7) { + Button { + runModelDiscovery() + } label: { + HStack(spacing: 5) { + if discovering { + ProgressView().controlSize(.mini) + } else { + Image(systemName: "arrow.clockwise") + .font(.system(size: 10, weight: .semibold)) + } + Text(appLanguage.localized("从接口获取模型")) + } + } + .buttonStyle(CompactActionButtonStyle()) + .disabled(discovering || !isValid || modelCatalog.availability == .unsupported) + .accessibilityIdentifier("provider.editor.discoverModels") + .help(appLanguage.localized( + modelCatalog.availability == .unsupported + ? "该服务未提供 /v1/models 接口" + : "调用 /v1/models 或 /models 并自动创建模型绑定" + )) + if let message = discoveryMessage { + Text(appLanguage.localized(message) + discoveryCountSuffix) + .font(.system(size: 11)) + .foregroundStyle( + modelCatalog.availability == .available + ? Theme.mutedForeground : Theme.danger + ) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + } + + private var discoveryMessage: String? { + switch modelCatalog.availability { + case .unknown: return modelCatalog.message + case .available: return "已发现模型" + case .unsupported: return modelCatalog.message ?? "该服务未提供模型列表接口" + } + } + + /// The model count is a number, so it is appended rather than interpolated into a phrase + /// that would then have to be translated once per locale. + private var discoveryCountSuffix: String { + modelCatalog.availability == .available ? " · \(modelCatalog.models.count)" : "" + } + private var mappings: some View { DisclosureGroup(isExpanded: $mappingsExpanded) { VStack(alignment: .leading, spacing: 8) { Text("把客户端模型名精确映射到上游模型;未命中时才使用自动映射。") .font(.system(size: 11.5)) .foregroundStyle(Theme.mutedForeground) + discoverModelsControl ForEach(Array(draft.models.indices), id: \.self) { index in HStack(spacing: 7) { TextField("客户端别名", text: mappingBinding(index, \.alias)) @@ -388,7 +457,6 @@ struct ProviderEditorView: View { testing = false testSucceeded = result.succeeded if result.succeeded { - if let migrated = result.migratedBaseURL { draft.baseUrl = migrated } testMessage = "连接成功 · \(result.model ?? draft.defaultModel)" } else { switch result.reason { @@ -404,6 +472,37 @@ struct ProviderEditorView: View { } } + /// Probes the provider's model listing and folds whatever it returns into the bindings. + /// + /// Discovered models are added as identity mappings — the caller-facing name and the upstream + /// name are the same — because that is what a provider advertising its own catalog means. + /// Rows the user wrote are never touched. + private func runModelDiscovery() { + discovering = true + let provider = draft + let insecure = model.config.insecureSkipVerify + Task { + let catalog = await ProviderModelDiscoveryService().discover( + provider, + insecureSkipVerify: insecure + ) + await MainActor.run { + discovering = false + modelCatalog = catalog + guard catalog.availability == .available else { return } + draft.models = ProviderModelDiscoveryService.merging( + discovered: catalog.models, + into: draft.models + ) + if draft.defaultModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let first = catalog.models.first { + draft.defaultModel = first + } + mappingsExpanded = true + } + } + } + private func chooseIcon() { let panel = NSOpenPanel() panel.canChooseFiles = true diff --git a/native/Sources/Views/Providers/ProvidersView.swift b/native/Sources/Views/Providers/ProvidersView.swift index 559f0ec1..c014a640 100644 --- a/native/Sources/Views/Providers/ProvidersView.swift +++ b/native/Sources/Views/Providers/ProvidersView.swift @@ -208,11 +208,6 @@ struct ProvidersView: View { provider, insecureSkipVerify: model.config.insecureSkipVerify ) - if result.succeeded, let migrated = result.migratedBaseURL { - var updated = provider - updated.baseUrl = migrated - await model.upsertProvider(updated) - } probeStates[provider.id] = result.succeeded ? .succeeded : .failed probeMessageSucceeded = result.succeeded if result.succeeded { diff --git a/native/Sources/Views/Settings/AboutSettingsPane.swift b/native/Sources/Views/Settings/AboutSettingsPane.swift index 0164b476..6c796221 100644 --- a/native/Sources/Views/Settings/AboutSettingsPane.swift +++ b/native/Sources/Views/Settings/AboutSettingsPane.swift @@ -2,7 +2,7 @@ import AppKit import SwiftUI struct AboutVersionPresentation: Equatable { - static let bifrostVersion = "v1.6.11" + static let bifrostVersion = "v2.1.1" let current: String let latest: String diff --git a/native/Sources/Views/Settings/GatewaySettingsPane.swift b/native/Sources/Views/Settings/GatewaySettingsPane.swift index 3dd61035..5c7293a2 100644 --- a/native/Sources/Views/Settings/GatewaySettingsPane.swift +++ b/native/Sources/Views/Settings/GatewaySettingsPane.swift @@ -118,14 +118,18 @@ struct GatewaySettingsPane: View { } /// cc-switch's failover queue, which the config and the Bifrost route builder already model: - /// while it is on, the queue *is* the route set, tried strictly in order. The queue therefore - /// owns its own order here rather than borrowing the provider list's, because the order is the - /// only thing it expresses. + /// while it is on, the queue *is* the route set. It owns its own order here rather than + /// borrowing the provider list's, because the order is what it expresses. + /// + /// The queue is also the gateway's protocol set. A client speaking a protocol one of these + /// providers already speaks is handed to that provider untouched; one speaking a protocol + /// none of them offers is converted and sent to the head of the queue. Failure still falls + /// through the queue in order either way. private var failoverCard: some View { SettingsCard("故障转移") { SettingsToggleRow( "自动故障转移", - detail: "开启后网关按队列顺序发送请求,上游失败时自动改用下一个供应商;关闭时只使用当前启用的供应商。", + detail: "开启后队列即是网关的上游集合:客户端请求的协议如果队列里有,就直接透传给那个供应商;没有就转换协议后交给队首。任一上游失败时按队列顺序自动改用下一个。关闭时只使用当前启用的供应商。", isOn: Binding( get: { model.config.gatewayFailover.enabled }, set: { enabled in Task { await model.setGatewayFailoverEnabled(enabled) } } diff --git a/native/Tests/Unit/BifrostConfigTests.swift b/native/Tests/Unit/BifrostConfigTests.swift index e5548152..8a3e7626 100644 --- a/native/Tests/Unit/BifrostConfigTests.swift +++ b/native/Tests/Unit/BifrostConfigTests.swift @@ -35,7 +35,14 @@ final class BifrostConfigTests: XCTestCase { XCTAssertTrue(provider.customProviderConfig.allowedRequests.responsesStream) XCTAssertTrue(provider.customProviderConfig.allowedRequests.countTokens) XCTAssertFalse(output.client.compat.convertChatToResponses) - XCTAssertEqual(provider.networkConfig.baseURL, "https://open.bigmodel.cn/api/anthropic/v1") + // Bifrost appends its own "/v1/messages", so the trailing segment of the configured + // base URL has to come off. Leaving it on asked every upstream for "/v1/v1/messages", + // which is a 404 from all of them. + XCTAssertEqual(provider.networkConfig.baseURL, "https://open.bigmodel.cn/api/anthropic") + XCTAssertNil( + provider.customProviderConfig.requestPathOverrides, + "shortening the base is the complete fix for a trailing /v1; overrides are for the rest" + ) XCTAssertEqual(provider.networkConfig.maxRetries, 3) XCTAssertEqual(provider.networkConfig.retryBackoffInitial, 500) XCTAssertEqual(provider.networkConfig.retryBackoffMax, 4_000) @@ -65,7 +72,7 @@ final class BifrostConfigTests: XCTestCase { let logsStore = try XCTUnwrap(object["logs_store"] as? [String: Any]) XCTAssertNil( logsStore["retention_days"], - "SQLite retention is controlled by client.log_retention_days in Bifrost v1.6.11" + "SQLite retention is controlled by client.log_retention_days in Bifrost v2.1.1" ) } @@ -317,7 +324,14 @@ final class BifrostConfigTests: XCTestCase { XCTAssertNil(document[" upstream-primary "]) } - func testResponsesChatConversionIsDisabledWhenNoConfiguredModelCanBeCatalogued() throws { + /// Chat -> Responses conversion follows the configured *protocol*, not the generated model + /// catalog. + /// + /// It used to additionally require a non-empty catalog, which meant a Responses provider whose + /// models the user had not enumerated in advance silently lost the ability to serve a Chat + /// client at all — a gateway that accepts three caller shapes has to keep accepting them + /// whether or not it happens to know the upstream's model names. + func testResponsesChatConversionSurvivesAnUncataloguedResponsesProvider() throws { var config = AppConfig.fixture config.providers[0].protocol = .openAIResponses config.providers[0].defaultModel = "" @@ -329,7 +343,7 @@ final class BifrostConfigTests: XCTestCase { logDatabaseURL: URL(fileURLWithPath: "/tmp/logs.db"), managementCredentials: managementCredentials ) - XCTAssertFalse(output.client.compat.convertChatToResponses) + XCTAssertTrue(output.client.compat.convertChatToResponses) XCTAssertEqual(String(decoding: try BifrostConfigBuilder.modelParametersData(from: config), as: UTF8.self), "{}") } diff --git a/native/Tests/Unit/BifrostSupervisorIntegrationTests.swift b/native/Tests/Unit/BifrostSupervisorIntegrationTests.swift index 52c9ee4b..81e6b0b7 100644 --- a/native/Tests/Unit/BifrostSupervisorIntegrationTests.swift +++ b/native/Tests/Unit/BifrostSupervisorIntegrationTests.swift @@ -56,9 +56,11 @@ final class BifrostSupervisorIntegrationTests: XCTestCase { port: config.port, credentials: managementCredentials ) + // Bifrost appends its own "/v1/messages", so the configured base has to stop short of + // the version segment or every upstream is asked for "/v1/v1/messages". XCTAssertEqual( firstNetwork["base_url"] as? String, - "https://open.bigmodel.cn/api/anthropic/v1" + "https://open.bigmodel.cn/api/anthropic" ) XCTAssertEqual(firstNetwork["max_retries"] as? Int, 3) let storeDirectory = root.appendingPathComponent("bifrost", isDirectory: true) diff --git a/native/Tests/Unit/BifrostSupervisorTests.swift b/native/Tests/Unit/BifrostSupervisorTests.swift index d83374c7..60928e33 100644 --- a/native/Tests/Unit/BifrostSupervisorTests.swift +++ b/native/Tests/Unit/BifrostSupervisorTests.swift @@ -44,9 +44,25 @@ final class BifrostSupervisorTests: XCTestCase { "/responses/compact": "/openai/v1/responses/compact", "/v1/responses/compact": "/openai/v1/responses/compact", "/v1/messages/count_tokens": "/anthropic/v1/messages/count_tokens", + "/models": "/v1/models", ]) } + /// Bifrost serves its management UI from the root, so an unversioned model listing used to + /// answer a client with a page of HTML. + func testUnversionedModelListingReachesTheCatalogRouteAndIsStillIntercepted() { + XCTAssertEqual(LegacyGatewayRouteCompatibility.destination(for: "/models"), "/v1/models") + XCTAssertTrue(LegacyGatewayRouteCompatibility.capturesKnownModels( + method: "GET", requestTarget: "/models" + )) + XCTAssertTrue(LegacyGatewayRouteCompatibility.capturesKnownModels( + method: "GET", requestTarget: "/v1/models" + )) + XCTAssertFalse(LegacyGatewayRouteCompatibility.capturesKnownModels( + method: "POST", requestTarget: "/models" + )) + } + func testLegacyGatewayRouteRewritePreservesQueryAndLegacyTrailingSlashMatching() { XCTAssertEqual( LegacyGatewayRouteCompatibility.destination( diff --git a/native/Tests/Unit/BoundedMemoryCacheTests.swift b/native/Tests/Unit/BoundedMemoryCacheTests.swift new file mode 100644 index 00000000..277f6ff0 --- /dev/null +++ b/native/Tests/Unit/BoundedMemoryCacheTests.swift @@ -0,0 +1,111 @@ +import XCTest + +@testable import CCBuddy + +/// The bound is the whole point: these caches front work that is cheap to redo, and before this +/// they grew for the life of the process. A library with tens of thousands of sessions turned +/// "remember everything we ever parsed" into hundreds of megabytes the app never handed back, +/// which is what made a big history first slow the app and then the machine. +final class BoundedMemoryCacheTests: XCTestCase { + func testTheLeastRecentlyUsedEntryIsTheOneEvicted() { + var cache = BoundedMemoryCache(costLimit: 100) + cache.setValue("a", forKey: "a", cost: 40) + cache.setValue("b", forKey: "b", cost: 40) + XCTAssertEqual(cache.count, 2) + XCTAssertEqual(cache.totalCost, 80) + + XCTAssertEqual(cache.value(forKey: "a"), "a") + cache.setValue("c", forKey: "c", cost: 40) + + XCTAssertEqual(cache.peek(forKey: "a"), "a", "a was used most recently") + XCTAssertNil(cache.peek(forKey: "b"), "b was the least recently used") + XCTAssertEqual(cache.peek(forKey: "c"), "c") + XCTAssertLessThanOrEqual(cache.totalCost, cache.costLimit) + } + + func testPeekDoesNotCountAsUse() { + var cache = BoundedMemoryCache(costLimit: 20) + cache.setValue(1, forKey: "a", cost: 10) + cache.setValue(2, forKey: "b", cost: 10) + _ = cache.peek(forKey: "a") + cache.setValue(3, forKey: "c", cost: 10) + XCTAssertNil(cache.peek(forKey: "a"), "peeking is bookkeeping, not use") + } + + func testAnEntryLargerThanTheBudgetIsRefusedRatherThanEmptyingTheCache() { + var cache = BoundedMemoryCache(costLimit: 100) + cache.setValue("a", forKey: "a", cost: 40) + cache.setValue("b", forKey: "b", cost: 40) + cache.setValue("huge", forKey: "huge", cost: 500) + XCTAssertNil(cache.peek(forKey: "huge")) + XCTAssertEqual(cache.count, 2, "one oversized value must not evict everything else") + } + + func testCountIsBoundedEvenWhenEveryEntryIsTiny() { + var cache = BoundedMemoryCache(costLimit: 1_000_000, countLimit: 3) + for value in 0..<10 { cache.setValue(value, forKey: value, cost: 1) } + XCTAssertEqual(cache.count, 3) + XCTAssertNotNil(cache.peek(forKey: 9)) + XCTAssertNil(cache.peek(forKey: 0)) + } + + func testShrinkingUnderPressureKeepsTheMostRecentEntries() { + var cache = BoundedMemoryCache(costLimit: 100) + for value in 0..<10 { cache.setValue(value, forKey: value, cost: 10) } + XCTAssertEqual(cache.totalCost, 100) + + cache.shrink(toFraction: 0.25) + + XCTAssertLessThanOrEqual(cache.totalCost, 25) + XCTAssertNotNil(cache.peek(forKey: 9)) + XCTAssertNil(cache.peek(forKey: 0)) + } + + func testEntriesCanBeDroppedToFollowAnAuthoritativeList() { + var cache = BoundedMemoryCache(costLimit: 100) + cache.setValue(1, forKey: "kept", cost: 1) + cache.setValue(2, forKey: "gone", cost: 1) + cache.removeAll { $0 == "gone" } + XCTAssertEqual(cache.peek(forKey: "kept"), 1) + XCTAssertNil(cache.peek(forKey: "gone")) + XCTAssertEqual(cache.totalCost, 1, "removal must return the entry's budget") + } + + func testReplacingAnEntryDoesNotLeakItsOldCost() { + var cache = BoundedMemoryCache(costLimit: 100) + cache.setValue(1, forKey: "a", cost: 50) + cache.setValue(2, forKey: "a", cost: 10) + XCTAssertEqual(cache.count, 1) + XCTAssertEqual(cache.totalCost, 10) + XCTAssertEqual(cache.peek(forKey: "a"), 2) + } + + func testRemovingEverythingResetsTheBudget() { + var cache = BoundedMemoryCache(costLimit: 100) + cache.setValue(1, forKey: "a", cost: 50) + cache.removeAll() + XCTAssertTrue(cache.isEmpty) + XCTAssertEqual(cache.totalCost, 0) + } + + /// Eviction is amortised: a full cache sheds a batch rather than one entry per insert, so a + /// walk over a whole library does not pay for recency ordering on every single insert. + func testAFullCacheShedsABatchRatherThanOneEntryPerInsert() { + var cache = BoundedMemoryCache(costLimit: 1_000) + for value in 0..<100 { cache.setValue(value, forKey: value, cost: 10) } + XCTAssertEqual(cache.totalCost, 1_000) + + cache.setValue(100, forKey: 100, cost: 10) + + XCTAssertLessThanOrEqual(cache.totalCost, 900, "one insert must shed down to the mark") + XCTAssertNotNil(cache.peek(forKey: 100)) + XCTAssertNil(cache.peek(forKey: 0), "the oldest entries go first") + } + + /// A cache small enough that a tenth rounds to nothing must still hold exactly its limit. + func testASmallCountLimitIsStillExact() { + var cache = BoundedMemoryCache(costLimit: 1_000_000, countLimit: 3) + for value in 0..<10 { cache.setValue(value, forKey: value, cost: 1) } + XCTAssertEqual(cache.count, 3) + } +} diff --git a/native/Tests/Unit/GatewayProtocolRoutingTests.swift b/native/Tests/Unit/GatewayProtocolRoutingTests.swift new file mode 100644 index 00000000..59287ae6 --- /dev/null +++ b/native/Tests/Unit/GatewayProtocolRoutingTests.swift @@ -0,0 +1,230 @@ +import XCTest + +@testable import CCBuddy + +/// The gateway's one-of-three / three-of-three contract. +/// +/// The user configures upstreams; clients keep speaking whatever they already speak. When an +/// upstream speaking the caller's protocol exists the request must reach it untouched, and when +/// none does the request must still be served, converted, by the head of the queue. Before this, +/// provider selection was left to Bifrost's model catalog, which has no idea which protocol the +/// caller spoke: with several providers configured every caller collapsed onto whichever one the +/// catalog indexed first, so a Codex client could be answered by an Anthropic upstream even +/// though a Responses upstream was sitting right there in the queue. +final class GatewayProtocolRoutingTests: XCTestCase { + private func provider( + _ id: String, + _ wireProtocol: Provider.WireProtocol, + primary: String + ) -> Provider { + Provider( + id: id, + name: id, + baseUrl: "https://\(id).example.com", + authToken: "token", + defaultModel: primary, + smallFastModel: "\(primary)-fast", + mapDefaultModels: true, + protocol: wireProtocol + ) + } + + private func config(_ providers: [Provider]) -> AppConfig { + var config = AppConfig() + config.providers = providers + config.activeProviderId = providers.first?.id + if providers.count > 1 { + config.gatewayFailover = .init(enabled: true, providerIds: providers.map(\.id)) + } + config.normalize() + return config + } + + private var anthropicProvider: Provider { provider("anth", .anthropic, primary: "claude-up") } + private var chatProvider: Provider { provider("chat", .openAIChat, primary: "chat-up") } + private var responsesProvider: Provider { + provider("resp", .openAIResponses, primary: "resp-up") + } + + func testEveryCallerFacingPathIsClassifiedByItsWireProtocol() { + let expectations: [(String, GatewayClientProtocol)] = [ + ("/v1/messages", .anthropic), + ("/messages", .anthropic), + ("/anthropic/v1/messages", .anthropic), + ("/v1/messages/count_tokens", .anthropic), + ("/anthropic/v1/messages/count_tokens", .anthropic), + ("/v1/complete", .anthropic), + ("/v1/chat/completions", .openAIChat), + ("/chat/completions", .openAIChat), + ("/openai/v1/chat/completions", .openAIChat), + ("/v1/responses", .openAIResponses), + ("/responses", .openAIResponses), + ("/openai/v1/responses", .openAIResponses), + // Bifrost publishes this third spelling of its Responses route. + ("/openai/responses", .openAIResponses), + ("/v1/responses/compact", .openAIResponses), + ("/v1/responses/resp_123", .openAIResponses), + ("/v1/responses/resp_123/cancel", .openAIResponses), + ] + for (path, expected) in expectations { + XCTAssertEqual(GatewayClientProtocol.classify(path: path), expected, path) + } + // A trailing slash and a query string are part of ordinary client traffic. + XCTAssertEqual(GatewayClientProtocol.classify(path: "/v1/messages/"), .anthropic) + XCTAssertEqual( + GatewayClientProtocol.classify(path: "/v1/messages?beta=true"), .anthropic + ) + for path in ["/v1/models", "/models", "/messages-extra", "/", "/metrics"] { + XCTAssertNil(GatewayClientProtocol.classify(path: path), path) + } + } + + func testThreeOfThreeHandsEveryCallerToItsOwnUpstreamWithoutConverting() { + let config = config([anthropicProvider, chatProvider, responsesProvider]) + let router = GatewayProtocolRouter(config: config) + XCTAssertTrue(router.servesEveryProtocolDirectly) + XCTAssertTrue(router.pinsProviderName) + + let expectations: [(GatewayClientProtocol, String)] = [ + (.anthropic, "anth"), (.openAIChat, "chat"), (.openAIResponses, "resp"), + ] + let routing = LegacyModelRoutingCompatibility(config: config) + for (clientProtocol, providerID) in expectations { + XCTAssertEqual( + router.route(for: clientProtocol)?.provider.id, providerID, + "\(clientProtocol.rawValue) must reach the upstream that speaks it" + ) + XCTAssertFalse( + router.requiresConversion(for: clientProtocol), clientProtocol.rawValue + ) + let bifrostName = router.routes.first { $0.provider.id == providerID }?.bifrostName + XCTAssertNotNil(bifrostName) + let route = routing.resolve("claude-sonnet-5", clientProtocol: clientProtocol) + XCTAssertEqual(route?.pinnedProviderName, bifrostName) + XCTAssertEqual( + route?.wireModel?.hasPrefix("\(bifrostName ?? "")/"), true, + "the wire model must name the provider so Bifrost cannot pick another" + ) + } + } + + func testOneOfThreeServesEveryCallerThroughTheSingleConfiguredUpstream() { + let config = config([responsesProvider]) + let router = GatewayProtocolRouter(config: config) + XCTAssertFalse(router.servesEveryProtocolDirectly) + XCTAssertFalse( + router.pinsProviderName, + "one upstream is already unambiguous, so its requests stay byte-identical" + ) + for clientProtocol in GatewayClientProtocol.allCases { + XCTAssertEqual(router.route(for: clientProtocol)?.provider.id, "resp") + } + XCTAssertTrue(router.requiresConversion(for: .anthropic)) + XCTAssertTrue(router.requiresConversion(for: .openAIChat)) + XCTAssertFalse(router.requiresConversion(for: .openAIResponses)) + + let routing = LegacyModelRoutingCompatibility(config: config) + let route = routing.resolve("claude-sonnet-4-5", clientProtocol: .anthropic) + XCTAssertEqual(route?.wireModel, "resp-up") + XCTAssertNil(route?.pinnedProviderName) + XCTAssertEqual(route?.fallbackModels, []) + } + + func testTwoOfThreeConvertsOnlyTheProtocolItDoesNotHave() { + let config = config([chatProvider, responsesProvider]) + let router = GatewayProtocolRouter(config: config) + XCTAssertEqual(router.route(for: .openAIChat)?.provider.id, "chat") + XCTAssertEqual(router.route(for: .openAIResponses)?.provider.id, "resp") + XCTAssertEqual( + router.route(for: .anthropic)?.provider.id, "chat", + "an unconfigured caller protocol falls to the head of the queue" + ) + XCTAssertFalse(router.requiresConversion(for: .openAIChat)) + XCTAssertFalse(router.requiresConversion(for: .openAIResponses)) + XCTAssertTrue(router.requiresConversion(for: .anthropic)) + } + + /// Pinning a provider takes provider choice away from Bifrost's virtual-key balancer, which + /// is also what used to provide failover. The rest of the queue therefore has to travel with + /// the request. + func testPinnedRequestsStillCarryTheRestOfTheQueueAsFallbacks() { + let config = config([chatProvider, responsesProvider, anthropicProvider]) + let router = GatewayProtocolRouter(config: config) + let routing = LegacyModelRoutingCompatibility(config: config) + let names = Dictionary( + uniqueKeysWithValues: router.routes.map { ($0.provider.id, $0.bifrostName) } + ) + + let route = routing.resolve("claude-haiku-4-5", clientProtocol: .openAIResponses) + XCTAssertNotNil(route) + XCTAssertEqual(route?.pinnedProviderName, names["resp"]) + XCTAssertEqual( + route?.fallbackModels, + ["\(names["chat"] ?? "")/chat-up-fast", "\(names["anth"] ?? "")/claude-up-fast"], + "fallbacks keep the configured queue order and each provider's own model mapping" + ) + } + + /// A model the user mapped explicitly keeps its caller spelling on the wire so Bifrost's own + /// alias resolver still runs and still records which alias it applied. + func testConfiguredAliasesKeepTheirCallerSpellingBehindTheProviderPrefix() { + var aliased = chatProvider + aliased.models = [ModelMapping(alias: "my-alias", upstream: "real-upstream")] + let config = config([aliased, responsesProvider]) + let router = GatewayProtocolRouter(config: config) + let routing = LegacyModelRoutingCompatibility(config: config) + let name = router.routes.first { $0.provider.id == "chat" }?.bifrostName ?? "" + + let route = routing.resolve("my-alias", clientProtocol: .openAIChat) + XCTAssertEqual(route?.wireModel, "\(name)/my-alias") + XCTAssertEqual(route?.outgoingModel, "real-upstream") + XCTAssertTrue(route?.usesNativeAlias == true) + XCTAssertTrue( + route?.needsResponseRestoration == true, + "the caller must see back the model name it sent" + ) + } + + func testModelMappingFollowsTheUpstreamThatActuallyServesTheRequest() { + var chat = chatProvider + chat.models = [ModelMapping(alias: "shared", upstream: "chat-target")] + var responses = responsesProvider + responses.models = [ModelMapping(alias: "shared", upstream: "responses-target")] + let routing = LegacyModelRoutingCompatibility(config: config([chat, responses])) + + XCTAssertEqual( + routing.resolve("shared", clientProtocol: .openAIChat)?.outgoingModel, + "chat-target" + ) + XCTAssertEqual( + routing.resolve("shared", clientProtocol: .openAIResponses)?.outgoingModel, + "responses-target", + "a Responses caller must be mapped through the Responses provider's own aliases" + ) + } + + func testInternalProviderNamesAreStrippedBeforeAModelListReachesAClient() { + let router = GatewayProtocolRouter(config: config([chatProvider, responsesProvider])) + let name = router.routes[0].bifrostName + XCTAssertEqual(router.strippingProviderPrefix("\(name)/gpt-5.4"), "gpt-5.4") + XCTAssertEqual(router.strippingProviderPrefix("vendor/model-x"), "vendor/model-x") + XCTAssertEqual(router.strippingProviderPrefix("gpt-5.4"), "gpt-5.4") + } + + func testAModelAlreadyNamingItsProviderIsNotPrefixedTwice() { + let route = LegacyModelRoute( + requestedModel: "ccbud-1-abc/gpt-5.4", + outgoingModel: "ccbud-1-abc/gpt-5.4", + usesNativeAlias: false, + pinnedProviderName: "ccbud-1-abc" + ) + XCTAssertEqual(route.wireModel, "ccbud-1-abc/gpt-5.4") + } + + func testNoConfiguredProviderProducesNoRoute() { + let router = GatewayProtocolRouter(config: AppConfig()) + XCTAssertNil(router.route(for: .anthropic)) + XCTAssertFalse(router.servesEveryProtocolDirectly) + XCTAssertNil(LegacyModelRoutingCompatibility(config: AppConfig()).resolve("anything")) + } +} diff --git a/native/Tests/Unit/GatewayUpstreamURLTests.swift b/native/Tests/Unit/GatewayUpstreamURLTests.swift new file mode 100644 index 00000000..5a0e84aa --- /dev/null +++ b/native/Tests/Unit/GatewayUpstreamURLTests.swift @@ -0,0 +1,160 @@ +import XCTest + +@testable import CCBuddy + +/// One character of path decided whether the gateway worked at all. +/// +/// Bifrost builds the whole upstream path and inserts its own `/v1`. Every provider preset ships +/// the base URL its vendor documents, and those already end where the vendor's version segment +/// begins. Handed over untouched, that produced `…/v1/v1/messages`, which every upstream answers +/// with 404 — for seventy-one of the seventy-two presets. The connection test did not catch it, +/// because it computed its own URL and happened to compute the right one. +final class GatewayUpstreamURLTests: XCTestCase { + func testABaseEndingInTheSegmentBifrostAddsIsClassifiedAsSuch() { + for base in [ + "https://api.anthropic.com/v1", + "https://api.moonshot.cn/anthropic/v1", + "https://api.aicodemirror.ai/api/claudecode/v1", + "https://host/v1/", + "https://host/v1//", + ] { + XCTAssertEqual(GatewayUpstreamURL.versioning(of: base), .trailingV1, base) + } + for base in [ + "https://open.bigmodel.cn/api/paas/v4", + "https://generativelanguage.googleapis.com/v1beta/openai", + "https://host/v2alpha/chat", + ] { + XCTAssertEqual(GatewayUpstreamURL.versioning(of: base), .foreign, base) + } + for base in [ + "https://open.bigmodel.cn/api/anthropic", + "https://api.anthropic.com", + "https://host", + "", + ] { + XCTAssertEqual(GatewayUpstreamURL.versioning(of: base), Optional(.none), base) + } + } + + /// Dropping the trailing segment is the complete fix for this shape: Bifrost puts it back on + /// every endpoint, including the Responses routes whose path carries a response id. + func testATrailingVersionSegmentIsRemovedBecauseBifrostRestoresIt() { + XCTAssertEqual( + GatewayUpstreamURL.bifrostBaseURL(for: "https://api.anthropic.com/v1"), + "https://api.anthropic.com" + ) + XCTAssertEqual( + GatewayUpstreamURL.bifrostBaseURL(for: "https://api.moonshot.cn/anthropic/v1/"), + "https://api.moonshot.cn/anthropic" + ) + // Anything else is preserved exactly as the user typed it. + XCTAssertEqual( + GatewayUpstreamURL.bifrostBaseURL(for: "https://open.bigmodel.cn/api/anthropic"), + "https://open.bigmodel.cn/api/anthropic" + ) + XCTAssertEqual( + GatewayUpstreamURL.bifrostBaseURL(for: "https://open.bigmodel.cn/api/paas/v4"), + "https://open.bigmodel.cn/api/paas/v4" + ) + } + + func testOverridesAreEmittedOnlyForAVersionBifrostCannotReproduce() { + XCTAssertTrue( + GatewayUpstreamURL.requestPathOverrides( + for: .anthropic, baseURL: "https://api.anthropic.com/v1" + ).isEmpty, + "a trailing /v1 is handled by shortening the base instead" + ) + XCTAssertTrue( + GatewayUpstreamURL.requestPathOverrides( + for: .anthropic, baseURL: "https://open.bigmodel.cn/api/anthropic" + ).isEmpty + ) + let google = GatewayUpstreamURL.requestPathOverrides( + for: .openAIChat, + baseURL: "https://generativelanguage.googleapis.com/v1beta/openai" + ) + XCTAssertEqual(google["chat_completion"], "/chat/completions") + XCTAssertEqual(google["chat_completion_stream"], "/chat/completions") + XCTAssertEqual(google["list_models"], "/models") + let glm = GatewayUpstreamURL.requestPathOverrides( + for: .anthropic, baseURL: "https://open.bigmodel.cn/api/paas/v4" + ) + XCTAssertEqual(glm["chat_completion"], "/messages") + XCTAssertEqual(glm["count_tokens"], "/messages/count_tokens") + } + + /// The regression itself: whatever the base URL's shape, the version segment must appear + /// exactly once in the URL the upstream is asked for. + func testTheVersionSegmentAppearsExactlyOnceForEveryBaseShape() { + let cases: [(String, Provider.WireProtocol, String)] = [ + ("https://api.anthropic.com/v1", .anthropic, "https://api.anthropic.com/v1/messages"), + ("https://api.anthropic.com", .anthropic, "https://api.anthropic.com/v1/messages"), + ( + "https://api.moonshot.cn/anthropic/v1", .anthropic, + "https://api.moonshot.cn/anthropic/v1/messages" + ), + ( + "https://open.bigmodel.cn/api/anthropic", .anthropic, + "https://open.bigmodel.cn/api/anthropic/v1/messages" + ), + ("https://api.openai.com/v1", .openAIChat, "https://api.openai.com/v1/chat/completions"), + ( + "https://generativelanguage.googleapis.com/v1beta/openai", .openAIChat, + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" + ), + ("https://api.openai.com/v1", .openAIResponses, "https://api.openai.com/v1/responses"), + ] + for (base, wireProtocol, expected) in cases { + XCTAssertEqual( + GatewayUpstreamURL.endpointURL(baseURL: base, wireProtocol: wireProtocol)? + .absoluteString, + expected, + base + ) + } + } + + /// What the generated configuration and the connection test each resolve to must be the same + /// URL. They disagreeing is how a provider could test healthy and then answer every real + /// request with 404. + func testEveryShippedPresetResolvesIdenticallyForTheGatewayAndTheProbe() { + let presets = ProviderPreset.all.filter { !$0.baseURL.isEmpty } + XCTAssertGreaterThan(presets.count, 60, "the catalog should not have quietly emptied") + for preset in presets { + let bifrostBase = GatewayUpstreamURL.bifrostBaseURL(for: preset.baseURL) + let overrides = GatewayUpstreamURL.requestPathOverrides( + for: preset.wireProtocol, baseURL: preset.baseURL + ) + let defaultPath: String + let overrideKey: String + switch preset.wireProtocol { + case .anthropic: + defaultPath = "/v1/messages" + overrideKey = "chat_completion" + case .openAIChat: + defaultPath = "/v1/chat/completions" + overrideKey = "chat_completion" + case .openAIResponses: + defaultPath = "/v1/responses" + overrideKey = "responses" + } + let gateway = bifrostBase + (overrides[overrideKey] ?? defaultPath) + let probe = GatewayUpstreamURL.endpointURL( + baseURL: preset.baseURL, wireProtocol: preset.wireProtocol + )?.absoluteString + XCTAssertEqual(gateway, probe, preset.name) + XCTAssertFalse( + gateway.contains("/v1/v1/"), + "\(preset.name) would ask \(preset.baseURL) for a doubled version segment" + ) + } + } + + func testAnEmptyOrSchemeOnlyBaseIsNotMangled() { + XCTAssertNil(GatewayUpstreamURL.endpointURL(baseURL: "", wireProtocol: .anthropic)) + XCTAssertNil(GatewayUpstreamURL.endpointURL(baseURL: " ", wireProtocol: .anthropic)) + XCTAssertEqual(GatewayUpstreamURL.bifrostBaseURL(for: ""), "") + } +} diff --git a/native/Tests/Unit/ProviderModelDiscoveryTests.swift b/native/Tests/Unit/ProviderModelDiscoveryTests.swift new file mode 100644 index 00000000..fc083cbf --- /dev/null +++ b/native/Tests/Unit/ProviderModelDiscoveryTests.swift @@ -0,0 +1,106 @@ +import XCTest + +@testable import CCBuddy + +/// Sniffing `/v1/models` is a convention, not a contract: the parsing has to accept every shape +/// these endpoints are actually served in, and the availability answer has to distinguish "this +/// host has no listing endpoint" from "the request failed" — because only the first one should +/// grey the refresh control out. +final class ProviderModelDiscoveryTests: XCTestCase { + func testCandidateOrderAvoidsAppendingASecondVersionSegment() { + // A base URL that already carries a version is asked for /models first, because + // appending another /v1 to it is exactly how `.../v1/v1/models` 404s get produced. + XCTAssertEqual( + ProviderModelDiscoveryService.candidateURLs(baseURL: "https://api.example.com/v1") + .map(\.absoluteString), + ["https://api.example.com/v1/models", "https://api.example.com/v1/v1/models"] + ) + XCTAssertEqual( + ProviderModelDiscoveryService.candidateURLs(baseURL: "https://api.example.com") + .map(\.absoluteString), + ["https://api.example.com/v1/models", "https://api.example.com/models"] + ) + XCTAssertEqual( + ProviderModelDiscoveryService.candidateURLs(baseURL: "https://api.example.com/") + .map(\.absoluteString), + ["https://api.example.com/v1/models", "https://api.example.com/models"] + ) + for invalid in ["", " ", "not a url", "ftp://api.example.com", "/relative/path"] { + XCTAssertTrue( + ProviderModelDiscoveryService.candidateURLs(baseURL: invalid).isEmpty, invalid + ) + } + } + + func testEveryCatalogShapeTheseEndpointsAreServedInIsParsed() { + let openAI = #"{"object":"list","data":[{"id":"gpt-5.4"},{"id":"gpt-5.4-mini"}]}"# + XCTAssertEqual( + ProviderModelDiscoveryService.parseModels(from: Data(openAI.utf8)), + ["gpt-5.4", "gpt-5.4-mini"] + ) + let anthropic = """ + {"data":[{"id":"claude-sonnet-5","type":"model","display_name":"Claude Sonnet 5"}], + "has_more":false} + """ + XCTAssertEqual( + ProviderModelDiscoveryService.parseModels(from: Data(anthropic.utf8)), + ["claude-sonnet-5"] + ) + let selfHosted = #"{"models":[{"name":"llama-3.3"},{"name":"qwen-3"}]}"# + XCTAssertEqual( + ProviderModelDiscoveryService.parseModels(from: Data(selfHosted.utf8)), + ["llama-3.3", "qwen-3"] + ) + let bareArray = #"[{"id":"a"},{"model":"b"},"c"]"# + XCTAssertEqual( + ProviderModelDiscoveryService.parseModels(from: Data(bareArray.utf8)), + ["a", "b", "c"] + ) + // Order is the provider's; duplicates and blanks are dropped. + let messy = #"{"data":[{"id":"b"},{"id":"a"},{"id":"b"},{"id":" "},{"unrelated":1}]}"# + XCTAssertEqual( + ProviderModelDiscoveryService.parseModels(from: Data(messy.utf8)), ["b", "a"] + ) + for empty in ["{}", "[]", #"{"data":[]}"#, "not json", ""] { + XCTAssertTrue( + ProviderModelDiscoveryService.parseModels(from: Data(empty.utf8)).isEmpty, empty + ) + } + } + + func testMergingNeverDisturbsBindingsTheUserWrote() { + let existing = [ + ModelMapping(alias: "fast", upstream: "gpt-5.4-mini"), + ModelMapping(alias: "", upstream: ""), + ] + let merged = ProviderModelDiscoveryService.merging( + discovered: ["gpt-5.4", "gpt-5.4-mini", "gpt-5.4", "*", " "], + into: existing + ) + XCTAssertEqual(merged.map(\.alias), ["fast", "gpt-5.4"]) + XCTAssertEqual(merged.map(\.upstream), ["gpt-5.4-mini", "gpt-5.4"]) + } + + func testDiscoveredModelsBecomeIdentityBindings() { + let merged = ProviderModelDiscoveryService.merging( + discovered: ["a", "b"], into: [] + ) + XCTAssertEqual(merged.count, 2) + for mapping in merged { XCTAssertEqual(mapping.alias, mapping.upstream) } + } + + func testAnEmptyAddressIsReportedWithoutDisablingRefresh() async { + let catalog = await ProviderModelDiscoveryService().discover(Provider()) + XCTAssertEqual(catalog.availability, .unknown) + XCTAssertTrue( + catalog.canRefresh, + "an address the user has not finished typing is not proof the endpoint is missing" + ) + } + + func testOnlyAnUnsupportedCatalogDisablesRefresh() { + XCTAssertTrue(ProviderModelCatalog(availability: .unknown).canRefresh) + XCTAssertTrue(ProviderModelCatalog(availability: .available).canRefresh) + XCTAssertFalse(ProviderModelCatalog(availability: .unsupported).canRefresh) + } +} diff --git a/native/Tests/Unit/ProviderProbeServiceTests.swift b/native/Tests/Unit/ProviderProbeServiceTests.swift index c7ed8933..3547dfb7 100644 --- a/native/Tests/Unit/ProviderProbeServiceTests.swift +++ b/native/Tests/Unit/ProviderProbeServiceTests.swift @@ -8,10 +8,14 @@ final class ProviderProbeServiceTests: XCTestCase { } func testUsesDeclaredWireEndpointHeadersAndBody() async throws { + // The base URL carries no version segment, so Bifrost supplies its own and the probe + // has to ask for the same address. Testing "/api/messages" while the gateway called + // "/api/v1/messages" is what let a provider pass its connection test and then answer + // every real request with 404. let cases: [(Provider.WireProtocol, String, String, String)] = [ - (.anthropic, "/api/messages", "type", "message"), - (.openAIChat, "/api/chat/completions", "choices", "[]"), - (.openAIResponses, "/api/responses", "output", "[]"), + (.anthropic, "/api/v1/messages", "type", "message"), + (.openAIChat, "/api/v1/chat/completions", "choices", "[]"), + (.openAIResponses, "/api/v1/responses", "output", "[]"), ] for (wireProtocol, expectedPath, responseKey, responseValue) in cases { var captured: URLRequest? @@ -55,14 +59,52 @@ final class ProviderProbeServiceTests: XCTestCase { } } - func testRetriesOnlyEligibleUnversionedEndpointAndReportsMigration() async throws { + /// One request, to one address, whatever shape the base URL has. + /// + /// The probe used to try a second spelling and, when it answered, silently rewrite the + /// user's base URL. That is what allowed the probe and the gateway to disagree about + /// whether a base already carried its version segment, which is the disagreement that made + /// every preset 404. Each base URL now maps to exactly one upstream URL. + func testProbesExactlyTheAddressTheGatewayWillCall() async throws { + let cases: [(String, Provider.WireProtocol, [String])] = [ + ("https://provider.example", .openAIChat, ["/v1/chat/completions"]), + ("https://provider.example/v1", .openAIChat, ["/v1/chat/completions"]), + ("https://provider.example/api/paas/v4", .openAIChat, ["/api/paas/v4/chat/completions"]), + ("https://provider.example/v1", .anthropic, ["/v1/messages"]), + ("https://provider.example", .anthropic, ["/v1/messages"]), + ] + for (baseURL, wireProtocol, expected) in cases { + var targets: [String] = [] + ProviderProbeURLProtocol.handler = { request in + targets.append(request.url?.path ?? "") + return wireProtocol == .anthropic + ? (200, #"{"model":"m","type":"message"}"#) + : (200, #"{"model":"m","choices":[]}"#) + } + let provider = Provider( + name: "Probe", + baseUrl: baseURL, + authToken: "token", + defaultModel: "m", + protocol: wireProtocol + ) + + let result = await ProviderProbeService(session: makeSession()).test( + provider, + insecureSkipVerify: false + ) + + XCTAssertTrue(result.succeeded, baseURL) + XCTAssertEqual(targets, expected, baseURL) + } + } + + /// A refused endpoint is reported as a failure rather than retried somewhere else. + func testARefusedEndpointIsNotRetriedAtAnotherAddress() async throws { var targets: [String] = [] ProviderProbeURLProtocol.handler = { request in targets.append(request.url?.path ?? "") - if request.url?.path == "/chat/completions" { - return (404, #"{"error":{"message":"missing"}}"#) - } - return (200, #"{"model":"chat-model","choices":[]}"#) + return (404, #"{"error":{"message":"missing"}}"#) } let provider = Provider( name: "Chat", @@ -77,9 +119,9 @@ final class ProviderProbeServiceTests: XCTestCase { insecureSkipVerify: false ) - XCTAssertTrue(result.succeeded) - XCTAssertEqual(result.migratedBaseURL, "https://provider.example/v1") - XCTAssertEqual(targets, ["/chat/completions", "/v1/chat/completions"]) + XCTAssertFalse(result.succeeded) + XCTAssertEqual(result.statusCode, 404) + XCTAssertEqual(targets, ["/v1/chat/completions"]) } func testSuccessfulHTTPWithWrongProtocolShapeIsRejected() async {