From 76443e67a42d22a9dcbf169bd7a0497268322eb4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 10:46:24 +0100 Subject: [PATCH 001/110] chore: add provider hash manifest for 1.4.2-alpha (#410) chore: add provider hash manifest for version 1.4.2-alpha (dual platform) Co-authored-by: github-actions[bot] --- hashes/1.4.2-alpha.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 hashes/1.4.2-alpha.json diff --git a/hashes/1.4.2-alpha.json b/hashes/1.4.2-alpha.json new file mode 100644 index 00000000..487404d9 --- /dev/null +++ b/hashes/1.4.2-alpha.json @@ -0,0 +1,26 @@ +{ + "providers": { + "SmartHopper.Providers.DeepSeek.dll-net7.0-windows": "2e2f3cc3e04560a79a1b0eae8e99dc86ec93f7dcc969fc0b62045c0cf93d9b46", + "SmartHopper.Providers.MistralAI.dll-net7.0-windows": "5b6ff6ccfb45bb22794f7a22b227c77b2297744fb99c10bd8b34168271f906cc", + "SmartHopper.Providers.Anthropic.dll-net7.0": "60ccf8f9fb55565ea75bb2b6ee93a9944340425905fb5711686edab2de0de906", + "SmartHopper.Providers.Anthropic.dll-net7.0-windows": "6f89fcd847f00853ffeee5bd969b9c5b5cb0f8a464efdc30e57dde2f1c19574a", + "SmartHopper.Providers.MistralAI.dll-net7.0": "47d29112f15ff231478fa765f008279ee8e4a33621483bc9809bf9bcc056982b", + "SmartHopper.Providers.OpenRouter.dll-net7.0": "dab141487559563f77a1dee1621d392916d59a40dba8facdf13f8d401f9edc6b", + "SmartHopper.Providers.DeepSeek.dll-net7.0": "9116e88fe35548849dd90b0309b410a91c7aa95bbf4e9379ef3cb31a1f2de31a", + "SmartHopper.Providers.OpenAI.dll-net7.0": "11d86b819bd2efa5de5f68f80f48ade8856e6d2da61873e3b9712a969e360552", + "SmartHopper.Providers.OpenRouter.dll-net7.0-windows": "56e8dd4b5cbaa1f02ab09f51e8ce0f1a76b3138330b95c5f8b5d1408aeb4fba2", + "SmartHopper.Providers.OpenAI.dll-net7.0-windows": "c742dfa60b29aff9d33cb82e3b9d4d5fad33edd36323016dd828722c351f8091" + }, + "algorithm": "SHA-256", + "metadata": { + "platforms": [ + "net7.0-windows", + "net7.0" + ], + "commitSha": "bea0f4759fe4a3f386381a3a81db49357665e14b", + "repository": "architects-toolkit/SmartHopper", + "buildNumber": "39" + }, + "version": "1.4.2-alpha", + "generated": "2026-03-14T09:43:52Z" +} From 8fb87d39ddd080be97a395ba1e6f1886f40d502a Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:59:57 +0100 Subject: [PATCH 002/110] docs(ci): add comprehensive milestone management system with automated lifecycle and version scoping --- .github/MILESTONE_MANAGEMENT_GUIDE.md | 190 ++++++++++++++++ .../check-issues-for-version/action.yml | 87 ++++++++ .../create-version-label/action.yml | 75 +++++++ .../versioning/manage-milestones/action.yml | 190 ++++++++++++++++ .../move-milestone-items/action.yml | 211 ++++++++++++++++++ .../versioning/promote-version/action.yml | 195 ++++++++++++++++ .github/workflows/issue-auto-tag.yml | 111 +++++++++ .github/workflows/milestone-management.yml | 206 +++-------------- .github/workflows/release-1-milestone.yml | 34 +-- .github/workflows/release-4-build.yml | 7 + .github/workflows/release-6-upload-yak.yml | 7 + .github/workflows/release-promotion.yml | 210 +++++++++++++++++ 12 files changed, 1330 insertions(+), 193 deletions(-) create mode 100644 .github/MILESTONE_MANAGEMENT_GUIDE.md create mode 100644 .github/actions/versioning/check-issues-for-version/action.yml create mode 100644 .github/actions/versioning/create-version-label/action.yml create mode 100644 .github/actions/versioning/manage-milestones/action.yml create mode 100644 .github/actions/versioning/move-milestone-items/action.yml create mode 100644 .github/actions/versioning/promote-version/action.yml create mode 100644 .github/workflows/issue-auto-tag.yml create mode 100644 .github/workflows/release-promotion.yml diff --git a/.github/MILESTONE_MANAGEMENT_GUIDE.md b/.github/MILESTONE_MANAGEMENT_GUIDE.md new file mode 100644 index 00000000..f01f889b --- /dev/null +++ b/.github/MILESTONE_MANAGEMENT_GUIDE.md @@ -0,0 +1,190 @@ +# Milestone Management System Guide + +## Overview + +The milestone management system automates the creation and lifecycle of milestones across different release stages (alpha → beta → rc → stable). The key principle is **scoping all operations to the same major version**, allowing different major versions to coexist independently. + +## System Behavior Summary + +| Scenario | Released Version | Action | Milestones Created | Milestones Closed | Notes | +| --- | --- | --- | --- | --- | --- | +| **Alpha Release** | `1.4.3-alpha` | Creates beta + next minor alpha | `1.4.3-beta`, `1.5.0-alpha` | Older `1.x.x-beta` only | Closes only beta milestones in major version 1 | +| **Alpha Release** | `2.0.0-alpha` | Creates beta + next minor alpha | `2.0.0-beta`, `2.1.0-alpha` | Older `2.x.x-beta` only | Closes only beta milestones in major version 2 | +| **Beta Release** | `1.4.3-beta` | Creates rc | `1.4.3-rc` | Older `1.x.x-rc` only | Closes only rc milestones in major version 1 | +| **RC Release** | `1.4.3-rc` | Creates stable | `1.4.3` | Older `1.x.x` (stable) only | Closes only stable milestones in major version 1 | +| **Stable Release** | `1.4.3` | No action | — | — | No milestones created for stable releases | + +## Coexistence Examples + +### Multiple Major Versions in Flight + +```text +Open Milestones: +├── 1.3.2-beta ✓ Active (latest beta in v1) +├── 1.4.3-beta ✓ Active (latest beta in v1) ← Would close 1.3.2-beta when 1.4.3-beta created +├── 1.5.0-alpha ✓ Active (unlimited alphas) +├── 1.6.0-alpha ✓ Active (unlimited alphas) +├── 2.0.0-beta ✓ Active (latest beta in v2) +├── 2.1.0-alpha ✓ Active (unlimited alphas) +└── 3.0.0-rc ✓ Active (latest rc in v3) +``` + +**Key Points:** + +- ✅ `1.3.2-beta` and `2.0.0-beta` coexist (different major versions) +- ✅ Unlimited alpha milestones allowed per major version +- ✅ Only one active beta per major version +- ✅ Only one active rc per major version +- ✅ Only one active stable per major version + +### Closure Behavior + +When `1.4.3-beta` is released: + +- ✅ Creates `1.4.3-beta` milestone +- ✅ Closes `1.3.2-beta` (older beta in major version 1) +- ✅ **Does NOT** close `2.0.0-beta` (different major version) +- ✅ Creates `1.5.0-alpha` (next minor alpha) + +When `2.0.0-beta` is released: + +- ✅ Creates `2.0.0-beta` milestone +- ✅ Closes any older `2.x.x-beta` milestones +- ✅ **Does NOT** affect `1.x.x-beta` milestones +- ✅ Creates `2.1.0-alpha` (next minor alpha) + +## Issue Migration on Milestone Closure + +When a milestone is closed, open issues/PRs are migrated: + +| Closed Milestone Type | Target Milestone | Example | +| --- | --- | --- | +| `X.Y.Z-alpha` | `X.(Y+1).0-alpha` | `1.4.3-alpha` → `1.5.0-alpha` | +| `X.Y.Z-beta` | `X.(Y+1).0-alpha` | `1.4.3-beta` → `1.5.0-alpha` | +| `X.Y.Z-rc` | `X.(Y+1).0-alpha` | `1.4.3-rc` → `1.5.0-alpha` | +| `X.Y.Z` (stable) | `X.(Y+1).0-alpha` or `X.Y.(Z+1)` | `1.4.3` → `1.5.0-alpha` | + +**Note:** Target milestone is created automatically if it doesn't exist. + +**Rationale:** Pre-release milestones (alpha, beta, rc) represent work towards a specific release. When closed, issues migrate to the next minor version's alpha, aligning with the natural progression of the release cycle. + +## Release Promotion Workflow + +```text +1.4.3-alpha (released) + ↓ + Creates: 1.4.3-beta, 1.5.0-alpha + Closes: older 1.x.x-beta milestones + ↓ + (30 days, no issues reported) + ↓ +1.4.3-beta (released) + ↓ + Creates: 1.4.3-rc + Closes: older 1.x.x-rc milestones + ↓ + (30 days, no issues reported) + ↓ +1.4.3-rc (released) + ↓ + Creates: 1.4.3 (stable) + Closes: older 1.x.x (stable) milestones + ↓ + (Stable release complete) +``` + +## Manual Control + +You can manually control promotion paths by: + +1. **Closing a milestone** → Stops its promotion path +2. **Reopening a milestone** → Resumes its promotion path +3. **Deleting a milestone** → Removes it entirely (issues migrate to next version) + +## Scope Principle + +**All milestone operations are scoped to the same major version:** + +- Closing older milestones only affects milestones with the same major version +- Different major versions maintain independent milestone hierarchies +- This allows parallel development of multiple major versions + +## Implementation Details + +### Files Modified + +- `.github/actions/versioning/manage-milestones/action.yml` - Creates/closes milestones +- `.github/actions/versioning/move-milestone-items/action.yml` - Migrates issues +- `.github/workflows/milestone-management.yml` - Orchestrates the workflow + +### Key Functions + +**`closeOlderMilestones(suffix, majorVersion)`** + +- Filters milestones by suffix AND major version +- Sorts by minor.patch descending +- Closes all but the latest + +**`move-milestone-items` action** + +- Triggered on milestone closure +- Determines target milestone based on closed milestone type +- Migrates all open issues/PRs + +## Examples + +### Example 1: Parallel v1 and v2 Development + +```text +Release: 1.4.3-alpha + → Creates: 1.4.3-beta, 1.5.0-alpha + → Closes: 1.4.2-beta (if exists) + +Release: 2.0.0-alpha (same day) + → Creates: 2.0.0-beta, 2.1.0-alpha + → Closes: (no older 2.x.x-beta) + +Result: Both 1.4.3-beta and 2.0.0-beta coexist ✓ +``` + +### Example 2: Multiple Alphas + +```text +Open Milestones: + 1.4.0-alpha + 1.4.1-alpha + 1.4.2-alpha + 1.4.3-alpha ← Latest + +All remain open. No closure happens for alphas. +When 1.4.3-alpha closes, issues migrate to 1.5.0-alpha. +``` + +### Example 3: Beta Progression + +```text +Release: 1.4.3-beta + → Creates: 1.4.3-rc + → Closes: 1.4.2-beta, 1.4.1-beta, 1.4.0-beta + → Keeps: 1.4.3-beta (the newly created one) + → Issues from 1.4.3-beta migrate to 1.5.0-alpha + +Result: Only 1.4.3-beta remains open in v1 ✓ +``` + +## Troubleshooting + +### Issue: Old milestone not closing + +**Cause:** Different major version +**Solution:** Check if the old milestone has a different major version. This is expected behavior. + +### Issue: Milestone not created + +**Cause:** Already exists or invalid version format +**Solution:** Check logs for "already exists" message. Verify version format is `X.Y.Z` or `X.Y.Z-suffix`. + +### Issue: Issues not migrating + +**Cause:** No target milestone exists +**Solution:** The action creates the target milestone automatically. Check logs for creation status. diff --git a/.github/actions/versioning/check-issues-for-version/action.yml b/.github/actions/versioning/check-issues-for-version/action.yml new file mode 100644 index 00000000..dbe6f772 --- /dev/null +++ b/.github/actions/versioning/check-issues-for-version/action.yml @@ -0,0 +1,87 @@ +name: 'Check Issues for Version' +description: 'Check if any open issues exist with a specific version label within a time period' +inputs: + version: + description: 'Version to check for issues (e.g., 1.4.2-alpha)' + required: true + days-lookback: + description: 'Number of days to look back for issues (default: 30)' + required: false + default: '30' + token: + description: 'GitHub token for API access' + required: true + default: ${{ github.token }} + +outputs: + has-issues: + description: 'Whether any open issues exist with this version label' + value: ${{ steps.check-issues.outputs.has_issues }} + issue-count: + description: 'Number of open issues with this version label' + value: ${{ steps.check-issues.outputs.issue_count }} + issues-list: + description: 'Comma-separated list of issue numbers' + value: ${{ steps.check-issues.outputs.issues_list }} + +runs: + using: "composite" + steps: + - name: Check for issues with version label + id: check-issues + shell: pwsh + env: + GITHUB_TOKEN: ${{ inputs.token }} + run: | + $version = "${{ inputs.version }}" + $labelName = "version: $version" + $daysLookback = [int]"${{ inputs.days-lookback }}" + $repo = "${{ github.repository }}" + + Write-Host "Checking for issues with label: $labelName" + Write-Host "Looking back $daysLookback days" + + $headers = @{ + "Authorization" = "Bearer $env:GITHUB_TOKEN" + "Accept" = "application/vnd.github.v3+json" + } + + # Calculate cutoff date + $cutoffDate = (Get-Date).AddDays(-$daysLookback).ToString("yyyy-MM-ddTHH:mm:ssZ") + + # Search for open issues with this label created since cutoff + $issues = @() + $page = 1 + $perPage = 100 + + do { + $uri = "https://api.github.com/repos/$repo/issues?state=open&labels=$([Uri]::EscapeDataString($labelName))&since=$cutoffDate&per_page=$perPage&page=$page" + + try { + $pageIssues = Invoke-RestMethod -Uri $uri -Headers $headers -Method GET + $issues += $pageIssues + + if ($pageIssues.Count -lt $perPage) { + break + } + $page++ + } catch { + Write-Error "Failed to fetch issues: $($_.Exception.Message)" + exit 1 + } + } while ($pageIssues.Count -eq $perPage) + + $issueCount = $issues.Count + $issueNumbers = ($issues | ForEach-Object { $_.number }) -join "," + + Write-Host "Found $issueCount open issues with label '$labelName'" + + if ($issueCount -gt 0) { + Write-Host "Issues: $issueNumbers" + echo "has_issues=true" >> $env:GITHUB_OUTPUT + } else { + echo "has_issues=false" >> $env:GITHUB_OUTPUT + } + + echo "issue_count=$issueCount" >> $env:GITHUB_OUTPUT + echo "issues_list=$issueNumbers" >> $env:GITHUB_OUTPUT diff --git a/.github/actions/versioning/create-version-label/action.yml b/.github/actions/versioning/create-version-label/action.yml new file mode 100644 index 00000000..cd240513 --- /dev/null +++ b/.github/actions/versioning/create-version-label/action.yml @@ -0,0 +1,75 @@ +name: 'Create Version Label' +description: 'Create or verify a version label in the repository' +inputs: + version: + description: 'Version to create label for (e.g., 1.4.2-alpha)' + required: true + token: + description: 'GitHub token for API access' + required: true + default: ${{ github.token }} + color: + description: 'Label color (hex without #, default: 0366d6)' + required: false + default: '0366d6' + +outputs: + label-created: + description: 'Whether the label was newly created' + value: ${{ steps.create-label.outputs.created }} + label-exists: + description: 'Whether the label already existed' + value: ${{ steps.create-label.outputs.exists }} + label-name: + description: 'The full label name created' + value: ${{ steps.create-label.outputs.label }} + +runs: + using: "composite" + steps: + - name: Create or verify version label + id: create-label + shell: pwsh + env: + GITHUB_TOKEN: ${{ inputs.token }} + run: | + $version = "${{ inputs.version }}" + $labelName = "version: $version" + $color = "${{ inputs.color }}" + $repo = "${{ github.repository }}" + + Write-Host "Processing version label: $labelName" + + # Check if label already exists + $headers = @{ + "Authorization" = "Bearer $env:GITHUB_TOKEN" + "Accept" = "application/vnd.github.v3+json" + } + + try { + $existingLabel = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/labels/$([Uri]::EscapeDataString($labelName))" -Headers $headers -Method GET -ErrorAction SilentlyContinue + Write-Host "Label already exists: $labelName" + echo "created=false" >> $env:GITHUB_OUTPUT + echo "exists=true" >> $env:GITHUB_OUTPUT + echo "label=$labelName" >> $env:GITHUB_OUTPUT + } catch { + # Label doesn't exist, create it + Write-Host "Creating new label: $labelName" + + $body = @{ + name = $labelName + color = $color + description = "Issues related to version $version" + } | ConvertTo-Json + + try { + $newLabel = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/labels" -Headers $headers -Method POST -Body $body -ContentType "application/json" + Write-Host "Successfully created label: $labelName" + echo "created=true" >> $env:GITHUB_OUTPUT + echo "exists=true" >> $env:GITHUB_OUTPUT + echo "label=$labelName" >> $env:GITHUB_OUTPUT + } catch { + Write-Error "Failed to create label: $($_.Exception.Message)" + exit 1 + } + } diff --git a/.github/actions/versioning/manage-milestones/action.yml b/.github/actions/versioning/manage-milestones/action.yml new file mode 100644 index 00000000..968297c8 --- /dev/null +++ b/.github/actions/versioning/manage-milestones/action.yml @@ -0,0 +1,190 @@ +name: Manage Release Milestones +description: Creates next-stage milestones and manages active milestones for release versions + +inputs: + released-version: + description: 'The released version tag (e.g., 1.4.3-alpha)' + required: true + token: + description: 'GitHub token for API access' + required: true + +outputs: + created-milestones: + description: 'JSON array of created milestone titles' + value: ${{ steps.manage.outputs.created-milestones }} + closed-milestones: + description: 'JSON array of closed milestone titles' + value: ${{ steps.manage.outputs.closed-milestones }} + +runs: + using: composite + steps: + - name: Manage milestones for released version + id: manage + uses: actions/github-script@v7 + with: + github-token: ${{ inputs.token }} + script: | + // Parse semantic version from string + function parseVersion(versionStr) { + const match = versionStr.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); + if (!match) return null; + + return { + major: parseInt(match[1]), + minor: parseInt(match[2]), + patch: parseInt(match[3]), + suffix: match[4] || 'stable', + original: versionStr + }; + } + + // Format version object back to string + function formatVersion(version) { + let versionStr = `${version.major}.${version.minor}.${version.patch}`; + if (version.suffix && version.suffix !== 'stable') { + versionStr += `-${version.suffix}`; + } + return versionStr; + } + + // Create a milestone + async function createMilestone(title, description) { + try { + const response = await github.rest.issues.createMilestone({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + description: description || `Auto-created milestone for ${title}` + }); + console.log(`✅ Created milestone: ${title}`); + return { title, created: true }; + } catch (error) { + if (error.message.includes('already exists')) { + console.log(`ℹ️ Milestone ${title} already exists`); + return { title, created: false }; + } + throw error; + } + } + + // Close older milestones of the same suffix within the same major version + async function closeOlderMilestones(suffix, majorVersion) { + const { data: allMilestones } = await github.rest.issues.listMilestones({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }); + + // Filter for same suffix AND same major version + const sameSuffixMilestones = allMilestones.filter(m => { + const mParsed = parseVersion(m.title); + return mParsed && mParsed.suffix === suffix && mParsed.major === majorVersion; + }); + + sameSuffixMilestones.sort((a, b) => { + const aParsed = parseVersion(a.title); + const bParsed = parseVersion(b.title); + + if (aParsed.minor !== bParsed.minor) return bParsed.minor - aParsed.minor; + return bParsed.patch - aParsed.patch; + }); + + const closedTitles = []; + for (let i = 1; i < sameSuffixMilestones.length; i++) { + const oldMilestone = sameSuffixMilestones[i]; + console.log(`Closing older ${suffix} milestone (major ${majorVersion}): ${oldMilestone.title}`); + + await github.rest.issues.updateMilestone({ + owner: context.repo.owner, + repo: context.repo.repo, + milestone_number: oldMilestone.number, + state: 'closed' + }); + closedTitles.push(oldMilestone.title); + } + return closedTitles; + } + + const releasedVersion = '${{ inputs.released-version }}'; + console.log(`Processing released version: ${releasedVersion}`); + + const parsed = parseVersion(releasedVersion); + if (!parsed) { + console.log('Release tag is not a valid semantic version, skipping.'); + core.setOutput('created-milestones', JSON.stringify([])); + core.setOutput('closed-milestones', JSON.stringify([])); + return; + } + + const createdMilestones = []; + const closedMilestones = []; + + // Alpha releases create two milestones: beta and next minor alpha + if (parsed.suffix === 'alpha') { + // Create beta milestone + const betaVersion = { + major: parsed.major, + minor: parsed.minor, + patch: parsed.patch, + suffix: 'beta' + }; + const betaVersionStr = formatVersion(betaVersion); + const betaResult = await createMilestone(betaVersionStr, `Auto-created milestone for ${betaVersionStr} (stability path from ${releasedVersion})`); + if (betaResult.created) createdMilestones.push(betaVersionStr); + + // Close older beta milestones within same major version + const closedBetas = await closeOlderMilestones('beta', parsed.major); + closedMilestones.push(...closedBetas); + + // Create next minor alpha milestone + const nextMinorAlpha = { + major: parsed.major, + minor: parsed.minor + 1, + patch: 0, + suffix: 'alpha' + }; + const nextMinorAlphaStr = formatVersion(nextMinorAlpha); + const nextAlphaResult = await createMilestone(nextMinorAlphaStr, `Auto-created next minor alpha (from ${releasedVersion})`); + if (nextAlphaResult.created) createdMilestones.push(nextMinorAlphaStr); + } + // Beta releases create rc milestone + else if (parsed.suffix === 'beta') { + const rcVersion = { + major: parsed.major, + minor: parsed.minor, + patch: parsed.patch, + suffix: 'rc' + }; + const rcVersionStr = formatVersion(rcVersion); + const rcResult = await createMilestone(rcVersionStr, `Auto-created milestone for ${rcVersionStr} (stability path from ${releasedVersion})`); + if (rcResult.created) createdMilestones.push(rcVersionStr); + + // Close older rc milestones within same major version + const closedRcs = await closeOlderMilestones('rc', parsed.major); + closedMilestones.push(...closedRcs); + } + // RC releases create stable milestone + else if (parsed.suffix === 'rc') { + const stableVersion = { + major: parsed.major, + minor: parsed.minor, + patch: parsed.patch, + suffix: null + }; + const stableVersionStr = formatVersion(stableVersion); + const stableResult = await createMilestone(stableVersionStr, `Auto-created milestone for ${stableVersionStr} (stability path from ${releasedVersion})`); + if (stableResult.created) createdMilestones.push(stableVersionStr); + + // Close older stable milestones within same major version + const closedStables = await closeOlderMilestones('stable', parsed.major); + closedMilestones.push(...closedStables); + } + + console.log(`Created milestones: ${JSON.stringify(createdMilestones)}`); + console.log(`Closed milestones: ${JSON.stringify(closedMilestones)}`); + + core.setOutput('created-milestones', JSON.stringify(createdMilestones)); + core.setOutput('closed-milestones', JSON.stringify(closedMilestones)); diff --git a/.github/actions/versioning/move-milestone-items/action.yml b/.github/actions/versioning/move-milestone-items/action.yml new file mode 100644 index 00000000..4512bc07 --- /dev/null +++ b/.github/actions/versioning/move-milestone-items/action.yml @@ -0,0 +1,211 @@ +name: Move Milestone Items +description: Moves open issues and PRs from a closed milestone to a target milestone + +inputs: + closed-milestone-title: + description: 'Title of the closed milestone' + required: true + token: + description: 'GitHub token for API access' + required: true + +outputs: + target-milestone: + description: 'Title of the target milestone items were moved to' + value: ${{ steps.move.outputs.target-milestone }} + items-moved: + description: 'Number of items moved' + value: ${{ steps.move.outputs.items-moved }} + +runs: + using: composite + steps: + - name: Move items to target milestone + id: move + uses: actions/github-script@v7 + with: + github-token: ${{ inputs.token }} + script: | + // Parse semantic version from string + function parseVersion(versionStr) { + const match = versionStr.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); + if (!match) return null; + + return { + major: parseInt(match[1]), + minor: parseInt(match[2]), + patch: parseInt(match[3]), + prerelease: match[4] || null, + original: versionStr + }; + } + + // Format version object back to string + function formatVersion(version) { + let versionStr = `${version.major}.${version.minor}.${version.patch}`; + if (version.prerelease) { + versionStr += `-${version.prerelease}`; + } + return versionStr; + } + + // Find milestone by title + async function findMilestone(title) { + const milestones = await github.rest.issues.listMilestones({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open' + }); + + return milestones.data.find(m => m.title === title); + } + + // Create new milestone + async function createMilestone(title, description) { + const response = await github.rest.issues.createMilestone({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + description: description || `Auto-created milestone for version ${title}` + }); + + console.log(`Created new milestone: ${title}`); + return response.data; + } + + // Get open issues and PRs for a milestone + async function getOpenItemsInMilestone(milestoneNumber) { + const [issues, pulls] = await Promise.all([ + github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + milestone: milestoneNumber, + state: 'open' + }), + github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open' + }) + ]); + + const prsInMilestone = pulls.data.filter(pr => + pr.milestone && pr.milestone.number === milestoneNumber + ); + + return { + issues: issues.data.filter(issue => !issue.pull_request), + prs: prsInMilestone + }; + } + + // Move item to new milestone + async function moveItemToMilestone(itemNumber, newMilestoneNumber) { + try { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: itemNumber, + milestone: newMilestoneNumber + }); + console.log(`Successfully moved item #${itemNumber} to milestone #${newMilestoneNumber}`); + return true; + } catch (error) { + console.error(`Failed to move item #${itemNumber}:`, error.message); + return false; + } + } + + const closedMilestoneTitle = '${{ inputs.closed-milestone-title }}'; + console.log(`Processing closed milestone: ${closedMilestoneTitle}`); + + const closedVersion = parseVersion(closedMilestoneTitle); + if (!closedVersion) { + console.log('Milestone title is not a valid semantic version, skipping.'); + core.setOutput('target-milestone', ''); + core.setOutput('items-moved', '0'); + return; + } + + // Get open items in the closed milestone (from context) + const closedMilestone = context.payload.milestone; + const openItems = await getOpenItemsInMilestone(closedMilestone.number); + const totalItems = openItems.issues.length + openItems.prs.length; + + console.log(`Found ${openItems.issues.length} open issues and ${openItems.prs.length} open PRs`); + + if (totalItems === 0) { + console.log('No open items to move, exiting.'); + core.setOutput('target-milestone', ''); + core.setOutput('items-moved', '0'); + return; + } + + // Determine target milestone + let targetMilestone = null; + + // For alpha, beta, rc: move to next minor alpha + if (closedVersion.prerelease && closedVersion.prerelease !== 'stable') { + const nextMinorAlpha = { + major: closedVersion.major, + minor: closedVersion.minor + 1, + patch: 0, + prerelease: 'alpha' + }; + const nextMinorAlphaTitle = formatVersion(nextMinorAlpha); + + console.log(`Closed milestone is ${closedVersion.prerelease}, looking for next minor alpha: ${nextMinorAlphaTitle}`); + + targetMilestone = await findMilestone(nextMinorAlphaTitle); + if (!targetMilestone) { + targetMilestone = await createMilestone(nextMinorAlphaTitle, `Next minor alpha version after ${closedMilestoneTitle}`); + } + } else { + // For stable: try next minor then patch + const nextMinor = { + major: closedVersion.major, + minor: closedVersion.minor + 1, + patch: 0, + prerelease: 'alpha' + }; + const nextMinorTitle = formatVersion(nextMinor); + + targetMilestone = await findMilestone(nextMinorTitle); + if (!targetMilestone) { + const nextPatch = { + major: closedVersion.major, + minor: closedVersion.minor, + patch: closedVersion.patch + 1, + prerelease: null + }; + const nextPatchTitle = formatVersion(nextPatch); + targetMilestone = await findMilestone(nextPatchTitle); + + if (!targetMilestone) { + targetMilestone = await createMilestone(nextMinorTitle); + } + } + } + + console.log(`Target milestone: ${targetMilestone.title}`); + + // Move all items + const movePromises = []; + + for (const issue of openItems.issues) { + console.log(`Moving issue #${issue.number}: ${issue.title}`); + movePromises.push(moveItemToMilestone(issue.number, targetMilestone.number)); + } + + for (const pr of openItems.prs) { + console.log(`Moving PR #${pr.number}: ${pr.title}`); + movePromises.push(moveItemToMilestone(pr.number, targetMilestone.number)); + } + + const results = await Promise.all(movePromises); + const successCount = results.filter(r => r).length; + + console.log(`Successfully moved ${successCount}/${totalItems} items to "${targetMilestone.title}"`); + + core.setOutput('target-milestone', targetMilestone.title); + core.setOutput('items-moved', successCount.toString()); diff --git a/.github/actions/versioning/promote-version/action.yml b/.github/actions/versioning/promote-version/action.yml new file mode 100644 index 00000000..161820e1 --- /dev/null +++ b/.github/actions/versioning/promote-version/action.yml @@ -0,0 +1,195 @@ +name: 'Promote Version' +description: 'Promote a version from one stage to the next (alpha→beta→rc→stable)' +inputs: + current-version: + description: 'Current version (e.g., 1.4.2-alpha)' + required: true + token: + description: 'GitHub token for API access' + required: true + default: ${{ github.token }} + auto-merge: + description: 'Auto-merge the PR after creation' + required: false + default: 'false' + +outputs: + new-version: + description: 'The promoted version' + value: ${{ steps.promote.outputs.new_version }} + previous-stage: + description: 'The previous stage (alpha/beta/rc)' + value: ${{ steps.promote.outputs.previous_stage }} + new-stage: + description: 'The new stage (beta/rc/stable)' + value: ${{ steps.promote.outputs.new_stage }} + pr-created: + description: 'Whether a PR was created' + value: ${{ steps.promote.outputs.pr_created }} + pr-number: + description: 'The PR number if created' + value: ${{ steps.promote.outputs.pr_number }} + +runs: + using: "composite" + steps: + - name: Parse and promote version + id: parse + shell: pwsh + run: | + $currentVersion = "${{ inputs.current-version }}" + + # Parse version: X.Y.Z-SUFFIX or X.Y.Z + if ($currentVersion -match '^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([a-zA-Z0-9]+))?$') { + $major = $matches[1] + $minor = $matches[2] + $patch = $matches[3] + $suffix = $matches[4] + + echo "major=$major" >> $env:GITHUB_OUTPUT + echo "minor=$minor" >> $env:GITHUB_OUTPUT + echo "patch=$patch" >> $env:GITHUB_OUTPUT + echo "suffix=$suffix" >> $env:GITHUB_OUTPUT + + # Determine promotion path + switch ($suffix.ToLower()) { + 'alpha' { + $newVersion = "$major.$minor.$patch-beta" + $previousStage = 'alpha' + $newStage = 'beta' + } + 'beta' { + $newVersion = "$major.$minor.$patch-rc" + $previousStage = 'beta' + $newStage = 'rc' + } + 'rc' { + $newVersion = "$major.$minor.$patch" + $previousStage = 'rc' + $newStage = 'stable' + } + default { + Write-Error "Unknown or missing version suffix: $suffix. Cannot determine promotion path." + exit 1 + } + } + + Write-Host "Promoting: $currentVersion -> $newVersion" + echo "new_version=$newVersion" >> $env:GITHUB_OUTPUT + echo "previous_stage=$previousStage" >> $env:GITHUB_OUTPUT + echo "new_stage=$newStage" >> $env:GITHUB_OUTPUT + } else { + Write-Error "Invalid version format: $currentVersion" + exit 1 + } + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: dev + + - name: Set up Git user + shell: bash + run: | + git config user.name "github-actions" + git config user.email "action@github.com" + + - name: Remove existing release branch if it exists + shell: bash + run: | + NEW_VERSION="${{ steps.parse.outputs.new_version }}" + if git ls-remote --exit-code --heads origin release/$NEW_VERSION; then + git push origin --delete release/$NEW_VERSION + fi + + - name: Create release branch + shell: bash + run: | + NEW_VERSION="${{ steps.parse.outputs.new_version }}" + git checkout -b release/$NEW_VERSION + + - name: Update version in Solution.props + uses: ./.github/actions/versioning/update-version + with: + new-version: ${{ steps.parse.outputs.new_version }} + + - name: Update changelog section + uses: ./.github/actions/documentation/update-changelog + with: + action: create-release + version: ${{ steps.parse.outputs.new_version }} + + - name: Commit and push changes + shell: bash + run: | + NEW_VERSION="${{ steps.parse.outputs.new_version }}" + PREVIOUS_STAGE="${{ steps.parse.outputs.previous_stage }}" + NEW_STAGE="${{ steps.parse.outputs.new_stage }}" + + git add Solution.props CHANGELOG.md + git commit -m "chore: promote version to $NEW_VERSION ($PREVIOUS_STAGE -> $NEW_STAGE)" + git push origin release/$NEW_VERSION + + - name: Create Pull Request + id: create-pr + shell: pwsh + env: + GITHUB_TOKEN: ${{ inputs.token }} + run: | + $newVersion = "${{ steps.parse.outputs.new_version }}" + $previousStage = "${{ steps.parse.outputs.previous_stage }}" + $newStage = "${{ steps.parse.outputs.new_stage }}" + $repo = "${{ github.repository }}" + + $prBody = @" + ## Version Promotion: ``$previousStage`` → ``$newStage`` + + This PR automatically promotes the version from ``$previousStage`` to ``$newStage`` after no issues were reported for ``${{ inputs.current-version }}`` in the last 30 days. + + ### Changes + - Updated version in `Solution.props` from `${{ inputs.current-version }}` to ``$newVersion`` + - Created new release section in CHANGELOG.md + + ### Next Steps + 1. Review and merge this PR to ``dev`` + 2. The ``release-2-pr-to-dev-closed.yml`` workflow will automatically create a PR to ``main`` + 3. After merging to ``main``, the release will be published + + --- + *This PR was automatically generated by the release promotion workflow.* + "@ + + # Create PR + $prJson = gh pr create ` + --base dev ` + --head "release/$newVersion" ` + --title "chore: promote version to $newVersion ($previousStage -> $newStage)" ` + --body $prBody ` + --json number + + if ($LASTEXITCODE -eq 0) { + $prNumber = ($prJson | ConvertFrom-Json).number + Write-Host "Created PR #$prNumber" + echo "pr_created=true" >> $env:GITHUB_OUTPUT + echo "pr_number=$prNumber" >> $env:GITHUB_OUTPUT + + # Auto-merge if requested + if ("${{ inputs.auto-merge }}" -eq "true") { + Write-Host "Auto-merging PR #$prNumber" + gh pr merge $prNumber --auto --squash --delete-branch + } + } else { + Write-Error "Failed to create PR" + exit 1 + } + + - name: Output results + id: promote + shell: pwsh + run: | + echo "new_version=${{ steps.parse.outputs.new_version }}" >> $env:GITHUB_OUTPUT + echo "previous_stage=${{ steps.parse.outputs.previous_stage }}" >> $env:GITHUB_OUTPUT + echo "new_stage=${{ steps.parse.outputs.new_stage }}" >> $env:GITHUB_OUTPUT + echo "pr_created=${{ steps.create-pr.outputs.pr_created }}" >> $env:GITHUB_OUTPUT + echo "pr_number=${{ steps.create-pr.outputs.pr_number }}" >> $env:GITHUB_OUTPUT diff --git a/.github/workflows/issue-auto-tag.yml b/.github/workflows/issue-auto-tag.yml new file mode 100644 index 00000000..e73f6212 --- /dev/null +++ b/.github/workflows/issue-auto-tag.yml @@ -0,0 +1,111 @@ +name: 🏷️ Auto-Tag Issues with Version + +# Description: Automatically tags newly created issues with the appropriate version label +# based on the SmartHopper Version field from the bug report template. +# +# Triggers: +# - Automatically when an issue is created or opened +# +# Permissions: +# - issues:write - Required to add labels to issues + +on: + issues: + types: [opened] + +permissions: + issues: write + contents: read + +jobs: + auto-tag-issue: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get current version + id: get-version + uses: ./.github/actions/versioning/get-version + with: + branch: main + + - name: Parse issue body for version + id: parse-version + uses: actions/github-script@v7 + with: + script: | + const issueBody = context.payload.issue.body || ''; + + // Try to extract SmartHopper Version from issue body + // Pattern: "SmartHopper Version" followed by input value + const versionPatterns = [ + /SmartHopper Version\s*\n\s*([\w\.\-]+)/i, + /### SmartHopper Version\s*\n\s*([\w\.\-]+)/i, + /SmartHopper Version:\s*([\w\.\-]+)/i, + /Version[\s\w:]*\n?\s*([\d]+\.[\d]+\.[\d]+[\-\w]*)/i, + /([\d]+\.[\d]+\.[\d]+-(alpha|beta|rc)(\.\d+)?)/i + ]; + + let detectedVersion = null; + for (const pattern of versionPatterns) { + const match = issueBody.match(pattern); + if (match) { + detectedVersion = match[1].trim(); + break; + } + } + + // If no version found in body, use current version + if (!detectedVersion) { + const currentVersion = '${{ steps.get-version.outputs.version }}'; + console.log(`No version found in issue body, using current version: ${currentVersion}`); + detectedVersion = currentVersion; + } else { + console.log(`Detected version from issue: ${detectedVersion}`); + } + + core.setOutput('detected_version', detectedVersion); + + - name: Create version label if doesn't exist + id: create-label + uses: ./.github/actions/versioning/create-version-label + with: + version: ${{ steps.parse-version.outputs.detected_version }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Apply version label to issue + uses: actions/github-script@v7 + with: + script: | + const issueNumber = context.payload.issue.number; + const versionLabel = '${{ steps.create-label.outputs.label-name }}'; + + console.log(`Applying label "${versionLabel}" to issue #${issueNumber}`); + + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [versionLabel] + }); + + console.log(`✅ Successfully labeled issue #${issueNumber} with "${versionLabel}"`); + } catch (error) { + console.error(`❌ Failed to label issue: ${error.message}`); + core.setFailed(error.message); + } + + - name: Summary + if: always() + run: | + echo "## Issue Auto-Tagging Summary" + echo "" + echo "- Issue: #${{ github.event.issue.number }}" + echo "- Title: ${{ github.event.issue.title }}" + echo "- Detected Version: ${{ steps.parse-version.outputs.detected_version }}" + echo "- Label Applied: ${{ steps.create-label.outputs.label-name }}" + echo "- Label Created: ${{ steps.create-label.outputs.label-created }}" diff --git a/.github/workflows/milestone-management.yml b/.github/workflows/milestone-management.yml index 7a7fdb14..02326b29 100644 --- a/.github/workflows/milestone-management.yml +++ b/.github/workflows/milestone-management.yml @@ -1,11 +1,19 @@ -name: move-open-issues-and-pr-to-next-milestone +name: 📋 Milestone Management # Milestone Management Workflow -# Automatically moves open issues and PRs from closed milestones to the next appropriate milestone +# 1. Creates next-stage milestones when versions are released: +# - alpha → beta + next minor alpha +# - beta → rc +# - rc → stable +# 2. Keeps only the latest beta, rc, and stable milestones active (closes older ones) +# 3. Moves open issues from closed milestones to the next alpha version +# 4. Allows unlimited alpha milestones on: milestone: types: [closed] + release: + types: [published] permissions: issues: write @@ -13,7 +21,22 @@ permissions: contents: read jobs: + create-next-stage-milestone: + if: github.event_name == 'release' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Create next-stage milestones + uses: ./.github/actions/versioning/manage-milestones + with: + released-version: ${{ github.event.release.tag_name }} + token: ${{ secrets.GITHUB_TOKEN }} + move-open-items: + if: github.event_name == 'milestone' runs-on: ubuntu-latest steps: @@ -21,180 +44,7 @@ jobs: uses: actions/checkout@v4 - name: Move open issues and PRs to next milestone - uses: actions/github-script@v7 + uses: ./.github/actions/versioning/move-milestone-items with: - script: | - // Parse semantic version from milestone title - function parseVersion(versionStr) { - const match = versionStr.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); - if (!match) return null; - - return { - major: parseInt(match[1]), - minor: parseInt(match[2]), - patch: parseInt(match[3]), - prerelease: match[4] || null, - original: versionStr - }; - } - - // Compare versions to determine next MINOR or PATCH - function getNextVersions(closedVersion) { - const nextMinor = { - major: closedVersion.major, - minor: closedVersion.minor + 1, - patch: 0, - prerelease: 'alpha' - }; - - const nextPatch = { - major: closedVersion.major, - minor: closedVersion.minor, - patch: closedVersion.patch + 1, - prerelease: closedVersion.prerelease - }; - - return { nextMinor, nextPatch }; - } - - // Format version object back to string - function formatVersion(version) { - let versionStr = `${version.major}.${version.minor}.${version.patch}`; - if (version.prerelease) { - versionStr += `-${version.prerelease}`; - } - return versionStr; - } - - // Find milestone by title - async function findMilestone(title) { - const milestones = await github.rest.issues.listMilestones({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open' - }); - - return milestones.data.find(m => m.title === title); - } - - // Create new milestone - async function createMilestone(title, description) { - const response = await github.rest.issues.createMilestone({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - description: description || `Auto-created milestone for version ${title}` - }); - - console.log(`Created new milestone: ${title}`); - return response.data; - } - - // Get open issues and PRs for a milestone - async function getOpenItemsInMilestone(milestoneNumber) { - const [issues, pulls] = await Promise.all([ - github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - milestone: milestoneNumber, - state: 'open' - }), - github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open' - }) - ]); - - // Filter PRs that belong to the milestone - const prsInMilestone = pulls.data.filter(pr => - pr.milestone && pr.milestone.number === milestoneNumber - ); - - return { - issues: issues.data.filter(issue => !issue.pull_request), // Exclude PRs from issues - prs: prsInMilestone - }; - } - - // Move item to new milestone - async function moveItemToMilestone(itemNumber, newMilestoneNumber) { - try { - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - milestone: newMilestoneNumber - }); - console.log(`Successfully moved item #${itemNumber} to milestone #${newMilestoneNumber}`); - } catch (error) { - console.error(`Failed to move item #${itemNumber} to milestone #${newMilestoneNumber}:`, error.message); - // Optionally rethrow the error or handle it gracefully - } - } - - // Main logic - const closedMilestone = context.payload.milestone; - console.log(`Processing closed milestone: ${closedMilestone.title}`); - - // Parse the closed milestone version - const closedVersion = parseVersion(closedMilestone.title); - if (!closedVersion) { - console.log('Milestone title is not a valid semantic version, skipping.'); - return; - } - - console.log(`Parsed closed version: ${JSON.stringify(closedVersion)}`); - - // Get open items in the closed milestone - const openItems = await getOpenItemsInMilestone(closedMilestone.number); - const totalItems = openItems.issues.length + openItems.prs.length; - - console.log(`Found ${openItems.issues.length} open issues and ${openItems.prs.length} open PRs in milestone`); - - if (totalItems === 0) { - console.log('No open items to move, exiting.'); - return; - } - - // Determine next versions - const { nextMinor, nextPatch } = getNextVersions(closedVersion); - const nextMinorTitle = formatVersion(nextMinor); - const nextPatchTitle = formatVersion(nextPatch); - - console.log(`Looking for next milestones: MINOR=${nextMinorTitle}, PATCH=${nextPatchTitle}`); - - // Try to find next MINOR milestone first - let targetMilestone = await findMilestone(nextMinorTitle); - let milestoneType = 'MINOR'; - - if (!targetMilestone) { - // Try to find next PATCH milestone - targetMilestone = await findMilestone(nextPatchTitle); - milestoneType = 'PATCH'; - - if (!targetMilestone) { - // Create new MINOR milestone - targetMilestone = await createMilestone(nextMinorTitle); - milestoneType = 'MINOR (created)'; - } - } - - console.log(`Target milestone: ${targetMilestone.title} (${milestoneType})`); - - // Move all open issues and PRs to the target milestone - const movePromises = []; - - for (const issue of openItems.issues) { - console.log(`Moving issue #${issue.number}: ${issue.title}`); - movePromises.push(moveItemToMilestone(issue.number, targetMilestone.number)); - } - - for (const pr of openItems.prs) { - console.log(`Moving PR #${pr.number}: ${pr.title}`); - movePromises.push(moveItemToMilestone(pr.number, targetMilestone.number)); - } - - await Promise.all(movePromises); - - console.log(`Successfully moved ${totalItems} items to milestone "${targetMilestone.title}"`); + closed-milestone-title: ${{ github.event.milestone.title }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-1-milestone.yml b/.github/workflows/release-1-milestone.yml index e9d7c5ff..95773615 100644 --- a/.github/workflows/release-1-milestone.yml +++ b/.github/workflows/release-1-milestone.yml @@ -1,11 +1,11 @@ name: 🏁 1 Prepare Release on Milestone Close -# Description: This workflow automatically prepares a release branch when a milestone is closed. +# Description: This workflow prepares a release branch for a milestone. # It extracts the milestone title as the version number and compiles release notes from # all issues and pull requests associated with the milestone. # # Triggers: -# - Automatically when a milestone is closed +# - Manual dispatch from GitHub Actions page (select any milestone) # # Permissions: # - contents:write - Required to create GitHub releases @@ -18,8 +18,12 @@ permissions: pull-requests: write on: - milestone: - types: [ closed ] + workflow_dispatch: + inputs: + milestone-title: + description: 'Milestone title (version number, e.g., 1.4.3 or 1.4.3-beta)' + required: true + type: string jobs: release-preparation: @@ -37,12 +41,12 @@ jobs: - name: Remove existing release branch if it exists run: | - if git ls-remote --exit-code --heads origin release/${{ github.event.milestone.title }}; then - git push origin --delete release/${{ github.event.milestone.title }} + if git ls-remote --exit-code --heads origin release/${{ inputs.milestone-title }}; then + git push origin --delete release/${{ inputs.milestone-title }} fi - name: Create release branch - run: git checkout -b release/${{ github.event.milestone.title }} + run: git checkout -b release/${{ inputs.milestone-title }} - name: Replace SmartHopperPublicKey with placeholder run: | @@ -62,7 +66,7 @@ jobs: - name: Update version in Solution.props uses: ./.github/actions/versioning/update-version with: - new-version: ${{ github.event.milestone.title }} + new-version: ${{ inputs.milestone-title }} - name: Include missing issues in changelog uses: ./.github/actions/documentation/update-changelog-issues @@ -74,7 +78,7 @@ jobs: uses: ./.github/actions/documentation/update-changelog with: action: create-release - version: ${{ github.event.milestone.title }} + version: ${{ inputs.milestone-title }} - name: Update README badges uses: ./.github/actions/documentation/update-badges @@ -101,20 +105,20 @@ jobs: - name: Commit and push changes run: | git add Solution.props CHANGELOG.md README.md src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj src/ - git commit -m "chore: prepare release ${{ github.event.milestone.title }} with version update and code style fixes" - git push origin release/${{ github.event.milestone.title }} + git commit -m "chore: prepare release ${{ inputs.milestone-title }} with version update and code style fixes" + git push origin release/${{ inputs.milestone-title }} - name: Create Pull Request id: create-pr run: | gh pr create \ --base dev \ - --head release/${{ github.event.milestone.title }} \ - --title "chore: prepare release ${{ github.event.milestone.title }} with version update and code style fixes" \ - --body $'This PR prepares the release for version ${{ github.event.milestone.title }} with version update and code style fixes:\n\n- Updated version in Solution.props\n- Updated changelog with closed-solved issues\n- Updated README badges' + --head release/${{ inputs.milestone-title }} \ + --title "chore: prepare release ${{ inputs.milestone-title }} with version update and code style fixes" \ + --body $'This PR prepares the release for version ${{ inputs.milestone-title }} with version update and code style fixes:\n\n- Updated version in Solution.props\n- Updated changelog with closed-solved issues\n- Updated README badges' # Capture PR number - PR_NUMBER=$(gh pr list --base dev --head release/${{ github.event.milestone.title }} --json number --jq '.[0].number') + PR_NUMBER=$(gh pr list --base dev --head release/${{ inputs.milestone-title }} --json number --jq '.[0].number') echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-4-build.yml b/.github/workflows/release-4-build.yml index 082ff802..ae63743b 100644 --- a/.github/workflows/release-4-build.yml +++ b/.github/workflows/release-4-build.yml @@ -105,6 +105,13 @@ jobs: exit 1 } + - name: Create version label if doesn't exist + if: github.event_name == 'release' || github.event.inputs.upload_to_release == 'true' + uses: ./.github/actions/versioning/create-version-label + with: + version: ${{ steps.determine_version.outputs.VERSION }} + token: ${{ secrets.GITHUB_TOKEN }} + - name: Check if release is draft if: github.event_name == 'release' id: check_draft diff --git a/.github/workflows/release-6-upload-yak.yml b/.github/workflows/release-6-upload-yak.yml index 6932dd85..61a14f52 100644 --- a/.github/workflows/release-6-upload-yak.yml +++ b/.github/workflows/release-6-upload-yak.yml @@ -79,6 +79,13 @@ jobs: } echo "version=$version" >> $env:GITHUB_OUTPUT + - name: Create version label if doesn't exist + if: inputs.upload_to_yak == 'true' + uses: ./.github/actions/versioning/create-version-label + with: + version: ${{ steps.get_version.outputs.version }} + token: ${{ secrets.GITHUB_TOKEN }} + - name: Create artifacts directory shell: pwsh run: | diff --git a/.github/workflows/release-promotion.yml b/.github/workflows/release-promotion.yml new file mode 100644 index 00000000..3b656dbb --- /dev/null +++ b/.github/workflows/release-promotion.yml @@ -0,0 +1,210 @@ +name: 🔄 Release Promotion (Alpha→Beta→RC→Stable) + +# Description: Automatically promotes versions through release stages when no issues +# are reported within 30 days of release. +# +# Flow: +# 1. Checks for issues labeled with the released version +# 2. If no issues found after 30 days, promotes to next stage (alpha→beta→rc→stable) +# 3. Creates release branch and PR for the promotion +# +# Triggers: +# - Workflow dispatch (manual) +# - Scheduled daily (cron) +# - After release published + +on: + workflow_dispatch: + inputs: + version: + description: 'Specific version to check for promotion (leave empty to check all recent releases)' + required: false + type: string + default: '' + force-promote: + description: 'Force promotion even if issues exist (use with caution)' + required: false + type: boolean + default: false + schedule: + # Run daily at 2 AM UTC + - cron: '0 2 * * *' + release: + types: [published] + +permissions: + contents: write + issues: read + pull-requests: write + +jobs: + determine-versions-to-check: + runs-on: ubuntu-latest + outputs: + versions: ${{ steps.get-versions.outputs.versions }} + has-versions: ${{ steps.get-versions.outputs.has_versions }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get versions to check + id: get-versions + uses: actions/github-script@v7 + with: + script: | + // Parse semantic version from string + function parseVersion(versionStr) { + const match = versionStr.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); + if (!match) return null; + + return { + major: parseInt(match[1]), + minor: parseInt(match[2]), + patch: parseInt(match[3]), + suffix: match[4] || 'stable', + original: versionStr + }; + } + + // Get open milestones + const { data: milestones } = await github.rest.issues.listMilestones({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open' + }); + + const openMilestoneVersions = new Set(milestones.map(m => m.title)); + console.log('Open milestones:', Array.from(openMilestoneVersions)); + + const { data: releases } = await github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 20 + }); + + const specificVersion = '${{ github.event.inputs.version }}'; + let versionsToCheck = []; + + if (specificVersion) { + // Use specific version from input + versionsToCheck = [specificVersion]; + } else { + // Filter for alpha, beta, rc releases (not stable) that have open milestones + const preReleases = releases.filter(r => { + const tag = r.tag_name; + const isPreRelease = tag.includes('-alpha') || tag.includes('-beta') || tag.includes('-rc'); + const hasOpenMilestone = openMilestoneVersions.has(tag); + return isPreRelease && hasOpenMilestone; + }); + + // Group by base version and suffix, keep only latest of each suffix + const versionsByBaseAndSuffix = {}; + + for (const release of preReleases) { + const parsed = parseVersion(release.tag_name); + if (!parsed) continue; + + const baseKey = `${parsed.major}.${parsed.minor}.${parsed.patch}`; + const suffixKey = `${baseKey}:${parsed.suffix}`; + + if (!versionsByBaseAndSuffix[suffixKey]) { + versionsByBaseAndSuffix[suffixKey] = release.tag_name; + } + } + + versionsToCheck = Object.values(versionsByBaseAndSuffix); + } + + console.log('Versions to check for promotion:', versionsToCheck); + + core.setOutput('versions', JSON.stringify(versionsToCheck)); + core.setOutput('has_versions', versionsToCheck.length > 0 ? 'true' : 'false'); + + check-and-promote: + needs: determine-versions-to-check + if: needs.determine-versions-to-check.outputs.has-versions == 'true' + runs-on: ubuntu-latest + strategy: + matrix: + version: ${{ fromJson(needs.determine-versions-to-check.outputs.versions) }} + fail-fast: false + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for issues with version label + id: check-issues + uses: ./.github/actions/versioning/check-issues-for-version + with: + version: ${{ matrix.version }} + days-lookback: '30' + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine if promotion needed + id: should-promote + shell: pwsh + run: | + $hasIssues = "${{ steps.check-issues.outputs.has-issues }}" -eq "true" + $forcePromote = "${{ github.event.inputs.force-promote }}" -eq "true" + $issueCount = "${{ steps.check-issues.outputs.issue-count }}" + $version = "${{ matrix.version }}" + + Write-Host "Checking promotion for version: $version" + Write-Host "Has issues: $hasIssues" + Write-Host "Issue count: $issueCount" + Write-Host "Force promote: $forcePromote" + + # Check if version is old enough (at least 30 days since release) + # This is a basic check - in a full implementation you'd query the release date + + if ($forcePromote) { + Write-Host "⚠️ Force promotion enabled - promoting despite $issueCount issues" + echo "should_promote=true" >> $env:GITHUB_OUTPUT + echo "reason=force_promote" >> $env:GITHUB_OUTPUT + } elseif (-not $hasIssues) { + Write-Host "✅ No issues reported for 30 days - ready for promotion" + echo "should_promote=true" >> $env:GITHUB_OUTPUT + echo "reason=no_issues" >> $env:GITHUB_OUTPUT + } else { + Write-Host "⏸️ $issueCount issues still open - deferring promotion" + echo "should_promote=false" >> $env:GITHUB_OUTPUT + echo "reason=issues_exist" >> $env:GITHUB_OUTPUT + } + + - name: Promote version + if: steps.should-promote.outputs.should-promote == 'true' + id: promote + uses: ./.github/actions/versioning/promote-version + with: + current-version: ${{ matrix.version }} + token: ${{ secrets.GITHUB_TOKEN }} + auto-merge: 'false' + + - name: Create new version label + if: steps.should-promote.outputs.should-promote == 'true' + uses: ./.github/actions/versioning/create-version-label + with: + version: ${{ steps.promote.outputs.new-version }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + if: always() + run: | + echo "## Promotion Check for ${{ matrix.version }}" + echo "" + echo "- Has Issues: ${{ steps.check-issues.outputs.has-issues }}" + echo "- Issue Count: ${{ steps.check-issues.outputs.issue-count }}" + echo "- Should Promote: ${{ steps.should-promote.outputs.should-promote }}" + echo "- Reason: ${{ steps.should-promote.outputs.reason }}" + echo "" + if [ "${{ steps.should-promote.outputs.should-promote }}" == "true" ]; then + echo "- ✅ Promoted: ${{ matrix.version }} → ${{ steps.promote.outputs.new-version }}" + echo "- Previous Stage: ${{ steps.promote.outputs.previous-stage }}" + echo "- New Stage: ${{ steps.promote.outputs.new-stage }}" + echo "- PR Created: ${{ steps.promote.outputs.pr-created }}" + echo "- PR Number: ${{ steps.promote.outputs.pr-number }}" + fi From f5484439991662ba52895a33b6aca04cea36b7db Mon Sep 17 00:00:00 2001 From: Marc Roca-Musach <49920661+marc-romu@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:08:54 +0100 Subject: [PATCH 003/110] Potential fix for code scanning alert no. 20: Code injection Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/issue-auto-tag.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/issue-auto-tag.yml b/.github/workflows/issue-auto-tag.yml index e73f6212..5bfc21c0 100644 --- a/.github/workflows/issue-auto-tag.yml +++ b/.github/workflows/issue-auto-tag.yml @@ -101,11 +101,17 @@ jobs: - name: Summary if: always() + env: + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + DETECTED_VERSION: ${{ steps.parse-version.outputs.detected_version }} + LABEL_NAME: ${{ steps.create-label.outputs.label-name }} + LABEL_CREATED: ${{ steps.create-label.outputs.label-created }} run: | echo "## Issue Auto-Tagging Summary" echo "" - echo "- Issue: #${{ github.event.issue.number }}" - echo "- Title: ${{ github.event.issue.title }}" - echo "- Detected Version: ${{ steps.parse-version.outputs.detected_version }}" - echo "- Label Applied: ${{ steps.create-label.outputs.label-name }}" - echo "- Label Created: ${{ steps.create-label.outputs.label-created }}" + echo "- Issue: #$ISSUE_NUMBER" + echo "- Title: $ISSUE_TITLE" + echo "- Detected Version: $DETECTED_VERSION" + echo "- Label Applied: $LABEL_NAME" + echo "- Label Created: $LABEL_CREATED" From 69fff92f9a1a6319df5a309e0ddf79fc8c972fe4 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:09:01 +0100 Subject: [PATCH 004/110] docs(ci): add comprehensive versioning action documentation and standardize version parsing utilities --- .github/actions/versioning/README.md | 228 ++++++++++++++++++ .../check-issues-for-version/action.yml | 17 +- .../versioning/format-version/action.yml | 41 ++++ .../actions/versioning/get-version/action.yml | 9 +- .../versioning/manage-milestones/action.yml | 39 +-- .../move-milestone-items/action.yml | 23 +- .../versioning/parse-version/action.yml | 65 +++++ .../versioning/promote-version/action.yml | 78 +++--- .../versioning/update-version/action.yml | 12 +- .github/workflows/release-1-milestone.yml | 12 +- .github/workflows/release-4-build.yml | 1 + .github/workflows/release-6-upload-yak.yml | 1 + 12 files changed, 446 insertions(+), 80 deletions(-) create mode 100644 .github/actions/versioning/README.md create mode 100644 .github/actions/versioning/format-version/action.yml create mode 100644 .github/actions/versioning/parse-version/action.yml diff --git a/.github/actions/versioning/README.md b/.github/actions/versioning/README.md new file mode 100644 index 00000000..dbf1b7a0 --- /dev/null +++ b/.github/actions/versioning/README.md @@ -0,0 +1,228 @@ +# Versioning Actions + +This directory contains reusable GitHub Actions for semantic version management across the SmartHopper release pipeline. + +## Shared Version Utilities + +All versioning actions use **consistent version parsing patterns** to ensure uniform behavior across the pipeline. + +### Version Format + +All versions follow semantic versioning with optional pre-release suffixes: + +- **Stable**: `X.Y.Z` (e.g., `1.4.3`) +- **Pre-release**: `X.Y.Z-STAGE[.DATE]` (e.g., `1.4.3-alpha`, `1.4.3-alpha.240101`) + +### Shared Parsing Logic + +The following functions are standardized across all actions: + +#### `parseVersion(versionStr)` + +Parses a version string into components: + +```javascript +{ + major: number, + minor: number, + patch: number, + suffix: string | null, // Full suffix (e.g., "alpha.240101") + original: string +} +``` + +#### `formatVersion(version)` + +Formats a version object back to string: + +```javascript +formatVersion({ major: 1, minor: 4, patch: 3, suffix: "alpha" }) +// Returns: "1.4.3-alpha" +``` + +#### `getStage(suffix)` + +Extracts release stage from suffix: + +```javascript +getStage("alpha.240101") // Returns: "alpha" +getStage("beta") // Returns: "beta" +getStage(null) // Returns: "stable" +``` + +### Regex Pattern + +All actions use this consistent regex for version parsing: + +```regex +^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.]+))?$ +``` + +This pattern: + +- Captures major, minor, patch as separate groups +- Allows optional suffix with dots (e.g., `alpha.240101`) +- Handles both stable (`1.4.3`) and pre-release (`1.4.3-alpha`) formats + +## Actions Overview + +### Core Parsing Actions + +#### `parse-version` + +Parses a version string into components (major, minor, patch, suffix, stage). + +**Inputs:** + +- `version`: Version string to parse + +**Outputs:** + +- `major`, `minor`, `patch`: Version components +- `suffix`: Full suffix (if any) +- `stage`: Release stage (alpha, beta, rc, stable) +- `is-prerelease`: Boolean flag + +#### `format-version` + +Formats version components back into a semantic version string. + +**Inputs:** + +- `major`, `minor`, `patch`: Version components +- `suffix`: Optional suffix + +**Outputs:** + +- `version`: Formatted version string + +### Milestone Management + +#### `manage-milestones` + +Creates next-stage milestones and manages active milestones for release versions. + +**Shared Utilities:** + +- `parseVersion()` - Parses version strings +- `formatVersion()` - Formats version objects +- `getStage()` - Extracts stage from suffix + +#### `move-milestone-items` + +Moves open issues and PRs from closed milestones to target milestones. + +**Shared Utilities:** + +- `parseVersion()` - Parses version strings +- `formatVersion()` - Formats version objects +- `getStage()` - Extracts stage from suffix + +### Version Promotion + +#### `promote-version` + +Promotes a version from one stage to the next (alpha→beta→rc→stable). + +**Uses:** `parse-version` action for consistent parsing + +### Version Retrieval and Updates + +#### `get-version` + +Extracts current version from Solution.props with component parsing. + +**Standardized Parsing:** + +- Uses consistent regex pattern +- Extracts stage from suffix +- Outputs major, minor, patch, suffix, stage + +#### `update-version` + +Updates version in Solution.props with component parsing. + +**Standardized Parsing:** + +- Uses consistent regex pattern +- Extracts stage from suffix +- Outputs major, minor, patch, suffix, stage + +## Shared Version Utilities Implementation + +### Pattern: Inline Shared Functions + +All JavaScript actions that use version parsing include the same shared utility functions marked with clear delimiters: + +```javascript +// ===== SHARED VERSION UTILITIES (used across versioning actions) ===== +function parseVersion(versionStr) { ... } +function formatVersion(version) { ... } +function getStage(suffix) { ... } +// ===== END SHARED VERSION UTILITIES ===== +``` + +### Why Inline Functions? + +GitHub Actions limitations make inline functions the most practical approach: + +- `actions/github-script` doesn't support passing scripts as inputs +- Composite actions can't effectively output multi-line code blocks +- Inline functions are self-contained and require no external dependencies +- Clear delimiters make utilities easy to identify and maintain + +### Shared Functions Reference + +#### parseVersion() + +Parses a semantic version string into components. + +**Input:** `"1.4.3-alpha.240101"` + +**Output:** + +```javascript +{ + major: 1, + minor: 4, + patch: 3, + suffix: "alpha.240101", + original: "1.4.3-alpha.240101" +} +``` + +#### formatVersion() + +Formats a version object back to string. + +**Input:** + +```javascript +{ major: 1, minor: 4, patch: 3, suffix: "alpha" } +``` + +**Output:** `"1.4.3-alpha"` + +#### getStage() + +Extracts release stage from suffix. + +**Examples:** + +- `getStage("alpha.240101")` → `"alpha"` +- `getStage("beta")` → `"beta"` +- `getStage(null)` → `"stable"` + +### Actions Using Shared Utilities + +- **manage-milestones** - Uses all three functions +- **move-milestone-items** - Uses all three functions + +### Maintenance + +When updating shared utilities: + +1. Update the function in `manage-milestones/action.yml` +2. Update the function in `move-milestone-items/action.yml` +3. Update the reference documentation in this README +4. Ensure both implementations remain identical diff --git a/.github/actions/versioning/check-issues-for-version/action.yml b/.github/actions/versioning/check-issues-for-version/action.yml index dbe6f772..5908ce22 100644 --- a/.github/actions/versioning/check-issues-for-version/action.yml +++ b/.github/actions/versioning/check-issues-for-version/action.yml @@ -47,21 +47,24 @@ runs: } # Calculate cutoff date - $cutoffDate = (Get-Date).AddDays(-$daysLookback).ToString("yyyy-MM-ddTHH:mm:ssZ") + $cutoffDate = (Get-Date).AddDays(-$daysLookback).ToString("yyyy-MM-dd") - # Search for open issues with this label created since cutoff + # Use Search API with created date filter for accurate "created since" semantics + # This is more accurate than the REST API's 'since' parameter which filters by updated_at $issues = @() $page = 1 $perPage = 100 do { - $uri = "https://api.github.com/repos/$repo/issues?state=open&labels=$([Uri]::EscapeDataString($labelName))&since=$cutoffDate&per_page=$perPage&page=$page" + # Search API query: state:open label:"version: X.Y.Z" created:>=YYYY-MM-DD + $query = "state:open label:""$labelName"" created:>=$cutoffDate repo:$repo" + $uri = "https://api.github.com/search/issues?q=$([Uri]::EscapeDataString($query))&per_page=$perPage&page=$page" try { - $pageIssues = Invoke-RestMethod -Uri $uri -Headers $headers -Method GET - $issues += $pageIssues + $response = Invoke-RestMethod -Uri $uri -Headers $headers -Method GET + $issues += $response.items - if ($pageIssues.Count -lt $perPage) { + if ($response.items.Count -lt $perPage) { break } $page++ @@ -69,7 +72,7 @@ runs: Write-Error "Failed to fetch issues: $($_.Exception.Message)" exit 1 } - } while ($pageIssues.Count -eq $perPage) + } while ($response.items.Count -eq $perPage) $issueCount = $issues.Count $issueNumbers = ($issues | ForEach-Object { $_.number }) -join "," diff --git a/.github/actions/versioning/format-version/action.yml b/.github/actions/versioning/format-version/action.yml new file mode 100644 index 00000000..cd897a34 --- /dev/null +++ b/.github/actions/versioning/format-version/action.yml @@ -0,0 +1,41 @@ +name: 'Format Version' +description: 'Format version components back into a semantic version string' +inputs: + major: + description: 'Major version component' + required: true + minor: + description: 'Minor version component' + required: true + patch: + description: 'Patch version component' + required: true + suffix: + description: 'Optional suffix (e.g., alpha.240101)' + required: false + default: '' + +outputs: + version: + description: 'Formatted version string' + value: ${{ steps.format.outputs.version }} + +runs: + using: "composite" + steps: + - name: Format version + id: format + shell: pwsh + run: | + $major = "${{ inputs.major }}" + $minor = "${{ inputs.minor }}" + $patch = "${{ inputs.patch }}" + $suffix = "${{ inputs.suffix }}" + + $version = "$major.$minor.$patch" + if ($suffix) { + $version += "-$suffix" + } + + Write-Host "Formatted version: $version" + "version=$version" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 diff --git a/.github/actions/versioning/get-version/action.yml b/.github/actions/versioning/get-version/action.yml index dcd7ecb2..2cc99dd7 100644 --- a/.github/actions/versioning/get-version/action.yml +++ b/.github/actions/versioning/get-version/action.yml @@ -39,16 +39,21 @@ runs: shell: pwsh run: | $VERSION = '${{ steps.get-version.outputs.version }}' - if ($VERSION -match '^(\d+)\.(\d+)\.(\d+)(-[A-Za-z0-9]+(\.[0-9]+)?)?$') { + if ($VERSION -match '^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.]+))?$') { $major = $Matches[1] $minor = $Matches[2] $patch = $Matches[3] $suffix = $Matches[4] + + # Extract stage from suffix (e.g., 'alpha' from 'alpha.240101') + $stage = if ($suffix) { $suffix.Split('.')[0].ToLower() } else { 'stable' } + "major=$major" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 "minor=$minor" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 "patch=$patch" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 "suffix=$suffix" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 - Write-Host "Parsed version: Major=$major, Minor=$minor, Patch=$patch, Suffix=$suffix" + "stage=$stage" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Host "Parsed version: Major=$major, Minor=$minor, Patch=$patch, Suffix=$suffix, Stage=$stage" } else { Write-Error "Error: Version format doesn't follow semantic versioning: $VERSION" exit 1 diff --git a/.github/actions/versioning/manage-milestones/action.yml b/.github/actions/versioning/manage-milestones/action.yml index 968297c8..5eb796b3 100644 --- a/.github/actions/versioning/manage-milestones/action.yml +++ b/.github/actions/versioning/manage-milestones/action.yml @@ -26,6 +26,7 @@ runs: with: github-token: ${{ inputs.token }} script: | + // ===== SHARED VERSION UTILITIES (used across versioning actions) ===== // Parse semantic version from string function parseVersion(versionStr) { const match = versionStr.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); @@ -35,7 +36,7 @@ runs: major: parseInt(match[1]), minor: parseInt(match[2]), patch: parseInt(match[3]), - suffix: match[4] || 'stable', + suffix: match[4] || null, original: versionStr }; } @@ -43,11 +44,12 @@ runs: // Format version object back to string function formatVersion(version) { let versionStr = `${version.major}.${version.minor}.${version.patch}`; - if (version.suffix && version.suffix !== 'stable') { + if (version.suffix) { versionStr += `-${version.suffix}`; } return versionStr; } + // ===== END SHARED VERSION UTILITIES ===== // Create a milestone async function createMilestone(title, description) { @@ -69,8 +71,14 @@ runs: } } - // Close older milestones of the same suffix within the same major version - async function closeOlderMilestones(suffix, majorVersion) { + // Extract stage from suffix (e.g., 'alpha' from 'alpha.240101') + function getStage(suffix) { + if (!suffix || suffix === 'stable') return 'stable'; + return suffix.split('.')[0].toLowerCase(); + } + + // Close older milestones of the same stage within the same major version + async function closeOlderMilestones(stage, majorVersion) { const { data: allMilestones } = await github.rest.issues.listMilestones({ owner: context.repo.owner, repo: context.repo.repo, @@ -78,13 +86,13 @@ runs: per_page: 100 }); - // Filter for same suffix AND same major version - const sameSuffixMilestones = allMilestones.filter(m => { + // Filter for same stage AND same major version + const sameStageMilestones = allMilestones.filter(m => { const mParsed = parseVersion(m.title); - return mParsed && mParsed.suffix === suffix && mParsed.major === majorVersion; + return mParsed && getStage(mParsed.suffix) === stage && mParsed.major === majorVersion; }); - sameSuffixMilestones.sort((a, b) => { + sameStageMilestones.sort((a, b) => { const aParsed = parseVersion(a.title); const bParsed = parseVersion(b.title); @@ -93,9 +101,9 @@ runs: }); const closedTitles = []; - for (let i = 1; i < sameSuffixMilestones.length; i++) { - const oldMilestone = sameSuffixMilestones[i]; - console.log(`Closing older ${suffix} milestone (major ${majorVersion}): ${oldMilestone.title}`); + for (let i = 1; i < sameStageMilestones.length; i++) { + const oldMilestone = sameStageMilestones[i]; + console.log(`Closing older ${stage} milestone (major ${majorVersion}): ${oldMilestone.title}`); await github.rest.issues.updateMilestone({ owner: context.repo.owner, @@ -122,8 +130,11 @@ runs: const createdMilestones = []; const closedMilestones = []; + // Extract stage from suffix + const stage = getStage(parsed.suffix); + // Alpha releases create two milestones: beta and next minor alpha - if (parsed.suffix === 'alpha') { + if (stage === 'alpha') { // Create beta milestone const betaVersion = { major: parsed.major, @@ -151,7 +162,7 @@ runs: if (nextAlphaResult.created) createdMilestones.push(nextMinorAlphaStr); } // Beta releases create rc milestone - else if (parsed.suffix === 'beta') { + else if (stage === 'beta') { const rcVersion = { major: parsed.major, minor: parsed.minor, @@ -167,7 +178,7 @@ runs: closedMilestones.push(...closedRcs); } // RC releases create stable milestone - else if (parsed.suffix === 'rc') { + else if (stage === 'rc') { const stableVersion = { major: parsed.major, minor: parsed.minor, diff --git a/.github/actions/versioning/move-milestone-items/action.yml b/.github/actions/versioning/move-milestone-items/action.yml index 4512bc07..7d289b5f 100644 --- a/.github/actions/versioning/move-milestone-items/action.yml +++ b/.github/actions/versioning/move-milestone-items/action.yml @@ -26,6 +26,7 @@ runs: with: github-token: ${{ inputs.token }} script: | + // ===== SHARED VERSION UTILITIES (used across versioning actions) ===== // Parse semantic version from string function parseVersion(versionStr) { const match = versionStr.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); @@ -36,6 +37,7 @@ runs: minor: parseInt(match[2]), patch: parseInt(match[3]), prerelease: match[4] || null, + suffix: match[4] || null, // Alias for consistency with manage-milestones original: versionStr }; } @@ -43,11 +45,13 @@ runs: // Format version object back to string function formatVersion(version) { let versionStr = `${version.major}.${version.minor}.${version.patch}`; - if (version.prerelease) { - versionStr += `-${version.prerelease}`; + const suffix = version.prerelease || version.suffix; + if (suffix) { + versionStr += `-${suffix}`; } return versionStr; } + // ===== END SHARED VERSION UTILITIES ===== // Find milestone by title async function findMilestone(title) { @@ -144,17 +148,24 @@ runs: // Determine target milestone let targetMilestone = null; - // For alpha, beta, rc: move to next minor alpha + // Extract stage from prerelease (e.g., 'alpha' from 'alpha.240101') + function getStage(prerelease) { + if (!prerelease || prerelease === 'stable') return 'stable'; + return prerelease.split('.')[0].toLowerCase(); + } + + // For alpha, beta, rc: move to next minor with same stage if (closedVersion.prerelease && closedVersion.prerelease !== 'stable') { + const stage = getStage(closedVersion.prerelease); const nextMinorAlpha = { major: closedVersion.major, minor: closedVersion.minor + 1, patch: 0, - prerelease: 'alpha' + prerelease: stage }; const nextMinorAlphaTitle = formatVersion(nextMinorAlpha); - console.log(`Closed milestone is ${closedVersion.prerelease}, looking for next minor alpha: ${nextMinorAlphaTitle}`); + console.log(`Closed milestone is ${closedVersion.prerelease} (stage: ${stage}), looking for next minor ${stage}: ${nextMinorAlphaTitle}`); targetMilestone = await findMilestone(nextMinorAlphaTitle); if (!targetMilestone) { @@ -167,7 +178,7 @@ runs: minor: closedVersion.minor + 1, patch: 0, prerelease: 'alpha' - }; + }; // Stable releases default to next minor alpha const nextMinorTitle = formatVersion(nextMinor); targetMilestone = await findMilestone(nextMinorTitle); diff --git a/.github/actions/versioning/parse-version/action.yml b/.github/actions/versioning/parse-version/action.yml new file mode 100644 index 00000000..362800ce --- /dev/null +++ b/.github/actions/versioning/parse-version/action.yml @@ -0,0 +1,65 @@ +name: 'Parse Version' +description: 'Parse semantic version string into components (major, minor, patch, suffix, stage)' +inputs: + version: + description: 'Version string to parse (e.g., 1.4.3 or 1.4.3-alpha.240101)' + required: true + +outputs: + major: + description: 'Major version component' + value: ${{ steps.parse.outputs.major }} + minor: + description: 'Minor version component' + value: ${{ steps.parse.outputs.minor }} + patch: + description: 'Patch version component' + value: ${{ steps.parse.outputs.patch }} + suffix: + description: 'Full suffix including stage and date (e.g., alpha.240101)' + value: ${{ steps.parse.outputs.suffix }} + stage: + description: 'Release stage extracted from suffix (alpha, beta, rc, or stable)' + value: ${{ steps.parse.outputs.stage }} + is-prerelease: + description: 'Whether this is a pre-release version' + value: ${{ steps.parse.outputs.is-prerelease }} + +runs: + using: "composite" + steps: + - name: Parse version + id: parse + shell: pwsh + run: | + $version = "${{ inputs.version }}" + + # Parse semantic version: X.Y.Z or X.Y.Z-SUFFIX + if ($version -match '^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.]+))?$') { + $major = $Matches[1] + $minor = $Matches[2] + $patch = $Matches[3] + $suffix = $Matches[4] + + # Extract stage from suffix (e.g., 'alpha' from 'alpha.240101') + $stage = if ($suffix) { $suffix.Split('.')[0].ToLower() } else { 'stable' } + $isPrerelease = if ($suffix) { 'true' } else { 'false' } + + Write-Host "Parsed version: $version" + Write-Host " Major: $major" + Write-Host " Minor: $minor" + Write-Host " Patch: $patch" + Write-Host " Suffix: $suffix" + Write-Host " Stage: $stage" + Write-Host " Is Pre-release: $isPrerelease" + + "major=$major" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 + "minor=$minor" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 + "patch=$patch" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 + "suffix=$suffix" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 + "stage=$stage" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 + "is-prerelease=$isPrerelease" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 + } else { + Write-Error "Invalid version format: $version (expected X.Y.Z or X.Y.Z-SUFFIX)" + exit 1 + } diff --git a/.github/actions/versioning/promote-version/action.yml b/.github/actions/versioning/promote-version/action.yml index 161820e1..65a3acb0 100644 --- a/.github/actions/versioning/promote-version/action.yml +++ b/.github/actions/versioning/promote-version/action.yml @@ -33,55 +33,47 @@ outputs: runs: using: "composite" steps: - - name: Parse and promote version + - name: Parse current version id: parse + uses: ./.github/actions/versioning/parse-version + with: + version: ${{ inputs.current-version }} + + - name: Determine promotion path + id: promote shell: pwsh run: | - $currentVersion = "${{ inputs.current-version }}" + $stage = "${{ steps.parse.outputs.stage }}" + $major = "${{ steps.parse.outputs.major }}" + $minor = "${{ steps.parse.outputs.minor }}" + $patch = "${{ steps.parse.outputs.patch }}" - # Parse version: X.Y.Z-SUFFIX or X.Y.Z - if ($currentVersion -match '^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([a-zA-Z0-9]+))?$') { - $major = $matches[1] - $minor = $matches[2] - $patch = $matches[3] - $suffix = $matches[4] - - echo "major=$major" >> $env:GITHUB_OUTPUT - echo "minor=$minor" >> $env:GITHUB_OUTPUT - echo "patch=$patch" >> $env:GITHUB_OUTPUT - echo "suffix=$suffix" >> $env:GITHUB_OUTPUT - - # Determine promotion path - switch ($suffix.ToLower()) { - 'alpha' { - $newVersion = "$major.$minor.$patch-beta" - $previousStage = 'alpha' - $newStage = 'beta' - } - 'beta' { - $newVersion = "$major.$minor.$patch-rc" - $previousStage = 'beta' - $newStage = 'rc' - } - 'rc' { - $newVersion = "$major.$minor.$patch" - $previousStage = 'rc' - $newStage = 'stable' - } - default { - Write-Error "Unknown or missing version suffix: $suffix. Cannot determine promotion path." - exit 1 - } + switch ($stage) { + 'alpha' { + $newVersion = "$major.$minor.$patch-beta" + $previousStage = 'alpha' + $newStage = 'beta' + } + 'beta' { + $newVersion = "$major.$minor.$patch-rc" + $previousStage = 'beta' + $newStage = 'rc' + } + 'rc' { + $newVersion = "$major.$minor.$patch" + $previousStage = 'rc' + $newStage = 'stable' + } + default { + Write-Error "Unknown version stage: $stage. Cannot determine promotion path." + exit 1 } - - Write-Host "Promoting: $currentVersion -> $newVersion" - echo "new_version=$newVersion" >> $env:GITHUB_OUTPUT - echo "previous_stage=$previousStage" >> $env:GITHUB_OUTPUT - echo "new_stage=$newStage" >> $env:GITHUB_OUTPUT - } else { - Write-Error "Invalid version format: $currentVersion" - exit 1 } + + Write-Host "Promoting: ${{ inputs.current-version }} -> $newVersion" + echo "new_version=$newVersion" >> $env:GITHUB_OUTPUT + echo "previous_stage=$previousStage" >> $env:GITHUB_OUTPUT + echo "new_stage=$newStage" >> $env:GITHUB_OUTPUT - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/actions/versioning/update-version/action.yml b/.github/actions/versioning/update-version/action.yml index 49cec136..1ae887db 100644 --- a/.github/actions/versioning/update-version/action.yml +++ b/.github/actions/versioning/update-version/action.yml @@ -42,18 +42,26 @@ runs: shell: bash run: | VERSION="${{ steps.get-version.outputs.version }}" - if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z0-9]+(\.[0-9]+)?)?$ ]]; then + if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([a-zA-Z0-9.]+))?$ ]]; then MAJOR="${BASH_REMATCH[1]}" MINOR="${BASH_REMATCH[2]}" PATCH="${BASH_REMATCH[3]}" SUFFIX="${BASH_REMATCH[4]}" + # Extract stage from suffix (e.g., 'alpha' from 'alpha.240101') + if [[ -z "$SUFFIX" ]]; then + STAGE="stable" + else + STAGE=$(echo "$SUFFIX" | cut -d. -f1 | tr '[:upper:]' '[:lower:]') + fi + echo "major=$MAJOR" >> $GITHUB_OUTPUT echo "minor=$MINOR" >> $GITHUB_OUTPUT echo "patch=$PATCH" >> $GITHUB_OUTPUT echo "suffix=$SUFFIX" >> $GITHUB_OUTPUT + echo "stage=$STAGE" >> $GITHUB_OUTPUT - echo "Parsed version: Major=$MAJOR, Minor=$MINOR, Patch=$PATCH, Suffix=$SUFFIX" + echo "Parsed version: Major=$MAJOR, Minor=$MINOR, Patch=$PATCH, Suffix=$SUFFIX, Stage=$STAGE" else echo "Error: Version format doesn't follow semantic versioning: $VERSION" exit 1 diff --git a/.github/workflows/release-1-milestone.yml b/.github/workflows/release-1-milestone.yml index 95773615..9c3a0123 100644 --- a/.github/workflows/release-1-milestone.yml +++ b/.github/workflows/release-1-milestone.yml @@ -1,17 +1,17 @@ -name: 🏁 1 Prepare Release on Milestone Close +name: 🏁 1 Prepare Release Branch -# Description: This workflow prepares a release branch for a milestone. -# It extracts the milestone title as the version number and compiles release notes from -# all issues and pull requests associated with the milestone. +# Description: This workflow prepares a release branch for a specified version. +# It updates the version number and compiles release notes from all issues and +# pull requests associated with the milestone. # # Triggers: -# - Manual dispatch from GitHub Actions page (select any milestone) +# - Manual dispatch from GitHub Actions page (specify version as input) # # Permissions: # - contents:write - Required to create GitHub releases # - issues:read - Required to read issue information for release notes # - pull-requests:write - Required to create pull requests -# + permissions: contents: write issues: read diff --git a/.github/workflows/release-4-build.yml b/.github/workflows/release-4-build.yml index ae63743b..2d57c90f 100644 --- a/.github/workflows/release-4-build.yml +++ b/.github/workflows/release-4-build.yml @@ -34,6 +34,7 @@ on: permissions: contents: write + issues: write pages: write id-token: write pull-requests: write diff --git a/.github/workflows/release-6-upload-yak.yml b/.github/workflows/release-6-upload-yak.yml index 61a14f52..be27258a 100644 --- a/.github/workflows/release-6-upload-yak.yml +++ b/.github/workflows/release-6-upload-yak.yml @@ -52,6 +52,7 @@ jobs: permissions: contents: read + issues: write steps: - name: Checkout repository From b9ba58edcb208ee1e36ac713a99182371b5db365 Mon Sep 17 00:00:00 2001 From: Marc Roca-Musach <49920661+marc-romu@users.noreply.github.com> Date: Tue, 24 Mar 2026 19:20:12 +0100 Subject: [PATCH 005/110] Update .github/actions/versioning/move-milestone-items/action.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/actions/versioning/move-milestone-items/action.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/versioning/move-milestone-items/action.yml b/.github/actions/versioning/move-milestone-items/action.yml index 7d289b5f..bb5f1838 100644 --- a/.github/actions/versioning/move-milestone-items/action.yml +++ b/.github/actions/versioning/move-milestone-items/action.yml @@ -154,18 +154,18 @@ runs: return prerelease.split('.')[0].toLowerCase(); } - // For alpha, beta, rc: move to next minor with same stage + // For alpha, beta, rc: move to next minor alpha if (closedVersion.prerelease && closedVersion.prerelease !== 'stable') { const stage = getStage(closedVersion.prerelease); const nextMinorAlpha = { major: closedVersion.major, minor: closedVersion.minor + 1, patch: 0, - prerelease: stage + prerelease: 'alpha' }; const nextMinorAlphaTitle = formatVersion(nextMinorAlpha); - console.log(`Closed milestone is ${closedVersion.prerelease} (stage: ${stage}), looking for next minor ${stage}: ${nextMinorAlphaTitle}`); + console.log(`Closed milestone is ${closedVersion.prerelease} (stage: ${stage}), looking for next minor alpha: ${nextMinorAlphaTitle}`); targetMilestone = await findMilestone(nextMinorAlphaTitle); if (!targetMilestone) { From 21bec53b806e71fdd2d01adfd8955cf22784e914 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:42:20 +0100 Subject: [PATCH 006/110] fix(ci): correct step references and improve milestone pagination in versioning workflows --- .../versioning/manage-milestones/action.yml | 15 ++++++++----- .../versioning/promote-version/action.yml | 22 +++++++++---------- .../versioning/update-version/action.yml | 4 ++-- .github/workflows/release-promotion.yml | 16 +++++++++----- 4 files changed, 32 insertions(+), 25 deletions(-) diff --git a/.github/actions/versioning/manage-milestones/action.yml b/.github/actions/versioning/manage-milestones/action.yml index 5eb796b3..4f01e4f8 100644 --- a/.github/actions/versioning/manage-milestones/action.yml +++ b/.github/actions/versioning/manage-milestones/action.yml @@ -79,12 +79,15 @@ runs: // Close older milestones of the same stage within the same major version async function closeOlderMilestones(stage, majorVersion) { - const { data: allMilestones } = await github.rest.issues.listMilestones({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - per_page: 100 - }); + const allMilestones = await github.paginate( + github.rest.issues.listMilestones, + { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + } + ); // Filter for same stage AND same major version const sameStageMilestones = allMilestones.filter(m => { diff --git a/.github/actions/versioning/promote-version/action.yml b/.github/actions/versioning/promote-version/action.yml index 65a3acb0..988dd435 100644 --- a/.github/actions/versioning/promote-version/action.yml +++ b/.github/actions/versioning/promote-version/action.yml @@ -90,7 +90,7 @@ runs: - name: Remove existing release branch if it exists shell: bash run: | - NEW_VERSION="${{ steps.parse.outputs.new_version }}" + NEW_VERSION="${{ steps.promote.outputs.new_version }}" if git ls-remote --exit-code --heads origin release/$NEW_VERSION; then git push origin --delete release/$NEW_VERSION fi @@ -98,26 +98,26 @@ runs: - name: Create release branch shell: bash run: | - NEW_VERSION="${{ steps.parse.outputs.new_version }}" + NEW_VERSION="${{ steps.promote.outputs.new_version }}" git checkout -b release/$NEW_VERSION - name: Update version in Solution.props uses: ./.github/actions/versioning/update-version with: - new-version: ${{ steps.parse.outputs.new_version }} + new-version: ${{ steps.promote.outputs.new_version }} - name: Update changelog section uses: ./.github/actions/documentation/update-changelog with: action: create-release - version: ${{ steps.parse.outputs.new_version }} + version: ${{ steps.promote.outputs.new_version }} - name: Commit and push changes shell: bash run: | - NEW_VERSION="${{ steps.parse.outputs.new_version }}" - PREVIOUS_STAGE="${{ steps.parse.outputs.previous_stage }}" - NEW_STAGE="${{ steps.parse.outputs.new_stage }}" + NEW_VERSION="${{ steps.promote.outputs.new_version }}" + PREVIOUS_STAGE="${{ steps.promote.outputs.previous_stage }}" + NEW_STAGE="${{ steps.promote.outputs.new_stage }}" git add Solution.props CHANGELOG.md git commit -m "chore: promote version to $NEW_VERSION ($PREVIOUS_STAGE -> $NEW_STAGE)" @@ -129,9 +129,9 @@ runs: env: GITHUB_TOKEN: ${{ inputs.token }} run: | - $newVersion = "${{ steps.parse.outputs.new_version }}" - $previousStage = "${{ steps.parse.outputs.previous_stage }}" - $newStage = "${{ steps.parse.outputs.new_stage }}" + $newVersion = "${{ steps.promote.outputs.new_version }}" + $previousStage = "${{ steps.promote.outputs.previous_stage }}" + $newStage = "${{ steps.promote.outputs.new_stage }}" $repo = "${{ github.repository }}" $prBody = @" @@ -177,7 +177,7 @@ runs: } - name: Output results - id: promote + id: output shell: pwsh run: | echo "new_version=${{ steps.parse.outputs.new_version }}" >> $env:GITHUB_OUTPUT diff --git a/.github/actions/versioning/update-version/action.yml b/.github/actions/versioning/update-version/action.yml index 1ae887db..0950c4c0 100644 --- a/.github/actions/versioning/update-version/action.yml +++ b/.github/actions/versioning/update-version/action.yml @@ -42,11 +42,11 @@ runs: shell: bash run: | VERSION="${{ steps.get-version.outputs.version }}" - if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([a-zA-Z0-9.]+))?$ ]]; then + if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-([a-zA-Z0-9.]+))?$ ]]; then MAJOR="${BASH_REMATCH[1]}" MINOR="${BASH_REMATCH[2]}" PATCH="${BASH_REMATCH[3]}" - SUFFIX="${BASH_REMATCH[4]}" + SUFFIX="${BASH_REMATCH[5]}" # Extract stage from suffix (e.g., 'alpha' from 'alpha.240101') if [[ -z "$SUFFIX" ]]; then diff --git a/.github/workflows/release-promotion.yml b/.github/workflows/release-promotion.yml index 3b656dbb..8e879e06 100644 --- a/.github/workflows/release-promotion.yml +++ b/.github/workflows/release-promotion.yml @@ -68,12 +68,16 @@ jobs: }; } - // Get open milestones - const { data: milestones } = await github.rest.issues.listMilestones({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open' - }); + // Get open milestones (paginate to ensure we fetch all) + const milestones = await github.paginate( + github.rest.issues.listMilestones, + { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + } + ); const openMilestoneVersions = new Set(milestones.map(m => m.title)); console.log('Open milestones:', Array.from(openMilestoneVersions)); From 0ad00fbcac9c6f658893ade669b42e1eb3ffb4ce Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:19:19 +0100 Subject: [PATCH 007/110] docs: add milestone management guide and update release workflow documentation --- .github/MILESTONE_MANAGEMENT_GUIDE.md | 200 ++++++++++++++++++ .github/workflows/RELEASE_WORKFLOW.md | 82 +++++-- .github/workflows/release-auto-upload-yak.yml | 61 ++++++ 3 files changed, 325 insertions(+), 18 deletions(-) create mode 100644 .github/MILESTONE_MANAGEMENT_GUIDE.md create mode 100644 .github/workflows/release-auto-upload-yak.yml diff --git a/.github/MILESTONE_MANAGEMENT_GUIDE.md b/.github/MILESTONE_MANAGEMENT_GUIDE.md new file mode 100644 index 00000000..9b8ad209 --- /dev/null +++ b/.github/MILESTONE_MANAGEMENT_GUIDE.md @@ -0,0 +1,200 @@ +# Milestone Management System Guide + +## Overview + +The milestone management system automates the creation and lifecycle of milestones across different release stages (alpha → beta → rc → stable). The key principle is **scoping all operations to the same major version**, allowing different major versions to coexist independently. + +## System Behavior Summary + +| Scenario | Released Version | Action | Milestones Created | Milestones Closed | Notes | +| --- | --- | --- | --- | --- | --- | +| **Alpha Release** | `1.4.3-alpha` | Creates beta + next minor alpha | `1.4.3-beta`, `1.5.0-alpha` | Older `1.x.x-beta` only | Closes only beta milestones in major version 1 | +| **Alpha Release** | `2.0.0-alpha` | Creates beta + next minor alpha | `2.0.0-beta`, `2.1.0-alpha` | Older `2.x.x-beta` only | Closes only beta milestones in major version 2 | +| **Beta Release** | `1.4.3-beta` | Creates rc | `1.4.3-rc` | Older `1.x.x-rc` only | Closes only rc milestones in major version 1 | +| **RC Release** | `1.4.3-rc` | Creates stable | `1.4.3` | Older `1.x.x` (stable) only | Closes only stable milestones in major version 1 | +| **Stable Release** | `1.4.3` | No action | — | — | No milestones created for stable releases | + +## Coexistence Examples + +### Multiple Major Versions in Flight + +```text +Open Milestones: +├── 1.3.2-beta ✓ Active (latest beta in v1) +├── 1.4.3-beta ✓ Active (latest beta in v1) ← Would close 1.3.2-beta when 1.4.3-beta created +├── 1.5.0-alpha ✓ Active (unlimited alphas) +├── 1.6.0-alpha ✓ Active (unlimited alphas) +├── 2.0.0-beta ✓ Active (latest beta in v2) +├── 2.1.0-alpha ✓ Active (unlimited alphas) +└── 3.0.0-rc ✓ Active (latest rc in v3) +``` + +**Key Points:** + +- ✅ `1.3.2-beta` and `2.0.0-beta` coexist (different major versions) +- ✅ Unlimited alpha milestones allowed per major version +- ✅ Only one active beta per major version +- ✅ Only one active rc per major version +- ✅ Only one active stable per major version + +### Closure Behavior + +When `1.4.3-alpha` is released (published): + +- ✅ Closes `1.4.3-alpha` milestone (release triggers closure) +- ✅ Creates `1.4.3-beta` milestone (next stage) +- ✅ Creates `1.5.0-alpha` milestone (next minor) +- ✅ Closes older `1.x.x-beta` milestones (keeps only latest beta per major) +- ✅ **Does NOT** affect `2.x.x-beta` milestones (different major version) + +When `1.4.3-beta` is released (published): + +- ✅ Closes `1.4.3-beta` milestone +- ✅ Creates `1.4.3-rc` milestone (next stage) +- ✅ Creates `1.5.0-alpha` milestone (next minor, if not exists) +- ✅ Closes older `1.x.x-rc` milestones (keeps only latest rc per major) +- ✅ **Does NOT** affect `1.x.x-beta` or `2.x.x-rc` milestones + +When `2.0.0-beta` is released (published): + +- ✅ Closes `2.0.0-beta` milestone +- ✅ Creates `2.0.0-rc` milestone (next stage) +- ✅ Creates `2.1.0-alpha` milestone (next minor) +- ✅ Closes older `2.x.x-beta` milestones +- ✅ **Does NOT** affect `1.x.x-beta` milestones (different major version) + +## Issue Migration on Milestone Closure + +When a milestone is closed, open issues/PRs are migrated: + +| Closed Milestone Type | Target Milestone | Example | +| --- | --- | --- | +| `X.Y.Z-alpha` | `X.(Y+1).0-alpha` | `1.4.3-alpha` → `1.5.0-alpha` | +| `X.Y.Z-beta` | `X.(Y+1).0-alpha` | `1.4.3-beta` → `1.5.0-alpha` | +| `X.Y.Z-rc` | `X.(Y+1).0-alpha` | `1.4.3-rc` → `1.5.0-alpha` | +| `X.Y.Z` (stable) | `X.(Y+1).0-alpha` or `X.Y.(Z+1)` | `1.4.3` → `1.5.0-alpha` | + +**Note:** Target milestone is created automatically if it doesn't exist. + +**Rationale:** Pre-release milestones (alpha, beta, rc) represent work towards a specific release. When closed, issues migrate to the next minor version's alpha, aligning with the natural progression of the release cycle. + +## Release Promotion Workflow + +```text +1.4.3-alpha (released) + ↓ + Creates: 1.4.3-beta, 1.5.0-alpha + Closes: older 1.x.x-beta milestones + ↓ + (30 days, no issues reported) + ↓ +1.4.3-beta (released) + ↓ + Creates: 1.4.3-rc + Closes: older 1.x.x-rc milestones + ↓ + (30 days, no issues reported) + ↓ +1.4.3-rc (released) + ↓ + Creates: 1.4.3 (stable) + Closes: older 1.x.x (stable) milestones + ↓ + (Stable release complete) +``` + +## Manual Control + +You can manually control promotion paths by: + +1. **Closing a milestone** → Stops its promotion path +2. **Reopening a milestone** → Resumes its promotion path +3. **Deleting a milestone** → Removes it entirely (issues migrate to next version) + +## Scope Principle + +**All milestone operations are scoped to the same major version:** + +- Closing older milestones only affects milestones with the same major version +- Different major versions maintain independent milestone hierarchies +- This allows parallel development of multiple major versions + +## Implementation Details + +### Files Modified + +- `.github/actions/versioning/manage-milestones/action.yml` - Creates/closes milestones +- `.github/actions/versioning/move-milestone-items/action.yml` - Migrates issues +- `.github/workflows/milestone-management.yml` - Orchestrates the workflow + +### Key Functions + +**`closeOlderMilestones(suffix, majorVersion)`** + +- Filters milestones by suffix AND major version +- Sorts by minor.patch descending +- Closes all but the latest + +**`move-milestone-items` action** + +- Triggered on milestone closure +- Determines target milestone based on closed milestone type +- Migrates all open issues/PRs + +## Examples + +### Example 1: Parallel v1 and v2 Development + +```text +Release: 1.4.3-alpha + → Creates: 1.4.3-beta, 1.5.0-alpha + → Closes: 1.4.2-beta (if exists) + +Release: 2.0.0-alpha (same day) + → Creates: 2.0.0-beta, 2.1.0-alpha + → Closes: (no older 2.x.x-beta) + +Result: Both 1.4.3-beta and 2.0.0-beta coexist ✓ +``` + +### Example 2: Multiple Alphas + +```text +Open Milestones: + 1.4.0-alpha + 1.4.1-alpha + 1.4.2-alpha + 1.4.3-alpha ← Latest + +All remain open. No closure happens for alphas. +When 1.4.3-alpha closes, issues migrate to 1.5.0-alpha. +``` + +### Example 3: Beta Progression + +```text +Release: 1.4.3-beta + → Creates: 1.4.3-rc + → Closes: 1.4.2-beta, 1.4.1-beta, 1.4.0-beta + → Keeps: 1.4.3-beta (the newly created one) + → Issues from 1.4.3-beta migrate to 1.5.0-alpha + +Result: Only 1.4.3-beta remains open in v1 ✓ +``` + +## Troubleshooting + +### Issue: Old milestone not closing + +**Cause:** Different major version +**Solution:** Check if the old milestone has a different major version. This is expected behavior. + +### Issue: Milestone not created + +**Cause:** Already exists or invalid version format +**Solution:** Check logs for "already exists" message. Verify version format is `X.Y.Z` or `X.Y.Z-suffix`. + +### Issue: Issues not migrating + +**Cause:** No target milestone exists +**Solution:** The action creates the target milestone automatically. Check logs for creation status. diff --git a/.github/workflows/RELEASE_WORKFLOW.md b/.github/workflows/RELEASE_WORKFLOW.md index 16544107..1f3d5050 100644 --- a/.github/workflows/RELEASE_WORKFLOW.md +++ b/.github/workflows/RELEASE_WORKFLOW.md @@ -4,13 +4,29 @@ This guide explains the standard release process for SmartHopper, which is trigg ## Overview -The regular release workflow allows you to: +The release workflow supports two paths: -- Release planned features and improvements from the `dev` branch -- Automatically prepare release documentation and version updates -- Create a structured PR flow: `release/*` → `dev` → `main` -- Build and publish to GitHub Releases and Yak package manager -- Maintain clean version history with milestone-based releases +1. **Regular Releases**: Planned releases from milestones (dev → main) +2. **Promotion Releases**: Automatic stage progression (alpha → beta → rc → stable) + +## Regular Release Flow + +Triggered manually via `release-1-milestone.yml` when a milestone is ready to release: + +1. **Manual trigger** with milestone title (e.g., `1.4.3-alpha`) +2. Creates `release/X.Y.Z-stage` branch from `dev` +3. Updates version, changelog, badges +4. Creates PR to `dev` → merge → PR to `main` → merge +5. Publishes release and builds artifacts + +## Promotion Release Flow + +Automatic stage progression after 30 days with no issues: + +1. Daily cron job checks all prerelease versions (alpha/beta/rc) +2. If no open issues for 30 days → promotes to next stage +3. Creates promotion PR (e.g., `1.4.3-alpha` → `1.4.3-beta`) +4. On merge, release-1-milestone can be triggered for the new version ## When to Use Regular Releases @@ -19,7 +35,7 @@ Use the regular release workflow for: - **Planned feature releases** with multiple changes - **Minor/major version bumps** (X.Y.0 or X.0.0) - **Milestone completions** with grouped issues/PRs -- **Scheduled releases** following your development cycle +- **Manual release of any version** from an existing milestone **Do NOT use for:** @@ -36,13 +52,14 @@ Use the regular release workflow for: 3. Associate PRs and issues with a milestone (e.g., `1.2.0`) 4. Ensure all changes update `CHANGELOG.md` under `[Unreleased]` -### Step 2: Close the Milestone +### Step 2: Trigger Release Preparation -1. Go to **Issues** → **Milestones** -2. Ensure all issues/PRs are completed -3. Click **Close milestone** for the target version (e.g., `1.2.0`) +1. Go to **Actions** → **🏁 1 Prepare Release Branch** +2. Click **Run workflow** +3. Enter the **milestone title** (e.g., `1.2.0` or `1.4.3-alpha`) +4. Click **Run workflow** -**This automatically triggers:** 🏁 1 Prepare Release on Milestone Close +**This automatically triggers:** 🏁 1 Prepare Release Branch ### Step 3: Automatic Release Preparation (Workflow 1) @@ -119,14 +136,21 @@ The workflow automatically: 3. Review the release notes 4. Click **Publish release** -### Step 10: Upload to Yak (Manual) +### Step 10: Upload to Yak (Automatic for Stable Releases) + +**For stable releases (X.Y.Z without prerelease suffix):** +- Automatically triggered when release is published +- Uploads both Windows and Mac packages to production Yak server +- No manual action required -1. Go to **Actions** → **🚀 5 Upload to Yak Rhino Server** +**For prerelease versions (alpha/beta/rc) or manual override:** +1. Go to **Actions** → **🚀 6 Upload to Yak Rhino Server** 2. Click **Run workflow** 3. Configure: - **Version**: Leave empty (uses main branch version) or specify + - **Platform**: Select `both` (default) - **Confirm upload to Yak**: Check this box - - **Just testing**: Uncheck for production, check for test server + - **Just testing**: Check for test server, uncheck for production 4. Click **Run workflow** The workflow will: @@ -148,11 +172,13 @@ Version is determined by the milestone title (e.g., milestone `1.2.0` → releas ## Workflow Files -- **release-1-milestone.yml** - Prepares release branch when milestone closes +- **release-1-milestone.yml** - Manually prepares release branch for a milestone +- **release-promotion.yml** - Automatically promotes versions (alpha→beta→rc→stable) - **release-2-pr-to-dev-closed.yml** - Creates PR from dev to main - **release-3-pr-to-main-closed.yml** - Creates GitHub Release (draft) - **release-4-build.yml** - Builds and uploads artifacts -- **release-5-upload-yak.yml** - Uploads to Yak package manager +- **release-6-upload-yak.yml** - Uploads to Yak package manager (manual) +- **release-auto-upload-yak.yml** - Auto-uploads to Yak for stable releases ## Validations @@ -169,7 +195,27 @@ All PRs (release → dev, dev → main) run: - **main**: Protected branch, requires PR reviews - **release/\***: Temporary branches, deleted after merge -## Example Scenario +### Promotion Release Example + +**Scenario**: Version `1.4.3-alpha` has been stable for 30 days with no issues. + +**Process:** + +1. **Daily cron** checks `1.4.3-alpha` milestone - no open issues +2. **release-promotion.yml** creates PR: `release/1.4.3-beta` → `dev` +3. Review and merge PR to `dev` +4. **Workflow 2** creates PR from `dev` to `main` +5. Review and merge PR to `main` +6. **Workflow 3** creates draft release `1.4.3-beta` +7. **Workflow 4** builds and uploads artifacts +8. Publish release +9. **manage-milestones** action: + - Creates `1.4.3-rc` milestone (next stage) + - Creates `1.5.0-alpha` milestone (next minor) + - Closes older `1.x.x-beta` milestones +10. Run **Workflow 5** to upload to Yak + +### Regular Release Example **Goal:** Release version `1.2.0` with new AI features. diff --git a/.github/workflows/release-auto-upload-yak.yml b/.github/workflows/release-auto-upload-yak.yml new file mode 100644 index 00000000..a624d6c2 --- /dev/null +++ b/.github/workflows/release-auto-upload-yak.yml @@ -0,0 +1,61 @@ +name: 🚀 Auto Upload to Yak on Stable Release + +# Description: Automatically uploads release artifacts to Yak when a non-prerelease +# release is published. +# +# Prerequisites: +# - A YAK_AUTH_TOKEN secret must be configured in your repository settings +# +# Triggers: +# - Automatically when a release is published and is NOT a prerelease +# +# Permissions: +# - contents:read - Required to read repository content + +on: + release: + types: [published] + +jobs: + check-and-upload: + # Only run for non-prerelease releases + if: ${{ !github.event.release.prerelease }} + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + + steps: + - name: Extract version from release + id: extract_version + run: | + # Extract version from release tag (remove 'v' prefix if present) + tag="${{ github.event.release.tag_name }}" + version="${tag#v}" + echo "version=$version" >> $GITHUB_OUTPUT + echo "Detected stable release version: $version" + + - name: Trigger Yak Upload Workflow + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const version = '${{ steps.extract_version.outputs.version }}'; + + console.log(`Triggering Yak upload for non-prerelease release ${version}`); + + // Trigger the yak upload workflow + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'release-6-upload-yak.yml', + ref: 'main', + inputs: { + version: version, + platform: 'both', + upload_to_yak: 'true', + testing: 'false' + } + }); + + console.log('✅ Yak upload workflow triggered successfully'); From f263f72d4b2a2a696885e1b20c5506378a27d4b9 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:57:16 +0100 Subject: [PATCH 008/110] ci: enhance promotion eligibility checks with comprehensive validation criteria --- .../check-issues-for-version/action.yml | 191 ++++++++++++++++-- .github/workflows/RELEASE_WORKFLOW.md | 25 ++- .github/workflows/release-promotion.yml | 73 ++++--- 3 files changed, 239 insertions(+), 50 deletions(-) diff --git a/.github/actions/versioning/check-issues-for-version/action.yml b/.github/actions/versioning/check-issues-for-version/action.yml index 5908ce22..e2aa9c7f 100644 --- a/.github/actions/versioning/check-issues-for-version/action.yml +++ b/.github/actions/versioning/check-issues-for-version/action.yml @@ -1,5 +1,5 @@ name: 'Check Issues for Version' -description: 'Check if any open issues exist with a specific version label within a time period' +description: 'Check promotion eligibility: milestone status, release age, and open issues with version label (all stages)' inputs: version: description: 'Version to check for issues (e.g., 1.4.2-alpha)' @@ -23,6 +23,30 @@ outputs: issues-list: description: 'Comma-separated list of issue numbers' value: ${{ steps.check-issues.outputs.issues_list }} + release-date: + description: 'Release published date (ISO 8601)' + value: ${{ steps.check-issues.outputs.release_date }} + release-age-days: + description: 'Number of days since release was published' + value: ${{ steps.check-issues.outputs.release_age_days }} + release-old-enough: + description: 'Whether release is at least 30 days old' + value: ${{ steps.check-issues.outputs.release_old_enough }} + last-closed-issue-date: + description: 'Date of last closed issue with this version label (ISO 8601)' + value: ${{ steps.check-issues.outputs.last_closed_issue_date }} + last-closed-age-days: + description: 'Number of days since last issue was closed' + value: ${{ steps.check-issues.outputs.last_closed_age_days }} + last-closed-old-enough: + description: 'Whether last closed issue is at least 30 days old' + value: ${{ steps.check-issues.outputs.last_closed_old_enough }} + can-promote: + description: 'Whether all conditions are met for promotion (milestone clear, release old enough, no recent closed issues)' + value: ${{ steps.check-issues.outputs.can_promote }} + blocking-reason: + description: 'Reason why promotion is blocked (if can-promote is false)' + value: ${{ steps.check-issues.outputs.blocking_reason }} runs: using: "composite" @@ -38,53 +62,180 @@ runs: $daysLookback = [int]"${{ inputs.days-lookback }}" $repo = "${{ github.repository }}" - Write-Host "Checking for issues with label: $labelName" - Write-Host "Looking back $daysLookback days" + Write-Host "=== Promotion Eligibility Check for $version ===" + Write-Host "" $headers = @{ "Authorization" = "Bearer $env:GITHUB_TOKEN" "Accept" = "application/vnd.github.v3+json" } - # Calculate cutoff date - $cutoffDate = (Get-Date).AddDays(-$daysLookback).ToString("yyyy-MM-dd") + $repoOwner = $repo.Split('/')[0] + $repoName = $repo.Split('/')[1] + $canPromote = $true + $blockingReasons = @() - # Use Search API with created date filter for accurate "created since" semantics - # This is more accurate than the REST API's 'since' parameter which filters by updated_at - $issues = @() + # ============================================================ + # CHECK 1: Open issues with version label (any stage) + # ============================================================ + Write-Host "CHECK 1: Looking for ALL open issues with version label (base + all stages)" + + # Parse version to get base version (X.Y.Z without stage suffix) + if ($version -match '^(\d+\.\d+\.\d+)') { + $baseVersion = $matches[1] + Write-Host " Base version: $baseVersion" + } else { + Write-Host " ⚠️ Could not parse base version from '$version'" + $baseVersion = $version + } + + # Search for all open issues with labels matching the base version (any stage) + # This will match: version: X.Y.Z, version: X.Y.Z-alpha, version: X.Y.Z-beta, etc. + $openIssues = @() $page = 1 $perPage = 100 do { - # Search API query: state:open label:"version: X.Y.Z" created:>=YYYY-MM-DD - $query = "state:open label:""$labelName"" created:>=$cutoffDate repo:$repo" + # Search for issues with labels starting with "version: X.Y.Z" + $query = "state:open label:""version: $baseVersion"" repo:$repo" $uri = "https://api.github.com/search/issues?q=$([Uri]::EscapeDataString($query))&per_page=$perPage&page=$page" try { $response = Invoke-RestMethod -Uri $uri -Headers $headers -Method GET - $issues += $response.items + $openIssues += $response.items if ($response.items.Count -lt $perPage) { break } $page++ } catch { - Write-Error "Failed to fetch issues: $($_.Exception.Message)" + Write-Error "Failed to fetch open issues: $($_.Exception.Message)" exit 1 } } while ($response.items.Count -eq $perPage) - $issueCount = $issues.Count - $issueNumbers = ($issues | ForEach-Object { $_.number }) -join "," + $openIssueCount = $openIssues.Count + $openIssueNumbers = ($openIssues | ForEach-Object { $_.number }) -join "," - Write-Host "Found $issueCount open issues with label '$labelName'" - - if ($issueCount -gt 0) { - Write-Host "Issues: $issueNumbers" + if ($openIssueCount -gt 0) { + Write-Host " ❌ Found $openIssueCount open issue(s): $openIssueNumbers" + Write-Host " (Issues discovered in version $baseVersion line, any stage)" echo "has_issues=true" >> $env:GITHUB_OUTPUT + $canPromote = $false + $blockingReasons += "version_label_has_open_issues" } else { + Write-Host " ✅ No open issues with version label (checked all stages)" echo "has_issues=false" >> $env:GITHUB_OUTPUT } - echo "issue_count=$issueCount" >> $env:GITHUB_OUTPUT - echo "issues_list=$issueNumbers" >> $env:GITHUB_OUTPUT + echo "issue_count=$openIssueCount" >> $env:GITHUB_OUTPUT + echo "issues_list=$openIssueNumbers" >> $env:GITHUB_OUTPUT + Write-Host "" + + # (CHECK 2 removed: target milestone check was redundant with CHECK 1. + # Bugs from alpha labeled 'version: 1.4.3-alpha' are caught by CHECK 1. + # Checking the beta milestone also incorrectly blocks on planned features.) + + # ============================================================ + # CHECK 3: Release age (must be >= 30 days old) + # ============================================================ + Write-Host "CHECK 3: Checking release age" + + try { + # Get release by tag + $releaseUri = "https://api.github.com/repos/$repo/releases/tags/$version" + $release = Invoke-RestMethod -Uri $releaseUri -Headers $headers -Method GET + + $publishedAt = [DateTime]::Parse($release.published_at) + $now = Get-Date + $ageDays = ($now - $publishedAt).Days + $releaseOldEnough = $ageDays -ge $daysLookback + + Write-Host " Release published: $($publishedAt.ToString('yyyy-MM-dd'))" + Write-Host " Age: $ageDays days" + + if ($releaseOldEnough) { + Write-Host " ✅ Release is old enough (>= $daysLookback days)" + echo "release_old_enough=true" >> $env:GITHUB_OUTPUT + } else { + Write-Host " ❌ Release is too recent (< $daysLookback days)" + echo "release_old_enough=false" >> $env:GITHUB_OUTPUT + $canPromote = $false + $blockingReasons += "release_too_recent" + } + + echo "release_date=$($publishedAt.ToString('yyyy-MM-ddTHH:mm:ssZ'))" >> $env:GITHUB_OUTPUT + echo "release_age_days=$ageDays" >> $env:GITHUB_OUTPUT + } catch { + Write-Warning "Failed to fetch release: $($_.Exception.Message)" + echo "release_date=unknown" >> $env:GITHUB_OUTPUT + echo "release_age_days=-1" >> $env:GITHUB_OUTPUT + echo "release_old_enough=unknown" >> $env:GITHUB_OUTPUT + } + Write-Host "" + + # ============================================================ + # CHECK 4: Last closed issue age (must be >= 30 days old) + # ============================================================ + Write-Host "CHECK 4: Checking last closed issue with label '$labelName'" + + try { + # Search for closed issues with version label, sorted by closed date (most recent first) + $query = "state:closed label:""$labelName"" repo:$repo" + $closedIssuesUri = "https://api.github.com/search/issues?q=$([Uri]::EscapeDataString($query))&sort=updated&order=desc&per_page=1" + $closedResponse = Invoke-RestMethod -Uri $closedIssuesUri -Headers $headers -Method GET + + if ($closedResponse.total_count -gt 0 -and $closedResponse.items.Count -gt 0) { + $lastClosedIssue = $closedResponse.items[0] + $closedAt = [DateTime]::Parse($lastClosedIssue.closed_at) + $now = Get-Date + $closedAgeDays = ($now - $closedAt).Days + $closedOldEnough = $closedAgeDays -ge $daysLookback + + Write-Host " Last closed issue: #$($lastClosedIssue.number)" + Write-Host " Closed: $($closedAt.ToString('yyyy-MM-dd'))" + Write-Host " Age: $closedAgeDays days" + + if ($closedOldEnough) { + Write-Host " ✅ Last closed issue is old enough (>= $daysLookback days)" + echo "last_closed_old_enough=true" >> $env:GITHUB_OUTPUT + } else { + Write-Host " ❌ Last closed issue is too recent (< $daysLookback days)" + echo "last_closed_old_enough=false" >> $env:GITHUB_OUTPUT + $canPromote = $false + $blockingReasons += "last_closed_too_recent" + } + + echo "last_closed_issue_date=$($closedAt.ToString('yyyy-MM-ddTHH:mm:ssZ'))" >> $env:GITHUB_OUTPUT + echo "last_closed_age_days=$closedAgeDays" >> $env:GITHUB_OUTPUT + } else { + Write-Host " ✅ No closed issues found with version label" + echo "last_closed_issue_date=none" >> $env:GITHUB_OUTPUT + echo "last_closed_age_days=-1" >> $env:GITHUB_OUTPUT + echo "last_closed_old_enough=true" >> $env:GITHUB_OUTPUT + } + } catch { + Write-Warning "Failed to check closed issues: $($_.Exception.Message)" + echo "last_closed_issue_date=unknown" >> $env:GITHUB_OUTPUT + echo "last_closed_age_days=-1" >> $env:GITHUB_OUTPUT + echo "last_closed_old_enough=unknown" >> $env:GITHUB_OUTPUT + } + Write-Host "" + + # ============================================================ + # FINAL DECISION + # ============================================================ + Write-Host "=== PROMOTION DECISION ===" + + if ($canPromote) { + Write-Host "✅ ALL CONDITIONS MET - Can promote $version" + echo "can_promote=true" >> $env:GITHUB_OUTPUT + echo "blocking_reason=none" >> $env:GITHUB_OUTPUT + } else { + $reasonsText = $blockingReasons -join ", " + Write-Host "❌ BLOCKED - Cannot promote $version" + Write-Host " Reasons: $reasonsText" + echo "can_promote=false" >> $env:GITHUB_OUTPUT + echo "blocking_reason=$reasonsText" >> $env:GITHUB_OUTPUT + } + Write-Host "" diff --git a/.github/workflows/RELEASE_WORKFLOW.md b/.github/workflows/RELEASE_WORKFLOW.md index 1f3d5050..fca16cf2 100644 --- a/.github/workflows/RELEASE_WORKFLOW.md +++ b/.github/workflows/RELEASE_WORKFLOW.md @@ -21,11 +21,14 @@ Triggered manually via `release-1-milestone.yml` when a milestone is ready to re ## Promotion Release Flow -Automatic stage progression after 30 days with no issues: +Automatic stage progression when ALL conditions are met: 1. Daily cron job checks all prerelease versions (alpha/beta/rc) -2. If no open issues for 30 days → promotes to next stage -3. Creates promotion PR (e.g., `1.4.3-alpha` → `1.4.3-beta`) +2. **Promotion requires ALL three conditions**: + - ✅ **No open issues with version label** (any stage of base version, e.g., `version: 1.4.3-alpha`) + - ✅ **Original release published at least 30 days ago** + - ✅ **Last closed issue with original version label at least 30 days ago** +3. If all conditions met → creates promotion PR (e.g., `1.4.3-alpha` → `1.4.3-beta`) 4. On merge, release-1-milestone can be triggered for the new version ## When to Use Regular Releases @@ -197,11 +200,17 @@ All PRs (release → dev, dev → main) run: ### Promotion Release Example -**Scenario**: Version `1.4.3-alpha` has been stable for 30 days with no issues. +**Scenario**: Version `1.4.3-alpha` meets all promotion criteria. + +**Validation Checks (checking `1.4.3-alpha` for promotion to `1.4.3-beta`):** + +1. ✅ **Version label issues**: No open issues labeled `version: 1.4.3` or `version: 1.4.3-alpha` +2. ✅ **Release age**: `1.4.3-alpha` published 35 days ago (≥30 days required) +3. ✅ **Last closed issue**: Last issue labeled `version: 1.4.3-alpha` closed 32 days ago (≥30 days required) **Process:** -1. **Daily cron** checks `1.4.3-alpha` milestone - no open issues +1. **Daily cron** validates `1.4.3-alpha` against all three conditions 2. **release-promotion.yml** creates PR: `release/1.4.3-beta` → `dev` 3. Review and merge PR to `dev` 4. **Workflow 2** creates PR from `dev` to `main` @@ -215,6 +224,12 @@ All PRs (release → dev, dev → main) run: - Closes older `1.x.x-beta` milestones 10. Run **Workflow 5** to upload to Yak +**Blocking Scenarios** (promotion will NOT happen): + +- ❌ Any open issue labeled `version: 1.4.3` or `version: 1.4.3-alpha` (bugs still unresolved) +- ❌ `1.4.3-alpha` release published < 30 days ago +- ❌ Last issue with `version: 1.4.3-alpha` label closed < 30 days ago + ### Regular Release Example **Goal:** Release version `1.2.0` with new AI features. diff --git a/.github/workflows/release-promotion.yml b/.github/workflows/release-promotion.yml index 8e879e06..87d23149 100644 --- a/.github/workflows/release-promotion.yml +++ b/.github/workflows/release-promotion.yml @@ -152,31 +152,36 @@ jobs: id: should-promote shell: pwsh run: | - $hasIssues = "${{ steps.check-issues.outputs.has-issues }}" -eq "true" + $canPromote = "${{ steps.check-issues.outputs.can-promote }}" -eq "true" $forcePromote = "${{ github.event.inputs.force-promote }}" -eq "true" - $issueCount = "${{ steps.check-issues.outputs.issue-count }}" + $blockingReason = "${{ steps.check-issues.outputs.blocking-reason }}" $version = "${{ matrix.version }}" - Write-Host "Checking promotion for version: $version" - Write-Host "Has issues: $hasIssues" - Write-Host "Issue count: $issueCount" - Write-Host "Force promote: $forcePromote" - - # Check if version is old enough (at least 30 days since release) - # This is a basic check - in a full implementation you'd query the release date + Write-Host "=== Promotion Decision for $version ===" + Write-Host "" + Write-Host "Validation Results:" + Write-Host " • Milestone open issues: ${{ steps.check-issues.outputs.milestone-open-count }}" + Write-Host " • Release age: ${{ steps.check-issues.outputs.release-age-days }} days" + Write-Host " • Last closed issue age: ${{ steps.check-issues.outputs.last-closed-age-days }} days" + Write-Host " • Can promote: $canPromote" + if (-not $canPromote) { + Write-Host " • Blocking reason: $blockingReason" + } + Write-Host " • Force promote: $forcePromote" + Write-Host "" if ($forcePromote) { - Write-Host "⚠️ Force promotion enabled - promoting despite $issueCount issues" + Write-Host "⚠️ FORCE PROMOTION ENABLED - Bypassing all validation checks" echo "should_promote=true" >> $env:GITHUB_OUTPUT echo "reason=force_promote" >> $env:GITHUB_OUTPUT - } elseif (-not $hasIssues) { - Write-Host "✅ No issues reported for 30 days - ready for promotion" + } elseif ($canPromote) { + Write-Host "✅ ALL CONDITIONS MET - Promoting $version" echo "should_promote=true" >> $env:GITHUB_OUTPUT - echo "reason=no_issues" >> $env:GITHUB_OUTPUT + echo "reason=all_conditions_met" >> $env:GITHUB_OUTPUT } else { - Write-Host "⏸️ $issueCount issues still open - deferring promotion" + Write-Host "❌ PROMOTION BLOCKED - $blockingReason" echo "should_promote=false" >> $env:GITHUB_OUTPUT - echo "reason=issues_exist" >> $env:GITHUB_OUTPUT + echo "reason=$blockingReason" >> $env:GITHUB_OUTPUT } - name: Promote version @@ -198,17 +203,35 @@ jobs: - name: Summary if: always() run: | - echo "## Promotion Check for ${{ matrix.version }}" + echo "## 🔄 Promotion Check for ${{ matrix.version }}" + echo "" + echo "### Validation Checks" echo "" - echo "- Has Issues: ${{ steps.check-issues.outputs.has-issues }}" - echo "- Issue Count: ${{ steps.check-issues.outputs.issue-count }}" - echo "- Should Promote: ${{ steps.should-promote.outputs.should-promote }}" - echo "- Reason: ${{ steps.should-promote.outputs.reason }}" + echo "| Check | Status | Details |" + echo "|-------|--------|---------|" + echo "| **Version Label Issues** | ${{ steps.check-issues.outputs.has-issues == 'false' && '✅ Pass' || '❌ Fail' }} | ${{ steps.check-issues.outputs.issue-count }} open issue(s) with label |" + echo "| **Release Age** | ${{ steps.check-issues.outputs.release-old-enough == 'true' && '✅ Pass' || '❌ Fail' }} | ${{ steps.check-issues.outputs.release-age-days }} days (min: 30) |" + echo "| **Last Closed Issue** | ${{ steps.check-issues.outputs.last-closed-old-enough == 'true' && '✅ Pass' || '❌ Fail' }} | ${{ steps.check-issues.outputs.last-closed-age-days }} days since last close |" + echo "" + echo "### Decision" + echo "" + echo "- **Can Promote:** ${{ steps.check-issues.outputs.can-promote }}" + echo "- **Should Promote:** ${{ steps.should-promote.outputs.should-promote }}" + echo "- **Reason:** ${{ steps.should-promote.outputs.reason }}" + if [ "${{ steps.check-issues.outputs.can-promote }}" != "true" ]; then + echo "- **Blocking Reason:** ${{ steps.check-issues.outputs.blocking-reason }}" + fi echo "" if [ "${{ steps.should-promote.outputs.should-promote }}" == "true" ]; then - echo "- ✅ Promoted: ${{ matrix.version }} → ${{ steps.promote.outputs.new-version }}" - echo "- Previous Stage: ${{ steps.promote.outputs.previous-stage }}" - echo "- New Stage: ${{ steps.promote.outputs.new-stage }}" - echo "- PR Created: ${{ steps.promote.outputs.pr-created }}" - echo "- PR Number: ${{ steps.promote.outputs.pr-number }}" + echo "### ✅ Promotion Successful" + echo "" + echo "- **Promoted:** ${{ matrix.version }} → ${{ steps.promote.outputs.new-version }}" + echo "- **Previous Stage:** ${{ steps.promote.outputs.previous-stage }}" + echo "- **New Stage:** ${{ steps.promote.outputs.new-stage }}" + echo "- **PR Created:** ${{ steps.promote.outputs.pr-created }}" + echo "- **PR Number:** #${{ steps.promote.outputs.pr-number }}" + else + echo "### ❌ Promotion Blocked" + echo "" + echo "Version ${{ matrix.version }} cannot be promoted at this time." fi From b398f3e4e2388c6abf0cdd2e54dc5dceeaa06771 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:05:21 +0100 Subject: [PATCH 009/110] ci: remove milestone check from promotion eligibility to allow promotion of closed milestone releases --- .github/workflows/release-promotion.yml | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release-promotion.yml b/.github/workflows/release-promotion.yml index 87d23149..3a2d71ac 100644 --- a/.github/workflows/release-promotion.yml +++ b/.github/workflows/release-promotion.yml @@ -68,20 +68,6 @@ jobs: }; } - // Get open milestones (paginate to ensure we fetch all) - const milestones = await github.paginate( - github.rest.issues.listMilestones, - { - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - per_page: 100 - } - ); - - const openMilestoneVersions = new Set(milestones.map(m => m.title)); - console.log('Open milestones:', Array.from(openMilestoneVersions)); - const { data: releases } = await github.rest.repos.listReleases({ owner: context.repo.owner, repo: context.repo.repo, @@ -95,12 +81,11 @@ jobs: // Use specific version from input versionsToCheck = [specificVersion]; } else { - // Filter for alpha, beta, rc releases (not stable) that have open milestones + // Filter for alpha, beta, rc releases (not stable) + // Note: We don't check for open milestones because milestones are closed at release time const preReleases = releases.filter(r => { const tag = r.tag_name; - const isPreRelease = tag.includes('-alpha') || tag.includes('-beta') || tag.includes('-rc'); - const hasOpenMilestone = openMilestoneVersions.has(tag); - return isPreRelease && hasOpenMilestone; + return tag.includes('-alpha') || tag.includes('-beta') || tag.includes('-rc'); }); // Group by base version and suffix, keep only latest of each suffix From b6838c85c77226bd0e073c7c683749abf8476546 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:11:23 +0100 Subject: [PATCH 010/110] ci: fix version grouping to use minor version and standardize PowerShell output commands --- .github/workflows/release-promotion.yml | 28 +++++++++++++------------ 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release-promotion.yml b/.github/workflows/release-promotion.yml index 3a2d71ac..5eb12a43 100644 --- a/.github/workflows/release-promotion.yml +++ b/.github/workflows/release-promotion.yml @@ -88,22 +88,24 @@ jobs: return tag.includes('-alpha') || tag.includes('-beta') || tag.includes('-rc'); }); - // Group by base version and suffix, keep only latest of each suffix - const versionsByBaseAndSuffix = {}; + // Group by minor version and suffix, keep only latest patch of each + const versionsByMinorAndSuffix = {}; for (const release of preReleases) { const parsed = parseVersion(release.tag_name); if (!parsed) continue; - const baseKey = `${parsed.major}.${parsed.minor}.${parsed.patch}`; - const suffixKey = `${baseKey}:${parsed.suffix}`; + // Group by major.minor (not patch) + suffix + const minorKey = `${parsed.major}.${parsed.minor}`; + const suffixKey = `${minorKey}:${parsed.suffix}`; - if (!versionsByBaseAndSuffix[suffixKey]) { - versionsByBaseAndSuffix[suffixKey] = release.tag_name; + // Keep first occurrence (releases are sorted newest first) + if (!versionsByMinorAndSuffix[suffixKey]) { + versionsByMinorAndSuffix[suffixKey] = release.tag_name; } } - versionsToCheck = Object.values(versionsByBaseAndSuffix); + versionsToCheck = Object.values(versionsByMinorAndSuffix); } console.log('Versions to check for promotion:', versionsToCheck); @@ -157,16 +159,16 @@ jobs: if ($forcePromote) { Write-Host "⚠️ FORCE PROMOTION ENABLED - Bypassing all validation checks" - echo "should_promote=true" >> $env:GITHUB_OUTPUT - echo "reason=force_promote" >> $env:GITHUB_OUTPUT + Add-Content -Path $env:GITHUB_OUTPUT -Value "should_promote=true" + Add-Content -Path $env:GITHUB_OUTPUT -Value "reason=force_promote" } elseif ($canPromote) { Write-Host "✅ ALL CONDITIONS MET - Promoting $version" - echo "should_promote=true" >> $env:GITHUB_OUTPUT - echo "reason=all_conditions_met" >> $env:GITHUB_OUTPUT + Add-Content -Path $env:GITHUB_OUTPUT -Value "should_promote=true" + Add-Content -Path $env:GITHUB_OUTPUT -Value "reason=all_conditions_met" } else { Write-Host "❌ PROMOTION BLOCKED - $blockingReason" - echo "should_promote=false" >> $env:GITHUB_OUTPUT - echo "reason=$blockingReason" >> $env:GITHUB_OUTPUT + Add-Content -Path $env:GITHUB_OUTPUT -Value "should_promote=false" + Add-Content -Path $env:GITHUB_OUTPUT -Value "reason=$blockingReason" } - name: Promote version From ae28638ade6f0ef50178876fbd04eb3482fe9313 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:19:24 +0100 Subject: [PATCH 011/110] ci: skip promotion for versions with closed target milestones and fix output variable naming --- .github/workflows/release-promotion.yml | 64 ++++++++++++++++++------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release-promotion.yml b/.github/workflows/release-promotion.yml index 5eb12a43..a2603b3f 100644 --- a/.github/workflows/release-promotion.yml +++ b/.github/workflows/release-promotion.yml @@ -68,11 +68,34 @@ jobs: }; } - const { data: releases } = await github.rest.repos.listReleases({ - owner: context.repo.owner, - repo: context.repo.repo, - per_page: 20 - }); + // Compute target version for a given prerelease version + function getTargetVersion(parsed) { + const base = `${parsed.major}.${parsed.minor}.${parsed.patch}`; + if (parsed.suffix === 'alpha') return `${base}-beta`; + if (parsed.suffix === 'beta') return `${base}-rc`; + if (parsed.suffix === 'rc') return base; + return null; + } + + const [{ data: releases }, allMilestones] = await Promise.all([ + github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 20 + }), + github.paginate(github.rest.issues.listMilestones, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + per_page: 100 + }) + ]); + + // Build a set of closed milestone titles for quick lookup + const closedMilestoneTitles = new Set( + allMilestones.filter(m => m.state === 'closed').map(m => m.title) + ); + console.log('Closed milestones:', Array.from(closedMilestoneTitles)); const specificVersion = '${{ github.event.inputs.version }}'; let versionsToCheck = []; @@ -82,30 +105,35 @@ jobs: versionsToCheck = [specificVersion]; } else { // Filter for alpha, beta, rc releases (not stable) - // Note: We don't check for open milestones because milestones are closed at release time const preReleases = releases.filter(r => { const tag = r.tag_name; return tag.includes('-alpha') || tag.includes('-beta') || tag.includes('-rc'); }); - // Group by minor version and suffix, keep only latest patch of each - const versionsByMinorAndSuffix = {}; + // Group by major version and suffix, keep only latest of each + // (latest = first occurrence since releases are sorted newest first) + const versionsByMajorAndSuffix = {}; for (const release of preReleases) { const parsed = parseVersion(release.tag_name); if (!parsed) continue; - // Group by major.minor (not patch) + suffix - const minorKey = `${parsed.major}.${parsed.minor}`; - const suffixKey = `${minorKey}:${parsed.suffix}`; + // Skip if target version milestone already exists and is closed (already released) + const targetVersion = getTargetVersion(parsed); + if (targetVersion && closedMilestoneTitles.has(targetVersion)) { + console.log(`Skipping ${release.tag_name}: target ${targetVersion} milestone already closed`); + continue; + } + + // Group by major + suffix only → one candidate per major per stage + const suffixKey = `${parsed.major}:${parsed.suffix}`; - // Keep first occurrence (releases are sorted newest first) - if (!versionsByMinorAndSuffix[suffixKey]) { - versionsByMinorAndSuffix[suffixKey] = release.tag_name; + if (!versionsByMajorAndSuffix[suffixKey]) { + versionsByMajorAndSuffix[suffixKey] = release.tag_name; } } - versionsToCheck = Object.values(versionsByMinorAndSuffix); + versionsToCheck = Object.values(versionsByMajorAndSuffix); } console.log('Versions to check for promotion:', versionsToCheck); @@ -159,15 +187,15 @@ jobs: if ($forcePromote) { Write-Host "⚠️ FORCE PROMOTION ENABLED - Bypassing all validation checks" - Add-Content -Path $env:GITHUB_OUTPUT -Value "should_promote=true" + Add-Content -Path $env:GITHUB_OUTPUT -Value "should-promote=true" Add-Content -Path $env:GITHUB_OUTPUT -Value "reason=force_promote" } elseif ($canPromote) { Write-Host "✅ ALL CONDITIONS MET - Promoting $version" - Add-Content -Path $env:GITHUB_OUTPUT -Value "should_promote=true" + Add-Content -Path $env:GITHUB_OUTPUT -Value "should-promote=true" Add-Content -Path $env:GITHUB_OUTPUT -Value "reason=all_conditions_met" } else { Write-Host "❌ PROMOTION BLOCKED - $blockingReason" - Add-Content -Path $env:GITHUB_OUTPUT -Value "should_promote=false" + Add-Content -Path $env:GITHUB_OUTPUT -Value "should-promote=false" Add-Content -Path $env:GITHUB_OUTPUT -Value "reason=$blockingReason" } From eb59bb56b21ffd7af138d504a413c491afbc087f Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:22:54 +0100 Subject: [PATCH 012/110] ci: standardize PowerShell output commands and improve PR URL parsing in version promotion workflow --- .../actions/versioning/promote-version/action.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/actions/versioning/promote-version/action.yml b/.github/actions/versioning/promote-version/action.yml index 988dd435..6c4cf303 100644 --- a/.github/actions/versioning/promote-version/action.yml +++ b/.github/actions/versioning/promote-version/action.yml @@ -152,19 +152,18 @@ runs: *This PR was automatically generated by the release promotion workflow.* "@ - # Create PR - $prJson = gh pr create ` + # Create PR (gh pr create outputs the PR URL on success) + $prUrl = gh pr create ` --base dev ` --head "release/$newVersion" ` --title "chore: promote version to $newVersion ($previousStage -> $newStage)" ` - --body $prBody ` - --json number + --body $prBody if ($LASTEXITCODE -eq 0) { - $prNumber = ($prJson | ConvertFrom-Json).number - Write-Host "Created PR #$prNumber" - echo "pr_created=true" >> $env:GITHUB_OUTPUT - echo "pr_number=$prNumber" >> $env:GITHUB_OUTPUT + $prNumber = $prUrl -replace '.*\/pull\/(\d+).*', '$1' + Write-Host "Created PR #$prNumber ($prUrl)" + "pr_created=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "pr_number=$prNumber" | Out-File -FilePath $env:GITHUB_OUTPUT -Append # Auto-merge if requested if ("${{ inputs.auto-merge }}" -eq "true") { From 98fda0ec1ef72e27d037964123659cf089ec1664 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:27:32 +0100 Subject: [PATCH 013/110] ci: prevent older versions from being promoted when target milestone is closed --- .github/workflows/release-promotion.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-promotion.yml b/.github/workflows/release-promotion.yml index a2603b3f..80c7d4f7 100644 --- a/.github/workflows/release-promotion.yml +++ b/.github/workflows/release-promotion.yml @@ -113,24 +113,28 @@ jobs: // Group by major version and suffix, keep only latest of each // (latest = first occurrence since releases are sorted newest first) const versionsByMajorAndSuffix = {}; + const blockedSuffixKeys = new Set(); for (const release of preReleases) { const parsed = parseVersion(release.tag_name); if (!parsed) continue; + // Group by major + suffix only → one candidate per major per stage + const suffixKey = `${parsed.major}:${parsed.suffix}`; + + // Skip if this group is already blocked or already has a candidate + if (blockedSuffixKeys.has(suffixKey) || versionsByMajorAndSuffix[suffixKey]) continue; + // Skip if target version milestone already exists and is closed (already released) + // Block the entire group so older versions in the same major:suffix don't get promoted const targetVersion = getTargetVersion(parsed); if (targetVersion && closedMilestoneTitles.has(targetVersion)) { - console.log(`Skipping ${release.tag_name}: target ${targetVersion} milestone already closed`); + console.log(`Skipping ${release.tag_name}: target ${targetVersion} milestone already closed (blocking entire ${suffixKey} group)`); + blockedSuffixKeys.add(suffixKey); continue; } - // Group by major + suffix only → one candidate per major per stage - const suffixKey = `${parsed.major}:${parsed.suffix}`; - - if (!versionsByMajorAndSuffix[suffixKey]) { - versionsByMajorAndSuffix[suffixKey] = release.tag_name; - } + versionsByMajorAndSuffix[suffixKey] = release.tag_name; } versionsToCheck = Object.values(versionsByMajorAndSuffix); From 9550eaddf3391e0f49252bad40f55154bbe88659 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:51:40 +0100 Subject: [PATCH 014/110] ci: new stabilization-aware milestone workflow --- .github/actions/versioning/README.md | 46 +++- .../versioning/manage-milestones/action.yml | 63 +---- .../move-milestone-items/action.yml | 80 ++++-- .../versioning/promote-version/action.yml | 186 ------------- .github/workflows/RELEASE_WORKFLOW.md | 138 ++++++---- .github/workflows/ci-dotnet-tests.yml | 3 + .../workflows/pr-build-hash-validation.yml | 2 + .github/workflows/pr-manifest-validation.yml | 1 + .github/workflows/pr-validation.yml | 2 + .github/workflows/pr-version-validation.yml | 2 + .github/workflows/release-1-milestone.yml | 42 ++- .../workflows/release-2-pr-to-dev-closed.yml | 23 +- .../workflows/release-3-pr-to-main-closed.yml | 20 +- .github/workflows/release-4-build.yml | 36 +++ .github/workflows/release-auto-upload-yak.yml | 61 ----- .github/workflows/release-promotion.yml | 248 +++++++++++------- .github/workflows/stabilization-0-init.yml | 142 ++++++++++ .github/workflows/stabilization-1-cancel.yml | 177 +++++++++++++ .../workflows/stabilization-2-complete.yml | 184 +++++++++++++ 19 files changed, 958 insertions(+), 498 deletions(-) delete mode 100644 .github/actions/versioning/promote-version/action.yml delete mode 100644 .github/workflows/release-auto-upload-yak.yml create mode 100644 .github/workflows/stabilization-0-init.yml create mode 100644 .github/workflows/stabilization-1-cancel.yml create mode 100644 .github/workflows/stabilization-2-complete.yml diff --git a/.github/actions/versioning/README.md b/.github/actions/versioning/README.md index dbf1b7a0..10e361f7 100644 --- a/.github/actions/versioning/README.md +++ b/.github/actions/versioning/README.md @@ -50,6 +50,16 @@ getStage("beta") // Returns: "beta" getStage(null) // Returns: "stable" ``` +#### `getNextStage(stage)` *(move-milestone-items only)* + +Returns the next stage in the stabilization promotion sequence: + +```javascript +getNextStage("alpha") // Returns: "beta" +getNextStage("beta") // Returns: "rc" +getNextStage("rc") // Returns: null (→ stable, no suffix) +``` + ### Regex Pattern All actions use this consistent regex for version parsing: @@ -100,7 +110,13 @@ Formats version components back into a semantic version string. #### `manage-milestones` -Creates next-stage milestones and manages active milestones for release versions. +Creates next-stage milestones for released versions. Does **not** close older milestones — milestone lifecycle is managed by `release-promotion.yml`. + +| Released stage | Creates | +|---|---| +| alpha | `X.Y.Z-beta` + next minor `X.Y+1.0-alpha` | +| beta | `X.Y.Z-rc` | +| rc | `X.Y.Z` (stable, no suffix) | **Shared Utilities:** @@ -110,21 +126,23 @@ Creates next-stage milestones and manages active milestones for release versions #### `move-milestone-items` -Moves open issues and PRs from closed milestones to target milestones. +Moves open issues and PRs from a closed milestone to a target milestone. + +**Stabilization-aware routing:** When closing `X.Y.Z-stage` and an open parent `X.Y.Z` (no-suffix) milestone exists, items are routed to `X.Y.Z-{nextStage}` (same series) instead of the next minor alpha. + +| Scenario | Target | +|---|---| +| Closed `X.Y.Z-stage`, parent `X.Y.Z` open | `X.Y.Z-{nextStage}` | +| Closed `X.Y.Z-stage`, no parent milestone | `X.Y+1.0-alpha` (reuse or create) | +| Closed stable `X.Y.Z` | `X.Y+1.0-alpha` (reuse or create) | **Shared Utilities:** - `parseVersion()` - Parses version strings - `formatVersion()` - Formats version objects - `getStage()` - Extracts stage from suffix +- `getNextStage()` - Returns next stabilization stage -### Version Promotion - -#### `promote-version` - -Promotes a version from one stage to the next (alpha→beta→rc→stable). - -**Uses:** `parse-version` action for consistent parsing ### Version Retrieval and Updates @@ -215,14 +233,16 @@ Extracts release stage from suffix. ### Actions Using Shared Utilities -- **manage-milestones** - Uses all three functions -- **move-milestone-items** - Uses all three functions +- **manage-milestones** - Uses `parseVersion`, `formatVersion`, `getStage` +- **move-milestone-items** - Uses `parseVersion`, `formatVersion`, `getStage`, `getNextStage` + +> `getNextStage` is only in `move-milestone-items` (stabilization path routing). It is not needed in `manage-milestones`. ### Maintenance When updating shared utilities: -1. Update the function in `manage-milestones/action.yml` +1. Update the function in `manage-milestones/action.yml` (if applicable) 2. Update the function in `move-milestone-items/action.yml` 3. Update the reference documentation in this README -4. Ensure both implementations remain identical +4. Ensure implementations remain consistent (note: `getNextStage` is only in `move-milestone-items`) diff --git a/.github/actions/versioning/manage-milestones/action.yml b/.github/actions/versioning/manage-milestones/action.yml index 4f01e4f8..0ab59103 100644 --- a/.github/actions/versioning/manage-milestones/action.yml +++ b/.github/actions/versioning/manage-milestones/action.yml @@ -1,5 +1,5 @@ name: Manage Release Milestones -description: Creates next-stage milestones and manages active milestones for release versions +description: Creates next-stage milestones for release versions inputs: released-version: @@ -13,9 +13,6 @@ outputs: created-milestones: description: 'JSON array of created milestone titles' value: ${{ steps.manage.outputs.created-milestones }} - closed-milestones: - description: 'JSON array of closed milestone titles' - value: ${{ steps.manage.outputs.closed-milestones }} runs: using: composite @@ -77,48 +74,6 @@ runs: return suffix.split('.')[0].toLowerCase(); } - // Close older milestones of the same stage within the same major version - async function closeOlderMilestones(stage, majorVersion) { - const allMilestones = await github.paginate( - github.rest.issues.listMilestones, - { - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - per_page: 100 - } - ); - - // Filter for same stage AND same major version - const sameStageMilestones = allMilestones.filter(m => { - const mParsed = parseVersion(m.title); - return mParsed && getStage(mParsed.suffix) === stage && mParsed.major === majorVersion; - }); - - sameStageMilestones.sort((a, b) => { - const aParsed = parseVersion(a.title); - const bParsed = parseVersion(b.title); - - if (aParsed.minor !== bParsed.minor) return bParsed.minor - aParsed.minor; - return bParsed.patch - aParsed.patch; - }); - - const closedTitles = []; - for (let i = 1; i < sameStageMilestones.length; i++) { - const oldMilestone = sameStageMilestones[i]; - console.log(`Closing older ${stage} milestone (major ${majorVersion}): ${oldMilestone.title}`); - - await github.rest.issues.updateMilestone({ - owner: context.repo.owner, - repo: context.repo.repo, - milestone_number: oldMilestone.number, - state: 'closed' - }); - closedTitles.push(oldMilestone.title); - } - return closedTitles; - } - const releasedVersion = '${{ inputs.released-version }}'; console.log(`Processing released version: ${releasedVersion}`); @@ -126,12 +81,10 @@ runs: if (!parsed) { console.log('Release tag is not a valid semantic version, skipping.'); core.setOutput('created-milestones', JSON.stringify([])); - core.setOutput('closed-milestones', JSON.stringify([])); return; } const createdMilestones = []; - const closedMilestones = []; // Extract stage from suffix const stage = getStage(parsed.suffix); @@ -149,10 +102,6 @@ runs: const betaResult = await createMilestone(betaVersionStr, `Auto-created milestone for ${betaVersionStr} (stability path from ${releasedVersion})`); if (betaResult.created) createdMilestones.push(betaVersionStr); - // Close older beta milestones within same major version - const closedBetas = await closeOlderMilestones('beta', parsed.major); - closedMilestones.push(...closedBetas); - // Create next minor alpha milestone const nextMinorAlpha = { major: parsed.major, @@ -175,10 +124,6 @@ runs: const rcVersionStr = formatVersion(rcVersion); const rcResult = await createMilestone(rcVersionStr, `Auto-created milestone for ${rcVersionStr} (stability path from ${releasedVersion})`); if (rcResult.created) createdMilestones.push(rcVersionStr); - - // Close older rc milestones within same major version - const closedRcs = await closeOlderMilestones('rc', parsed.major); - closedMilestones.push(...closedRcs); } // RC releases create stable milestone else if (stage === 'rc') { @@ -191,14 +136,8 @@ runs: const stableVersionStr = formatVersion(stableVersion); const stableResult = await createMilestone(stableVersionStr, `Auto-created milestone for ${stableVersionStr} (stability path from ${releasedVersion})`); if (stableResult.created) createdMilestones.push(stableVersionStr); - - // Close older stable milestones within same major version - const closedStables = await closeOlderMilestones('stable', parsed.major); - closedMilestones.push(...closedStables); } console.log(`Created milestones: ${JSON.stringify(createdMilestones)}`); - console.log(`Closed milestones: ${JSON.stringify(closedMilestones)}`); core.setOutput('created-milestones', JSON.stringify(createdMilestones)); - core.setOutput('closed-milestones', JSON.stringify(closedMilestones)); diff --git a/.github/actions/versioning/move-milestone-items/action.yml b/.github/actions/versioning/move-milestone-items/action.yml index bb5f1838..e9f8fcf4 100644 --- a/.github/actions/versioning/move-milestone-items/action.yml +++ b/.github/actions/versioning/move-milestone-items/action.yml @@ -154,9 +154,58 @@ runs: return prerelease.split('.')[0].toLowerCase(); } - // For alpha, beta, rc: move to next minor alpha + // Get next stage in stabilization path + function getNextStage(stage) { + if (stage === 'alpha') return 'beta'; + if (stage === 'beta') return 'rc'; + if (stage === 'rc') return null; // stable (no suffix) + return null; + } + + // Check whether a parent stabilization milestone (X.Y.Z, no suffix) is still open + async function findOpenStabilizationMilestone(major, minor, patch) { + const parentTitle = `${major}.${minor}.${patch}`; + const milestones = await github.paginate( + github.rest.issues.listMilestones, + { owner: context.repo.owner, repo: context.repo.repo, state: 'open', per_page: 100 } + ); + return milestones.find(m => m.title === parentTitle) || null; + } + + // For alpha, beta, rc: check if we're in a stabilization path first if (closedVersion.prerelease && closedVersion.prerelease !== 'stable') { const stage = getStage(closedVersion.prerelease); + const parentMilestone = await findOpenStabilizationMilestone( + closedVersion.major, closedVersion.minor, closedVersion.patch + ); + + if (parentMilestone) { + // Stabilization path: route to same series next stage + const nextStage = getNextStage(stage); + const nextStageSuffix = nextStage ? `-${nextStage}` : ''; + const nextStageTitle = `${closedVersion.major}.${closedVersion.minor}.${closedVersion.patch}${nextStageSuffix}`; + console.log(`Stabilization path detected (parent milestone '${parentMilestone.title}' is open). Routing to: ${nextStageTitle}`); + targetMilestone = await findMilestone(nextStageTitle); + if (!targetMilestone) { + targetMilestone = await createMilestone(nextStageTitle, `Stabilization path milestone for ${nextStageTitle}`); + } + } else { + // Normal dev path: move to next minor alpha + const nextMinorAlpha = { + major: closedVersion.major, + minor: closedVersion.minor + 1, + patch: 0, + prerelease: 'alpha' + }; + const nextMinorAlphaTitle = formatVersion(nextMinorAlpha); + console.log(`Closed milestone is ${closedVersion.prerelease} (stage: ${stage}), looking for next minor alpha: ${nextMinorAlphaTitle}`); + targetMilestone = await findMilestone(nextMinorAlphaTitle); + if (!targetMilestone) { + targetMilestone = await createMilestone(nextMinorAlphaTitle, `Next minor alpha version after ${closedMilestoneTitle}`); + } + } + } else { + // For stable: always route to next minor alpha (create if needed) const nextMinorAlpha = { major: closedVersion.major, minor: closedVersion.minor + 1, @@ -164,38 +213,11 @@ runs: prerelease: 'alpha' }; const nextMinorAlphaTitle = formatVersion(nextMinorAlpha); - - console.log(`Closed milestone is ${closedVersion.prerelease} (stage: ${stage}), looking for next minor alpha: ${nextMinorAlphaTitle}`); - + console.log(`Closed stable milestone, routing to: ${nextMinorAlphaTitle}`); targetMilestone = await findMilestone(nextMinorAlphaTitle); if (!targetMilestone) { targetMilestone = await createMilestone(nextMinorAlphaTitle, `Next minor alpha version after ${closedMilestoneTitle}`); } - } else { - // For stable: try next minor then patch - const nextMinor = { - major: closedVersion.major, - minor: closedVersion.minor + 1, - patch: 0, - prerelease: 'alpha' - }; // Stable releases default to next minor alpha - const nextMinorTitle = formatVersion(nextMinor); - - targetMilestone = await findMilestone(nextMinorTitle); - if (!targetMilestone) { - const nextPatch = { - major: closedVersion.major, - minor: closedVersion.minor, - patch: closedVersion.patch + 1, - prerelease: null - }; - const nextPatchTitle = formatVersion(nextPatch); - targetMilestone = await findMilestone(nextPatchTitle); - - if (!targetMilestone) { - targetMilestone = await createMilestone(nextMinorTitle); - } - } } console.log(`Target milestone: ${targetMilestone.title}`); diff --git a/.github/actions/versioning/promote-version/action.yml b/.github/actions/versioning/promote-version/action.yml deleted file mode 100644 index 6c4cf303..00000000 --- a/.github/actions/versioning/promote-version/action.yml +++ /dev/null @@ -1,186 +0,0 @@ -name: 'Promote Version' -description: 'Promote a version from one stage to the next (alpha→beta→rc→stable)' -inputs: - current-version: - description: 'Current version (e.g., 1.4.2-alpha)' - required: true - token: - description: 'GitHub token for API access' - required: true - default: ${{ github.token }} - auto-merge: - description: 'Auto-merge the PR after creation' - required: false - default: 'false' - -outputs: - new-version: - description: 'The promoted version' - value: ${{ steps.promote.outputs.new_version }} - previous-stage: - description: 'The previous stage (alpha/beta/rc)' - value: ${{ steps.promote.outputs.previous_stage }} - new-stage: - description: 'The new stage (beta/rc/stable)' - value: ${{ steps.promote.outputs.new_stage }} - pr-created: - description: 'Whether a PR was created' - value: ${{ steps.promote.outputs.pr_created }} - pr-number: - description: 'The PR number if created' - value: ${{ steps.promote.outputs.pr_number }} - -runs: - using: "composite" - steps: - - name: Parse current version - id: parse - uses: ./.github/actions/versioning/parse-version - with: - version: ${{ inputs.current-version }} - - - name: Determine promotion path - id: promote - shell: pwsh - run: | - $stage = "${{ steps.parse.outputs.stage }}" - $major = "${{ steps.parse.outputs.major }}" - $minor = "${{ steps.parse.outputs.minor }}" - $patch = "${{ steps.parse.outputs.patch }}" - - switch ($stage) { - 'alpha' { - $newVersion = "$major.$minor.$patch-beta" - $previousStage = 'alpha' - $newStage = 'beta' - } - 'beta' { - $newVersion = "$major.$minor.$patch-rc" - $previousStage = 'beta' - $newStage = 'rc' - } - 'rc' { - $newVersion = "$major.$minor.$patch" - $previousStage = 'rc' - $newStage = 'stable' - } - default { - Write-Error "Unknown version stage: $stage. Cannot determine promotion path." - exit 1 - } - } - - Write-Host "Promoting: ${{ inputs.current-version }} -> $newVersion" - echo "new_version=$newVersion" >> $env:GITHUB_OUTPUT - echo "previous_stage=$previousStage" >> $env:GITHUB_OUTPUT - echo "new_stage=$newStage" >> $env:GITHUB_OUTPUT - - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: dev - - - name: Set up Git user - shell: bash - run: | - git config user.name "github-actions" - git config user.email "action@github.com" - - - name: Remove existing release branch if it exists - shell: bash - run: | - NEW_VERSION="${{ steps.promote.outputs.new_version }}" - if git ls-remote --exit-code --heads origin release/$NEW_VERSION; then - git push origin --delete release/$NEW_VERSION - fi - - - name: Create release branch - shell: bash - run: | - NEW_VERSION="${{ steps.promote.outputs.new_version }}" - git checkout -b release/$NEW_VERSION - - - name: Update version in Solution.props - uses: ./.github/actions/versioning/update-version - with: - new-version: ${{ steps.promote.outputs.new_version }} - - - name: Update changelog section - uses: ./.github/actions/documentation/update-changelog - with: - action: create-release - version: ${{ steps.promote.outputs.new_version }} - - - name: Commit and push changes - shell: bash - run: | - NEW_VERSION="${{ steps.promote.outputs.new_version }}" - PREVIOUS_STAGE="${{ steps.promote.outputs.previous_stage }}" - NEW_STAGE="${{ steps.promote.outputs.new_stage }}" - - git add Solution.props CHANGELOG.md - git commit -m "chore: promote version to $NEW_VERSION ($PREVIOUS_STAGE -> $NEW_STAGE)" - git push origin release/$NEW_VERSION - - - name: Create Pull Request - id: create-pr - shell: pwsh - env: - GITHUB_TOKEN: ${{ inputs.token }} - run: | - $newVersion = "${{ steps.promote.outputs.new_version }}" - $previousStage = "${{ steps.promote.outputs.previous_stage }}" - $newStage = "${{ steps.promote.outputs.new_stage }}" - $repo = "${{ github.repository }}" - - $prBody = @" - ## Version Promotion: ``$previousStage`` → ``$newStage`` - - This PR automatically promotes the version from ``$previousStage`` to ``$newStage`` after no issues were reported for ``${{ inputs.current-version }}`` in the last 30 days. - - ### Changes - - Updated version in `Solution.props` from `${{ inputs.current-version }}` to ``$newVersion`` - - Created new release section in CHANGELOG.md - - ### Next Steps - 1. Review and merge this PR to ``dev`` - 2. The ``release-2-pr-to-dev-closed.yml`` workflow will automatically create a PR to ``main`` - 3. After merging to ``main``, the release will be published - - --- - *This PR was automatically generated by the release promotion workflow.* - "@ - - # Create PR (gh pr create outputs the PR URL on success) - $prUrl = gh pr create ` - --base dev ` - --head "release/$newVersion" ` - --title "chore: promote version to $newVersion ($previousStage -> $newStage)" ` - --body $prBody - - if ($LASTEXITCODE -eq 0) { - $prNumber = $prUrl -replace '.*\/pull\/(\d+).*', '$1' - Write-Host "Created PR #$prNumber ($prUrl)" - "pr_created=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - "pr_number=$prNumber" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - - # Auto-merge if requested - if ("${{ inputs.auto-merge }}" -eq "true") { - Write-Host "Auto-merging PR #$prNumber" - gh pr merge $prNumber --auto --squash --delete-branch - } - } else { - Write-Error "Failed to create PR" - exit 1 - } - - - name: Output results - id: output - shell: pwsh - run: | - echo "new_version=${{ steps.parse.outputs.new_version }}" >> $env:GITHUB_OUTPUT - echo "previous_stage=${{ steps.parse.outputs.previous_stage }}" >> $env:GITHUB_OUTPUT - echo "new_stage=${{ steps.parse.outputs.new_stage }}" >> $env:GITHUB_OUTPUT - echo "pr_created=${{ steps.create-pr.outputs.pr_created }}" >> $env:GITHUB_OUTPUT - echo "pr_number=${{ steps.create-pr.outputs.pr_number }}" >> $env:GITHUB_OUTPUT diff --git a/.github/workflows/RELEASE_WORKFLOW.md b/.github/workflows/RELEASE_WORKFLOW.md index fca16cf2..38bd1029 100644 --- a/.github/workflows/RELEASE_WORKFLOW.md +++ b/.github/workflows/RELEASE_WORKFLOW.md @@ -4,10 +4,11 @@ This guide explains the standard release process for SmartHopper, which is trigg ## Overview -The release workflow supports two paths: +The release workflow supports three paths: 1. **Regular Releases**: Planned releases from milestones (dev → main) -2. **Promotion Releases**: Automatic stage progression (alpha → beta → rc → stable) +2. **Stabilization Path**: Milestone-driven stage progression for specific versions, on isolated branches +3. **Hotfix Releases**: Emergency patches from main ## Regular Release Flow @@ -19,17 +20,48 @@ Triggered manually via `release-1-milestone.yml` when a milestone is ready to re 4. Creates PR to `dev` → merge → PR to `main` → merge 5. Publishes release and builds artifacts -## Promotion Release Flow +## Stabilization Path Flow -Automatic stage progression when ALL conditions are met: +Use when you want to promote a specific version to stable without conflicting with other active development. -1. Daily cron job checks all prerelease versions (alpha/beta/rc) -2. **Promotion requires ALL three conditions**: - - ✅ **No open issues with version label** (any stage of base version, e.g., `version: 1.4.3-alpha`) - - ✅ **Original release published at least 30 days ago** - - ✅ **Last closed issue with original version label at least 30 days ago** -3. If all conditions met → creates promotion PR (e.g., `1.4.3-alpha` → `1.4.3-beta`) -4. On merge, release-1-milestone can be triggered for the new version +### Starting a Stabilization Path + +1. **Create a milestone** with no suffix (e.g., `1.4.2`) in GitHub +2. `stabilization-0-init.yml` automatically: + - Finds the latest prerelease tag matching `1.4.2-*` + - Creates `dev-1.4.2` and `main-1.4.2` branches from that tag +3. The daily `release-promotion.yml` run detects the open `1.4.2` milestone and checks the current staged release (`1.4.2-alpha`) for promotion eligibility + +### Promotion Loop (per stage) + +When ALL conditions are met for the staged release: + +- ✅ No open issues with version label +- ✅ Release published at least 30 days ago +- ✅ Last closed issue at least 30 days ago + +Automation: + +1. Creates next-stage milestone (e.g., `1.4.2-beta`) +2. Closes current staged milestone (e.g., `1.4.2-alpha`) +3. Dispatches `release-1-milestone.yml` targeting `dev-1.4.2` +4. PR flow: `release/1.4.2-beta` → `dev-1.4.2` → `main-1.4.2` → GitHub Release `1.4.2-beta` +5. Repeat for beta → rc → stable + +### Completing a Stabilization Path + +1. When `1.4.2` (stable) is released on `main-1.4.2`, close the `1.4.2` milestone +2. `stabilization-2-complete.yml` automatically creates a backport PR: `main-1.4.2` → `main` +3. After manual approval and merge, `dev-1.4.2` and `main-1.4.2` branches are deleted + +### Cancelling a Stabilization Path + +Close the `1.4.2` milestone while sub-milestones (`1.4.2-alpha`, etc.) are still open. +`stabilization-1-cancel.yml` automatically: + +- Closes all open `1.4.2-*` sub-milestones +- Migrates their open issues to the next dev alpha milestone +- Deletes `dev-1.4.2` and `main-1.4.2` branches ## When to Use Regular Releases @@ -142,11 +174,11 @@ The workflow automatically: ### Step 10: Upload to Yak (Automatic for Stable Releases) **For stable releases (X.Y.Z without prerelease suffix):** -- Automatically triggered when release is published -- Uploads both Windows and Mac packages to production Yak server +- Automatically triggered by `release-4-build.yml` after a successful build (race-condition-free) +- The `trigger-yak-upload` job waits for `merge-and-release` to complete before dispatching `release-6-upload-yak.yml` - No manual action required -**For prerelease versions (alpha/beta/rc) or manual override:** +**For prerelease versions (alpha/beta/rc) or manual re-upload:** 1. Go to **Actions** → **🚀 6 Upload to Yak Rhino Server** 2. Click **Run workflow** 3. Configure: @@ -175,13 +207,20 @@ Version is determined by the milestone title (e.g., milestone `1.2.0` → releas ## Workflow Files -- **release-1-milestone.yml** - Manually prepares release branch for a milestone -- **release-promotion.yml** - Automatically promotes versions (alpha→beta→rc→stable) -- **release-2-pr-to-dev-closed.yml** - Creates PR from dev to main -- **release-3-pr-to-main-closed.yml** - Creates GitHub Release (draft) -- **release-4-build.yml** - Builds and uploads artifacts -- **release-6-upload-yak.yml** - Uploads to Yak package manager (manual) -- **release-auto-upload-yak.yml** - Auto-uploads to Yak for stable releases +### Release Workflows + +- **release-1-milestone.yml** — Prepares release branch; auto-detects `dev-X.Y.Z` for stabilization paths +- **release-2-pr-to-dev-closed.yml** — Creates PR from `dev` (or `dev-X.Y.Z`) to `main` (or `main-X.Y.Z`) +- **release-3-pr-to-main-closed.yml** — Creates GitHub Release; supports `main-*` branches +- **release-4-build.yml** — Builds artifacts and auto-triggers Yak upload after successful build (stable only) +- **release-promotion.yml** — Scans open no-suffix milestones daily; promotes eligible staged releases +- **release-6-upload-yak.yml** — Uploads to Yak package manager (manual or dispatched by build) + +### Stabilization Workflows + +- **stabilization-0-init.yml** — Triggered on `milestone.created` for `X.Y.Z` titles; creates `dev-X.Y.Z` / `main-X.Y.Z` branches +- **stabilization-1-cancel.yml** — Triggered on `milestone.closed` (with open sub-milestones); cancels path, migrates issues, deletes branches +- **stabilization-2-complete.yml** — Triggered on `milestone.closed` (no open sub-milestones); creates backport PR and cleans up branches ## Validations @@ -196,39 +235,50 @@ All PRs (release → dev, dev → main) run: - **dev**: Protected branch, requires PR reviews - **main**: Protected branch, requires PR reviews +- **dev-X.Y.Z**: Protected stabilization branch (created by automation); `github-actions[bot]` has bypass for create/delete +- **main-X.Y.Z**: Protected stabilization branch (created by automation); `github-actions[bot]` has bypass for create/delete - **release/\***: Temporary branches, deleted after merge -### Promotion Release Example +All CI checks (`ci-dotnet-tests`, `pr-validation`, `pr-version-validation`, `pr-build-hash-validation`, `pr-manifest-validation`) run on PRs to `dev-*` and `main-*` branches identical to `dev` and `main`. -**Scenario**: Version `1.4.3-alpha` meets all promotion criteria. +### Stabilization Path Example -**Validation Checks (checking `1.4.3-alpha` for promotion to `1.4.3-beta`):** +**Scenario**: Promote version `1.4.2` from alpha through to stable. -1. ✅ **Version label issues**: No open issues labeled `version: 1.4.3` or `version: 1.4.3-alpha` -2. ✅ **Release age**: `1.4.3-alpha` published 35 days ago (≥30 days required) -3. ✅ **Last closed issue**: Last issue labeled `version: 1.4.3-alpha` closed 32 days ago (≥30 days required) +**Setup:** -**Process:** +1. Create milestone `1.4.2` (no suffix) in GitHub +2. `stabilization-0-init.yml` creates `dev-1.4.2` and `main-1.4.2` from tag `1.4.2-alpha` + +**Daily promotion loop (alpha → beta):** + +1. `release-promotion.yml` scans open no-suffix milestones → finds `1.4.2` +2. Looks up staged release → finds `1.4.2-alpha` tag +3. Validates: + - ✅ No open issues labeled `version: 1.4.2` + - ✅ `1.4.2-alpha` published 35 days ago + - ✅ Last closed issue 32 days ago +4. Creates milestone `1.4.2-beta`, closes `1.4.2-alpha` +5. Dispatches `release-1-milestone.yml` with `milestone-title: 1.4.2-beta` +6. Workflow 1 detects `dev-1.4.2` → creates `release/1.4.2-beta` → PR to `dev-1.4.2` +7. PR merged → Workflow 2 creates PR `dev-1.4.2` → `main-1.4.2` +8. PR merged → Workflow 3 creates draft release `1.4.2-beta` on `main-1.4.2` +9. Publish release → Workflow 4 builds artifacts + +**Repeat for beta → rc → stable.** + +**Completion:** -1. **Daily cron** validates `1.4.3-alpha` against all three conditions -2. **release-promotion.yml** creates PR: `release/1.4.3-beta` → `dev` -3. Review and merge PR to `dev` -4. **Workflow 2** creates PR from `dev` to `main` -5. Review and merge PR to `main` -6. **Workflow 3** creates draft release `1.4.3-beta` -7. **Workflow 4** builds and uploads artifacts -8. Publish release -9. **manage-milestones** action: - - Creates `1.4.3-rc` milestone (next stage) - - Creates `1.5.0-alpha` milestone (next minor) - - Closes older `1.x.x-beta` milestones -10. Run **Workflow 5** to upload to Yak +1. `1.4.2` released on `main-1.4.2` +2. Close milestone `1.4.2` +3. `stabilization-2-complete.yml` creates backport PR: `main-1.4.2` → `main` +4. After merge, branches `dev-1.4.2` and `main-1.4.2` are deleted **Blocking Scenarios** (promotion will NOT happen): -- ❌ Any open issue labeled `version: 1.4.3` or `version: 1.4.3-alpha` (bugs still unresolved) -- ❌ `1.4.3-alpha` release published < 30 days ago -- ❌ Last issue with `version: 1.4.3-alpha` label closed < 30 days ago +- ❌ Any open issue labeled `version: 1.4.2` (any stage) +- ❌ `1.4.2-alpha` release published < 30 days ago +- ❌ No open `1.4.2` milestone exists (stabilization path not initialized) ### Regular Release Example diff --git a/.github/workflows/ci-dotnet-tests.yml b/.github/workflows/ci-dotnet-tests.yml index a0d5a3d7..afaee7a3 100644 --- a/.github/workflows/ci-dotnet-tests.yml +++ b/.github/workflows/ci-dotnet-tests.yml @@ -18,10 +18,13 @@ on: push: branches: - main + - 'main-*' pull_request: branches: - main - dev + - 'main-*' + - 'dev-*' - hotfix/** - release/** diff --git a/.github/workflows/pr-build-hash-validation.yml b/.github/workflows/pr-build-hash-validation.yml index 90da86aa..9ef156bb 100644 --- a/.github/workflows/pr-build-hash-validation.yml +++ b/.github/workflows/pr-build-hash-validation.yml @@ -8,6 +8,8 @@ on: branches: - main - dev + - 'main-*' + - 'dev-*' - 'hotfix/**' - 'release/**' diff --git a/.github/workflows/pr-manifest-validation.yml b/.github/workflows/pr-manifest-validation.yml index db37f643..ac23d71a 100644 --- a/.github/workflows/pr-manifest-validation.yml +++ b/.github/workflows/pr-manifest-validation.yml @@ -14,6 +14,7 @@ on: pull_request: branches: - main + - 'main-*' paths: - 'yak-package/manifest.yml' - 'Solution.props' diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 23ba8cfc..c89323c6 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -16,6 +16,8 @@ on: branches: - main - dev + - 'main-*' + - 'dev-*' - 'hotfix/**' - 'release/**' diff --git a/.github/workflows/pr-version-validation.yml b/.github/workflows/pr-version-validation.yml index bba8c387..0c3aec5f 100644 --- a/.github/workflows/pr-version-validation.yml +++ b/.github/workflows/pr-version-validation.yml @@ -8,6 +8,8 @@ on: branches: - main - dev + - 'main-*' + - 'dev-*' - 'hotfix/**' - 'release/**' diff --git a/.github/workflows/release-1-milestone.yml b/.github/workflows/release-1-milestone.yml index 9c3a0123..f7949a4e 100644 --- a/.github/workflows/release-1-milestone.yml +++ b/.github/workflows/release-1-milestone.yml @@ -29,10 +29,45 @@ jobs: release-preparation: runs-on: ubuntu-latest steps: + - name: Detect stabilization branch + id: detect-branch + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const milestoneTitle = '${{ inputs.milestone-title }}'; + + // Extract base version (strip suffix if present, e.g. "1.4.2-beta" → "1.4.2") + const match = milestoneTitle.match(/^(\d+\.\d+\.\d+)/); + if (!match) { + console.log(`Cannot parse base version from '${milestoneTitle}'. Using default dev branch.`); + core.setOutput('base-branch', 'dev'); + core.setOutput('is-stabilization', 'false'); + return; + } + const baseVersion = match[1]; + const devBranch = `dev-${baseVersion}`; + + // Check if the stabilization dev branch exists + try { + await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: devBranch + }); + console.log(`Stabilization branch '${devBranch}' found. Using stabilization path.`); + core.setOutput('base-branch', devBranch); + core.setOutput('is-stabilization', 'true'); + } catch (e) { + console.log(`No stabilization branch '${devBranch}' found. Using default dev branch.`); + core.setOutput('base-branch', 'dev'); + core.setOutput('is-stabilization', 'false'); + } + - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: dev + ref: ${{ steps.detect-branch.outputs.base-branch }} - name: Set up Git user run: | @@ -111,14 +146,15 @@ jobs: - name: Create Pull Request id: create-pr run: | + BASE_BRANCH="${{ steps.detect-branch.outputs.base-branch }}" gh pr create \ - --base dev \ + --base "$BASE_BRANCH" \ --head release/${{ inputs.milestone-title }} \ --title "chore: prepare release ${{ inputs.milestone-title }} with version update and code style fixes" \ --body $'This PR prepares the release for version ${{ inputs.milestone-title }} with version update and code style fixes:\n\n- Updated version in Solution.props\n- Updated changelog with closed-solved issues\n- Updated README badges' # Capture PR number - PR_NUMBER=$(gh pr list --base dev --head release/${{ inputs.milestone-title }} --json number --jq '.[0].number') + PR_NUMBER=$(gh pr list --base "$BASE_BRANCH" --head release/${{ inputs.milestone-title }} --json number --jq '.[0].number') echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-2-pr-to-dev-closed.yml b/.github/workflows/release-2-pr-to-dev-closed.yml index 7d2d5cb0..28aadd23 100644 --- a/.github/workflows/release-2-pr-to-dev-closed.yml +++ b/.github/workflows/release-2-pr-to-dev-closed.yml @@ -1,9 +1,10 @@ name: 🏁 2 PR Release to Main from Dev -# Description: This workflow automatically creates a PR to merge a release branch into main. When the release/* branch is merged to dev. +# Description: This workflow automatically creates a PR to merge a release branch into main (or main-X.Y.Z for stabilization paths). +# When the release/* branch is merged to dev or dev-X.Y.Z. # # Triggers: -# - Automatically when a PR to dev is closed +# - Automatically when a PR to dev or dev-* is closed # # Permissions: # - contents:write - Required to create GitHub releases @@ -15,6 +16,7 @@ on: types: [ closed ] branches: - dev + - 'dev-*' workflow_dispatch: inputs: pr-title: @@ -37,15 +39,24 @@ jobs: if: ${{ github.event_name == 'pull_request' }} id: create-pr-event run: | + DEV_BRANCH="${{ github.event.pull_request.base.ref }}" + # If merging into dev-X.Y.Z, target main-X.Y.Z; otherwise target main + if [[ "$DEV_BRANCH" == dev-* ]]; then + SUFFIX="${DEV_BRANCH#dev-}" + TARGET_BRANCH="main-${SUFFIX}" + else + TARGET_BRANCH="main" + fi + MAIN_HEAD="$DEV_BRANCH" gh pr create \ --repo ${{ github.repository }} \ - --base main \ - --head dev \ + --base "$TARGET_BRANCH" \ + --head "$MAIN_HEAD" \ --title "${{ github.event.pull_request.head.ref }}" \ --body "${{ github.event.pull_request.body }}" # Capture PR number - PR_NUMBER=$(gh pr list --base main --head dev --json number --jq '.[0].number') + PR_NUMBER=$(gh pr list --base "$TARGET_BRANCH" --head "$MAIN_HEAD" --json number --jq '.[0].number') echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -68,7 +79,7 @@ jobs: --title "${{ github.event.inputs['pr-title'] }}" \ --body "${{ github.event.inputs['pr-body'] }}" - # Capture PR number + # Capture PR number (manual dispatch always targets main) PR_NUMBER=$(gh pr list --base main --head dev --json number --jq '.[0].number') echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT env: diff --git a/.github/workflows/release-3-pr-to-main-closed.yml b/.github/workflows/release-3-pr-to-main-closed.yml index b832dca0..c06826b5 100644 --- a/.github/workflows/release-3-pr-to-main-closed.yml +++ b/.github/workflows/release-3-pr-to-main-closed.yml @@ -3,9 +3,10 @@ name: 🏁 3 Create Release on Release PR Close # Description: This workflow automatically creates a GitHub Release when a release PR is closed. # It extracts the milestone title as the version number and compiles release notes from # all issues and pull requests associated with the milestone. +# Supports both main (regular releases) and main-X.Y.Z (stabilization path releases). # # Triggers: -# - Automatically when a milestone is closed +# - Automatically when a PR to main or main-* is closed # # Permissions: # - contents:write - Required to create GitHub releases @@ -17,6 +18,7 @@ on: types: [ closed ] branches: - main + - 'main-*' workflow_dispatch: jobs: @@ -29,22 +31,30 @@ jobs: pull-requests: read steps: + - name: Determine target branch + id: target-branch + run: | + BASE_REF="${{ github.event.pull_request.base.ref }}" + # Use the actual base branch (main or main-X.Y.Z) + echo "branch=${BASE_REF:-main}" >> $GITHUB_OUTPUT + - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: main # Checkout the main branch + ref: ${{ steps.target-branch.outputs.branch }} - name: Get Release Version id: release_info uses: ./.github/actions/versioning/get-version with: - branch: main + branch: ${{ steps.target-branch.outputs.branch }} - name: Generate Release Notes id: issues uses: actions/github-script@v7 env: RELEASE_VERSION: ${{ steps.release_info.outputs.version }} + TARGET_BRANCH: ${{ steps.target-branch.outputs.branch }} with: script: | // Generate release notes for the new release version @@ -52,7 +62,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, tag_name: process.env.RELEASE_VERSION, - target_commitish: 'main' + target_commitish: process.env.TARGET_BRANCH }); await core.setOutput('changelog', releaseNotes.data.body); @@ -80,4 +90,4 @@ jobs: ${{ steps.issues.outputs.changelog }} draft: true prerelease: ${{ env.IS_PRERELEASE }} - target_commitish: main + target_commitish: ${{ steps.target-branch.outputs.branch }} diff --git a/.github/workflows/release-4-build.yml b/.github/workflows/release-4-build.yml index 2d57c90f..5e8e9e71 100644 --- a/.github/workflows/release-4-build.yml +++ b/.github/workflows/release-4-build.yml @@ -489,3 +489,39 @@ jobs: Write-Host "Next steps:" Write-Host " 1. Review and merge the hash PR to main branch" Write-Host " 2. GitHub Pages deployment will trigger automatically" + + # Trigger Yak upload after build succeeds — only for stable (non-prerelease) releases + # This replaces the standalone release-auto-upload-yak.yml trigger to avoid a race condition + # where Yak upload fires before build artifacts are ready. + trigger-yak-upload: + needs: [merge-and-release] + if: github.event_name == 'release' && !github.event.release.prerelease + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + steps: + - name: Trigger Yak upload workflow + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const tag = '${{ github.event.release.tag_name }}'; + const releaseVersion = tag.replace(/^v/, ''); + + console.log(`Build succeeded. Triggering Yak upload for stable release: ${releaseVersion}`); + + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'release-6-upload-yak.yml', + ref: 'main', + inputs: { + version: releaseVersion, + platform: 'both', + upload_to_yak: 'true', + testing: 'false' + } + }); + + console.log('✅ Yak upload workflow triggered successfully'); diff --git a/.github/workflows/release-auto-upload-yak.yml b/.github/workflows/release-auto-upload-yak.yml deleted file mode 100644 index a624d6c2..00000000 --- a/.github/workflows/release-auto-upload-yak.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: 🚀 Auto Upload to Yak on Stable Release - -# Description: Automatically uploads release artifacts to Yak when a non-prerelease -# release is published. -# -# Prerequisites: -# - A YAK_AUTH_TOKEN secret must be configured in your repository settings -# -# Triggers: -# - Automatically when a release is published and is NOT a prerelease -# -# Permissions: -# - contents:read - Required to read repository content - -on: - release: - types: [published] - -jobs: - check-and-upload: - # Only run for non-prerelease releases - if: ${{ !github.event.release.prerelease }} - runs-on: ubuntu-latest - permissions: - contents: read - actions: write - - steps: - - name: Extract version from release - id: extract_version - run: | - # Extract version from release tag (remove 'v' prefix if present) - tag="${{ github.event.release.tag_name }}" - version="${tag#v}" - echo "version=$version" >> $GITHUB_OUTPUT - echo "Detected stable release version: $version" - - - name: Trigger Yak Upload Workflow - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const version = '${{ steps.extract_version.outputs.version }}'; - - console.log(`Triggering Yak upload for non-prerelease release ${version}`); - - // Trigger the yak upload workflow - await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'release-6-upload-yak.yml', - ref: 'main', - inputs: { - version: version, - platform: 'both', - upload_to_yak: 'true', - testing: 'false' - } - }); - - console.log('✅ Yak upload workflow triggered successfully'); diff --git a/.github/workflows/release-promotion.yml b/.github/workflows/release-promotion.yml index 80c7d4f7..eac4d0bf 100644 --- a/.github/workflows/release-promotion.yml +++ b/.github/workflows/release-promotion.yml @@ -1,12 +1,14 @@ name: 🔄 Release Promotion (Alpha→Beta→RC→Stable) -# Description: Automatically promotes versions through release stages when no issues +# Description: Automatically promotes stabilization versions through release stages when no issues # are reported within 30 days of release. # # Flow: -# 1. Checks for issues labeled with the released version -# 2. If no issues found after 30 days, promotes to next stage (alpha→beta→rc→stable) -# 3. Creates release branch and PR for the promotion +# 1. Scans open no-suffix milestones (e.g. "1.4.2") to discover active stabilization paths +# 2. For each, finds the current staged release (alpha/beta/rc) +# 3. Checks promotion eligibility (age ≥ 30d, no open issues) +# 4. If eligible: creates next-stage milestone, closes current staged milestone, +# and dispatches release-1-milestone.yml for the new stage # # Triggers: # - Workflow dispatch (manual) @@ -17,7 +19,7 @@ on: workflow_dispatch: inputs: version: - description: 'Specific version to check for promotion (leave empty to check all recent releases)' + description: 'Specific staged version to check (e.g. 1.4.2-alpha). Leave empty to check all stabilization paths.' required: false type: string default: '' @@ -34,8 +36,9 @@ on: permissions: contents: write - issues: read + issues: write pull-requests: write + actions: write jobs: determine-versions-to-check: @@ -58,90 +61,69 @@ jobs: function parseVersion(versionStr) { const match = versionStr.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); if (!match) return null; - return { major: parseInt(match[1]), minor: parseInt(match[2]), patch: parseInt(match[3]), - suffix: match[4] || 'stable', + suffix: match[4] || null, original: versionStr }; } - - // Compute target version for a given prerelease version - function getTargetVersion(parsed) { - const base = `${parsed.major}.${parsed.minor}.${parsed.patch}`; - if (parsed.suffix === 'alpha') return `${base}-beta`; - if (parsed.suffix === 'beta') return `${base}-rc`; - if (parsed.suffix === 'rc') return base; - return null; - } - - const [{ data: releases }, allMilestones] = await Promise.all([ - github.rest.repos.listReleases({ - owner: context.repo.owner, - repo: context.repo.repo, - per_page: 20 - }), - github.paginate(github.rest.issues.listMilestones, { - owner: context.repo.owner, - repo: context.repo.repo, - state: 'all', - per_page: 100 - }) - ]); - - // Build a set of closed milestone titles for quick lookup - const closedMilestoneTitles = new Set( - allMilestones.filter(m => m.state === 'closed').map(m => m.title) - ); - console.log('Closed milestones:', Array.from(closedMilestoneTitles)); - + const specificVersion = '${{ github.event.inputs.version }}'; let versionsToCheck = []; - + if (specificVersion) { - // Use specific version from input versionsToCheck = [specificVersion]; } else { - // Filter for alpha, beta, rc releases (not stable) - const preReleases = releases.filter(r => { - const tag = r.tag_name; - return tag.includes('-alpha') || tag.includes('-beta') || tag.includes('-rc'); - }); - - // Group by major version and suffix, keep only latest of each - // (latest = first occurrence since releases are sorted newest first) - const versionsByMajorAndSuffix = {}; - const blockedSuffixKeys = new Set(); - - for (const release of preReleases) { - const parsed = parseVersion(release.tag_name); - if (!parsed) continue; - - // Group by major + suffix only → one candidate per major per stage - const suffixKey = `${parsed.major}:${parsed.suffix}`; - - // Skip if this group is already blocked or already has a candidate - if (blockedSuffixKeys.has(suffixKey) || versionsByMajorAndSuffix[suffixKey]) continue; - - // Skip if target version milestone already exists and is closed (already released) - // Block the entire group so older versions in the same major:suffix don't get promoted - const targetVersion = getTargetVersion(parsed); - if (targetVersion && closedMilestoneTitles.has(targetVersion)) { - console.log(`Skipping ${release.tag_name}: target ${targetVersion} milestone already closed (blocking entire ${suffixKey} group)`); - blockedSuffixKeys.add(suffixKey); - continue; + // Discover active stabilization paths via open no-suffix milestones + const [openMilestones, { data: releases }] = await Promise.all([ + github.paginate(github.rest.issues.listMilestones, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }), + github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100 + }) + ]); + + // Filter to X.Y.Z milestones (no suffix = stabilization path marker) + const stabilizationMilestones = openMilestones.filter(m => /^\d+\.\d+\.\d+$/.test(m.title)); + console.log('Active stabilization milestones:', stabilizationMilestones.map(m => m.title)); + + // Build a set of all release tag names for fast lookup + const releaseTags = new Set(releases.map(r => r.tag_name)); + + // For each stabilization path, find the current staged release + // Priority: rc > beta > alpha (promote the most advanced stage that exists) + const stagePriority = ['rc', 'beta', 'alpha']; + + for (const milestone of stabilizationMilestones) { + const base = milestone.title; // e.g. "1.4.2" + let currentStaged = null; + + for (const stage of stagePriority) { + const candidate = `${base}-${stage}`; + if (releaseTags.has(candidate)) { + currentStaged = candidate; + console.log(`Stabilization path ${base}: current staged release is ${candidate}`); + break; + } + } + + if (currentStaged) { + versionsToCheck.push(currentStaged); + } else { + console.log(`Stabilization path ${base}: no staged release found yet, skipping.`); } - - versionsByMajorAndSuffix[suffixKey] = release.tag_name; } - - versionsToCheck = Object.values(versionsByMajorAndSuffix); } - + console.log('Versions to check for promotion:', versionsToCheck); - core.setOutput('versions', JSON.stringify(versionsToCheck)); core.setOutput('has_versions', versionsToCheck.length > 0 ? 'true' : 'false'); @@ -175,7 +157,7 @@ jobs: $forcePromote = "${{ github.event.inputs.force-promote }}" -eq "true" $blockingReason = "${{ steps.check-issues.outputs.blocking-reason }}" $version = "${{ matrix.version }}" - + Write-Host "=== Promotion Decision for $version ===" Write-Host "" Write-Host "Validation Results:" @@ -188,7 +170,7 @@ jobs: } Write-Host " • Force promote: $forcePromote" Write-Host "" - + if ($forcePromote) { Write-Host "⚠️ FORCE PROMOTION ENABLED - Bypassing all validation checks" Add-Content -Path $env:GITHUB_OUTPUT -Value "should-promote=true" @@ -203,22 +185,112 @@ jobs: Add-Content -Path $env:GITHUB_OUTPUT -Value "reason=$blockingReason" } - - name: Promote version + - name: Compute next stage version if: steps.should-promote.outputs.should-promote == 'true' - id: promote - uses: ./.github/actions/versioning/promote-version + id: compute-next + uses: actions/github-script@v7 with: - current-version: ${{ matrix.version }} - token: ${{ secrets.GITHUB_TOKEN }} - auto-merge: 'false' + script: | + const current = '${{ matrix.version }}'; + const match = current.match(/^(\d+\.\d+\.\d+)-(\w+)$/); + if (!match) { + core.setFailed(`Cannot parse version: ${current}`); + return; + } + const base = match[1]; + const stage = match[2]; + let nextVersion, nextStage; + if (stage === 'alpha') { nextVersion = `${base}-beta`; nextStage = 'beta'; } + else if (stage === 'beta') { nextVersion = `${base}-rc`; nextStage = 'rc'; } + else if (stage === 'rc') { nextVersion = base; nextStage = 'stable'; } + else { core.setFailed(`Unknown stage: ${stage}`); return; } + + console.log(`Promoting ${current} → ${nextVersion}`); + core.setOutput('next-version', nextVersion); + core.setOutput('next-stage', nextStage); + core.setOutput('base-version', base); + core.setOutput('current-stage', stage); + + - name: Create next-stage milestone + if: steps.should-promote.outputs.should-promote == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const nextVersion = '${{ steps.compute-next.outputs.next-version }}'; + const nextStage = '${{ steps.compute-next.outputs.next-stage }}'; + if (nextStage === 'stable') { + console.log('Promoting to stable — no new milestone needed (parent milestone already exists).'); + return; + } + try { + await github.rest.issues.createMilestone({ + owner: context.repo.owner, + repo: context.repo.repo, + title: nextVersion, + description: `Auto-created by release-promotion for ${nextVersion}` + }); + console.log(`✅ Created milestone: ${nextVersion}`); + } catch (e) { + if (e.message.includes('already exists')) { + console.log(`ℹ️ Milestone ${nextVersion} already exists`); + } else { + throw e; + } + } + + - name: Close current staged milestone + if: steps.should-promote.outputs.should-promote == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const currentVersion = '${{ matrix.version }}'; + const milestones = await github.paginate(github.rest.issues.listMilestones, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }); + const milestone = milestones.find(m => m.title === currentVersion); + if (milestone) { + await github.rest.issues.updateMilestone({ + owner: context.repo.owner, + repo: context.repo.repo, + milestone_number: milestone.number, + state: 'closed' + }); + console.log(`✅ Closed milestone: ${currentVersion}`); + } else { + console.log(`ℹ️ Milestone '${currentVersion}' not found or already closed`); + } - name: Create new version label if: steps.should-promote.outputs.should-promote == 'true' uses: ./.github/actions/versioning/create-version-label with: - version: ${{ steps.promote.outputs.new-version }} + version: ${{ steps.compute-next.outputs.next-version }} token: ${{ secrets.GITHUB_TOKEN }} + - name: Dispatch release-1-milestone.yml + if: steps.should-promote.outputs.should-promote == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const nextVersion = '${{ steps.compute-next.outputs.next-version }}'; + console.log(`Dispatching release-1-milestone.yml for ${nextVersion}`); + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'release-1-milestone.yml', + ref: 'main', + inputs: { + 'milestone-title': nextVersion + } + }); + console.log(`✅ Dispatched release-1-milestone.yml for ${nextVersion}`); + - name: Summary if: always() run: | @@ -242,13 +314,11 @@ jobs: fi echo "" if [ "${{ steps.should-promote.outputs.should-promote }}" == "true" ]; then - echo "### ✅ Promotion Successful" + echo "### ✅ Promotion Dispatched" echo "" - echo "- **Promoted:** ${{ matrix.version }} → ${{ steps.promote.outputs.new-version }}" - echo "- **Previous Stage:** ${{ steps.promote.outputs.previous-stage }}" - echo "- **New Stage:** ${{ steps.promote.outputs.new-stage }}" - echo "- **PR Created:** ${{ steps.promote.outputs.pr-created }}" - echo "- **PR Number:** #${{ steps.promote.outputs.pr-number }}" + echo "- **Promoting:** ${{ matrix.version }} → ${{ steps.compute-next.outputs.next-version }}" + echo "- **New Stage:** ${{ steps.compute-next.outputs.next-stage }}" + echo "- **release-1-milestone.yml dispatched for:** ${{ steps.compute-next.outputs.next-version }}" else echo "### ❌ Promotion Blocked" echo "" diff --git a/.github/workflows/stabilization-0-init.yml b/.github/workflows/stabilization-0-init.yml new file mode 100644 index 00000000..7cf57718 --- /dev/null +++ b/.github/workflows/stabilization-0-init.yml @@ -0,0 +1,142 @@ +name: Stabilization 0 - 🌿 Init Path + +# Description: Initializes a stabilization path when a no-suffix milestone (e.g. "1.4.2") is created. +# Creates dedicated dev-X.Y.Z and main-X.Y.Z branches from the latest matching prerelease tag. +# +# Triggers: +# - milestone.created when title matches X.Y.Z (no suffix) +# +# Permissions: +# - contents: write - Required to create branches + +on: + milestone: + types: [created] + +permissions: + contents: write + +jobs: + init-stabilization-path: + runs-on: ubuntu-latest + if: ${{ github.event_name == 'milestone' && !contains(github.event.milestone.title, '-') && github.event.milestone.title != '' }} + steps: + - name: Validate milestone title format + id: validate + uses: actions/github-script@v7 + with: + script: | + const title = '${{ github.event.milestone.title }}'; + const isStableVersion = /^\d+\.\d+\.\d+$/.test(title); + if (!isStableVersion) { + console.log(`Milestone '${title}' is not a stable X.Y.Z version. Skipping.`); + core.setOutput('is-stabilization', 'false'); + } else { + console.log(`Milestone '${title}' is a stabilization milestone. Proceeding.`); + core.setOutput('is-stabilization', 'true'); + } + + - name: Checkout repository + if: steps.validate.outputs.is-stabilization == 'true' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Git user + if: steps.validate.outputs.is-stabilization == 'true' + run: | + git config user.name "github-actions" + git config user.email "action@github.com" + + - name: Find latest prerelease tag for this version + if: steps.validate.outputs.is-stabilization == 'true' + id: find-tag + uses: actions/github-script@v7 + with: + script: | + const version = '${{ github.event.milestone.title }}'; + console.log(`Looking for latest prerelease tag matching ${version}-*`); + + const tags = await github.paginate(github.rest.repos.listTags, { + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100 + }); + + // Filter tags starting with version- (e.g. 1.4.2-alpha, 1.4.2-beta) + const stagePriority = { rc: 3, beta: 2, alpha: 1 }; + const matchingTags = tags + .map(t => t.name) + .filter(name => name.startsWith(`${version}-`)) + .sort((a, b) => { + const aStage = Object.keys(stagePriority).find(s => a.includes(`-${s}`)) || ''; + const bStage = Object.keys(stagePriority).find(s => b.includes(`-${s}`)) || ''; + return (stagePriority[bStage] || 0) - (stagePriority[aStage] || 0); + }); + + if (matchingTags.length > 0) { + console.log(`Found matching tags: ${matchingTags.join(', ')}`); + console.log(`Using tag: ${matchingTags[0]}`); + core.setOutput('tag', matchingTags[0]); + core.setOutput('source', matchingTags[0]); + } else { + console.log(`No prerelease tags found for ${version}. Falling back to main branch.`); + core.setOutput('tag', ''); + core.setOutput('source', 'main'); + } + + - name: Create stabilization branches + if: steps.validate.outputs.is-stabilization == 'true' + run: | + VERSION="${{ github.event.milestone.title }}" + SOURCE="${{ steps.find-tag.outputs.source }}" + TAG="${{ steps.find-tag.outputs.tag }}" + + echo "Creating stabilization branches for ${VERSION} from ${SOURCE}" + + # Fetch all tags and branches + git fetch --tags origin + + # Determine the git ref to branch from + if [ -n "$TAG" ]; then + REF="refs/tags/${TAG}" + else + git fetch origin main + REF="origin/main" + fi + + echo "Using ref: ${REF}" + + # Create dev-X.Y.Z branch + DEV_BRANCH="dev-${VERSION}" + if git ls-remote --exit-code --heads origin "$DEV_BRANCH" > /dev/null 2>&1; then + echo "Branch ${DEV_BRANCH} already exists, skipping." + else + git checkout -b "$DEV_BRANCH" "$REF" + git push origin "$DEV_BRANCH" + echo "✅ Created ${DEV_BRANCH} from ${SOURCE}" + fi + + # Create main-X.Y.Z branch + MAIN_BRANCH="main-${VERSION}" + if git ls-remote --exit-code --heads origin "$MAIN_BRANCH" > /dev/null 2>&1; then + echo "Branch ${MAIN_BRANCH} already exists, skipping." + else + git checkout -b "$MAIN_BRANCH" "$REF" + git push origin "$MAIN_BRANCH" + echo "✅ Created ${MAIN_BRANCH} from ${SOURCE}" + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + if: steps.validate.outputs.is-stabilization == 'true' + run: | + echo "## 🌿 Stabilization Path Initialized" + echo "" + echo "- **Version:** ${{ github.event.milestone.title }}" + echo "- **Source:** ${{ steps.find-tag.outputs.source }}" + echo "- **dev branch:** dev-${{ github.event.milestone.title }}" + echo "- **main branch:** main-${{ github.event.milestone.title }}" + echo "" + echo "Next: wait for \`release-promotion.yml\` to detect and promote the current staged release." diff --git a/.github/workflows/stabilization-1-cancel.yml b/.github/workflows/stabilization-1-cancel.yml new file mode 100644 index 00000000..c3666049 --- /dev/null +++ b/.github/workflows/stabilization-1-cancel.yml @@ -0,0 +1,177 @@ +name: Stabilization 1 - 🛑 Cancel Path + +# Description: Cancels a stabilization path when a no-suffix milestone (e.g. "1.4.2") is closed +# while staged sub-milestones (1.4.2-alpha, 1.4.2-beta, etc.) are still open. +# Closes all sub-milestones, migrates their open issues to the next dev milestone, +# and deletes the dev-X.Y.Z and main-X.Y.Z branches. +# +# Triggers: +# - milestone.closed when title matches X.Y.Z (no suffix) AND open sub-milestones exist +# +# Permissions: +# - contents: write - Required to delete branches +# - issues: write - Required to close milestones and update issues + +on: + milestone: + types: [closed] + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + cancel-stabilization-path: + runs-on: ubuntu-latest + steps: + - name: Check if this is a cancellation scenario + id: check + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const title = '${{ github.event.milestone.title }}'; + + // Only handle stable X.Y.Z milestones (no suffix) + if (!/^\d+\.\d+\.\d+$/.test(title)) { + console.log(`Milestone '${title}' is not a stable X.Y.Z version. Skipping.`); + core.setOutput('should-cancel', 'false'); + return; + } + + // Check for open sub-milestones (e.g. 1.4.2-alpha, 1.4.2-beta, 1.4.2-rc) + const allMilestones = await github.paginate( + github.rest.issues.listMilestones, + { owner: context.repo.owner, repo: context.repo.repo, state: 'open', per_page: 100 } + ); + + const subMilestones = allMilestones.filter(m => m.title.startsWith(`${title}-`)); + + if (subMilestones.length > 0) { + console.log(`Found ${subMilestones.length} open sub-milestone(s): ${subMilestones.map(m => m.title).join(', ')}`); + console.log('Cancellation path triggered.'); + core.setOutput('should-cancel', 'true'); + core.setOutput('sub-milestones', JSON.stringify(subMilestones.map(m => ({ number: m.number, title: m.title })))); + } else { + console.log('No open sub-milestones found. This is a successful completion — handled by stabilization-2-complete.yml.'); + core.setOutput('should-cancel', 'false'); + } + + - name: Find next dev milestone for issue migration + if: steps.check.outputs.should-cancel == 'true' + id: find-next-milestone + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + // Find the lowest-numbered open alpha milestone (next dev target) + const allMilestones = await github.paginate( + github.rest.issues.listMilestones, + { owner: context.repo.owner, repo: context.repo.repo, state: 'open', per_page: 100 } + ); + + const alphaMilestones = allMilestones + .filter(m => /^\d+\.\d+\.\d+-alpha$/.test(m.title)) + .sort((a, b) => { + const [aMaj, aMin, aPat] = a.title.replace(/-alpha$/, '').split('.').map(Number); + const [bMaj, bMin, bPat] = b.title.replace(/-alpha$/, '').split('.').map(Number); + if (aMaj !== bMaj) return aMaj - bMaj; + if (aMin !== bMin) return aMin - bMin; + return aPat - bPat; + }); + + if (alphaMilestones.length > 0) { + const target = alphaMilestones[0]; + console.log(`Target migration milestone: ${target.title} (#${target.number})`); + core.setOutput('target-milestone-number', target.number.toString()); + core.setOutput('target-milestone-title', target.title); + } else { + console.log('No alpha milestone found for migration. Items will be unassigned.'); + core.setOutput('target-milestone-number', ''); + core.setOutput('target-milestone-title', ''); + } + + - name: Migrate issues and close sub-milestones + if: steps.check.outputs.should-cancel == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const subMilestones = JSON.parse('${{ steps.check.outputs.sub-milestones }}'); + const targetMilestoneNumber = '${{ steps.find-next-milestone.outputs.target-milestone-number }}'; + const targetMilestoneTitle = '${{ steps.find-next-milestone.outputs.target-milestone-title }}'; + + for (const sub of subMilestones) { + console.log(`Processing sub-milestone: ${sub.title} (#${sub.number})`); + + // Get all open issues and PRs in this milestone + const issues = await github.paginate( + github.rest.issues.listForRepo, + { + owner: context.repo.owner, + repo: context.repo.repo, + milestone: sub.number, + state: 'open', + per_page: 100 + } + ); + + console.log(` Found ${issues.length} open item(s) in ${sub.title}`); + + // Migrate each item + for (const issue of issues) { + const newMilestone = targetMilestoneNumber ? parseInt(targetMilestoneNumber) : null; + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + milestone: newMilestone + }); + const dest = targetMilestoneTitle || 'unassigned'; + console.log(` Moved #${issue.number} to ${dest}`); + } + + // Close the sub-milestone + await github.rest.issues.updateMilestone({ + owner: context.repo.owner, + repo: context.repo.repo, + milestone_number: sub.number, + state: 'closed' + }); + console.log(` ✅ Closed sub-milestone: ${sub.title}`); + } + + - name: Checkout repository + if: steps.check.outputs.should-cancel == 'true' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Delete stabilization branches + if: steps.check.outputs.should-cancel == 'true' + run: | + VERSION="${{ github.event.milestone.title }}" + DEV_BRANCH="dev-${VERSION}" + MAIN_BRANCH="main-${VERSION}" + + for BRANCH in "$DEV_BRANCH" "$MAIN_BRANCH"; do + if git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null 2>&1; then + git push origin --delete "$BRANCH" + echo "✅ Deleted branch: ${BRANCH}" + else + echo "Branch ${BRANCH} does not exist, skipping." + fi + done + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + if: steps.check.outputs.should-cancel == 'true' + run: | + echo "## 🛑 Stabilization Path Cancelled" + echo "" + echo "- **Version:** ${{ github.event.milestone.title }}" + echo "- **Sub-milestones closed:** ${{ steps.check.outputs.sub-milestones }}" + echo "- **Issues migrated to:** ${{ steps.find-next-milestone.outputs.target-milestone-title || 'unassigned' }}" + echo "- **Branches deleted:** dev-${{ github.event.milestone.title }}, main-${{ github.event.milestone.title }}" diff --git a/.github/workflows/stabilization-2-complete.yml b/.github/workflows/stabilization-2-complete.yml new file mode 100644 index 00000000..97518fe7 --- /dev/null +++ b/.github/workflows/stabilization-2-complete.yml @@ -0,0 +1,184 @@ +name: Stabilization 2 - ✅ Complete Path + +# Description: Completes a stabilization path when a no-suffix milestone (e.g. "1.4.2") is closed +# and no staged sub-milestones remain open (meaning the stable release was successfully shipped). +# Creates a backport PR from main-X.Y.Z to main. Stabilization branches are deleted after the PR merges. +# +# Triggers: +# - milestone.closed when title matches X.Y.Z (no suffix) AND no open sub-milestones exist +# +# Permissions: +# - contents: write - Required to create PRs and delete branches +# - pull-requests: write - Required to create the backport PR + +on: + milestone: + types: [closed] + pull_request: + types: [closed] + branches: + - main + +permissions: + contents: write + pull-requests: write + issues: read + +jobs: + complete-stabilization-path: + runs-on: ubuntu-latest + if: github.event_name == 'milestone' + steps: + - name: Check if this is a successful completion scenario + id: check + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const title = '${{ github.event.milestone.title }}'; + + // Only handle stable X.Y.Z milestones (no suffix) + if (!/^\d+\.\d+\.\d+$/.test(title)) { + console.log(`Milestone '${title}' is not a stable X.Y.Z version. Skipping.`); + core.setOutput('should-complete', 'false'); + return; + } + + // Check that NO sub-milestones are still open + const allMilestones = await github.paginate( + github.rest.issues.listMilestones, + { owner: context.repo.owner, repo: context.repo.repo, state: 'open', per_page: 100 } + ); + + const openSubMilestones = allMilestones.filter(m => m.title.startsWith(`${title}-`)); + + if (openSubMilestones.length > 0) { + console.log(`Found ${openSubMilestones.length} open sub-milestone(s): ${openSubMilestones.map(m => m.title).join(', ')}`); + console.log('This is a cancellation — handled by stabilization-1-cancel.yml. Skipping.'); + core.setOutput('should-complete', 'false'); + } else { + console.log('No open sub-milestones. This is a successful completion. Proceeding.'); + core.setOutput('should-complete', 'true'); + } + + - name: Check stabilization branches exist + if: steps.check.outputs.should-complete == 'true' + id: check-branches + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const version = '${{ github.event.milestone.title }}'; + const mainBranch = `main-${version}`; + + try { + await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: mainBranch + }); + console.log(`Branch ${mainBranch} exists.`); + core.setOutput('main-branch', mainBranch); + core.setOutput('branches-exist', 'true'); + } catch (e) { + console.log(`Branch ${mainBranch} does not exist. No backport PR needed.`); + core.setOutput('branches-exist', 'false'); + } + + - name: Checkout repository + if: steps.check.outputs.should-complete == 'true' && steps.check-branches.outputs.branches-exist == 'true' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create backport PR main-X.Y.Z → main + if: steps.check.outputs.should-complete == 'true' && steps.check-branches.outputs.branches-exist == 'true' + id: create-pr + run: | + VERSION="${{ github.event.milestone.title }}" + MAIN_BRANCH="main-${VERSION}" + + # Check if a backport PR already exists + EXISTING=$(gh pr list \ + --repo ${{ github.repository }} \ + --base main \ + --head "$MAIN_BRANCH" \ + --state open \ + --json number \ + --jq '.[0].number' 2>/dev/null || echo "") + + if [ -n "$EXISTING" ]; then + echo "Backport PR #${EXISTING} already exists. Skipping creation." + echo "pr_number=$EXISTING" >> $GITHUB_OUTPUT + else + PR_NUMBER=$(gh pr create \ + --repo ${{ github.repository }} \ + --base main \ + --head "$MAIN_BRANCH" \ + --title "chore: backport stable ${VERSION} to main" \ + --body $'This PR backports the stable release **'"${VERSION}"$'** from \`'"${MAIN_BRANCH}"$'\` to \`main\`.\n\nThis requires manual approval. After merging, the stabilization branches will be deleted automatically.' \ + 2>&1 | tail -1 | grep -oE '[0-9]+$' || echo "") + + if [ -n "$PR_NUMBER" ]; then + echo "✅ Created backport PR #${PR_NUMBER}" + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + else + PR_NUMBER=$(gh pr list --base main --head "$MAIN_BRANCH" --json number --jq '.[0].number') + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + fi + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + if: steps.check.outputs.should-complete == 'true' + run: | + echo "## ✅ Stabilization Path Completed" + echo "" + echo "- **Version:** ${{ github.event.milestone.title }}" + if [ "${{ steps.check-branches.outputs.branches-exist }}" == "true" ]; then + echo "- **Backport PR:** #${{ steps.create-pr.outputs.pr_number }} (main-${{ github.event.milestone.title }} → main)" + echo "- **Action required:** Approve and merge the backport PR" + echo "- **After merge:** Delete branches dev-${{ github.event.milestone.title }} and main-${{ github.event.milestone.title }} manually or via branch auto-deletion settings" + else + echo "- **Note:** Stabilization branches not found — no backport PR created" + fi + + # Delete stabilization branches after the backport PR is merged into main + cleanup-on-pr-merge: + runs-on: ubuntu-latest + if: | + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'main-') && + github.event.pull_request.base.ref == 'main' + permissions: + contents: write + steps: + - name: Extract version from branch name + id: extract-version + run: | + BRANCH="${{ github.event.pull_request.head.ref }}" + VERSION="${BRANCH#main-}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Delete stabilization branches + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const version = '${{ steps.extract-version.outputs.version }}'; + const branches = [`dev-${version}`, `main-${version}`]; + + for (const branch of branches) { + try { + await github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${branch}` + }); + console.log(`✅ Deleted branch: ${branch}`); + } catch (e) { + console.log(`Branch ${branch} not found or already deleted: ${e.message}`); + } + } From 4c61ce1b0a01b65faf3089c0ac45daa4f83ecd7c Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:55:41 +0100 Subject: [PATCH 015/110] ci: add workflows write permission to stabilization init workflow --- .github/workflows/stabilization-0-init.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/stabilization-0-init.yml b/.github/workflows/stabilization-0-init.yml index 7cf57718..b94ad647 100644 --- a/.github/workflows/stabilization-0-init.yml +++ b/.github/workflows/stabilization-0-init.yml @@ -8,6 +8,7 @@ name: Stabilization 0 - 🌿 Init Path # # Permissions: # - contents: write - Required to create branches +# - workflows: write - Required to push branches with workflow files on: milestone: @@ -15,6 +16,7 @@ on: permissions: contents: write + workflows: write jobs: init-stabilization-path: From f37899d0825f4d73029430e7fe99fcda16912f76 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:59:56 +0100 Subject: [PATCH 016/110] ci: remove unnecessary workflows write permission from stabilization init workflow --- .github/workflows/stabilization-0-init.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/stabilization-0-init.yml b/.github/workflows/stabilization-0-init.yml index b94ad647..653693e9 100644 --- a/.github/workflows/stabilization-0-init.yml +++ b/.github/workflows/stabilization-0-init.yml @@ -16,7 +16,6 @@ on: permissions: contents: write - workflows: write jobs: init-stabilization-path: From 75e4a6d151013577517fd166288eea0d9c8eeaf7 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:05:48 +0100 Subject: [PATCH 017/110] ci: replace git commands with GitHub API calls for branch creation in stabilization init workflow --- .github/workflows/stabilization-0-init.yml | 129 ++++++++++++++------- 1 file changed, 86 insertions(+), 43 deletions(-) diff --git a/.github/workflows/stabilization-0-init.yml b/.github/workflows/stabilization-0-init.yml index 653693e9..11c3088d 100644 --- a/.github/workflows/stabilization-0-init.yml +++ b/.github/workflows/stabilization-0-init.yml @@ -7,8 +7,7 @@ name: Stabilization 0 - 🌿 Init Path # - milestone.created when title matches X.Y.Z (no suffix) # # Permissions: -# - contents: write - Required to create branches -# - workflows: write - Required to push branches with workflow files +# - contents: write - Required to create branches via GitHub API on: milestone: @@ -86,49 +85,93 @@ jobs: core.setOutput('source', 'main'); } - - name: Create stabilization branches + - name: Get SHA for source ref if: steps.validate.outputs.is-stabilization == 'true' - run: | - VERSION="${{ github.event.milestone.title }}" - SOURCE="${{ steps.find-tag.outputs.source }}" - TAG="${{ steps.find-tag.outputs.tag }}" - - echo "Creating stabilization branches for ${VERSION} from ${SOURCE}" - - # Fetch all tags and branches - git fetch --tags origin - - # Determine the git ref to branch from - if [ -n "$TAG" ]; then - REF="refs/tags/${TAG}" - else - git fetch origin main - REF="origin/main" - fi - - echo "Using ref: ${REF}" - - # Create dev-X.Y.Z branch - DEV_BRANCH="dev-${VERSION}" - if git ls-remote --exit-code --heads origin "$DEV_BRANCH" > /dev/null 2>&1; then - echo "Branch ${DEV_BRANCH} already exists, skipping." - else - git checkout -b "$DEV_BRANCH" "$REF" - git push origin "$DEV_BRANCH" - echo "✅ Created ${DEV_BRANCH} from ${SOURCE}" - fi + id: get-sha + uses: actions/github-script@v7 + with: + script: | + const tag = '${{ steps.find-tag.outputs.tag }}'; + let sha; + + if (tag) { + // Get SHA from tag + const tagRef = await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${tag}` + }); + sha = tagRef.data.object.sha; + console.log(`Tag ${tag} points to SHA: ${sha}`); + } else { + // Get SHA from main branch + const branchRef = await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'heads/main' + }); + sha = branchRef.data.object.sha; + console.log(`Branch main points to SHA: ${sha}`); + } + + core.setOutput('sha', sha); - # Create main-X.Y.Z branch - MAIN_BRANCH="main-${VERSION}" - if git ls-remote --exit-code --heads origin "$MAIN_BRANCH" > /dev/null 2>&1; then - echo "Branch ${MAIN_BRANCH} already exists, skipping." - else - git checkout -b "$MAIN_BRANCH" "$REF" - git push origin "$MAIN_BRANCH" - echo "✅ Created ${MAIN_BRANCH} from ${SOURCE}" - fi - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Create stabilization branches + if: steps.validate.outputs.is-stabilization == 'true' + uses: actions/github-script@v7 + with: + script: | + const version = '${{ github.event.milestone.title }}'; + const source = '${{ steps.find-tag.outputs.source }}'; + const sha = '${{ steps.get-sha.outputs.sha }}'; + + console.log(`Creating stabilization branches for ${version} from ${source} (SHA: ${sha})`); + + // Create dev-X.Y.Z branch + const devBranch = `dev-${version}`; + try { + await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${devBranch}` + }); + console.log(`Branch ${devBranch} already exists, skipping.`); + } catch (error) { + if (error.status === 404) { + await github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/heads/${devBranch}`, + sha: sha + }); + console.log(`✅ Created ${devBranch} from ${source}`); + } else { + throw error; + } + } + + // Create main-X.Y.Z branch + const mainBranch = `main-${version}`; + try { + await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${mainBranch}` + }); + console.log(`Branch ${mainBranch} already exists, skipping.`); + } catch (error) { + if (error.status === 404) { + await github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/heads/${mainBranch}`, + sha: sha + }); + console.log(`✅ Created ${mainBranch} from ${source}`); + } else { + throw error; + } + } - name: Summary if: steps.validate.outputs.is-stabilization == 'true' From d93232d8349ad172184ecc3202c762422509fed3 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:18:58 +0100 Subject: [PATCH 018/110] ci: use PAT_TOKEN for branch creation in stabilization init workflow to bypass GitHub security restrictions --- .github/workflows/stabilization-0-init.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/stabilization-0-init.yml b/.github/workflows/stabilization-0-init.yml index 11c3088d..afec5ab2 100644 --- a/.github/workflows/stabilization-0-init.yml +++ b/.github/workflows/stabilization-0-init.yml @@ -7,14 +7,15 @@ name: Stabilization 0 - 🌿 Init Path # - milestone.created when title matches X.Y.Z (no suffix) # # Permissions: -# - contents: write - Required to create branches via GitHub API +# - Requires STABILITY_PAT_TOKEN secret with repo scope to create branches containing workflow files +# - GITHUB_TOKEN cannot create branches with .github/workflows/ files (security restriction) on: milestone: types: [created] permissions: - contents: write + contents: read jobs: init-stabilization-path: @@ -41,6 +42,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + token: ${{ secrets.STABILITY_PAT_TOKEN }} - name: Set up Git user if: steps.validate.outputs.is-stabilization == 'true' @@ -90,6 +92,7 @@ jobs: id: get-sha uses: actions/github-script@v7 with: + github-token: ${{ secrets.STABILITY_PAT_TOKEN }} script: | const tag = '${{ steps.find-tag.outputs.tag }}'; let sha; @@ -120,6 +123,7 @@ jobs: if: steps.validate.outputs.is-stabilization == 'true' uses: actions/github-script@v7 with: + github-token: ${{ secrets.STABILITY_PAT_TOKEN }} script: | const version = '${{ github.event.milestone.title }}'; const source = '${{ steps.find-tag.outputs.source }}'; From 84e2c204d2451b8231ebe05d3eba0798785d2dd3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:19:45 +0200 Subject: [PATCH 019/110] chore: prepare release 1.4.2-beta with version update and code style fixes (#422) * chore: add provider hash manifest for 1.4.2-alpha (#410) chore: add provider hash manifest for version 1.4.2-alpha (dual platform) Co-authored-by: github-actions[bot] * docs(ci): add comprehensive milestone management system with automated lifecycle and version scoping * Potential fix for code scanning alert no. 20: Code injection Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * docs(ci): add comprehensive versioning action documentation and standardize version parsing utilities * Update .github/actions/versioning/move-milestone-items/action.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(ci): correct step references and improve milestone pagination in versioning workflows * docs: add milestone management guide and update release workflow documentation * ci: enhance promotion eligibility checks with comprehensive validation criteria * ci: remove milestone check from promotion eligibility to allow promotion of closed milestone releases * ci: fix version grouping to use minor version and standardize PowerShell output commands * ci: skip promotion for versions with closed target milestones and fix output variable naming * ci: standardize PowerShell output commands and improve PR URL parsing in version promotion workflow * ci: prevent older versions from being promoted when target milestone is closed * ci: new stabilization-aware milestone workflow * ci: add workflows write permission to stabilization init workflow * ci: remove unnecessary workflows write permission from stabilization init workflow * ci: replace git commands with GitHub API calls for branch creation in stabilization init workflow * ci: use PAT_TOKEN for branch creation in stabilization init workflow to bypass GitHub security restrictions * chore: prepare release 1.4.2-beta with version update and code style fixes * fix: migration of critical fixes from 2.0.0-dev to 1.4.2-beta * style: reformat constructor calls and improve code consistency with 'this.' qualifier usage * refactor: replace result.Outputs.TryGetValue with result.TryGetValue across test components and improve code formatting consistency * refactor(statefulcomponentbase): supress unnecessary change in this release * refactor: supress unnecessary change in this release * refactor: simplify async code with ConfigureAwait and remove redundant null coalescing operators * chore: update CHANGELOG.md with unreleased changes for provider stability improvements and thread safety enhancements * ci: remove unnecessary JSON output flags from PR creation command in contributors workflow * refactor(ci): extract milestone creation logic into reusable composite action and add race condition handling * docs: update contributors section for release/1.4.2-beta --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: marc-romu <49920661+marc-romu@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: github-actions --- .github/MILESTONE_MANAGEMENT_GUIDE.md | 200 +++++++++++ .../actions/milestone/assign-pr/action.yml | 75 ++-- .../milestone/create-or-get/action.yml | 103 ++++++ .github/actions/versioning/README.md | 248 +++++++++++++ .../check-issues-for-version/action.yml | 241 +++++++++++++ .../create-version-label/action.yml | 75 ++++ .../versioning/format-version/action.yml | 41 +++ .../actions/versioning/get-version/action.yml | 9 +- .../versioning/manage-milestones/action.yml | 143 ++++++++ .../move-milestone-items/action.yml | 244 +++++++++++++ .../versioning/parse-version/action.yml | 65 ++++ .../versioning/update-version/action.yml | 14 +- .github/workflows/RELEASE_WORKFLOW.md | 153 ++++++-- .../workflows/chore-update-contributors.yml | 2 +- .github/workflows/ci-dotnet-tests.yml | 3 + .github/workflows/issue-auto-tag.yml | 117 +++++++ .github/workflows/milestone-management.yml | 206 ++--------- .../workflows/pr-build-hash-validation.yml | 2 + .github/workflows/pr-manifest-validation.yml | 1 + .github/workflows/pr-validation.yml | 2 + .github/workflows/pr-version-validation.yml | 2 + .github/workflows/release-1-milestone.yml | 82 +++-- .../workflows/release-2-pr-to-dev-closed.yml | 23 +- .../workflows/release-3-pr-to-main-closed.yml | 20 +- .github/workflows/release-4-build.yml | 44 +++ .github/workflows/release-6-upload-yak.yml | 8 + .github/workflows/release-promotion.yml | 326 ++++++++++++++++++ .github/workflows/stabilization-0-init.yml | 190 ++++++++++ .github/workflows/stabilization-1-cancel.yml | 177 ++++++++++ .../workflows/stabilization-2-complete.yml | 184 ++++++++++ CHANGELOG.md | 17 + README.md | 4 +- Solution.props | 2 +- hashes/1.4.2-alpha.json | 26 ++ .../Badges/TestBadgesOneComponent.cs | 14 +- .../Badges/TestBadgesThreeComponent.cs | 14 +- .../Badges/TestBadgesTwoComponent.cs | 14 +- ...TreeProcessorBranchFlattenTestComponent.cs | 61 ++-- ...reeProcessorBranchToBranchTestComponent.cs | 67 ++-- ...sorBroadcastDeeperDiffRootTestComponent.cs | 49 +-- ...sorBroadcastDeeperSameRootTestComponent.cs | 61 ++-- ...sorBroadcastMultipleNoZeroTestComponent.cs | 49 +-- ...rBroadcastMultipleTopLevelTestComponent.cs | 57 +-- ...ntPathsFirstOneSecondThreeTestComponent.cs | 15 +- ...ntPathsFirstThreeSecondOneTestComponent.cs | 55 +-- ...rDifferentPathsOneItemEachTestComponent.cs | 53 +-- ...fferentPathsThreeItemsEachTestComponent.cs | 63 ++-- ...essorDirectMatchPrecedenceTestComponent.cs | 55 +-- ...alPathsFirstOneSecondThreeTestComponent.cs | 57 +-- ...alPathsFirstThreeSecondOneTestComponent.cs | 57 +-- ...ataTreeProcessorEqualPathsTestComponent.cs | 56 +-- ...cessorEqualPathsThreeItemsTestComponent.cs | 76 ++-- ...reeProcessorGroupIdenticalTestComponent.cs | 73 ++-- ...DataTreeProcessorItemGraftTestComponent.cs | 73 ++-- ...ataTreeProcessorItemToItemTestComponent.cs | 57 +-- ...taTreeProcessorMixedDepthsTestComponent.cs | 53 +-- ...TreeProcessorRule2OverrideTestComponent.cs | 53 +-- .../Misc/StyledMessageDialogTestComponent.cs | 29 +- ...tAIStatefulTreePrimeCalculatorComponent.cs | 19 +- .../Misc/TestAsyncPrimeCalculatorComponent.cs | 19 +- .../Misc/TestStateManagerDebounceComponent.cs | 2 +- .../TestStateManagerRestorationComponent.cs | 2 +- .../TestStatefulPrimeCalculatorComponent.cs | 15 +- ...estStatefulTreePrimeCalculatorComponent.cs | 15 +- .../AI/AIChatComponent.cs | 16 +- .../AI/AIFileContextComponent.cs | 17 +- .../AI/AIModelsComponent.cs | 15 +- .../Grasshopper/GhPutComponents.cs | 2 +- .../Grasshopper/GhRetrieveComponents.cs | 14 +- .../Grasshopper/GhTidyUpComponents.cs | 9 +- .../Img/ImageViewerAttributes.cs | 5 +- .../List/AIListFilter.cs | 25 +- .../Misc/DeconstructMetricsComponent.cs | 11 +- .../Utils/CSharpIdentifierHelperTests.cs | 13 +- .../Utils/Parsing/AIResponseParserTests.cs | 52 --- .../AITools/ScriptCodeValidator.cs | 4 +- .../AITools/_gh_connect.cs | 2 - .../ComponentBase/StatefulComponentBase.cs | 39 ++- .../AdvancedConfigTests.cs | 3 +- .../Core/Interactions/AIInteractionImage.cs | 5 + .../AICall/Core/Requests/AIRequestBase.cs | 4 +- .../Policies/Request/RequestTimeoutPolicy.cs | 6 +- .../AICall/Sessions/ConversationSession.cs | 6 +- .../AICall/Tools/AIToolCall.cs | 2 +- .../AIModels/AICapability.cs | 50 ++- .../AIModels/AIModelCapabilities.cs | 4 +- .../AIProviders/AIProvider.cs | 105 +++++- .../AIProviders/AIProviderStreamingAdapter.cs | 2 +- .../AIProviders/IAIProvider.cs | 4 +- .../AIProviders/ProviderManager.cs | 84 +++-- .../AnthropicProvider.cs | 107 +++++- .../AnthropicProviderModels.cs | 60 +++- .../DeepSeekProvider.cs | 13 +- .../MistralAIProvider.cs | 48 ++- .../MistralAIProviderModels.cs | 94 ++++- .../OpenAIProvider.cs | 151 +++++++- .../OpenAIProviderModels.cs | 80 ++++- .../OpenRouterProvider.cs | 4 +- .../OpenRouterProviderModels.cs | 67 +++- 99 files changed, 4359 insertions(+), 1096 deletions(-) create mode 100644 .github/MILESTONE_MANAGEMENT_GUIDE.md create mode 100644 .github/actions/milestone/create-or-get/action.yml create mode 100644 .github/actions/versioning/README.md create mode 100644 .github/actions/versioning/check-issues-for-version/action.yml create mode 100644 .github/actions/versioning/create-version-label/action.yml create mode 100644 .github/actions/versioning/format-version/action.yml create mode 100644 .github/actions/versioning/manage-milestones/action.yml create mode 100644 .github/actions/versioning/move-milestone-items/action.yml create mode 100644 .github/actions/versioning/parse-version/action.yml create mode 100644 .github/workflows/issue-auto-tag.yml create mode 100644 .github/workflows/release-promotion.yml create mode 100644 .github/workflows/stabilization-0-init.yml create mode 100644 .github/workflows/stabilization-1-cancel.yml create mode 100644 .github/workflows/stabilization-2-complete.yml create mode 100644 hashes/1.4.2-alpha.json diff --git a/.github/MILESTONE_MANAGEMENT_GUIDE.md b/.github/MILESTONE_MANAGEMENT_GUIDE.md new file mode 100644 index 00000000..9b8ad209 --- /dev/null +++ b/.github/MILESTONE_MANAGEMENT_GUIDE.md @@ -0,0 +1,200 @@ +# Milestone Management System Guide + +## Overview + +The milestone management system automates the creation and lifecycle of milestones across different release stages (alpha → beta → rc → stable). The key principle is **scoping all operations to the same major version**, allowing different major versions to coexist independently. + +## System Behavior Summary + +| Scenario | Released Version | Action | Milestones Created | Milestones Closed | Notes | +| --- | --- | --- | --- | --- | --- | +| **Alpha Release** | `1.4.3-alpha` | Creates beta + next minor alpha | `1.4.3-beta`, `1.5.0-alpha` | Older `1.x.x-beta` only | Closes only beta milestones in major version 1 | +| **Alpha Release** | `2.0.0-alpha` | Creates beta + next minor alpha | `2.0.0-beta`, `2.1.0-alpha` | Older `2.x.x-beta` only | Closes only beta milestones in major version 2 | +| **Beta Release** | `1.4.3-beta` | Creates rc | `1.4.3-rc` | Older `1.x.x-rc` only | Closes only rc milestones in major version 1 | +| **RC Release** | `1.4.3-rc` | Creates stable | `1.4.3` | Older `1.x.x` (stable) only | Closes only stable milestones in major version 1 | +| **Stable Release** | `1.4.3` | No action | — | — | No milestones created for stable releases | + +## Coexistence Examples + +### Multiple Major Versions in Flight + +```text +Open Milestones: +├── 1.3.2-beta ✓ Active (latest beta in v1) +├── 1.4.3-beta ✓ Active (latest beta in v1) ← Would close 1.3.2-beta when 1.4.3-beta created +├── 1.5.0-alpha ✓ Active (unlimited alphas) +├── 1.6.0-alpha ✓ Active (unlimited alphas) +├── 2.0.0-beta ✓ Active (latest beta in v2) +├── 2.1.0-alpha ✓ Active (unlimited alphas) +└── 3.0.0-rc ✓ Active (latest rc in v3) +``` + +**Key Points:** + +- ✅ `1.3.2-beta` and `2.0.0-beta` coexist (different major versions) +- ✅ Unlimited alpha milestones allowed per major version +- ✅ Only one active beta per major version +- ✅ Only one active rc per major version +- ✅ Only one active stable per major version + +### Closure Behavior + +When `1.4.3-alpha` is released (published): + +- ✅ Closes `1.4.3-alpha` milestone (release triggers closure) +- ✅ Creates `1.4.3-beta` milestone (next stage) +- ✅ Creates `1.5.0-alpha` milestone (next minor) +- ✅ Closes older `1.x.x-beta` milestones (keeps only latest beta per major) +- ✅ **Does NOT** affect `2.x.x-beta` milestones (different major version) + +When `1.4.3-beta` is released (published): + +- ✅ Closes `1.4.3-beta` milestone +- ✅ Creates `1.4.3-rc` milestone (next stage) +- ✅ Creates `1.5.0-alpha` milestone (next minor, if not exists) +- ✅ Closes older `1.x.x-rc` milestones (keeps only latest rc per major) +- ✅ **Does NOT** affect `1.x.x-beta` or `2.x.x-rc` milestones + +When `2.0.0-beta` is released (published): + +- ✅ Closes `2.0.0-beta` milestone +- ✅ Creates `2.0.0-rc` milestone (next stage) +- ✅ Creates `2.1.0-alpha` milestone (next minor) +- ✅ Closes older `2.x.x-beta` milestones +- ✅ **Does NOT** affect `1.x.x-beta` milestones (different major version) + +## Issue Migration on Milestone Closure + +When a milestone is closed, open issues/PRs are migrated: + +| Closed Milestone Type | Target Milestone | Example | +| --- | --- | --- | +| `X.Y.Z-alpha` | `X.(Y+1).0-alpha` | `1.4.3-alpha` → `1.5.0-alpha` | +| `X.Y.Z-beta` | `X.(Y+1).0-alpha` | `1.4.3-beta` → `1.5.0-alpha` | +| `X.Y.Z-rc` | `X.(Y+1).0-alpha` | `1.4.3-rc` → `1.5.0-alpha` | +| `X.Y.Z` (stable) | `X.(Y+1).0-alpha` or `X.Y.(Z+1)` | `1.4.3` → `1.5.0-alpha` | + +**Note:** Target milestone is created automatically if it doesn't exist. + +**Rationale:** Pre-release milestones (alpha, beta, rc) represent work towards a specific release. When closed, issues migrate to the next minor version's alpha, aligning with the natural progression of the release cycle. + +## Release Promotion Workflow + +```text +1.4.3-alpha (released) + ↓ + Creates: 1.4.3-beta, 1.5.0-alpha + Closes: older 1.x.x-beta milestones + ↓ + (30 days, no issues reported) + ↓ +1.4.3-beta (released) + ↓ + Creates: 1.4.3-rc + Closes: older 1.x.x-rc milestones + ↓ + (30 days, no issues reported) + ↓ +1.4.3-rc (released) + ↓ + Creates: 1.4.3 (stable) + Closes: older 1.x.x (stable) milestones + ↓ + (Stable release complete) +``` + +## Manual Control + +You can manually control promotion paths by: + +1. **Closing a milestone** → Stops its promotion path +2. **Reopening a milestone** → Resumes its promotion path +3. **Deleting a milestone** → Removes it entirely (issues migrate to next version) + +## Scope Principle + +**All milestone operations are scoped to the same major version:** + +- Closing older milestones only affects milestones with the same major version +- Different major versions maintain independent milestone hierarchies +- This allows parallel development of multiple major versions + +## Implementation Details + +### Files Modified + +- `.github/actions/versioning/manage-milestones/action.yml` - Creates/closes milestones +- `.github/actions/versioning/move-milestone-items/action.yml` - Migrates issues +- `.github/workflows/milestone-management.yml` - Orchestrates the workflow + +### Key Functions + +**`closeOlderMilestones(suffix, majorVersion)`** + +- Filters milestones by suffix AND major version +- Sorts by minor.patch descending +- Closes all but the latest + +**`move-milestone-items` action** + +- Triggered on milestone closure +- Determines target milestone based on closed milestone type +- Migrates all open issues/PRs + +## Examples + +### Example 1: Parallel v1 and v2 Development + +```text +Release: 1.4.3-alpha + → Creates: 1.4.3-beta, 1.5.0-alpha + → Closes: 1.4.2-beta (if exists) + +Release: 2.0.0-alpha (same day) + → Creates: 2.0.0-beta, 2.1.0-alpha + → Closes: (no older 2.x.x-beta) + +Result: Both 1.4.3-beta and 2.0.0-beta coexist ✓ +``` + +### Example 2: Multiple Alphas + +```text +Open Milestones: + 1.4.0-alpha + 1.4.1-alpha + 1.4.2-alpha + 1.4.3-alpha ← Latest + +All remain open. No closure happens for alphas. +When 1.4.3-alpha closes, issues migrate to 1.5.0-alpha. +``` + +### Example 3: Beta Progression + +```text +Release: 1.4.3-beta + → Creates: 1.4.3-rc + → Closes: 1.4.2-beta, 1.4.1-beta, 1.4.0-beta + → Keeps: 1.4.3-beta (the newly created one) + → Issues from 1.4.3-beta migrate to 1.5.0-alpha + +Result: Only 1.4.3-beta remains open in v1 ✓ +``` + +## Troubleshooting + +### Issue: Old milestone not closing + +**Cause:** Different major version +**Solution:** Check if the old milestone has a different major version. This is expected behavior. + +### Issue: Milestone not created + +**Cause:** Already exists or invalid version format +**Solution:** Check logs for "already exists" message. Verify version format is `X.Y.Z` or `X.Y.Z-suffix`. + +### Issue: Issues not migrating + +**Cause:** No target milestone exists +**Solution:** The action creates the target milestone automatically. Check logs for creation status. diff --git a/.github/actions/milestone/assign-pr/action.yml b/.github/actions/milestone/assign-pr/action.yml index cc6c8315..cca711f2 100644 --- a/.github/actions/milestone/assign-pr/action.yml +++ b/.github/actions/milestone/assign-pr/action.yml @@ -17,10 +17,19 @@ inputs: required: false default: 'true' +outputs: + milestone-number: + description: 'The assigned milestone number' + value: ${{ steps.create-or-get.outputs.milestone-number }} + milestone-title: + description: 'The assigned milestone title' + value: ${{ steps.create-or-get.outputs.milestone-title }} + runs: using: 'composite' steps: - - name: Read version and assign to milestone + - name: Read version from Solution.props + id: read-version uses: actions/github-script@v7 with: github-token: ${{ inputs.token }} @@ -64,55 +73,37 @@ runs: processedVersion = processedVersion.replace('-dev', '-alpha'); console.log('Processed version for milestone:', processedVersion); - - // Find milestone with matching title - const { data: milestones } = await github.rest.issues.listMilestones({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'all' // Include both open and closed milestones - }); - - let targetMilestone = milestones.find(milestone => milestone.title === processedVersion); - - if (!targetMilestone) { - console.log(`No milestone found with title: ${processedVersion}`); - console.log('Available milestones:', milestones.map(m => m.title)); - - // Create the milestone if it doesn't exist - console.log(`Creating new milestone: ${processedVersion}`); - try { - const { data: newMilestone } = await github.rest.issues.createMilestone({ - owner: context.repo.owner, - repo: context.repo.repo, - title: processedVersion, - description: `Milestone for version ${processedVersion}`, - state: 'open' - }); - - targetMilestone = newMilestone; - console.log(`Successfully created milestone: ${targetMilestone.title}`); - - } catch (error) { - console.error('Error creating milestone:', error); - core.setFailed(`Failed to create milestone: ${error.message}`); - return; - } - } else { - console.log(`Found existing milestone: ${targetMilestone.title} (${targetMilestone.state})`); - } - - // Assign PR to milestone + core.setOutput('milestone-title', processedVersion); + + - name: Create or get milestone + id: create-or-get + if: steps.read-version.outputs.milestone-title != '' + uses: ./.github/actions/milestone/create-or-get + with: + title: ${{ steps.read-version.outputs.milestone-title }} + description: 'Milestone for version ${{ steps.read-version.outputs.milestone-title }}' + state: 'open' + token: ${{ inputs.token }} + + - name: Assign PR to milestone + if: steps.create-or-get.outputs.milestone-number != '' + uses: actions/github-script@v7 + with: + github-token: ${{ inputs.token }} + script: | const prNumber = parseInt('${{ inputs.pr-number }}', 10); + const milestoneNumber = parseInt('${{ steps.create-or-get.outputs.milestone-number }}', 10); + const milestoneTitle = '${{ steps.create-or-get.outputs.milestone-title }}'; try { await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, - milestone: targetMilestone.number + milestone: milestoneNumber }); - console.log(`Successfully assigned PR #${prNumber} to milestone "${targetMilestone.title}"`); + console.log(`Successfully assigned PR #${prNumber} to milestone "${milestoneTitle}"`); // Add a comment to the PR if requested if ('${{ inputs.comment-on-pr }}' === 'true') { @@ -120,7 +111,7 @@ runs: owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, - body: `🏷️ This PR has been automatically assigned to milestone **${targetMilestone.title}** based on the version in \`Solution.props\`.` + body: `🏷️ This PR has been automatically assigned to milestone **${milestoneTitle}** based on the version in \`Solution.props\`.` }); } diff --git a/.github/actions/milestone/create-or-get/action.yml b/.github/actions/milestone/create-or-get/action.yml new file mode 100644 index 00000000..3e9d61b3 --- /dev/null +++ b/.github/actions/milestone/create-or-get/action.yml @@ -0,0 +1,103 @@ +name: 'Create or Get Milestone' +description: 'Creates a milestone if it does not exist, or returns the existing one. Handles already_exists errors gracefully.' + +inputs: + title: + description: 'Milestone title to create or find' + required: true + description: + description: 'Milestone description (only used when creating new)' + required: false + default: '' + state: + description: 'Milestone state (open/closed), only used when creating new' + required: false + default: 'open' + token: + description: 'GitHub token with issues:write permission' + required: true + +outputs: + milestone-number: + description: 'The milestone number (for API usage)' + value: ${{ steps.create-or-get.outputs.milestone-number }} + milestone-title: + description: 'The milestone title' + value: ${{ steps.create-or-get.outputs.milestone-title }} + created: + description: 'Whether the milestone was newly created (true) or already existed (false)' + value: ${{ steps.create-or-get.outputs.created }} + +runs: + using: composite + steps: + - name: Create or get milestone + id: create-or-get + uses: actions/github-script@v7 + with: + github-token: ${{ inputs.token }} + script: | + const title = '${{ inputs.title }}'; + const description = '${{ inputs.description }}' || `Milestone for ${title}`; + const state = '${{ inputs.state }}'; + + // List milestones to check if it exists + const { data: milestones } = await github.rest.issues.listMilestones({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + per_page: 100 + }); + + let milestone = milestones.find(m => m.title === title); + + if (milestone) { + console.log(`Found existing milestone: ${milestone.title} (#${milestone.number})`); + core.setOutput('milestone-number', milestone.number); + core.setOutput('milestone-title', milestone.title); + core.setOutput('created', 'false'); + return; + } + + // Try to create the milestone + try { + const { data: newMilestone } = await github.rest.issues.createMilestone({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + description: description, + state: state + }); + + console.log(`Created new milestone: ${newMilestone.title} (#${newMilestone.number})`); + core.setOutput('milestone-number', newMilestone.number); + core.setOutput('milestone-title', newMilestone.title); + core.setOutput('created', 'true'); + + } catch (error) { + // Handle case where milestone was created by another concurrent process + if (error.status === 422 && error.message && error.message.includes('already_exists')) { + console.log(`Milestone "${title}" already exists (concurrent creation). Fetching...`); + + // Re-fetch to get the existing milestone + const { data: allMilestones } = await github.rest.issues.listMilestones({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + per_page: 100 + }); + + milestone = allMilestones.find(m => m.title === title); + + if (milestone) { + console.log(`Found existing milestone: ${milestone.title} (#${milestone.number})`); + core.setOutput('milestone-number', milestone.number); + core.setOutput('milestone-title', milestone.title); + core.setOutput('created', 'false'); + } else { + core.setFailed(`Milestone exists but could not be found: ${error.message}`); + } + } else { + core.setFailed(`Failed to create milestone: ${error.message}`); + } + } diff --git a/.github/actions/versioning/README.md b/.github/actions/versioning/README.md new file mode 100644 index 00000000..10e361f7 --- /dev/null +++ b/.github/actions/versioning/README.md @@ -0,0 +1,248 @@ +# Versioning Actions + +This directory contains reusable GitHub Actions for semantic version management across the SmartHopper release pipeline. + +## Shared Version Utilities + +All versioning actions use **consistent version parsing patterns** to ensure uniform behavior across the pipeline. + +### Version Format + +All versions follow semantic versioning with optional pre-release suffixes: + +- **Stable**: `X.Y.Z` (e.g., `1.4.3`) +- **Pre-release**: `X.Y.Z-STAGE[.DATE]` (e.g., `1.4.3-alpha`, `1.4.3-alpha.240101`) + +### Shared Parsing Logic + +The following functions are standardized across all actions: + +#### `parseVersion(versionStr)` + +Parses a version string into components: + +```javascript +{ + major: number, + minor: number, + patch: number, + suffix: string | null, // Full suffix (e.g., "alpha.240101") + original: string +} +``` + +#### `formatVersion(version)` + +Formats a version object back to string: + +```javascript +formatVersion({ major: 1, minor: 4, patch: 3, suffix: "alpha" }) +// Returns: "1.4.3-alpha" +``` + +#### `getStage(suffix)` + +Extracts release stage from suffix: + +```javascript +getStage("alpha.240101") // Returns: "alpha" +getStage("beta") // Returns: "beta" +getStage(null) // Returns: "stable" +``` + +#### `getNextStage(stage)` *(move-milestone-items only)* + +Returns the next stage in the stabilization promotion sequence: + +```javascript +getNextStage("alpha") // Returns: "beta" +getNextStage("beta") // Returns: "rc" +getNextStage("rc") // Returns: null (→ stable, no suffix) +``` + +### Regex Pattern + +All actions use this consistent regex for version parsing: + +```regex +^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.]+))?$ +``` + +This pattern: + +- Captures major, minor, patch as separate groups +- Allows optional suffix with dots (e.g., `alpha.240101`) +- Handles both stable (`1.4.3`) and pre-release (`1.4.3-alpha`) formats + +## Actions Overview + +### Core Parsing Actions + +#### `parse-version` + +Parses a version string into components (major, minor, patch, suffix, stage). + +**Inputs:** + +- `version`: Version string to parse + +**Outputs:** + +- `major`, `minor`, `patch`: Version components +- `suffix`: Full suffix (if any) +- `stage`: Release stage (alpha, beta, rc, stable) +- `is-prerelease`: Boolean flag + +#### `format-version` + +Formats version components back into a semantic version string. + +**Inputs:** + +- `major`, `minor`, `patch`: Version components +- `suffix`: Optional suffix + +**Outputs:** + +- `version`: Formatted version string + +### Milestone Management + +#### `manage-milestones` + +Creates next-stage milestones for released versions. Does **not** close older milestones — milestone lifecycle is managed by `release-promotion.yml`. + +| Released stage | Creates | +|---|---| +| alpha | `X.Y.Z-beta` + next minor `X.Y+1.0-alpha` | +| beta | `X.Y.Z-rc` | +| rc | `X.Y.Z` (stable, no suffix) | + +**Shared Utilities:** + +- `parseVersion()` - Parses version strings +- `formatVersion()` - Formats version objects +- `getStage()` - Extracts stage from suffix + +#### `move-milestone-items` + +Moves open issues and PRs from a closed milestone to a target milestone. + +**Stabilization-aware routing:** When closing `X.Y.Z-stage` and an open parent `X.Y.Z` (no-suffix) milestone exists, items are routed to `X.Y.Z-{nextStage}` (same series) instead of the next minor alpha. + +| Scenario | Target | +|---|---| +| Closed `X.Y.Z-stage`, parent `X.Y.Z` open | `X.Y.Z-{nextStage}` | +| Closed `X.Y.Z-stage`, no parent milestone | `X.Y+1.0-alpha` (reuse or create) | +| Closed stable `X.Y.Z` | `X.Y+1.0-alpha` (reuse or create) | + +**Shared Utilities:** + +- `parseVersion()` - Parses version strings +- `formatVersion()` - Formats version objects +- `getStage()` - Extracts stage from suffix +- `getNextStage()` - Returns next stabilization stage + + +### Version Retrieval and Updates + +#### `get-version` + +Extracts current version from Solution.props with component parsing. + +**Standardized Parsing:** + +- Uses consistent regex pattern +- Extracts stage from suffix +- Outputs major, minor, patch, suffix, stage + +#### `update-version` + +Updates version in Solution.props with component parsing. + +**Standardized Parsing:** + +- Uses consistent regex pattern +- Extracts stage from suffix +- Outputs major, minor, patch, suffix, stage + +## Shared Version Utilities Implementation + +### Pattern: Inline Shared Functions + +All JavaScript actions that use version parsing include the same shared utility functions marked with clear delimiters: + +```javascript +// ===== SHARED VERSION UTILITIES (used across versioning actions) ===== +function parseVersion(versionStr) { ... } +function formatVersion(version) { ... } +function getStage(suffix) { ... } +// ===== END SHARED VERSION UTILITIES ===== +``` + +### Why Inline Functions? + +GitHub Actions limitations make inline functions the most practical approach: + +- `actions/github-script` doesn't support passing scripts as inputs +- Composite actions can't effectively output multi-line code blocks +- Inline functions are self-contained and require no external dependencies +- Clear delimiters make utilities easy to identify and maintain + +### Shared Functions Reference + +#### parseVersion() + +Parses a semantic version string into components. + +**Input:** `"1.4.3-alpha.240101"` + +**Output:** + +```javascript +{ + major: 1, + minor: 4, + patch: 3, + suffix: "alpha.240101", + original: "1.4.3-alpha.240101" +} +``` + +#### formatVersion() + +Formats a version object back to string. + +**Input:** + +```javascript +{ major: 1, minor: 4, patch: 3, suffix: "alpha" } +``` + +**Output:** `"1.4.3-alpha"` + +#### getStage() + +Extracts release stage from suffix. + +**Examples:** + +- `getStage("alpha.240101")` → `"alpha"` +- `getStage("beta")` → `"beta"` +- `getStage(null)` → `"stable"` + +### Actions Using Shared Utilities + +- **manage-milestones** - Uses `parseVersion`, `formatVersion`, `getStage` +- **move-milestone-items** - Uses `parseVersion`, `formatVersion`, `getStage`, `getNextStage` + +> `getNextStage` is only in `move-milestone-items` (stabilization path routing). It is not needed in `manage-milestones`. + +### Maintenance + +When updating shared utilities: + +1. Update the function in `manage-milestones/action.yml` (if applicable) +2. Update the function in `move-milestone-items/action.yml` +3. Update the reference documentation in this README +4. Ensure implementations remain consistent (note: `getNextStage` is only in `move-milestone-items`) diff --git a/.github/actions/versioning/check-issues-for-version/action.yml b/.github/actions/versioning/check-issues-for-version/action.yml new file mode 100644 index 00000000..e2aa9c7f --- /dev/null +++ b/.github/actions/versioning/check-issues-for-version/action.yml @@ -0,0 +1,241 @@ +name: 'Check Issues for Version' +description: 'Check promotion eligibility: milestone status, release age, and open issues with version label (all stages)' +inputs: + version: + description: 'Version to check for issues (e.g., 1.4.2-alpha)' + required: true + days-lookback: + description: 'Number of days to look back for issues (default: 30)' + required: false + default: '30' + token: + description: 'GitHub token for API access' + required: true + default: ${{ github.token }} + +outputs: + has-issues: + description: 'Whether any open issues exist with this version label' + value: ${{ steps.check-issues.outputs.has_issues }} + issue-count: + description: 'Number of open issues with this version label' + value: ${{ steps.check-issues.outputs.issue_count }} + issues-list: + description: 'Comma-separated list of issue numbers' + value: ${{ steps.check-issues.outputs.issues_list }} + release-date: + description: 'Release published date (ISO 8601)' + value: ${{ steps.check-issues.outputs.release_date }} + release-age-days: + description: 'Number of days since release was published' + value: ${{ steps.check-issues.outputs.release_age_days }} + release-old-enough: + description: 'Whether release is at least 30 days old' + value: ${{ steps.check-issues.outputs.release_old_enough }} + last-closed-issue-date: + description: 'Date of last closed issue with this version label (ISO 8601)' + value: ${{ steps.check-issues.outputs.last_closed_issue_date }} + last-closed-age-days: + description: 'Number of days since last issue was closed' + value: ${{ steps.check-issues.outputs.last_closed_age_days }} + last-closed-old-enough: + description: 'Whether last closed issue is at least 30 days old' + value: ${{ steps.check-issues.outputs.last_closed_old_enough }} + can-promote: + description: 'Whether all conditions are met for promotion (milestone clear, release old enough, no recent closed issues)' + value: ${{ steps.check-issues.outputs.can_promote }} + blocking-reason: + description: 'Reason why promotion is blocked (if can-promote is false)' + value: ${{ steps.check-issues.outputs.blocking_reason }} + +runs: + using: "composite" + steps: + - name: Check for issues with version label + id: check-issues + shell: pwsh + env: + GITHUB_TOKEN: ${{ inputs.token }} + run: | + $version = "${{ inputs.version }}" + $labelName = "version: $version" + $daysLookback = [int]"${{ inputs.days-lookback }}" + $repo = "${{ github.repository }}" + + Write-Host "=== Promotion Eligibility Check for $version ===" + Write-Host "" + + $headers = @{ + "Authorization" = "Bearer $env:GITHUB_TOKEN" + "Accept" = "application/vnd.github.v3+json" + } + + $repoOwner = $repo.Split('/')[0] + $repoName = $repo.Split('/')[1] + $canPromote = $true + $blockingReasons = @() + + # ============================================================ + # CHECK 1: Open issues with version label (any stage) + # ============================================================ + Write-Host "CHECK 1: Looking for ALL open issues with version label (base + all stages)" + + # Parse version to get base version (X.Y.Z without stage suffix) + if ($version -match '^(\d+\.\d+\.\d+)') { + $baseVersion = $matches[1] + Write-Host " Base version: $baseVersion" + } else { + Write-Host " ⚠️ Could not parse base version from '$version'" + $baseVersion = $version + } + + # Search for all open issues with labels matching the base version (any stage) + # This will match: version: X.Y.Z, version: X.Y.Z-alpha, version: X.Y.Z-beta, etc. + $openIssues = @() + $page = 1 + $perPage = 100 + + do { + # Search for issues with labels starting with "version: X.Y.Z" + $query = "state:open label:""version: $baseVersion"" repo:$repo" + $uri = "https://api.github.com/search/issues?q=$([Uri]::EscapeDataString($query))&per_page=$perPage&page=$page" + + try { + $response = Invoke-RestMethod -Uri $uri -Headers $headers -Method GET + $openIssues += $response.items + + if ($response.items.Count -lt $perPage) { + break + } + $page++ + } catch { + Write-Error "Failed to fetch open issues: $($_.Exception.Message)" + exit 1 + } + } while ($response.items.Count -eq $perPage) + + $openIssueCount = $openIssues.Count + $openIssueNumbers = ($openIssues | ForEach-Object { $_.number }) -join "," + + if ($openIssueCount -gt 0) { + Write-Host " ❌ Found $openIssueCount open issue(s): $openIssueNumbers" + Write-Host " (Issues discovered in version $baseVersion line, any stage)" + echo "has_issues=true" >> $env:GITHUB_OUTPUT + $canPromote = $false + $blockingReasons += "version_label_has_open_issues" + } else { + Write-Host " ✅ No open issues with version label (checked all stages)" + echo "has_issues=false" >> $env:GITHUB_OUTPUT + } + + echo "issue_count=$openIssueCount" >> $env:GITHUB_OUTPUT + echo "issues_list=$openIssueNumbers" >> $env:GITHUB_OUTPUT + Write-Host "" + + # (CHECK 2 removed: target milestone check was redundant with CHECK 1. + # Bugs from alpha labeled 'version: 1.4.3-alpha' are caught by CHECK 1. + # Checking the beta milestone also incorrectly blocks on planned features.) + + # ============================================================ + # CHECK 3: Release age (must be >= 30 days old) + # ============================================================ + Write-Host "CHECK 3: Checking release age" + + try { + # Get release by tag + $releaseUri = "https://api.github.com/repos/$repo/releases/tags/$version" + $release = Invoke-RestMethod -Uri $releaseUri -Headers $headers -Method GET + + $publishedAt = [DateTime]::Parse($release.published_at) + $now = Get-Date + $ageDays = ($now - $publishedAt).Days + $releaseOldEnough = $ageDays -ge $daysLookback + + Write-Host " Release published: $($publishedAt.ToString('yyyy-MM-dd'))" + Write-Host " Age: $ageDays days" + + if ($releaseOldEnough) { + Write-Host " ✅ Release is old enough (>= $daysLookback days)" + echo "release_old_enough=true" >> $env:GITHUB_OUTPUT + } else { + Write-Host " ❌ Release is too recent (< $daysLookback days)" + echo "release_old_enough=false" >> $env:GITHUB_OUTPUT + $canPromote = $false + $blockingReasons += "release_too_recent" + } + + echo "release_date=$($publishedAt.ToString('yyyy-MM-ddTHH:mm:ssZ'))" >> $env:GITHUB_OUTPUT + echo "release_age_days=$ageDays" >> $env:GITHUB_OUTPUT + } catch { + Write-Warning "Failed to fetch release: $($_.Exception.Message)" + echo "release_date=unknown" >> $env:GITHUB_OUTPUT + echo "release_age_days=-1" >> $env:GITHUB_OUTPUT + echo "release_old_enough=unknown" >> $env:GITHUB_OUTPUT + } + Write-Host "" + + # ============================================================ + # CHECK 4: Last closed issue age (must be >= 30 days old) + # ============================================================ + Write-Host "CHECK 4: Checking last closed issue with label '$labelName'" + + try { + # Search for closed issues with version label, sorted by closed date (most recent first) + $query = "state:closed label:""$labelName"" repo:$repo" + $closedIssuesUri = "https://api.github.com/search/issues?q=$([Uri]::EscapeDataString($query))&sort=updated&order=desc&per_page=1" + $closedResponse = Invoke-RestMethod -Uri $closedIssuesUri -Headers $headers -Method GET + + if ($closedResponse.total_count -gt 0 -and $closedResponse.items.Count -gt 0) { + $lastClosedIssue = $closedResponse.items[0] + $closedAt = [DateTime]::Parse($lastClosedIssue.closed_at) + $now = Get-Date + $closedAgeDays = ($now - $closedAt).Days + $closedOldEnough = $closedAgeDays -ge $daysLookback + + Write-Host " Last closed issue: #$($lastClosedIssue.number)" + Write-Host " Closed: $($closedAt.ToString('yyyy-MM-dd'))" + Write-Host " Age: $closedAgeDays days" + + if ($closedOldEnough) { + Write-Host " ✅ Last closed issue is old enough (>= $daysLookback days)" + echo "last_closed_old_enough=true" >> $env:GITHUB_OUTPUT + } else { + Write-Host " ❌ Last closed issue is too recent (< $daysLookback days)" + echo "last_closed_old_enough=false" >> $env:GITHUB_OUTPUT + $canPromote = $false + $blockingReasons += "last_closed_too_recent" + } + + echo "last_closed_issue_date=$($closedAt.ToString('yyyy-MM-ddTHH:mm:ssZ'))" >> $env:GITHUB_OUTPUT + echo "last_closed_age_days=$closedAgeDays" >> $env:GITHUB_OUTPUT + } else { + Write-Host " ✅ No closed issues found with version label" + echo "last_closed_issue_date=none" >> $env:GITHUB_OUTPUT + echo "last_closed_age_days=-1" >> $env:GITHUB_OUTPUT + echo "last_closed_old_enough=true" >> $env:GITHUB_OUTPUT + } + } catch { + Write-Warning "Failed to check closed issues: $($_.Exception.Message)" + echo "last_closed_issue_date=unknown" >> $env:GITHUB_OUTPUT + echo "last_closed_age_days=-1" >> $env:GITHUB_OUTPUT + echo "last_closed_old_enough=unknown" >> $env:GITHUB_OUTPUT + } + Write-Host "" + + # ============================================================ + # FINAL DECISION + # ============================================================ + Write-Host "=== PROMOTION DECISION ===" + + if ($canPromote) { + Write-Host "✅ ALL CONDITIONS MET - Can promote $version" + echo "can_promote=true" >> $env:GITHUB_OUTPUT + echo "blocking_reason=none" >> $env:GITHUB_OUTPUT + } else { + $reasonsText = $blockingReasons -join ", " + Write-Host "❌ BLOCKED - Cannot promote $version" + Write-Host " Reasons: $reasonsText" + echo "can_promote=false" >> $env:GITHUB_OUTPUT + echo "blocking_reason=$reasonsText" >> $env:GITHUB_OUTPUT + } + Write-Host "" diff --git a/.github/actions/versioning/create-version-label/action.yml b/.github/actions/versioning/create-version-label/action.yml new file mode 100644 index 00000000..cd240513 --- /dev/null +++ b/.github/actions/versioning/create-version-label/action.yml @@ -0,0 +1,75 @@ +name: 'Create Version Label' +description: 'Create or verify a version label in the repository' +inputs: + version: + description: 'Version to create label for (e.g., 1.4.2-alpha)' + required: true + token: + description: 'GitHub token for API access' + required: true + default: ${{ github.token }} + color: + description: 'Label color (hex without #, default: 0366d6)' + required: false + default: '0366d6' + +outputs: + label-created: + description: 'Whether the label was newly created' + value: ${{ steps.create-label.outputs.created }} + label-exists: + description: 'Whether the label already existed' + value: ${{ steps.create-label.outputs.exists }} + label-name: + description: 'The full label name created' + value: ${{ steps.create-label.outputs.label }} + +runs: + using: "composite" + steps: + - name: Create or verify version label + id: create-label + shell: pwsh + env: + GITHUB_TOKEN: ${{ inputs.token }} + run: | + $version = "${{ inputs.version }}" + $labelName = "version: $version" + $color = "${{ inputs.color }}" + $repo = "${{ github.repository }}" + + Write-Host "Processing version label: $labelName" + + # Check if label already exists + $headers = @{ + "Authorization" = "Bearer $env:GITHUB_TOKEN" + "Accept" = "application/vnd.github.v3+json" + } + + try { + $existingLabel = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/labels/$([Uri]::EscapeDataString($labelName))" -Headers $headers -Method GET -ErrorAction SilentlyContinue + Write-Host "Label already exists: $labelName" + echo "created=false" >> $env:GITHUB_OUTPUT + echo "exists=true" >> $env:GITHUB_OUTPUT + echo "label=$labelName" >> $env:GITHUB_OUTPUT + } catch { + # Label doesn't exist, create it + Write-Host "Creating new label: $labelName" + + $body = @{ + name = $labelName + color = $color + description = "Issues related to version $version" + } | ConvertTo-Json + + try { + $newLabel = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/labels" -Headers $headers -Method POST -Body $body -ContentType "application/json" + Write-Host "Successfully created label: $labelName" + echo "created=true" >> $env:GITHUB_OUTPUT + echo "exists=true" >> $env:GITHUB_OUTPUT + echo "label=$labelName" >> $env:GITHUB_OUTPUT + } catch { + Write-Error "Failed to create label: $($_.Exception.Message)" + exit 1 + } + } diff --git a/.github/actions/versioning/format-version/action.yml b/.github/actions/versioning/format-version/action.yml new file mode 100644 index 00000000..cd897a34 --- /dev/null +++ b/.github/actions/versioning/format-version/action.yml @@ -0,0 +1,41 @@ +name: 'Format Version' +description: 'Format version components back into a semantic version string' +inputs: + major: + description: 'Major version component' + required: true + minor: + description: 'Minor version component' + required: true + patch: + description: 'Patch version component' + required: true + suffix: + description: 'Optional suffix (e.g., alpha.240101)' + required: false + default: '' + +outputs: + version: + description: 'Formatted version string' + value: ${{ steps.format.outputs.version }} + +runs: + using: "composite" + steps: + - name: Format version + id: format + shell: pwsh + run: | + $major = "${{ inputs.major }}" + $minor = "${{ inputs.minor }}" + $patch = "${{ inputs.patch }}" + $suffix = "${{ inputs.suffix }}" + + $version = "$major.$minor.$patch" + if ($suffix) { + $version += "-$suffix" + } + + Write-Host "Formatted version: $version" + "version=$version" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 diff --git a/.github/actions/versioning/get-version/action.yml b/.github/actions/versioning/get-version/action.yml index dcd7ecb2..2cc99dd7 100644 --- a/.github/actions/versioning/get-version/action.yml +++ b/.github/actions/versioning/get-version/action.yml @@ -39,16 +39,21 @@ runs: shell: pwsh run: | $VERSION = '${{ steps.get-version.outputs.version }}' - if ($VERSION -match '^(\d+)\.(\d+)\.(\d+)(-[A-Za-z0-9]+(\.[0-9]+)?)?$') { + if ($VERSION -match '^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.]+))?$') { $major = $Matches[1] $minor = $Matches[2] $patch = $Matches[3] $suffix = $Matches[4] + + # Extract stage from suffix (e.g., 'alpha' from 'alpha.240101') + $stage = if ($suffix) { $suffix.Split('.')[0].ToLower() } else { 'stable' } + "major=$major" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 "minor=$minor" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 "patch=$patch" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 "suffix=$suffix" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 - Write-Host "Parsed version: Major=$major, Minor=$minor, Patch=$patch, Suffix=$suffix" + "stage=$stage" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Host "Parsed version: Major=$major, Minor=$minor, Patch=$patch, Suffix=$suffix, Stage=$stage" } else { Write-Error "Error: Version format doesn't follow semantic versioning: $VERSION" exit 1 diff --git a/.github/actions/versioning/manage-milestones/action.yml b/.github/actions/versioning/manage-milestones/action.yml new file mode 100644 index 00000000..0ab59103 --- /dev/null +++ b/.github/actions/versioning/manage-milestones/action.yml @@ -0,0 +1,143 @@ +name: Manage Release Milestones +description: Creates next-stage milestones for release versions + +inputs: + released-version: + description: 'The released version tag (e.g., 1.4.3-alpha)' + required: true + token: + description: 'GitHub token for API access' + required: true + +outputs: + created-milestones: + description: 'JSON array of created milestone titles' + value: ${{ steps.manage.outputs.created-milestones }} + +runs: + using: composite + steps: + - name: Manage milestones for released version + id: manage + uses: actions/github-script@v7 + with: + github-token: ${{ inputs.token }} + script: | + // ===== SHARED VERSION UTILITIES (used across versioning actions) ===== + // Parse semantic version from string + function parseVersion(versionStr) { + const match = versionStr.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); + if (!match) return null; + + return { + major: parseInt(match[1]), + minor: parseInt(match[2]), + patch: parseInt(match[3]), + suffix: match[4] || null, + original: versionStr + }; + } + + // Format version object back to string + function formatVersion(version) { + let versionStr = `${version.major}.${version.minor}.${version.patch}`; + if (version.suffix) { + versionStr += `-${version.suffix}`; + } + return versionStr; + } + // ===== END SHARED VERSION UTILITIES ===== + + // Create a milestone + async function createMilestone(title, description) { + try { + const response = await github.rest.issues.createMilestone({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + description: description || `Auto-created milestone for ${title}` + }); + console.log(`✅ Created milestone: ${title}`); + return { title, created: true }; + } catch (error) { + if (error.message.includes('already exists')) { + console.log(`ℹ️ Milestone ${title} already exists`); + return { title, created: false }; + } + throw error; + } + } + + // Extract stage from suffix (e.g., 'alpha' from 'alpha.240101') + function getStage(suffix) { + if (!suffix || suffix === 'stable') return 'stable'; + return suffix.split('.')[0].toLowerCase(); + } + + const releasedVersion = '${{ inputs.released-version }}'; + console.log(`Processing released version: ${releasedVersion}`); + + const parsed = parseVersion(releasedVersion); + if (!parsed) { + console.log('Release tag is not a valid semantic version, skipping.'); + core.setOutput('created-milestones', JSON.stringify([])); + return; + } + + const createdMilestones = []; + + // Extract stage from suffix + const stage = getStage(parsed.suffix); + + // Alpha releases create two milestones: beta and next minor alpha + if (stage === 'alpha') { + // Create beta milestone + const betaVersion = { + major: parsed.major, + minor: parsed.minor, + patch: parsed.patch, + suffix: 'beta' + }; + const betaVersionStr = formatVersion(betaVersion); + const betaResult = await createMilestone(betaVersionStr, `Auto-created milestone for ${betaVersionStr} (stability path from ${releasedVersion})`); + if (betaResult.created) createdMilestones.push(betaVersionStr); + + // Create next minor alpha milestone + const nextMinorAlpha = { + major: parsed.major, + minor: parsed.minor + 1, + patch: 0, + suffix: 'alpha' + }; + const nextMinorAlphaStr = formatVersion(nextMinorAlpha); + const nextAlphaResult = await createMilestone(nextMinorAlphaStr, `Auto-created next minor alpha (from ${releasedVersion})`); + if (nextAlphaResult.created) createdMilestones.push(nextMinorAlphaStr); + } + // Beta releases create rc milestone + else if (stage === 'beta') { + const rcVersion = { + major: parsed.major, + minor: parsed.minor, + patch: parsed.patch, + suffix: 'rc' + }; + const rcVersionStr = formatVersion(rcVersion); + const rcResult = await createMilestone(rcVersionStr, `Auto-created milestone for ${rcVersionStr} (stability path from ${releasedVersion})`); + if (rcResult.created) createdMilestones.push(rcVersionStr); + } + // RC releases create stable milestone + else if (stage === 'rc') { + const stableVersion = { + major: parsed.major, + minor: parsed.minor, + patch: parsed.patch, + suffix: null + }; + const stableVersionStr = formatVersion(stableVersion); + const stableResult = await createMilestone(stableVersionStr, `Auto-created milestone for ${stableVersionStr} (stability path from ${releasedVersion})`); + if (stableResult.created) createdMilestones.push(stableVersionStr); + } + + console.log(`Created milestones: ${JSON.stringify(createdMilestones)}`); + + core.setOutput('created-milestones', JSON.stringify(createdMilestones)); diff --git a/.github/actions/versioning/move-milestone-items/action.yml b/.github/actions/versioning/move-milestone-items/action.yml new file mode 100644 index 00000000..e9f8fcf4 --- /dev/null +++ b/.github/actions/versioning/move-milestone-items/action.yml @@ -0,0 +1,244 @@ +name: Move Milestone Items +description: Moves open issues and PRs from a closed milestone to a target milestone + +inputs: + closed-milestone-title: + description: 'Title of the closed milestone' + required: true + token: + description: 'GitHub token for API access' + required: true + +outputs: + target-milestone: + description: 'Title of the target milestone items were moved to' + value: ${{ steps.move.outputs.target-milestone }} + items-moved: + description: 'Number of items moved' + value: ${{ steps.move.outputs.items-moved }} + +runs: + using: composite + steps: + - name: Move items to target milestone + id: move + uses: actions/github-script@v7 + with: + github-token: ${{ inputs.token }} + script: | + // ===== SHARED VERSION UTILITIES (used across versioning actions) ===== + // Parse semantic version from string + function parseVersion(versionStr) { + const match = versionStr.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); + if (!match) return null; + + return { + major: parseInt(match[1]), + minor: parseInt(match[2]), + patch: parseInt(match[3]), + prerelease: match[4] || null, + suffix: match[4] || null, // Alias for consistency with manage-milestones + original: versionStr + }; + } + + // Format version object back to string + function formatVersion(version) { + let versionStr = `${version.major}.${version.minor}.${version.patch}`; + const suffix = version.prerelease || version.suffix; + if (suffix) { + versionStr += `-${suffix}`; + } + return versionStr; + } + // ===== END SHARED VERSION UTILITIES ===== + + // Find milestone by title + async function findMilestone(title) { + const milestones = await github.rest.issues.listMilestones({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open' + }); + + return milestones.data.find(m => m.title === title); + } + + // Create new milestone + async function createMilestone(title, description) { + const response = await github.rest.issues.createMilestone({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + description: description || `Auto-created milestone for version ${title}` + }); + + console.log(`Created new milestone: ${title}`); + return response.data; + } + + // Get open issues and PRs for a milestone + async function getOpenItemsInMilestone(milestoneNumber) { + const [issues, pulls] = await Promise.all([ + github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + milestone: milestoneNumber, + state: 'open' + }), + github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open' + }) + ]); + + const prsInMilestone = pulls.data.filter(pr => + pr.milestone && pr.milestone.number === milestoneNumber + ); + + return { + issues: issues.data.filter(issue => !issue.pull_request), + prs: prsInMilestone + }; + } + + // Move item to new milestone + async function moveItemToMilestone(itemNumber, newMilestoneNumber) { + try { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: itemNumber, + milestone: newMilestoneNumber + }); + console.log(`Successfully moved item #${itemNumber} to milestone #${newMilestoneNumber}`); + return true; + } catch (error) { + console.error(`Failed to move item #${itemNumber}:`, error.message); + return false; + } + } + + const closedMilestoneTitle = '${{ inputs.closed-milestone-title }}'; + console.log(`Processing closed milestone: ${closedMilestoneTitle}`); + + const closedVersion = parseVersion(closedMilestoneTitle); + if (!closedVersion) { + console.log('Milestone title is not a valid semantic version, skipping.'); + core.setOutput('target-milestone', ''); + core.setOutput('items-moved', '0'); + return; + } + + // Get open items in the closed milestone (from context) + const closedMilestone = context.payload.milestone; + const openItems = await getOpenItemsInMilestone(closedMilestone.number); + const totalItems = openItems.issues.length + openItems.prs.length; + + console.log(`Found ${openItems.issues.length} open issues and ${openItems.prs.length} open PRs`); + + if (totalItems === 0) { + console.log('No open items to move, exiting.'); + core.setOutput('target-milestone', ''); + core.setOutput('items-moved', '0'); + return; + } + + // Determine target milestone + let targetMilestone = null; + + // Extract stage from prerelease (e.g., 'alpha' from 'alpha.240101') + function getStage(prerelease) { + if (!prerelease || prerelease === 'stable') return 'stable'; + return prerelease.split('.')[0].toLowerCase(); + } + + // Get next stage in stabilization path + function getNextStage(stage) { + if (stage === 'alpha') return 'beta'; + if (stage === 'beta') return 'rc'; + if (stage === 'rc') return null; // stable (no suffix) + return null; + } + + // Check whether a parent stabilization milestone (X.Y.Z, no suffix) is still open + async function findOpenStabilizationMilestone(major, minor, patch) { + const parentTitle = `${major}.${minor}.${patch}`; + const milestones = await github.paginate( + github.rest.issues.listMilestones, + { owner: context.repo.owner, repo: context.repo.repo, state: 'open', per_page: 100 } + ); + return milestones.find(m => m.title === parentTitle) || null; + } + + // For alpha, beta, rc: check if we're in a stabilization path first + if (closedVersion.prerelease && closedVersion.prerelease !== 'stable') { + const stage = getStage(closedVersion.prerelease); + const parentMilestone = await findOpenStabilizationMilestone( + closedVersion.major, closedVersion.minor, closedVersion.patch + ); + + if (parentMilestone) { + // Stabilization path: route to same series next stage + const nextStage = getNextStage(stage); + const nextStageSuffix = nextStage ? `-${nextStage}` : ''; + const nextStageTitle = `${closedVersion.major}.${closedVersion.minor}.${closedVersion.patch}${nextStageSuffix}`; + console.log(`Stabilization path detected (parent milestone '${parentMilestone.title}' is open). Routing to: ${nextStageTitle}`); + targetMilestone = await findMilestone(nextStageTitle); + if (!targetMilestone) { + targetMilestone = await createMilestone(nextStageTitle, `Stabilization path milestone for ${nextStageTitle}`); + } + } else { + // Normal dev path: move to next minor alpha + const nextMinorAlpha = { + major: closedVersion.major, + minor: closedVersion.minor + 1, + patch: 0, + prerelease: 'alpha' + }; + const nextMinorAlphaTitle = formatVersion(nextMinorAlpha); + console.log(`Closed milestone is ${closedVersion.prerelease} (stage: ${stage}), looking for next minor alpha: ${nextMinorAlphaTitle}`); + targetMilestone = await findMilestone(nextMinorAlphaTitle); + if (!targetMilestone) { + targetMilestone = await createMilestone(nextMinorAlphaTitle, `Next minor alpha version after ${closedMilestoneTitle}`); + } + } + } else { + // For stable: always route to next minor alpha (create if needed) + const nextMinorAlpha = { + major: closedVersion.major, + minor: closedVersion.minor + 1, + patch: 0, + prerelease: 'alpha' + }; + const nextMinorAlphaTitle = formatVersion(nextMinorAlpha); + console.log(`Closed stable milestone, routing to: ${nextMinorAlphaTitle}`); + targetMilestone = await findMilestone(nextMinorAlphaTitle); + if (!targetMilestone) { + targetMilestone = await createMilestone(nextMinorAlphaTitle, `Next minor alpha version after ${closedMilestoneTitle}`); + } + } + + console.log(`Target milestone: ${targetMilestone.title}`); + + // Move all items + const movePromises = []; + + for (const issue of openItems.issues) { + console.log(`Moving issue #${issue.number}: ${issue.title}`); + movePromises.push(moveItemToMilestone(issue.number, targetMilestone.number)); + } + + for (const pr of openItems.prs) { + console.log(`Moving PR #${pr.number}: ${pr.title}`); + movePromises.push(moveItemToMilestone(pr.number, targetMilestone.number)); + } + + const results = await Promise.all(movePromises); + const successCount = results.filter(r => r).length; + + console.log(`Successfully moved ${successCount}/${totalItems} items to "${targetMilestone.title}"`); + + core.setOutput('target-milestone', targetMilestone.title); + core.setOutput('items-moved', successCount.toString()); diff --git a/.github/actions/versioning/parse-version/action.yml b/.github/actions/versioning/parse-version/action.yml new file mode 100644 index 00000000..362800ce --- /dev/null +++ b/.github/actions/versioning/parse-version/action.yml @@ -0,0 +1,65 @@ +name: 'Parse Version' +description: 'Parse semantic version string into components (major, minor, patch, suffix, stage)' +inputs: + version: + description: 'Version string to parse (e.g., 1.4.3 or 1.4.3-alpha.240101)' + required: true + +outputs: + major: + description: 'Major version component' + value: ${{ steps.parse.outputs.major }} + minor: + description: 'Minor version component' + value: ${{ steps.parse.outputs.minor }} + patch: + description: 'Patch version component' + value: ${{ steps.parse.outputs.patch }} + suffix: + description: 'Full suffix including stage and date (e.g., alpha.240101)' + value: ${{ steps.parse.outputs.suffix }} + stage: + description: 'Release stage extracted from suffix (alpha, beta, rc, or stable)' + value: ${{ steps.parse.outputs.stage }} + is-prerelease: + description: 'Whether this is a pre-release version' + value: ${{ steps.parse.outputs.is-prerelease }} + +runs: + using: "composite" + steps: + - name: Parse version + id: parse + shell: pwsh + run: | + $version = "${{ inputs.version }}" + + # Parse semantic version: X.Y.Z or X.Y.Z-SUFFIX + if ($version -match '^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.]+))?$') { + $major = $Matches[1] + $minor = $Matches[2] + $patch = $Matches[3] + $suffix = $Matches[4] + + # Extract stage from suffix (e.g., 'alpha' from 'alpha.240101') + $stage = if ($suffix) { $suffix.Split('.')[0].ToLower() } else { 'stable' } + $isPrerelease = if ($suffix) { 'true' } else { 'false' } + + Write-Host "Parsed version: $version" + Write-Host " Major: $major" + Write-Host " Minor: $minor" + Write-Host " Patch: $patch" + Write-Host " Suffix: $suffix" + Write-Host " Stage: $stage" + Write-Host " Is Pre-release: $isPrerelease" + + "major=$major" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 + "minor=$minor" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 + "patch=$patch" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 + "suffix=$suffix" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 + "stage=$stage" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 + "is-prerelease=$isPrerelease" | Out-File -FilePath $Env:GITHUB_OUTPUT -Append -Encoding utf8 + } else { + Write-Error "Invalid version format: $version (expected X.Y.Z or X.Y.Z-SUFFIX)" + exit 1 + } diff --git a/.github/actions/versioning/update-version/action.yml b/.github/actions/versioning/update-version/action.yml index 49cec136..0950c4c0 100644 --- a/.github/actions/versioning/update-version/action.yml +++ b/.github/actions/versioning/update-version/action.yml @@ -42,18 +42,26 @@ runs: shell: bash run: | VERSION="${{ steps.get-version.outputs.version }}" - if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z0-9]+(\.[0-9]+)?)?$ ]]; then + if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-([a-zA-Z0-9.]+))?$ ]]; then MAJOR="${BASH_REMATCH[1]}" MINOR="${BASH_REMATCH[2]}" PATCH="${BASH_REMATCH[3]}" - SUFFIX="${BASH_REMATCH[4]}" + SUFFIX="${BASH_REMATCH[5]}" + + # Extract stage from suffix (e.g., 'alpha' from 'alpha.240101') + if [[ -z "$SUFFIX" ]]; then + STAGE="stable" + else + STAGE=$(echo "$SUFFIX" | cut -d. -f1 | tr '[:upper:]' '[:lower:]') + fi echo "major=$MAJOR" >> $GITHUB_OUTPUT echo "minor=$MINOR" >> $GITHUB_OUTPUT echo "patch=$PATCH" >> $GITHUB_OUTPUT echo "suffix=$SUFFIX" >> $GITHUB_OUTPUT + echo "stage=$STAGE" >> $GITHUB_OUTPUT - echo "Parsed version: Major=$MAJOR, Minor=$MINOR, Patch=$PATCH, Suffix=$SUFFIX" + echo "Parsed version: Major=$MAJOR, Minor=$MINOR, Patch=$PATCH, Suffix=$SUFFIX, Stage=$STAGE" else echo "Error: Version format doesn't follow semantic versioning: $VERSION" exit 1 diff --git a/.github/workflows/RELEASE_WORKFLOW.md b/.github/workflows/RELEASE_WORKFLOW.md index 16544107..38bd1029 100644 --- a/.github/workflows/RELEASE_WORKFLOW.md +++ b/.github/workflows/RELEASE_WORKFLOW.md @@ -4,13 +4,64 @@ This guide explains the standard release process for SmartHopper, which is trigg ## Overview -The regular release workflow allows you to: +The release workflow supports three paths: -- Release planned features and improvements from the `dev` branch -- Automatically prepare release documentation and version updates -- Create a structured PR flow: `release/*` → `dev` → `main` -- Build and publish to GitHub Releases and Yak package manager -- Maintain clean version history with milestone-based releases +1. **Regular Releases**: Planned releases from milestones (dev → main) +2. **Stabilization Path**: Milestone-driven stage progression for specific versions, on isolated branches +3. **Hotfix Releases**: Emergency patches from main + +## Regular Release Flow + +Triggered manually via `release-1-milestone.yml` when a milestone is ready to release: + +1. **Manual trigger** with milestone title (e.g., `1.4.3-alpha`) +2. Creates `release/X.Y.Z-stage` branch from `dev` +3. Updates version, changelog, badges +4. Creates PR to `dev` → merge → PR to `main` → merge +5. Publishes release and builds artifacts + +## Stabilization Path Flow + +Use when you want to promote a specific version to stable without conflicting with other active development. + +### Starting a Stabilization Path + +1. **Create a milestone** with no suffix (e.g., `1.4.2`) in GitHub +2. `stabilization-0-init.yml` automatically: + - Finds the latest prerelease tag matching `1.4.2-*` + - Creates `dev-1.4.2` and `main-1.4.2` branches from that tag +3. The daily `release-promotion.yml` run detects the open `1.4.2` milestone and checks the current staged release (`1.4.2-alpha`) for promotion eligibility + +### Promotion Loop (per stage) + +When ALL conditions are met for the staged release: + +- ✅ No open issues with version label +- ✅ Release published at least 30 days ago +- ✅ Last closed issue at least 30 days ago + +Automation: + +1. Creates next-stage milestone (e.g., `1.4.2-beta`) +2. Closes current staged milestone (e.g., `1.4.2-alpha`) +3. Dispatches `release-1-milestone.yml` targeting `dev-1.4.2` +4. PR flow: `release/1.4.2-beta` → `dev-1.4.2` → `main-1.4.2` → GitHub Release `1.4.2-beta` +5. Repeat for beta → rc → stable + +### Completing a Stabilization Path + +1. When `1.4.2` (stable) is released on `main-1.4.2`, close the `1.4.2` milestone +2. `stabilization-2-complete.yml` automatically creates a backport PR: `main-1.4.2` → `main` +3. After manual approval and merge, `dev-1.4.2` and `main-1.4.2` branches are deleted + +### Cancelling a Stabilization Path + +Close the `1.4.2` milestone while sub-milestones (`1.4.2-alpha`, etc.) are still open. +`stabilization-1-cancel.yml` automatically: + +- Closes all open `1.4.2-*` sub-milestones +- Migrates their open issues to the next dev alpha milestone +- Deletes `dev-1.4.2` and `main-1.4.2` branches ## When to Use Regular Releases @@ -19,7 +70,7 @@ Use the regular release workflow for: - **Planned feature releases** with multiple changes - **Minor/major version bumps** (X.Y.0 or X.0.0) - **Milestone completions** with grouped issues/PRs -- **Scheduled releases** following your development cycle +- **Manual release of any version** from an existing milestone **Do NOT use for:** @@ -36,13 +87,14 @@ Use the regular release workflow for: 3. Associate PRs and issues with a milestone (e.g., `1.2.0`) 4. Ensure all changes update `CHANGELOG.md` under `[Unreleased]` -### Step 2: Close the Milestone +### Step 2: Trigger Release Preparation -1. Go to **Issues** → **Milestones** -2. Ensure all issues/PRs are completed -3. Click **Close milestone** for the target version (e.g., `1.2.0`) +1. Go to **Actions** → **🏁 1 Prepare Release Branch** +2. Click **Run workflow** +3. Enter the **milestone title** (e.g., `1.2.0` or `1.4.3-alpha`) +4. Click **Run workflow** -**This automatically triggers:** 🏁 1 Prepare Release on Milestone Close +**This automatically triggers:** 🏁 1 Prepare Release Branch ### Step 3: Automatic Release Preparation (Workflow 1) @@ -119,14 +171,21 @@ The workflow automatically: 3. Review the release notes 4. Click **Publish release** -### Step 10: Upload to Yak (Manual) +### Step 10: Upload to Yak (Automatic for Stable Releases) + +**For stable releases (X.Y.Z without prerelease suffix):** +- Automatically triggered by `release-4-build.yml` after a successful build (race-condition-free) +- The `trigger-yak-upload` job waits for `merge-and-release` to complete before dispatching `release-6-upload-yak.yml` +- No manual action required -1. Go to **Actions** → **🚀 5 Upload to Yak Rhino Server** +**For prerelease versions (alpha/beta/rc) or manual re-upload:** +1. Go to **Actions** → **🚀 6 Upload to Yak Rhino Server** 2. Click **Run workflow** 3. Configure: - **Version**: Leave empty (uses main branch version) or specify + - **Platform**: Select `both` (default) - **Confirm upload to Yak**: Check this box - - **Just testing**: Uncheck for production, check for test server + - **Just testing**: Check for test server, uncheck for production 4. Click **Run workflow** The workflow will: @@ -148,11 +207,20 @@ Version is determined by the milestone title (e.g., milestone `1.2.0` → releas ## Workflow Files -- **release-1-milestone.yml** - Prepares release branch when milestone closes -- **release-2-pr-to-dev-closed.yml** - Creates PR from dev to main -- **release-3-pr-to-main-closed.yml** - Creates GitHub Release (draft) -- **release-4-build.yml** - Builds and uploads artifacts -- **release-5-upload-yak.yml** - Uploads to Yak package manager +### Release Workflows + +- **release-1-milestone.yml** — Prepares release branch; auto-detects `dev-X.Y.Z` for stabilization paths +- **release-2-pr-to-dev-closed.yml** — Creates PR from `dev` (or `dev-X.Y.Z`) to `main` (or `main-X.Y.Z`) +- **release-3-pr-to-main-closed.yml** — Creates GitHub Release; supports `main-*` branches +- **release-4-build.yml** — Builds artifacts and auto-triggers Yak upload after successful build (stable only) +- **release-promotion.yml** — Scans open no-suffix milestones daily; promotes eligible staged releases +- **release-6-upload-yak.yml** — Uploads to Yak package manager (manual or dispatched by build) + +### Stabilization Workflows + +- **stabilization-0-init.yml** — Triggered on `milestone.created` for `X.Y.Z` titles; creates `dev-X.Y.Z` / `main-X.Y.Z` branches +- **stabilization-1-cancel.yml** — Triggered on `milestone.closed` (with open sub-milestones); cancels path, migrates issues, deletes branches +- **stabilization-2-complete.yml** — Triggered on `milestone.closed` (no open sub-milestones); creates backport PR and cleans up branches ## Validations @@ -167,9 +235,52 @@ All PRs (release → dev, dev → main) run: - **dev**: Protected branch, requires PR reviews - **main**: Protected branch, requires PR reviews +- **dev-X.Y.Z**: Protected stabilization branch (created by automation); `github-actions[bot]` has bypass for create/delete +- **main-X.Y.Z**: Protected stabilization branch (created by automation); `github-actions[bot]` has bypass for create/delete - **release/\***: Temporary branches, deleted after merge -## Example Scenario +All CI checks (`ci-dotnet-tests`, `pr-validation`, `pr-version-validation`, `pr-build-hash-validation`, `pr-manifest-validation`) run on PRs to `dev-*` and `main-*` branches identical to `dev` and `main`. + +### Stabilization Path Example + +**Scenario**: Promote version `1.4.2` from alpha through to stable. + +**Setup:** + +1. Create milestone `1.4.2` (no suffix) in GitHub +2. `stabilization-0-init.yml` creates `dev-1.4.2` and `main-1.4.2` from tag `1.4.2-alpha` + +**Daily promotion loop (alpha → beta):** + +1. `release-promotion.yml` scans open no-suffix milestones → finds `1.4.2` +2. Looks up staged release → finds `1.4.2-alpha` tag +3. Validates: + - ✅ No open issues labeled `version: 1.4.2` + - ✅ `1.4.2-alpha` published 35 days ago + - ✅ Last closed issue 32 days ago +4. Creates milestone `1.4.2-beta`, closes `1.4.2-alpha` +5. Dispatches `release-1-milestone.yml` with `milestone-title: 1.4.2-beta` +6. Workflow 1 detects `dev-1.4.2` → creates `release/1.4.2-beta` → PR to `dev-1.4.2` +7. PR merged → Workflow 2 creates PR `dev-1.4.2` → `main-1.4.2` +8. PR merged → Workflow 3 creates draft release `1.4.2-beta` on `main-1.4.2` +9. Publish release → Workflow 4 builds artifacts + +**Repeat for beta → rc → stable.** + +**Completion:** + +1. `1.4.2` released on `main-1.4.2` +2. Close milestone `1.4.2` +3. `stabilization-2-complete.yml` creates backport PR: `main-1.4.2` → `main` +4. After merge, branches `dev-1.4.2` and `main-1.4.2` are deleted + +**Blocking Scenarios** (promotion will NOT happen): + +- ❌ Any open issue labeled `version: 1.4.2` (any stage) +- ❌ `1.4.2-alpha` release published < 30 days ago +- ❌ No open `1.4.2` milestone exists (stabilization path not initialized) + +### Regular Release Example **Goal:** Release version `1.2.0` with new AI features. diff --git a/.github/workflows/chore-update-contributors.yml b/.github/workflows/chore-update-contributors.yml index 357bd06b..02ddb0bf 100644 --- a/.github/workflows/chore-update-contributors.yml +++ b/.github/workflows/chore-update-contributors.yml @@ -319,7 +319,7 @@ jobs: This is an automated PR created by the Update Contributors workflow. EOF) - gh pr create --base ${{ env.TARGET_BRANCH }} --head ${{ env.CONTRIB_BRANCH }} --title "$PR_TITLE" --body "$PR_BODY" --json number --jq '.number' + gh pr create --base ${{ env.TARGET_BRANCH }} --head ${{ env.CONTRIB_BRANCH }} --title "$PR_TITLE" --body "$PR_BODY" # Capture PR number PR_NUMBER=$(gh pr list --base ${{ env.TARGET_BRANCH }} --head ${{ env.CONTRIB_BRANCH }} --json number --jq '.[0].number') diff --git a/.github/workflows/ci-dotnet-tests.yml b/.github/workflows/ci-dotnet-tests.yml index a0d5a3d7..afaee7a3 100644 --- a/.github/workflows/ci-dotnet-tests.yml +++ b/.github/workflows/ci-dotnet-tests.yml @@ -18,10 +18,13 @@ on: push: branches: - main + - 'main-*' pull_request: branches: - main - dev + - 'main-*' + - 'dev-*' - hotfix/** - release/** diff --git a/.github/workflows/issue-auto-tag.yml b/.github/workflows/issue-auto-tag.yml new file mode 100644 index 00000000..5bfc21c0 --- /dev/null +++ b/.github/workflows/issue-auto-tag.yml @@ -0,0 +1,117 @@ +name: 🏷️ Auto-Tag Issues with Version + +# Description: Automatically tags newly created issues with the appropriate version label +# based on the SmartHopper Version field from the bug report template. +# +# Triggers: +# - Automatically when an issue is created or opened +# +# Permissions: +# - issues:write - Required to add labels to issues + +on: + issues: + types: [opened] + +permissions: + issues: write + contents: read + +jobs: + auto-tag-issue: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get current version + id: get-version + uses: ./.github/actions/versioning/get-version + with: + branch: main + + - name: Parse issue body for version + id: parse-version + uses: actions/github-script@v7 + with: + script: | + const issueBody = context.payload.issue.body || ''; + + // Try to extract SmartHopper Version from issue body + // Pattern: "SmartHopper Version" followed by input value + const versionPatterns = [ + /SmartHopper Version\s*\n\s*([\w\.\-]+)/i, + /### SmartHopper Version\s*\n\s*([\w\.\-]+)/i, + /SmartHopper Version:\s*([\w\.\-]+)/i, + /Version[\s\w:]*\n?\s*([\d]+\.[\d]+\.[\d]+[\-\w]*)/i, + /([\d]+\.[\d]+\.[\d]+-(alpha|beta|rc)(\.\d+)?)/i + ]; + + let detectedVersion = null; + for (const pattern of versionPatterns) { + const match = issueBody.match(pattern); + if (match) { + detectedVersion = match[1].trim(); + break; + } + } + + // If no version found in body, use current version + if (!detectedVersion) { + const currentVersion = '${{ steps.get-version.outputs.version }}'; + console.log(`No version found in issue body, using current version: ${currentVersion}`); + detectedVersion = currentVersion; + } else { + console.log(`Detected version from issue: ${detectedVersion}`); + } + + core.setOutput('detected_version', detectedVersion); + + - name: Create version label if doesn't exist + id: create-label + uses: ./.github/actions/versioning/create-version-label + with: + version: ${{ steps.parse-version.outputs.detected_version }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Apply version label to issue + uses: actions/github-script@v7 + with: + script: | + const issueNumber = context.payload.issue.number; + const versionLabel = '${{ steps.create-label.outputs.label-name }}'; + + console.log(`Applying label "${versionLabel}" to issue #${issueNumber}`); + + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [versionLabel] + }); + + console.log(`✅ Successfully labeled issue #${issueNumber} with "${versionLabel}"`); + } catch (error) { + console.error(`❌ Failed to label issue: ${error.message}`); + core.setFailed(error.message); + } + + - name: Summary + if: always() + env: + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + DETECTED_VERSION: ${{ steps.parse-version.outputs.detected_version }} + LABEL_NAME: ${{ steps.create-label.outputs.label-name }} + LABEL_CREATED: ${{ steps.create-label.outputs.label-created }} + run: | + echo "## Issue Auto-Tagging Summary" + echo "" + echo "- Issue: #$ISSUE_NUMBER" + echo "- Title: $ISSUE_TITLE" + echo "- Detected Version: $DETECTED_VERSION" + echo "- Label Applied: $LABEL_NAME" + echo "- Label Created: $LABEL_CREATED" diff --git a/.github/workflows/milestone-management.yml b/.github/workflows/milestone-management.yml index 7a7fdb14..02326b29 100644 --- a/.github/workflows/milestone-management.yml +++ b/.github/workflows/milestone-management.yml @@ -1,11 +1,19 @@ -name: move-open-issues-and-pr-to-next-milestone +name: 📋 Milestone Management # Milestone Management Workflow -# Automatically moves open issues and PRs from closed milestones to the next appropriate milestone +# 1. Creates next-stage milestones when versions are released: +# - alpha → beta + next minor alpha +# - beta → rc +# - rc → stable +# 2. Keeps only the latest beta, rc, and stable milestones active (closes older ones) +# 3. Moves open issues from closed milestones to the next alpha version +# 4. Allows unlimited alpha milestones on: milestone: types: [closed] + release: + types: [published] permissions: issues: write @@ -13,7 +21,22 @@ permissions: contents: read jobs: + create-next-stage-milestone: + if: github.event_name == 'release' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Create next-stage milestones + uses: ./.github/actions/versioning/manage-milestones + with: + released-version: ${{ github.event.release.tag_name }} + token: ${{ secrets.GITHUB_TOKEN }} + move-open-items: + if: github.event_name == 'milestone' runs-on: ubuntu-latest steps: @@ -21,180 +44,7 @@ jobs: uses: actions/checkout@v4 - name: Move open issues and PRs to next milestone - uses: actions/github-script@v7 + uses: ./.github/actions/versioning/move-milestone-items with: - script: | - // Parse semantic version from milestone title - function parseVersion(versionStr) { - const match = versionStr.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); - if (!match) return null; - - return { - major: parseInt(match[1]), - minor: parseInt(match[2]), - patch: parseInt(match[3]), - prerelease: match[4] || null, - original: versionStr - }; - } - - // Compare versions to determine next MINOR or PATCH - function getNextVersions(closedVersion) { - const nextMinor = { - major: closedVersion.major, - minor: closedVersion.minor + 1, - patch: 0, - prerelease: 'alpha' - }; - - const nextPatch = { - major: closedVersion.major, - minor: closedVersion.minor, - patch: closedVersion.patch + 1, - prerelease: closedVersion.prerelease - }; - - return { nextMinor, nextPatch }; - } - - // Format version object back to string - function formatVersion(version) { - let versionStr = `${version.major}.${version.minor}.${version.patch}`; - if (version.prerelease) { - versionStr += `-${version.prerelease}`; - } - return versionStr; - } - - // Find milestone by title - async function findMilestone(title) { - const milestones = await github.rest.issues.listMilestones({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open' - }); - - return milestones.data.find(m => m.title === title); - } - - // Create new milestone - async function createMilestone(title, description) { - const response = await github.rest.issues.createMilestone({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - description: description || `Auto-created milestone for version ${title}` - }); - - console.log(`Created new milestone: ${title}`); - return response.data; - } - - // Get open issues and PRs for a milestone - async function getOpenItemsInMilestone(milestoneNumber) { - const [issues, pulls] = await Promise.all([ - github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - milestone: milestoneNumber, - state: 'open' - }), - github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open' - }) - ]); - - // Filter PRs that belong to the milestone - const prsInMilestone = pulls.data.filter(pr => - pr.milestone && pr.milestone.number === milestoneNumber - ); - - return { - issues: issues.data.filter(issue => !issue.pull_request), // Exclude PRs from issues - prs: prsInMilestone - }; - } - - // Move item to new milestone - async function moveItemToMilestone(itemNumber, newMilestoneNumber) { - try { - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: itemNumber, - milestone: newMilestoneNumber - }); - console.log(`Successfully moved item #${itemNumber} to milestone #${newMilestoneNumber}`); - } catch (error) { - console.error(`Failed to move item #${itemNumber} to milestone #${newMilestoneNumber}:`, error.message); - // Optionally rethrow the error or handle it gracefully - } - } - - // Main logic - const closedMilestone = context.payload.milestone; - console.log(`Processing closed milestone: ${closedMilestone.title}`); - - // Parse the closed milestone version - const closedVersion = parseVersion(closedMilestone.title); - if (!closedVersion) { - console.log('Milestone title is not a valid semantic version, skipping.'); - return; - } - - console.log(`Parsed closed version: ${JSON.stringify(closedVersion)}`); - - // Get open items in the closed milestone - const openItems = await getOpenItemsInMilestone(closedMilestone.number); - const totalItems = openItems.issues.length + openItems.prs.length; - - console.log(`Found ${openItems.issues.length} open issues and ${openItems.prs.length} open PRs in milestone`); - - if (totalItems === 0) { - console.log('No open items to move, exiting.'); - return; - } - - // Determine next versions - const { nextMinor, nextPatch } = getNextVersions(closedVersion); - const nextMinorTitle = formatVersion(nextMinor); - const nextPatchTitle = formatVersion(nextPatch); - - console.log(`Looking for next milestones: MINOR=${nextMinorTitle}, PATCH=${nextPatchTitle}`); - - // Try to find next MINOR milestone first - let targetMilestone = await findMilestone(nextMinorTitle); - let milestoneType = 'MINOR'; - - if (!targetMilestone) { - // Try to find next PATCH milestone - targetMilestone = await findMilestone(nextPatchTitle); - milestoneType = 'PATCH'; - - if (!targetMilestone) { - // Create new MINOR milestone - targetMilestone = await createMilestone(nextMinorTitle); - milestoneType = 'MINOR (created)'; - } - } - - console.log(`Target milestone: ${targetMilestone.title} (${milestoneType})`); - - // Move all open issues and PRs to the target milestone - const movePromises = []; - - for (const issue of openItems.issues) { - console.log(`Moving issue #${issue.number}: ${issue.title}`); - movePromises.push(moveItemToMilestone(issue.number, targetMilestone.number)); - } - - for (const pr of openItems.prs) { - console.log(`Moving PR #${pr.number}: ${pr.title}`); - movePromises.push(moveItemToMilestone(pr.number, targetMilestone.number)); - } - - await Promise.all(movePromises); - - console.log(`Successfully moved ${totalItems} items to milestone "${targetMilestone.title}"`); + closed-milestone-title: ${{ github.event.milestone.title }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-build-hash-validation.yml b/.github/workflows/pr-build-hash-validation.yml index 90da86aa..9ef156bb 100644 --- a/.github/workflows/pr-build-hash-validation.yml +++ b/.github/workflows/pr-build-hash-validation.yml @@ -8,6 +8,8 @@ on: branches: - main - dev + - 'main-*' + - 'dev-*' - 'hotfix/**' - 'release/**' diff --git a/.github/workflows/pr-manifest-validation.yml b/.github/workflows/pr-manifest-validation.yml index db37f643..ac23d71a 100644 --- a/.github/workflows/pr-manifest-validation.yml +++ b/.github/workflows/pr-manifest-validation.yml @@ -14,6 +14,7 @@ on: pull_request: branches: - main + - 'main-*' paths: - 'yak-package/manifest.yml' - 'Solution.props' diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 23ba8cfc..c89323c6 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -16,6 +16,8 @@ on: branches: - main - dev + - 'main-*' + - 'dev-*' - 'hotfix/**' - 'release/**' diff --git a/.github/workflows/pr-version-validation.yml b/.github/workflows/pr-version-validation.yml index bba8c387..0c3aec5f 100644 --- a/.github/workflows/pr-version-validation.yml +++ b/.github/workflows/pr-version-validation.yml @@ -8,6 +8,8 @@ on: branches: - main - dev + - 'main-*' + - 'dev-*' - 'hotfix/**' - 'release/**' diff --git a/.github/workflows/release-1-milestone.yml b/.github/workflows/release-1-milestone.yml index e9d7c5ff..f7949a4e 100644 --- a/.github/workflows/release-1-milestone.yml +++ b/.github/workflows/release-1-milestone.yml @@ -1,34 +1,73 @@ -name: 🏁 1 Prepare Release on Milestone Close +name: 🏁 1 Prepare Release Branch -# Description: This workflow automatically prepares a release branch when a milestone is closed. -# It extracts the milestone title as the version number and compiles release notes from -# all issues and pull requests associated with the milestone. +# Description: This workflow prepares a release branch for a specified version. +# It updates the version number and compiles release notes from all issues and +# pull requests associated with the milestone. # # Triggers: -# - Automatically when a milestone is closed +# - Manual dispatch from GitHub Actions page (specify version as input) # # Permissions: # - contents:write - Required to create GitHub releases # - issues:read - Required to read issue information for release notes # - pull-requests:write - Required to create pull requests -# + permissions: contents: write issues: read pull-requests: write on: - milestone: - types: [ closed ] + workflow_dispatch: + inputs: + milestone-title: + description: 'Milestone title (version number, e.g., 1.4.3 or 1.4.3-beta)' + required: true + type: string jobs: release-preparation: runs-on: ubuntu-latest steps: + - name: Detect stabilization branch + id: detect-branch + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const milestoneTitle = '${{ inputs.milestone-title }}'; + + // Extract base version (strip suffix if present, e.g. "1.4.2-beta" → "1.4.2") + const match = milestoneTitle.match(/^(\d+\.\d+\.\d+)/); + if (!match) { + console.log(`Cannot parse base version from '${milestoneTitle}'. Using default dev branch.`); + core.setOutput('base-branch', 'dev'); + core.setOutput('is-stabilization', 'false'); + return; + } + const baseVersion = match[1]; + const devBranch = `dev-${baseVersion}`; + + // Check if the stabilization dev branch exists + try { + await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: devBranch + }); + console.log(`Stabilization branch '${devBranch}' found. Using stabilization path.`); + core.setOutput('base-branch', devBranch); + core.setOutput('is-stabilization', 'true'); + } catch (e) { + console.log(`No stabilization branch '${devBranch}' found. Using default dev branch.`); + core.setOutput('base-branch', 'dev'); + core.setOutput('is-stabilization', 'false'); + } + - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: dev + ref: ${{ steps.detect-branch.outputs.base-branch }} - name: Set up Git user run: | @@ -37,12 +76,12 @@ jobs: - name: Remove existing release branch if it exists run: | - if git ls-remote --exit-code --heads origin release/${{ github.event.milestone.title }}; then - git push origin --delete release/${{ github.event.milestone.title }} + if git ls-remote --exit-code --heads origin release/${{ inputs.milestone-title }}; then + git push origin --delete release/${{ inputs.milestone-title }} fi - name: Create release branch - run: git checkout -b release/${{ github.event.milestone.title }} + run: git checkout -b release/${{ inputs.milestone-title }} - name: Replace SmartHopperPublicKey with placeholder run: | @@ -62,7 +101,7 @@ jobs: - name: Update version in Solution.props uses: ./.github/actions/versioning/update-version with: - new-version: ${{ github.event.milestone.title }} + new-version: ${{ inputs.milestone-title }} - name: Include missing issues in changelog uses: ./.github/actions/documentation/update-changelog-issues @@ -74,7 +113,7 @@ jobs: uses: ./.github/actions/documentation/update-changelog with: action: create-release - version: ${{ github.event.milestone.title }} + version: ${{ inputs.milestone-title }} - name: Update README badges uses: ./.github/actions/documentation/update-badges @@ -101,20 +140,21 @@ jobs: - name: Commit and push changes run: | git add Solution.props CHANGELOG.md README.md src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj src/ - git commit -m "chore: prepare release ${{ github.event.milestone.title }} with version update and code style fixes" - git push origin release/${{ github.event.milestone.title }} + git commit -m "chore: prepare release ${{ inputs.milestone-title }} with version update and code style fixes" + git push origin release/${{ inputs.milestone-title }} - name: Create Pull Request id: create-pr run: | + BASE_BRANCH="${{ steps.detect-branch.outputs.base-branch }}" gh pr create \ - --base dev \ - --head release/${{ github.event.milestone.title }} \ - --title "chore: prepare release ${{ github.event.milestone.title }} with version update and code style fixes" \ - --body $'This PR prepares the release for version ${{ github.event.milestone.title }} with version update and code style fixes:\n\n- Updated version in Solution.props\n- Updated changelog with closed-solved issues\n- Updated README badges' + --base "$BASE_BRANCH" \ + --head release/${{ inputs.milestone-title }} \ + --title "chore: prepare release ${{ inputs.milestone-title }} with version update and code style fixes" \ + --body $'This PR prepares the release for version ${{ inputs.milestone-title }} with version update and code style fixes:\n\n- Updated version in Solution.props\n- Updated changelog with closed-solved issues\n- Updated README badges' # Capture PR number - PR_NUMBER=$(gh pr list --base dev --head release/${{ github.event.milestone.title }} --json number --jq '.[0].number') + PR_NUMBER=$(gh pr list --base "$BASE_BRANCH" --head release/${{ inputs.milestone-title }} --json number --jq '.[0].number') echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-2-pr-to-dev-closed.yml b/.github/workflows/release-2-pr-to-dev-closed.yml index 7d2d5cb0..28aadd23 100644 --- a/.github/workflows/release-2-pr-to-dev-closed.yml +++ b/.github/workflows/release-2-pr-to-dev-closed.yml @@ -1,9 +1,10 @@ name: 🏁 2 PR Release to Main from Dev -# Description: This workflow automatically creates a PR to merge a release branch into main. When the release/* branch is merged to dev. +# Description: This workflow automatically creates a PR to merge a release branch into main (or main-X.Y.Z for stabilization paths). +# When the release/* branch is merged to dev or dev-X.Y.Z. # # Triggers: -# - Automatically when a PR to dev is closed +# - Automatically when a PR to dev or dev-* is closed # # Permissions: # - contents:write - Required to create GitHub releases @@ -15,6 +16,7 @@ on: types: [ closed ] branches: - dev + - 'dev-*' workflow_dispatch: inputs: pr-title: @@ -37,15 +39,24 @@ jobs: if: ${{ github.event_name == 'pull_request' }} id: create-pr-event run: | + DEV_BRANCH="${{ github.event.pull_request.base.ref }}" + # If merging into dev-X.Y.Z, target main-X.Y.Z; otherwise target main + if [[ "$DEV_BRANCH" == dev-* ]]; then + SUFFIX="${DEV_BRANCH#dev-}" + TARGET_BRANCH="main-${SUFFIX}" + else + TARGET_BRANCH="main" + fi + MAIN_HEAD="$DEV_BRANCH" gh pr create \ --repo ${{ github.repository }} \ - --base main \ - --head dev \ + --base "$TARGET_BRANCH" \ + --head "$MAIN_HEAD" \ --title "${{ github.event.pull_request.head.ref }}" \ --body "${{ github.event.pull_request.body }}" # Capture PR number - PR_NUMBER=$(gh pr list --base main --head dev --json number --jq '.[0].number') + PR_NUMBER=$(gh pr list --base "$TARGET_BRANCH" --head "$MAIN_HEAD" --json number --jq '.[0].number') echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -68,7 +79,7 @@ jobs: --title "${{ github.event.inputs['pr-title'] }}" \ --body "${{ github.event.inputs['pr-body'] }}" - # Capture PR number + # Capture PR number (manual dispatch always targets main) PR_NUMBER=$(gh pr list --base main --head dev --json number --jq '.[0].number') echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT env: diff --git a/.github/workflows/release-3-pr-to-main-closed.yml b/.github/workflows/release-3-pr-to-main-closed.yml index b832dca0..c06826b5 100644 --- a/.github/workflows/release-3-pr-to-main-closed.yml +++ b/.github/workflows/release-3-pr-to-main-closed.yml @@ -3,9 +3,10 @@ name: 🏁 3 Create Release on Release PR Close # Description: This workflow automatically creates a GitHub Release when a release PR is closed. # It extracts the milestone title as the version number and compiles release notes from # all issues and pull requests associated with the milestone. +# Supports both main (regular releases) and main-X.Y.Z (stabilization path releases). # # Triggers: -# - Automatically when a milestone is closed +# - Automatically when a PR to main or main-* is closed # # Permissions: # - contents:write - Required to create GitHub releases @@ -17,6 +18,7 @@ on: types: [ closed ] branches: - main + - 'main-*' workflow_dispatch: jobs: @@ -29,22 +31,30 @@ jobs: pull-requests: read steps: + - name: Determine target branch + id: target-branch + run: | + BASE_REF="${{ github.event.pull_request.base.ref }}" + # Use the actual base branch (main or main-X.Y.Z) + echo "branch=${BASE_REF:-main}" >> $GITHUB_OUTPUT + - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: main # Checkout the main branch + ref: ${{ steps.target-branch.outputs.branch }} - name: Get Release Version id: release_info uses: ./.github/actions/versioning/get-version with: - branch: main + branch: ${{ steps.target-branch.outputs.branch }} - name: Generate Release Notes id: issues uses: actions/github-script@v7 env: RELEASE_VERSION: ${{ steps.release_info.outputs.version }} + TARGET_BRANCH: ${{ steps.target-branch.outputs.branch }} with: script: | // Generate release notes for the new release version @@ -52,7 +62,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, tag_name: process.env.RELEASE_VERSION, - target_commitish: 'main' + target_commitish: process.env.TARGET_BRANCH }); await core.setOutput('changelog', releaseNotes.data.body); @@ -80,4 +90,4 @@ jobs: ${{ steps.issues.outputs.changelog }} draft: true prerelease: ${{ env.IS_PRERELEASE }} - target_commitish: main + target_commitish: ${{ steps.target-branch.outputs.branch }} diff --git a/.github/workflows/release-4-build.yml b/.github/workflows/release-4-build.yml index 082ff802..5e8e9e71 100644 --- a/.github/workflows/release-4-build.yml +++ b/.github/workflows/release-4-build.yml @@ -34,6 +34,7 @@ on: permissions: contents: write + issues: write pages: write id-token: write pull-requests: write @@ -105,6 +106,13 @@ jobs: exit 1 } + - name: Create version label if doesn't exist + if: github.event_name == 'release' || github.event.inputs.upload_to_release == 'true' + uses: ./.github/actions/versioning/create-version-label + with: + version: ${{ steps.determine_version.outputs.VERSION }} + token: ${{ secrets.GITHUB_TOKEN }} + - name: Check if release is draft if: github.event_name == 'release' id: check_draft @@ -481,3 +489,39 @@ jobs: Write-Host "Next steps:" Write-Host " 1. Review and merge the hash PR to main branch" Write-Host " 2. GitHub Pages deployment will trigger automatically" + + # Trigger Yak upload after build succeeds — only for stable (non-prerelease) releases + # This replaces the standalone release-auto-upload-yak.yml trigger to avoid a race condition + # where Yak upload fires before build artifacts are ready. + trigger-yak-upload: + needs: [merge-and-release] + if: github.event_name == 'release' && !github.event.release.prerelease + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + steps: + - name: Trigger Yak upload workflow + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const tag = '${{ github.event.release.tag_name }}'; + const releaseVersion = tag.replace(/^v/, ''); + + console.log(`Build succeeded. Triggering Yak upload for stable release: ${releaseVersion}`); + + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'release-6-upload-yak.yml', + ref: 'main', + inputs: { + version: releaseVersion, + platform: 'both', + upload_to_yak: 'true', + testing: 'false' + } + }); + + console.log('✅ Yak upload workflow triggered successfully'); diff --git a/.github/workflows/release-6-upload-yak.yml b/.github/workflows/release-6-upload-yak.yml index 6932dd85..be27258a 100644 --- a/.github/workflows/release-6-upload-yak.yml +++ b/.github/workflows/release-6-upload-yak.yml @@ -52,6 +52,7 @@ jobs: permissions: contents: read + issues: write steps: - name: Checkout repository @@ -79,6 +80,13 @@ jobs: } echo "version=$version" >> $env:GITHUB_OUTPUT + - name: Create version label if doesn't exist + if: inputs.upload_to_yak == 'true' + uses: ./.github/actions/versioning/create-version-label + with: + version: ${{ steps.get_version.outputs.version }} + token: ${{ secrets.GITHUB_TOKEN }} + - name: Create artifacts directory shell: pwsh run: | diff --git a/.github/workflows/release-promotion.yml b/.github/workflows/release-promotion.yml new file mode 100644 index 00000000..eac4d0bf --- /dev/null +++ b/.github/workflows/release-promotion.yml @@ -0,0 +1,326 @@ +name: 🔄 Release Promotion (Alpha→Beta→RC→Stable) + +# Description: Automatically promotes stabilization versions through release stages when no issues +# are reported within 30 days of release. +# +# Flow: +# 1. Scans open no-suffix milestones (e.g. "1.4.2") to discover active stabilization paths +# 2. For each, finds the current staged release (alpha/beta/rc) +# 3. Checks promotion eligibility (age ≥ 30d, no open issues) +# 4. If eligible: creates next-stage milestone, closes current staged milestone, +# and dispatches release-1-milestone.yml for the new stage +# +# Triggers: +# - Workflow dispatch (manual) +# - Scheduled daily (cron) +# - After release published + +on: + workflow_dispatch: + inputs: + version: + description: 'Specific staged version to check (e.g. 1.4.2-alpha). Leave empty to check all stabilization paths.' + required: false + type: string + default: '' + force-promote: + description: 'Force promotion even if issues exist (use with caution)' + required: false + type: boolean + default: false + schedule: + # Run daily at 2 AM UTC + - cron: '0 2 * * *' + release: + types: [published] + +permissions: + contents: write + issues: write + pull-requests: write + actions: write + +jobs: + determine-versions-to-check: + runs-on: ubuntu-latest + outputs: + versions: ${{ steps.get-versions.outputs.versions }} + has-versions: ${{ steps.get-versions.outputs.has_versions }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get versions to check + id: get-versions + uses: actions/github-script@v7 + with: + script: | + // Parse semantic version from string + function parseVersion(versionStr) { + const match = versionStr.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); + if (!match) return null; + return { + major: parseInt(match[1]), + minor: parseInt(match[2]), + patch: parseInt(match[3]), + suffix: match[4] || null, + original: versionStr + }; + } + + const specificVersion = '${{ github.event.inputs.version }}'; + let versionsToCheck = []; + + if (specificVersion) { + versionsToCheck = [specificVersion]; + } else { + // Discover active stabilization paths via open no-suffix milestones + const [openMilestones, { data: releases }] = await Promise.all([ + github.paginate(github.rest.issues.listMilestones, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }), + github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100 + }) + ]); + + // Filter to X.Y.Z milestones (no suffix = stabilization path marker) + const stabilizationMilestones = openMilestones.filter(m => /^\d+\.\d+\.\d+$/.test(m.title)); + console.log('Active stabilization milestones:', stabilizationMilestones.map(m => m.title)); + + // Build a set of all release tag names for fast lookup + const releaseTags = new Set(releases.map(r => r.tag_name)); + + // For each stabilization path, find the current staged release + // Priority: rc > beta > alpha (promote the most advanced stage that exists) + const stagePriority = ['rc', 'beta', 'alpha']; + + for (const milestone of stabilizationMilestones) { + const base = milestone.title; // e.g. "1.4.2" + let currentStaged = null; + + for (const stage of stagePriority) { + const candidate = `${base}-${stage}`; + if (releaseTags.has(candidate)) { + currentStaged = candidate; + console.log(`Stabilization path ${base}: current staged release is ${candidate}`); + break; + } + } + + if (currentStaged) { + versionsToCheck.push(currentStaged); + } else { + console.log(`Stabilization path ${base}: no staged release found yet, skipping.`); + } + } + } + + console.log('Versions to check for promotion:', versionsToCheck); + core.setOutput('versions', JSON.stringify(versionsToCheck)); + core.setOutput('has_versions', versionsToCheck.length > 0 ? 'true' : 'false'); + + check-and-promote: + needs: determine-versions-to-check + if: needs.determine-versions-to-check.outputs.has-versions == 'true' + runs-on: ubuntu-latest + strategy: + matrix: + version: ${{ fromJson(needs.determine-versions-to-check.outputs.versions) }} + fail-fast: false + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for issues with version label + id: check-issues + uses: ./.github/actions/versioning/check-issues-for-version + with: + version: ${{ matrix.version }} + days-lookback: '30' + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine if promotion needed + id: should-promote + shell: pwsh + run: | + $canPromote = "${{ steps.check-issues.outputs.can-promote }}" -eq "true" + $forcePromote = "${{ github.event.inputs.force-promote }}" -eq "true" + $blockingReason = "${{ steps.check-issues.outputs.blocking-reason }}" + $version = "${{ matrix.version }}" + + Write-Host "=== Promotion Decision for $version ===" + Write-Host "" + Write-Host "Validation Results:" + Write-Host " • Milestone open issues: ${{ steps.check-issues.outputs.milestone-open-count }}" + Write-Host " • Release age: ${{ steps.check-issues.outputs.release-age-days }} days" + Write-Host " • Last closed issue age: ${{ steps.check-issues.outputs.last-closed-age-days }} days" + Write-Host " • Can promote: $canPromote" + if (-not $canPromote) { + Write-Host " • Blocking reason: $blockingReason" + } + Write-Host " • Force promote: $forcePromote" + Write-Host "" + + if ($forcePromote) { + Write-Host "⚠️ FORCE PROMOTION ENABLED - Bypassing all validation checks" + Add-Content -Path $env:GITHUB_OUTPUT -Value "should-promote=true" + Add-Content -Path $env:GITHUB_OUTPUT -Value "reason=force_promote" + } elseif ($canPromote) { + Write-Host "✅ ALL CONDITIONS MET - Promoting $version" + Add-Content -Path $env:GITHUB_OUTPUT -Value "should-promote=true" + Add-Content -Path $env:GITHUB_OUTPUT -Value "reason=all_conditions_met" + } else { + Write-Host "❌ PROMOTION BLOCKED - $blockingReason" + Add-Content -Path $env:GITHUB_OUTPUT -Value "should-promote=false" + Add-Content -Path $env:GITHUB_OUTPUT -Value "reason=$blockingReason" + } + + - name: Compute next stage version + if: steps.should-promote.outputs.should-promote == 'true' + id: compute-next + uses: actions/github-script@v7 + with: + script: | + const current = '${{ matrix.version }}'; + const match = current.match(/^(\d+\.\d+\.\d+)-(\w+)$/); + if (!match) { + core.setFailed(`Cannot parse version: ${current}`); + return; + } + const base = match[1]; + const stage = match[2]; + let nextVersion, nextStage; + if (stage === 'alpha') { nextVersion = `${base}-beta`; nextStage = 'beta'; } + else if (stage === 'beta') { nextVersion = `${base}-rc`; nextStage = 'rc'; } + else if (stage === 'rc') { nextVersion = base; nextStage = 'stable'; } + else { core.setFailed(`Unknown stage: ${stage}`); return; } + + console.log(`Promoting ${current} → ${nextVersion}`); + core.setOutput('next-version', nextVersion); + core.setOutput('next-stage', nextStage); + core.setOutput('base-version', base); + core.setOutput('current-stage', stage); + + - name: Create next-stage milestone + if: steps.should-promote.outputs.should-promote == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const nextVersion = '${{ steps.compute-next.outputs.next-version }}'; + const nextStage = '${{ steps.compute-next.outputs.next-stage }}'; + if (nextStage === 'stable') { + console.log('Promoting to stable — no new milestone needed (parent milestone already exists).'); + return; + } + try { + await github.rest.issues.createMilestone({ + owner: context.repo.owner, + repo: context.repo.repo, + title: nextVersion, + description: `Auto-created by release-promotion for ${nextVersion}` + }); + console.log(`✅ Created milestone: ${nextVersion}`); + } catch (e) { + if (e.message.includes('already exists')) { + console.log(`ℹ️ Milestone ${nextVersion} already exists`); + } else { + throw e; + } + } + + - name: Close current staged milestone + if: steps.should-promote.outputs.should-promote == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const currentVersion = '${{ matrix.version }}'; + const milestones = await github.paginate(github.rest.issues.listMilestones, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }); + const milestone = milestones.find(m => m.title === currentVersion); + if (milestone) { + await github.rest.issues.updateMilestone({ + owner: context.repo.owner, + repo: context.repo.repo, + milestone_number: milestone.number, + state: 'closed' + }); + console.log(`✅ Closed milestone: ${currentVersion}`); + } else { + console.log(`ℹ️ Milestone '${currentVersion}' not found or already closed`); + } + + - name: Create new version label + if: steps.should-promote.outputs.should-promote == 'true' + uses: ./.github/actions/versioning/create-version-label + with: + version: ${{ steps.compute-next.outputs.next-version }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Dispatch release-1-milestone.yml + if: steps.should-promote.outputs.should-promote == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const nextVersion = '${{ steps.compute-next.outputs.next-version }}'; + console.log(`Dispatching release-1-milestone.yml for ${nextVersion}`); + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'release-1-milestone.yml', + ref: 'main', + inputs: { + 'milestone-title': nextVersion + } + }); + console.log(`✅ Dispatched release-1-milestone.yml for ${nextVersion}`); + + - name: Summary + if: always() + run: | + echo "## 🔄 Promotion Check for ${{ matrix.version }}" + echo "" + echo "### Validation Checks" + echo "" + echo "| Check | Status | Details |" + echo "|-------|--------|---------|" + echo "| **Version Label Issues** | ${{ steps.check-issues.outputs.has-issues == 'false' && '✅ Pass' || '❌ Fail' }} | ${{ steps.check-issues.outputs.issue-count }} open issue(s) with label |" + echo "| **Release Age** | ${{ steps.check-issues.outputs.release-old-enough == 'true' && '✅ Pass' || '❌ Fail' }} | ${{ steps.check-issues.outputs.release-age-days }} days (min: 30) |" + echo "| **Last Closed Issue** | ${{ steps.check-issues.outputs.last-closed-old-enough == 'true' && '✅ Pass' || '❌ Fail' }} | ${{ steps.check-issues.outputs.last-closed-age-days }} days since last close |" + echo "" + echo "### Decision" + echo "" + echo "- **Can Promote:** ${{ steps.check-issues.outputs.can-promote }}" + echo "- **Should Promote:** ${{ steps.should-promote.outputs.should-promote }}" + echo "- **Reason:** ${{ steps.should-promote.outputs.reason }}" + if [ "${{ steps.check-issues.outputs.can-promote }}" != "true" ]; then + echo "- **Blocking Reason:** ${{ steps.check-issues.outputs.blocking-reason }}" + fi + echo "" + if [ "${{ steps.should-promote.outputs.should-promote }}" == "true" ]; then + echo "### ✅ Promotion Dispatched" + echo "" + echo "- **Promoting:** ${{ matrix.version }} → ${{ steps.compute-next.outputs.next-version }}" + echo "- **New Stage:** ${{ steps.compute-next.outputs.next-stage }}" + echo "- **release-1-milestone.yml dispatched for:** ${{ steps.compute-next.outputs.next-version }}" + else + echo "### ❌ Promotion Blocked" + echo "" + echo "Version ${{ matrix.version }} cannot be promoted at this time." + fi diff --git a/.github/workflows/stabilization-0-init.yml b/.github/workflows/stabilization-0-init.yml new file mode 100644 index 00000000..afec5ab2 --- /dev/null +++ b/.github/workflows/stabilization-0-init.yml @@ -0,0 +1,190 @@ +name: Stabilization 0 - 🌿 Init Path + +# Description: Initializes a stabilization path when a no-suffix milestone (e.g. "1.4.2") is created. +# Creates dedicated dev-X.Y.Z and main-X.Y.Z branches from the latest matching prerelease tag. +# +# Triggers: +# - milestone.created when title matches X.Y.Z (no suffix) +# +# Permissions: +# - Requires STABILITY_PAT_TOKEN secret with repo scope to create branches containing workflow files +# - GITHUB_TOKEN cannot create branches with .github/workflows/ files (security restriction) + +on: + milestone: + types: [created] + +permissions: + contents: read + +jobs: + init-stabilization-path: + runs-on: ubuntu-latest + if: ${{ github.event_name == 'milestone' && !contains(github.event.milestone.title, '-') && github.event.milestone.title != '' }} + steps: + - name: Validate milestone title format + id: validate + uses: actions/github-script@v7 + with: + script: | + const title = '${{ github.event.milestone.title }}'; + const isStableVersion = /^\d+\.\d+\.\d+$/.test(title); + if (!isStableVersion) { + console.log(`Milestone '${title}' is not a stable X.Y.Z version. Skipping.`); + core.setOutput('is-stabilization', 'false'); + } else { + console.log(`Milestone '${title}' is a stabilization milestone. Proceeding.`); + core.setOutput('is-stabilization', 'true'); + } + + - name: Checkout repository + if: steps.validate.outputs.is-stabilization == 'true' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.STABILITY_PAT_TOKEN }} + + - name: Set up Git user + if: steps.validate.outputs.is-stabilization == 'true' + run: | + git config user.name "github-actions" + git config user.email "action@github.com" + + - name: Find latest prerelease tag for this version + if: steps.validate.outputs.is-stabilization == 'true' + id: find-tag + uses: actions/github-script@v7 + with: + script: | + const version = '${{ github.event.milestone.title }}'; + console.log(`Looking for latest prerelease tag matching ${version}-*`); + + const tags = await github.paginate(github.rest.repos.listTags, { + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100 + }); + + // Filter tags starting with version- (e.g. 1.4.2-alpha, 1.4.2-beta) + const stagePriority = { rc: 3, beta: 2, alpha: 1 }; + const matchingTags = tags + .map(t => t.name) + .filter(name => name.startsWith(`${version}-`)) + .sort((a, b) => { + const aStage = Object.keys(stagePriority).find(s => a.includes(`-${s}`)) || ''; + const bStage = Object.keys(stagePriority).find(s => b.includes(`-${s}`)) || ''; + return (stagePriority[bStage] || 0) - (stagePriority[aStage] || 0); + }); + + if (matchingTags.length > 0) { + console.log(`Found matching tags: ${matchingTags.join(', ')}`); + console.log(`Using tag: ${matchingTags[0]}`); + core.setOutput('tag', matchingTags[0]); + core.setOutput('source', matchingTags[0]); + } else { + console.log(`No prerelease tags found for ${version}. Falling back to main branch.`); + core.setOutput('tag', ''); + core.setOutput('source', 'main'); + } + + - name: Get SHA for source ref + if: steps.validate.outputs.is-stabilization == 'true' + id: get-sha + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.STABILITY_PAT_TOKEN }} + script: | + const tag = '${{ steps.find-tag.outputs.tag }}'; + let sha; + + if (tag) { + // Get SHA from tag + const tagRef = await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${tag}` + }); + sha = tagRef.data.object.sha; + console.log(`Tag ${tag} points to SHA: ${sha}`); + } else { + // Get SHA from main branch + const branchRef = await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: 'heads/main' + }); + sha = branchRef.data.object.sha; + console.log(`Branch main points to SHA: ${sha}`); + } + + core.setOutput('sha', sha); + + - name: Create stabilization branches + if: steps.validate.outputs.is-stabilization == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.STABILITY_PAT_TOKEN }} + script: | + const version = '${{ github.event.milestone.title }}'; + const source = '${{ steps.find-tag.outputs.source }}'; + const sha = '${{ steps.get-sha.outputs.sha }}'; + + console.log(`Creating stabilization branches for ${version} from ${source} (SHA: ${sha})`); + + // Create dev-X.Y.Z branch + const devBranch = `dev-${version}`; + try { + await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${devBranch}` + }); + console.log(`Branch ${devBranch} already exists, skipping.`); + } catch (error) { + if (error.status === 404) { + await github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/heads/${devBranch}`, + sha: sha + }); + console.log(`✅ Created ${devBranch} from ${source}`); + } else { + throw error; + } + } + + // Create main-X.Y.Z branch + const mainBranch = `main-${version}`; + try { + await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${mainBranch}` + }); + console.log(`Branch ${mainBranch} already exists, skipping.`); + } catch (error) { + if (error.status === 404) { + await github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/heads/${mainBranch}`, + sha: sha + }); + console.log(`✅ Created ${mainBranch} from ${source}`); + } else { + throw error; + } + } + + - name: Summary + if: steps.validate.outputs.is-stabilization == 'true' + run: | + echo "## 🌿 Stabilization Path Initialized" + echo "" + echo "- **Version:** ${{ github.event.milestone.title }}" + echo "- **Source:** ${{ steps.find-tag.outputs.source }}" + echo "- **dev branch:** dev-${{ github.event.milestone.title }}" + echo "- **main branch:** main-${{ github.event.milestone.title }}" + echo "" + echo "Next: wait for \`release-promotion.yml\` to detect and promote the current staged release." diff --git a/.github/workflows/stabilization-1-cancel.yml b/.github/workflows/stabilization-1-cancel.yml new file mode 100644 index 00000000..c3666049 --- /dev/null +++ b/.github/workflows/stabilization-1-cancel.yml @@ -0,0 +1,177 @@ +name: Stabilization 1 - 🛑 Cancel Path + +# Description: Cancels a stabilization path when a no-suffix milestone (e.g. "1.4.2") is closed +# while staged sub-milestones (1.4.2-alpha, 1.4.2-beta, etc.) are still open. +# Closes all sub-milestones, migrates their open issues to the next dev milestone, +# and deletes the dev-X.Y.Z and main-X.Y.Z branches. +# +# Triggers: +# - milestone.closed when title matches X.Y.Z (no suffix) AND open sub-milestones exist +# +# Permissions: +# - contents: write - Required to delete branches +# - issues: write - Required to close milestones and update issues + +on: + milestone: + types: [closed] + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + cancel-stabilization-path: + runs-on: ubuntu-latest + steps: + - name: Check if this is a cancellation scenario + id: check + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const title = '${{ github.event.milestone.title }}'; + + // Only handle stable X.Y.Z milestones (no suffix) + if (!/^\d+\.\d+\.\d+$/.test(title)) { + console.log(`Milestone '${title}' is not a stable X.Y.Z version. Skipping.`); + core.setOutput('should-cancel', 'false'); + return; + } + + // Check for open sub-milestones (e.g. 1.4.2-alpha, 1.4.2-beta, 1.4.2-rc) + const allMilestones = await github.paginate( + github.rest.issues.listMilestones, + { owner: context.repo.owner, repo: context.repo.repo, state: 'open', per_page: 100 } + ); + + const subMilestones = allMilestones.filter(m => m.title.startsWith(`${title}-`)); + + if (subMilestones.length > 0) { + console.log(`Found ${subMilestones.length} open sub-milestone(s): ${subMilestones.map(m => m.title).join(', ')}`); + console.log('Cancellation path triggered.'); + core.setOutput('should-cancel', 'true'); + core.setOutput('sub-milestones', JSON.stringify(subMilestones.map(m => ({ number: m.number, title: m.title })))); + } else { + console.log('No open sub-milestones found. This is a successful completion — handled by stabilization-2-complete.yml.'); + core.setOutput('should-cancel', 'false'); + } + + - name: Find next dev milestone for issue migration + if: steps.check.outputs.should-cancel == 'true' + id: find-next-milestone + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + // Find the lowest-numbered open alpha milestone (next dev target) + const allMilestones = await github.paginate( + github.rest.issues.listMilestones, + { owner: context.repo.owner, repo: context.repo.repo, state: 'open', per_page: 100 } + ); + + const alphaMilestones = allMilestones + .filter(m => /^\d+\.\d+\.\d+-alpha$/.test(m.title)) + .sort((a, b) => { + const [aMaj, aMin, aPat] = a.title.replace(/-alpha$/, '').split('.').map(Number); + const [bMaj, bMin, bPat] = b.title.replace(/-alpha$/, '').split('.').map(Number); + if (aMaj !== bMaj) return aMaj - bMaj; + if (aMin !== bMin) return aMin - bMin; + return aPat - bPat; + }); + + if (alphaMilestones.length > 0) { + const target = alphaMilestones[0]; + console.log(`Target migration milestone: ${target.title} (#${target.number})`); + core.setOutput('target-milestone-number', target.number.toString()); + core.setOutput('target-milestone-title', target.title); + } else { + console.log('No alpha milestone found for migration. Items will be unassigned.'); + core.setOutput('target-milestone-number', ''); + core.setOutput('target-milestone-title', ''); + } + + - name: Migrate issues and close sub-milestones + if: steps.check.outputs.should-cancel == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const subMilestones = JSON.parse('${{ steps.check.outputs.sub-milestones }}'); + const targetMilestoneNumber = '${{ steps.find-next-milestone.outputs.target-milestone-number }}'; + const targetMilestoneTitle = '${{ steps.find-next-milestone.outputs.target-milestone-title }}'; + + for (const sub of subMilestones) { + console.log(`Processing sub-milestone: ${sub.title} (#${sub.number})`); + + // Get all open issues and PRs in this milestone + const issues = await github.paginate( + github.rest.issues.listForRepo, + { + owner: context.repo.owner, + repo: context.repo.repo, + milestone: sub.number, + state: 'open', + per_page: 100 + } + ); + + console.log(` Found ${issues.length} open item(s) in ${sub.title}`); + + // Migrate each item + for (const issue of issues) { + const newMilestone = targetMilestoneNumber ? parseInt(targetMilestoneNumber) : null; + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + milestone: newMilestone + }); + const dest = targetMilestoneTitle || 'unassigned'; + console.log(` Moved #${issue.number} to ${dest}`); + } + + // Close the sub-milestone + await github.rest.issues.updateMilestone({ + owner: context.repo.owner, + repo: context.repo.repo, + milestone_number: sub.number, + state: 'closed' + }); + console.log(` ✅ Closed sub-milestone: ${sub.title}`); + } + + - name: Checkout repository + if: steps.check.outputs.should-cancel == 'true' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Delete stabilization branches + if: steps.check.outputs.should-cancel == 'true' + run: | + VERSION="${{ github.event.milestone.title }}" + DEV_BRANCH="dev-${VERSION}" + MAIN_BRANCH="main-${VERSION}" + + for BRANCH in "$DEV_BRANCH" "$MAIN_BRANCH"; do + if git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null 2>&1; then + git push origin --delete "$BRANCH" + echo "✅ Deleted branch: ${BRANCH}" + else + echo "Branch ${BRANCH} does not exist, skipping." + fi + done + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + if: steps.check.outputs.should-cancel == 'true' + run: | + echo "## 🛑 Stabilization Path Cancelled" + echo "" + echo "- **Version:** ${{ github.event.milestone.title }}" + echo "- **Sub-milestones closed:** ${{ steps.check.outputs.sub-milestones }}" + echo "- **Issues migrated to:** ${{ steps.find-next-milestone.outputs.target-milestone-title || 'unassigned' }}" + echo "- **Branches deleted:** dev-${{ github.event.milestone.title }}, main-${{ github.event.milestone.title }}" diff --git a/.github/workflows/stabilization-2-complete.yml b/.github/workflows/stabilization-2-complete.yml new file mode 100644 index 00000000..97518fe7 --- /dev/null +++ b/.github/workflows/stabilization-2-complete.yml @@ -0,0 +1,184 @@ +name: Stabilization 2 - ✅ Complete Path + +# Description: Completes a stabilization path when a no-suffix milestone (e.g. "1.4.2") is closed +# and no staged sub-milestones remain open (meaning the stable release was successfully shipped). +# Creates a backport PR from main-X.Y.Z to main. Stabilization branches are deleted after the PR merges. +# +# Triggers: +# - milestone.closed when title matches X.Y.Z (no suffix) AND no open sub-milestones exist +# +# Permissions: +# - contents: write - Required to create PRs and delete branches +# - pull-requests: write - Required to create the backport PR + +on: + milestone: + types: [closed] + pull_request: + types: [closed] + branches: + - main + +permissions: + contents: write + pull-requests: write + issues: read + +jobs: + complete-stabilization-path: + runs-on: ubuntu-latest + if: github.event_name == 'milestone' + steps: + - name: Check if this is a successful completion scenario + id: check + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const title = '${{ github.event.milestone.title }}'; + + // Only handle stable X.Y.Z milestones (no suffix) + if (!/^\d+\.\d+\.\d+$/.test(title)) { + console.log(`Milestone '${title}' is not a stable X.Y.Z version. Skipping.`); + core.setOutput('should-complete', 'false'); + return; + } + + // Check that NO sub-milestones are still open + const allMilestones = await github.paginate( + github.rest.issues.listMilestones, + { owner: context.repo.owner, repo: context.repo.repo, state: 'open', per_page: 100 } + ); + + const openSubMilestones = allMilestones.filter(m => m.title.startsWith(`${title}-`)); + + if (openSubMilestones.length > 0) { + console.log(`Found ${openSubMilestones.length} open sub-milestone(s): ${openSubMilestones.map(m => m.title).join(', ')}`); + console.log('This is a cancellation — handled by stabilization-1-cancel.yml. Skipping.'); + core.setOutput('should-complete', 'false'); + } else { + console.log('No open sub-milestones. This is a successful completion. Proceeding.'); + core.setOutput('should-complete', 'true'); + } + + - name: Check stabilization branches exist + if: steps.check.outputs.should-complete == 'true' + id: check-branches + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const version = '${{ github.event.milestone.title }}'; + const mainBranch = `main-${version}`; + + try { + await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: mainBranch + }); + console.log(`Branch ${mainBranch} exists.`); + core.setOutput('main-branch', mainBranch); + core.setOutput('branches-exist', 'true'); + } catch (e) { + console.log(`Branch ${mainBranch} does not exist. No backport PR needed.`); + core.setOutput('branches-exist', 'false'); + } + + - name: Checkout repository + if: steps.check.outputs.should-complete == 'true' && steps.check-branches.outputs.branches-exist == 'true' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create backport PR main-X.Y.Z → main + if: steps.check.outputs.should-complete == 'true' && steps.check-branches.outputs.branches-exist == 'true' + id: create-pr + run: | + VERSION="${{ github.event.milestone.title }}" + MAIN_BRANCH="main-${VERSION}" + + # Check if a backport PR already exists + EXISTING=$(gh pr list \ + --repo ${{ github.repository }} \ + --base main \ + --head "$MAIN_BRANCH" \ + --state open \ + --json number \ + --jq '.[0].number' 2>/dev/null || echo "") + + if [ -n "$EXISTING" ]; then + echo "Backport PR #${EXISTING} already exists. Skipping creation." + echo "pr_number=$EXISTING" >> $GITHUB_OUTPUT + else + PR_NUMBER=$(gh pr create \ + --repo ${{ github.repository }} \ + --base main \ + --head "$MAIN_BRANCH" \ + --title "chore: backport stable ${VERSION} to main" \ + --body $'This PR backports the stable release **'"${VERSION}"$'** from \`'"${MAIN_BRANCH}"$'\` to \`main\`.\n\nThis requires manual approval. After merging, the stabilization branches will be deleted automatically.' \ + 2>&1 | tail -1 | grep -oE '[0-9]+$' || echo "") + + if [ -n "$PR_NUMBER" ]; then + echo "✅ Created backport PR #${PR_NUMBER}" + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + else + PR_NUMBER=$(gh pr list --base main --head "$MAIN_BRANCH" --json number --jq '.[0].number') + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + fi + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + if: steps.check.outputs.should-complete == 'true' + run: | + echo "## ✅ Stabilization Path Completed" + echo "" + echo "- **Version:** ${{ github.event.milestone.title }}" + if [ "${{ steps.check-branches.outputs.branches-exist }}" == "true" ]; then + echo "- **Backport PR:** #${{ steps.create-pr.outputs.pr_number }} (main-${{ github.event.milestone.title }} → main)" + echo "- **Action required:** Approve and merge the backport PR" + echo "- **After merge:** Delete branches dev-${{ github.event.milestone.title }} and main-${{ github.event.milestone.title }} manually or via branch auto-deletion settings" + else + echo "- **Note:** Stabilization branches not found — no backport PR created" + fi + + # Delete stabilization branches after the backport PR is merged into main + cleanup-on-pr-merge: + runs-on: ubuntu-latest + if: | + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'main-') && + github.event.pull_request.base.ref == 'main' + permissions: + contents: write + steps: + - name: Extract version from branch name + id: extract-version + run: | + BRANCH="${{ github.event.pull_request.head.ref }}" + VERSION="${BRANCH#main-}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Delete stabilization branches + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const version = '${{ steps.extract-version.outputs.version }}'; + const branches = [`dev-${version}`, `main-${version}`]; + + for (const branch of branches) { + try { + await github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${branch}` + }); + console.log(`✅ Deleted branch: ${branch}`); + } catch (e) { + console.log(`Branch ${branch} not found or already deleted: ${e.message}`); + } + } diff --git a/CHANGELOG.md b/CHANGELOG.md index 254f1d8a..764e9b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +Many thanks to the following contributors to this release: + +- [marc-romu](https://github.com/marc-romu) + +---- + +### Changed + +- **Infrastructure**: Migrated critical fixes including provider stability improvements, timeout policy refinements, and streaming adapter fixes +- **Thread Safety**: `ProviderManager` now uses `ConcurrentDictionary` for all provider collections to improve concurrent access safety +- **Code Quality**: Applied consistent code style with `this.` qualifiers and `ConfigureAwait()` patterns across Infrastructure and Providers + +### Fixed + +- `ProviderManager` now exposes `IsInfrastructureReady` flag to signal when provider infrastructure initialization completes +- All AI providers (Anthropic, DeepSeek, MistralAI, OpenAI, OpenRouter) received stability improvements and extended known list of models + ## [1.4.2-alpha] - 2026-03-14 Many thanks to the following contributors to this release: diff --git a/README.md b/README.md index 4dc5e98e..45683800 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # SmartHopper - AI-Powered Tools and Assistant for Grasshopper3D -[![Version](https://img.shields.io/badge/version-1.4.2--alpha-orange?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) -[![Status](https://img.shields.io/badge/status-Alpha-orange?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) +[![Version](https://img.shields.io/badge/version-1.4.2--beta-yellow?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) +[![Status](https://img.shields.io/badge/status-Beta-yellow?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) [![.NET CI](https://img.shields.io/github/actions/workflow/status/architects-toolkit/SmartHopper/.github/workflows/ci-dotnet-tests.yml?label=tests&logo=dotnet&style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/actions/workflows/ci-dotnet-tests.yml) [![Ready to use](https://img.shields.io/badge/ready_to_use-YES-brightgreen?style=for-the-badge)](https://smarthopper.xyz/#installation) [![License](https://img.shields.io/badge/license-LGPL%20v3-white?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/blob/main/LICENSE) diff --git a/Solution.props b/Solution.props index 04223071..3ee46eaa 100644 --- a/Solution.props +++ b/Solution.props @@ -1,5 +1,5 @@ - 1.4.2-alpha + 1.4.2-beta \ No newline at end of file diff --git a/hashes/1.4.2-alpha.json b/hashes/1.4.2-alpha.json new file mode 100644 index 00000000..487404d9 --- /dev/null +++ b/hashes/1.4.2-alpha.json @@ -0,0 +1,26 @@ +{ + "providers": { + "SmartHopper.Providers.DeepSeek.dll-net7.0-windows": "2e2f3cc3e04560a79a1b0eae8e99dc86ec93f7dcc969fc0b62045c0cf93d9b46", + "SmartHopper.Providers.MistralAI.dll-net7.0-windows": "5b6ff6ccfb45bb22794f7a22b227c77b2297744fb99c10bd8b34168271f906cc", + "SmartHopper.Providers.Anthropic.dll-net7.0": "60ccf8f9fb55565ea75bb2b6ee93a9944340425905fb5711686edab2de0de906", + "SmartHopper.Providers.Anthropic.dll-net7.0-windows": "6f89fcd847f00853ffeee5bd969b9c5b5cb0f8a464efdc30e57dde2f1c19574a", + "SmartHopper.Providers.MistralAI.dll-net7.0": "47d29112f15ff231478fa765f008279ee8e4a33621483bc9809bf9bcc056982b", + "SmartHopper.Providers.OpenRouter.dll-net7.0": "dab141487559563f77a1dee1621d392916d59a40dba8facdf13f8d401f9edc6b", + "SmartHopper.Providers.DeepSeek.dll-net7.0": "9116e88fe35548849dd90b0309b410a91c7aa95bbf4e9379ef3cb31a1f2de31a", + "SmartHopper.Providers.OpenAI.dll-net7.0": "11d86b819bd2efa5de5f68f80f48ade8856e6d2da61873e3b9712a969e360552", + "SmartHopper.Providers.OpenRouter.dll-net7.0-windows": "56e8dd4b5cbaa1f02ab09f51e8ce0f1a76b3138330b95c5f8b5d1408aeb4fba2", + "SmartHopper.Providers.OpenAI.dll-net7.0-windows": "c742dfa60b29aff9d33cb82e3b9d4d5fad33edd36323016dd828722c351f8091" + }, + "algorithm": "SHA-256", + "metadata": { + "platforms": [ + "net7.0-windows", + "net7.0" + ], + "commitSha": "bea0f4759fe4a3f386381a3a81db49357665e14b", + "repository": "architects-toolkit/SmartHopper", + "buildNumber": "39" + }, + "version": "1.4.2-alpha", + "generated": "2026-03-14T09:43:52Z" +} diff --git a/src/SmartHopper.Components.Test/Badges/TestBadgesOneComponent.cs b/src/SmartHopper.Components.Test/Badges/TestBadgesOneComponent.cs index b1e23158..c43ae325 100644 --- a/src/SmartHopper.Components.Test/Badges/TestBadgesOneComponent.cs +++ b/src/SmartHopper.Components.Test/Badges/TestBadgesOneComponent.cs @@ -51,9 +51,12 @@ public class TestBadgesOneComponent : AIStatefulAsyncComponentBase /// Initializes a new instance of the class. /// public TestBadgesOneComponent() - : base("Test Badges: One", "TBadges1", - "Renders a single sample badge above the component for visual verification.", - "SmartHopper", "Testing Badges") + : base( + "Test Badges: One", + "TBadges1", + "Renders a single sample badge above the component for visual verification.", + "SmartHopper", + "Testing Badges") { } @@ -153,7 +156,10 @@ private static void DrawSampleBadge(Graphics g, float x, float y) } } - public OneBadgeAttributes(AIProviderComponentBase owner) : base(owner) { } + public OneBadgeAttributes(AIProviderComponentBase owner) + : base(owner) + { + } /// protected override IEnumerable<(Action draw, string label)> GetAdditionalBadges() diff --git a/src/SmartHopper.Components.Test/Badges/TestBadgesThreeComponent.cs b/src/SmartHopper.Components.Test/Badges/TestBadgesThreeComponent.cs index 86aad08d..28bd17b5 100644 --- a/src/SmartHopper.Components.Test/Badges/TestBadgesThreeComponent.cs +++ b/src/SmartHopper.Components.Test/Badges/TestBadgesThreeComponent.cs @@ -45,9 +45,12 @@ public class TestBadgesThreeComponent : AIStatefulAsyncComponentBase public override GH_Exposure Exposure => GH_Exposure.quinary; public TestBadgesThreeComponent() - : base("Test Badges: Three", "TBadges3", - "Renders three sample badges above the component for visual verification.", - "SmartHopper", "Testing Badges") + : base( + "Test Badges: Three", + "TBadges3", + "Renders three sample badges above the component for visual verification.", + "SmartHopper", + "Testing Badges") { } @@ -150,7 +153,10 @@ private static void DrawTeal(Graphics g, float x, float y) g.DrawEllipse(pen, rect); } - public ThreeBadgesAttributes(AIProviderComponentBase owner) : base(owner) { } + public ThreeBadgesAttributes(AIProviderComponentBase owner) + : base(owner) + { + } protected override IEnumerable<(Action draw, string label)> GetAdditionalBadges() { diff --git a/src/SmartHopper.Components.Test/Badges/TestBadgesTwoComponent.cs b/src/SmartHopper.Components.Test/Badges/TestBadgesTwoComponent.cs index 896f52c9..ef7a6260 100644 --- a/src/SmartHopper.Components.Test/Badges/TestBadgesTwoComponent.cs +++ b/src/SmartHopper.Components.Test/Badges/TestBadgesTwoComponent.cs @@ -45,9 +45,12 @@ public class TestBadgesTwoComponent : AIStatefulAsyncComponentBase public override GH_Exposure Exposure => GH_Exposure.quinary; public TestBadgesTwoComponent() - : base("Test Badges: Two", "TBadges2", - "Renders two sample badges above the component for visual verification.", - "SmartHopper", "Testing Badges") + : base( + "Test Badges: Two", + "TBadges2", + "Renders two sample badges above the component for visual verification.", + "SmartHopper", + "Testing Badges") { } @@ -141,7 +144,10 @@ private static void DrawPurpleBadge(Graphics g, float x, float y) g.DrawEllipse(pen, rect); } - public TwoBadgesAttributes(AIProviderComponentBase owner) : base(owner) { } + public TwoBadgesAttributes(AIProviderComponentBase owner) + : base(owner) + { + } protected override IEnumerable<(Action draw, string label)> GetAdditionalBadges() { diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchFlattenTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchFlattenTestComponent.cs index ea98b663..46b47e02 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchFlattenTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchFlattenTestComponent.cs @@ -38,13 +38,18 @@ namespace SmartHopper.Components.Test.DataProcessor public class DataTreeProcessorBranchFlattenTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("E9642177-D368-4E9D-9BD6-E84C46D0958F"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.septenary; public DataTreeProcessorBranchFlattenTestComponent() - : base("Test DataTreeProcessor (BranchFlatten)", "TEST-DTP-FLATTEN", - "Tests DataTreeProcessor with BranchFlatten topology where all branches are flattened and processed together.", - "SmartHopper", "Testing Data") + : base( + "Test DataTreeProcessor (BranchFlatten)", + "TEST-DTP-FLATTEN", + "Tests DataTreeProcessor with BranchFlatten topology where all branches are flattened and processed together.", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -60,7 +65,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -74,7 +79,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorBranchFlattenTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -115,7 +120,7 @@ public override async Task DoWorkAsync(CancellationToken token) async Task>> Func(Dictionary> branches) { await Task.Yield(); - _functionCallCount++; + this._functionCallCount++; var aList = branches.ContainsKey("A") ? branches["A"] : null; if (aList == null || aList.Count == 0) @@ -134,48 +139,48 @@ async Task>> Func(Dictionary(); + this._resultTree = new GH_Structure(); // BranchFlatten should: // 1. Call function only ONCE with all items flattened: [1,2,3,4] // 2. Result sum = 1+2+3+4 = 10 // 3. Output placed in single branch {0} bool ok = - _functionCallCount == 1 && - _resultTree != null && - _resultTree.PathCount == 1 && - _resultTree.get_Branch(path0) != null && - _resultTree.get_Branch(path0).Count == 1 && - _resultTree.get_Branch(path0)[0] is GH_Integer gi && gi.Value == 10; - - _success = new GH_Boolean(ok); - _messages.Add(new GH_String($"BranchFlatten topology. Input: {{{path0}}}=[1,2], {{{path1}}}=[3,4].")); - _messages.Add(new GH_String($"Function called {_functionCallCount} time(s) (expected 1).")); - _messages.Add(new GH_String($"Expected flattened sum [10] at {{{path0}}}.")); - _messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); + this._functionCallCount == 1 && + this._resultTree != null && + this._resultTree.PathCount == 1 && + this._resultTree.get_Branch(path0) != null && + this._resultTree.get_Branch(path0).Count == 1 && + this._resultTree.get_Branch(path0)[0] is GH_Integer gi && gi.Value == 10; + + this._success = new GH_Boolean(ok); + this._messages.Add(new GH_String($"BranchFlatten topology. Input: {{{path0}}}=[1,2], {{{path1}}}=[3,4].")); + this._messages.Add(new GH_String($"Function called {this._functionCallCount} time(s) (expected 1).")); + this._messages.Add(new GH_String($"Expected flattened sum [10] at {{{path0}}}.")); + this._messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); } catch (OperationCanceledException) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String("Operation was cancelled.")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String("Operation was cancelled.")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Test cancelled."); } catch (Exception ex) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String($"Exception: {ex.Message}")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String($"Exception: {ex.Message}")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Error, ex.Message); } } public override void SetOutput(IGH_DataAccess DA, out string message) { - _parent.SetPersistentOutput("Result", _resultTree, DA); - _parent.SetPersistentOutput("Success", _success, DA); - _parent.SetPersistentOutput("Messages", _messages, DA); - message = _success.Value ? "Processed BranchFlatten topology successfully" : "Processing failed"; + this._parent.SetPersistentOutput("Result", this._resultTree, DA); + this._parent.SetPersistentOutput("Success", this._success, DA); + this._parent.SetPersistentOutput("Messages", this._messages, DA); + message = this._success.Value ? "Processed BranchFlatten topology successfully" : "Processing failed"; } } } diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchToBranchTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchToBranchTestComponent.cs index 288eca66..3a76b7e4 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchToBranchTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBranchToBranchTestComponent.cs @@ -39,13 +39,18 @@ namespace SmartHopper.Components.Test.DataProcessor public class DataTreeProcessorBranchToBranchTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("4FBE8A03-5A39-4C99-B190-F95468A0D3AC"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.septenary; public DataTreeProcessorBranchToBranchTestComponent() - : base("Test DataTreeProcessor (BranchToBranch)", "TEST-DTP-B2B", - "Tests DataTreeProcessor with BranchToBranch topology where each branch is processed as a complete list.", - "SmartHopper", "Testing Data") + : base( + "Test DataTreeProcessor (BranchToBranch)", + "TEST-DTP-B2B", + "Tests DataTreeProcessor with BranchToBranch topology - one-to-one branch matching.", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -61,7 +66,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -75,7 +80,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorBranchToBranchTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -117,7 +122,7 @@ public override async Task DoWorkAsync(CancellationToken token) async Task>> Func(Dictionary> branches) { await Task.Yield(); - _functionCallCount++; + this._functionCallCount++; var aList = branches.ContainsKey("A") ? branches["A"] : null; if (aList == null || aList.Count == 0) @@ -136,51 +141,51 @@ async Task>> Func(Dictionary(); + this._resultTree = new GH_Structure(); // BranchToBranch should: // 1. Call function TWICE (once per branch) // 2. First call with [1,2,3], sum = 6, placed at {0} // 3. Second call with [4,5], sum = 9, placed at {1} bool ok = - _functionCallCount == 2 && - _resultTree != null && - _resultTree.PathCount == 2 && - _resultTree.get_Branch(path0) != null && - _resultTree.get_Branch(path0).Count == 1 && - _resultTree.get_Branch(path0)[0] is GH_Integer gi0 && gi0.Value == 6 && - _resultTree.get_Branch(path1) != null && - _resultTree.get_Branch(path1).Count == 1 && - _resultTree.get_Branch(path1)[0] is GH_Integer gi1 && gi1.Value == 9; - - _success = new GH_Boolean(ok); - _messages.Add(new GH_String($"BranchToBranch topology. Input: {{{path0}}}=[1,2,3], {{{path1}}}=[4,5].")); - _messages.Add(new GH_String($"Function called {_functionCallCount} time(s) (expected 2).")); - _messages.Add(new GH_String($"Expected branch sums: {{{path0}}}=[6], {{{path1}}}=[9].")); - _messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); + this._functionCallCount == 2 && + this._resultTree != null && + this._resultTree.PathCount == 2 && + this._resultTree.get_Branch(path0) != null && + this._resultTree.get_Branch(path0).Count == 1 && + this._resultTree.get_Branch(path0)[0] is GH_Integer gi0 && gi0.Value == 6 && + this._resultTree.get_Branch(path1) != null && + this._resultTree.get_Branch(path1).Count == 1 && + this._resultTree.get_Branch(path1)[0] is GH_Integer gi1 && gi1.Value == 9; + + this._success = new GH_Boolean(ok); + this._messages.Add(new GH_String($"BranchToBranch topology. Input: {{{path0}}}=[1,2,3], {{{path1}}}=[4,5].")); + this._messages.Add(new GH_String($"Function called {this._functionCallCount} time(s) (expected 2).")); + this._messages.Add(new GH_String($"Expected branch sums: {{{path0}}}=[6], {{{path1}}}=[9].")); + this._messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); } catch (OperationCanceledException) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String("Operation was cancelled.")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String("Operation was cancelled.")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Test cancelled."); } catch (Exception ex) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String($"Exception: {ex.Message}")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String($"Exception: {ex.Message}")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Error, ex.Message); } } public override void SetOutput(IGH_DataAccess DA, out string message) { - _parent.SetPersistentOutput("Result", _resultTree, DA); - _parent.SetPersistentOutput("Success", _success, DA); - _parent.SetPersistentOutput("Messages", _messages, DA); - message = _success.Value ? "Processed BranchToBranch topology successfully" : "Processing failed"; + this._parent.SetPersistentOutput("Result", this._resultTree, DA); + this._parent.SetPersistentOutput("Success", this._success, DA); + this._parent.SetPersistentOutput("Messages", this._messages, DA); + message = this._success.Value ? "Processed BranchToBranch topology successfully" : "Processing failed"; } } } diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperDiffRootTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperDiffRootTestComponent.cs index bceb2f37..c86840cd 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperDiffRootTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorBroadcastDeeperDiffRootTestComponent.cs @@ -36,13 +36,18 @@ namespace SmartHopper.Components.Test.DataProcessor public class DataTreeProcessorBroadcastDeeperDiffRootTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("D659A768-A076-4946-80B9-A8AD99D4F740"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.quinary; public DataTreeProcessorBroadcastDeeperDiffRootTestComponent() - : base("Test Broadcast (Deeper Diff Root)", "TEST-BC-DEEP-ROOT1", - "Tests flat {0} broadcasting to deeper paths {1;0},{1;1}", - "SmartHopper", "Testing Data") + : base( + "Test Broadcast (Deeper Diff Root)", + "TEST-BC-DEEP-ROOT1", + "Tests flat {0} broadcasting to deeper paths {1;0},{1;1}", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -58,7 +63,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -71,7 +76,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorBroadcastDeeperDiffRootTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -133,37 +138,37 @@ async Task>> Func(Dictionary new Guid("0E1105D8-1EA0-446B-B51D-F90D1EC29342"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.quinary; public DataTreeProcessorBroadcastDeeperSameRootTestComponent() - : base("Test Broadcast (Deeper Same Root)", "TEST-BC-DEEP-ROOT0", - "Tests flat {0} broadcasting to deeper paths {0;0},{0;1}", - "SmartHopper", "Testing Data") + : base( + "Test Broadcast (Deeper Same Root)", + "TEST-BC-DEEP-ROOT0", + "Tests flat {0} broadcasting to deeper paths {0;0},{0;1}", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -58,7 +63,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -71,7 +76,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorBroadcastDeeperSameRootTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -134,43 +139,43 @@ async Task>> Func(Dictionary new Guid("11287A68-04D7-46F4-99DE-C5B0C45F0732"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.quinary; public DataTreeProcessorBroadcastMultipleNoZeroTestComponent() - : base("Test Broadcast (Multiple No {0})", "TEST-BC-MULTI-NO0", - "Tests flat {0} broadcasting to multiple paths {1},{2} (no {0} in B)", - "SmartHopper", "Testing Data") + : base( + "Test Broadcast (Multiple No {0}", + "TEST-BC-MULTI-NO0", + "Tests flat {0} broadcasting to multiple paths {1},{2} (no {0} in B)", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -58,7 +63,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -71,7 +76,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorBroadcastMultipleNoZeroTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -133,37 +138,37 @@ async Task>> Func(Dictionary new Guid("CBD8E900-B6EF-4ADE-B68C-A1A6AB486647"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.quinary; public DataTreeProcessorBroadcastMultipleTopLevelTestComponent() - : base("Test Broadcast (Multiple Top-Level)", "TEST-BC-MULTI-TOP", - "Tests flat {0} broadcasting to multiple top-level paths {0},{1}", - "SmartHopper", "Testing Data") + : base( + "Test Broadcast (Multiple Top-Level)", + "TEST-BC-MULTI-TOP", + "Tests flat {0} broadcasting to multiple top-level paths {0},{1}", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -58,7 +63,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -71,7 +76,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorBroadcastMultipleTopLevelTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -131,41 +136,41 @@ async Task>> Func(Dictionary new Guid("A8D1E0F3-3C2B-4E1E-9B3F-1A2C3D4E5F60"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.senary; public DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent() - : base("Test DataTreeProcessor (Different Paths, 1 + 3 items)", "TEST-DTP-DIFF-1-3", - "Tests DataTreeProcessor with different paths where A has 1 item and B has 3 items.", - "SmartHopper", "Testing Data") + : base( + "Test DataTreeProcessor (Different Paths, 1 + 3 items)", + "TEST-DTP-DIFF-1-3", + "Tests DataTreeProcessor with different paths where A has 1 item and B has 3 items.", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -58,7 +63,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -71,7 +76,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorDifferentPathsFirstOneSecondThreeTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent.cs index 7a711655..8d9aec98 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent.cs @@ -36,13 +36,18 @@ namespace SmartHopper.Components.Test.DataProcessor public class DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("7A6E5F0B-9D3C-4A0C-8B2E-1F3A4D5C6B7E"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.senary; public DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent() - : base("Test DataTreeProcessor (Different Paths, 3 + 1 items)", "TEST-DTP-DIFF-3-1", - "Tests DataTreeProcessor with different paths where A has 3 items and B has 1 item.", - "SmartHopper", "Testing Data") + : base( + "Test DataTreeProcessor (Different Paths, 3 + 1 items)", + "TEST-DTP-DIFF-3-1", + "Tests DataTreeProcessor with different paths where A has 3 items and B has 1 item.", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -71,7 +76,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorDifferentPathsFirstThreeSecondOneTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -140,44 +145,44 @@ async Task>> Func(Dictionary(); + this._resultTree = new GH_Structure(); // With different paths and A having multiple items, expect broadcast of B's single item across A's three items at pathA bool ok = - _resultTree != null && - _resultTree.PathCount == 1 && - _resultTree.get_Branch(pathA) != null && - _resultTree.get_Branch(pathA).Count == 3 && - _resultTree.get_Branch(pathA)[0] is GH_Integer gi0 && gi0.Value == (1 + 5) && - _resultTree.get_Branch(pathA)[1] is GH_Integer gi1 && gi1.Value == (2 + 5) && - _resultTree.get_Branch(pathA)[2] is GH_Integer gi2 && gi2.Value == (3 + 5); - - _success = new GH_Boolean(ok); - _messages.Add(new GH_String($"Different paths A={pathA} (3 items), B={pathB} (1 item). Expected broadcast at {pathA}: [6,7,8].")); - _messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); + this._resultTree != null && + this._resultTree.PathCount == 1 && + this._resultTree.get_Branch(pathA) != null && + this._resultTree.get_Branch(pathA).Count == 3 && + this._resultTree.get_Branch(pathA)[0] is GH_Integer gi0 && gi0.Value == (1 + 5) && + this._resultTree.get_Branch(pathA)[1] is GH_Integer gi1 && gi1.Value == (2 + 5) && + this._resultTree.get_Branch(pathA)[2] is GH_Integer gi2 && gi2.Value == (3 + 5); + + this._success = new GH_Boolean(ok); + this._messages.Add(new GH_String($"Different paths A={pathA} (3 items), B={pathB} (1 item). Expected broadcast at {pathA}: [6,7,8].")); + this._messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); } catch (OperationCanceledException) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String("Operation was cancelled.")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String("Operation was cancelled.")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Test cancelled."); } catch (Exception ex) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String($"Exception: {ex.Message}")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String($"Exception: {ex.Message}")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Error, ex.Message); } } public override void SetOutput(IGH_DataAccess DA, out string message) { - _parent.SetPersistentOutput("Result", _resultTree, DA); - _parent.SetPersistentOutput("Success", _success, DA); - _parent.SetPersistentOutput("Messages", _messages, DA); - message = _success.Value ? "Processed different-path trees (3+1) successfully" : "Processing failed"; + this._parent.SetPersistentOutput("Result", this._resultTree, DA); + this._parent.SetPersistentOutput("Success", this._success, DA); + this._parent.SetPersistentOutput("Messages", this._messages, DA); + message = this._success.Value ? "Processed different-path trees (3+1) successfully" : "Processing failed"; } } } diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsOneItemEachTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsOneItemEachTestComponent.cs index 50d8e275..a0562e28 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsOneItemEachTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsOneItemEachTestComponent.cs @@ -36,13 +36,18 @@ namespace SmartHopper.Components.Test.DataProcessor public class DataTreeProcessorDifferentPathsOneItemEachTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("F26E3A5B-2EFD-4F7B-8D8A-7C9A6B6882A2"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.quinary; public DataTreeProcessorDifferentPathsOneItemEachTestComponent() - : base("Test DataTreeProcessor (Different Paths, 1 item each)", "TEST-DTP-DIFF-1", - "Tests DataTreeProcessor with two input trees with different paths (one item each).", - "SmartHopper", "Testing Data") + : base( + "Test DataTreeProcessor (Different Paths, 1 item each)", + "TEST-DTP-DIFF-1", + "Tests DataTreeProcessor with two input trees with different paths (one item each).", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -58,7 +63,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -71,7 +76,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorDifferentPathsOneItemEachTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -138,44 +143,44 @@ async Task>> Func(Dictionary(); + this._resultTree = new GH_Structure(); int expected = 2 + 5; // 7 // Expect the result to be present only at the path of input A bool ok = - _resultTree != null && - _resultTree.PathCount == 1 && - _resultTree.get_Branch(pathA) != null && - _resultTree.get_Branch(pathA).Count == 1 && - _resultTree.get_Branch(pathA)[0] is GH_Integer giA && giA.Value == expected; - - _success = new GH_Boolean(ok); - _messages.Add(new GH_String($"Different paths A={pathA}, B={pathB}. Expected only path A with sum {expected}.")); - _messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); + this._resultTree != null && + this._resultTree.PathCount == 1 && + this._resultTree.get_Branch(pathA) != null && + this._resultTree.get_Branch(pathA).Count == 1 && + this._resultTree.get_Branch(pathA)[0] is GH_Integer giA && giA.Value == expected; + + this._success = new GH_Boolean(ok); + this._messages.Add(new GH_String($"Different paths A={pathA}, B={pathB}. Expected only path A with sum {expected}.")); + this._messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); } catch (OperationCanceledException) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String("Operation was cancelled.")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String("Operation was cancelled.")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Test cancelled."); } catch (Exception ex) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String($"Exception: {ex.Message}")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String($"Exception: {ex.Message}")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Error, ex.Message); } } public override void SetOutput(IGH_DataAccess DA, out string message) { - _parent.SetPersistentOutput("Result", _resultTree, DA); - _parent.SetPersistentOutput("Success", _success, DA); - _parent.SetPersistentOutput("Messages", _messages, DA); - message = _success.Value ? "Processed different-path trees (1 each) successfully" : "Processing failed"; + this._parent.SetPersistentOutput("Result", this._resultTree, DA); + this._parent.SetPersistentOutput("Success", this._success, DA); + this._parent.SetPersistentOutput("Messages", this._messages, DA); + message = this._success.Value ? "Processed different-path trees (1 each) successfully" : "Processing failed"; } } } diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsThreeItemsEachTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsThreeItemsEachTestComponent.cs index 66731372..ee3a12de 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsThreeItemsEachTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDifferentPathsThreeItemsEachTestComponent.cs @@ -39,13 +39,18 @@ namespace SmartHopper.Components.Test.DataProcessor public class DataTreeProcessorDifferentPathsThreeItemsEachTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("5A7B9B0C-12D0-4B90-AE17-5D1F764C6C5A"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.quinary; public DataTreeProcessorDifferentPathsThreeItemsEachTestComponent() - : base("Test DataTreeProcessor (Different Paths, 3 items each)", "TEST-DTP-DIFF-3", - "Tests DataTreeProcessor with two input trees with different paths (three items each).", - "SmartHopper", "Testing Data") + : base( + "Test DataTreeProcessor (Different Paths, 3 items each)", + "TEST-DTP-DIFF-3", + "Tests DataTreeProcessor with two input trees with different paths (three items each).", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -61,7 +66,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -74,7 +79,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorDifferentPathsThreeItemsEachTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -155,46 +160,46 @@ async Task>> Func(Dictionary(); + this._resultTree = new GH_Structure(); bool ok = - _resultTree != null && - _resultTree.PathCount == 2 && - _resultTree.get_Branch(pathA) != null && _resultTree.get_Branch(pathA).Count == 3 && - _resultTree.get_Branch(pathA)[0] is GH_Integer giA0 && giA0.Value == 1 && - _resultTree.get_Branch(pathA)[1] is GH_Integer giA1 && giA1.Value == 2 && - _resultTree.get_Branch(pathA)[2] is GH_Integer giA2 && giA2.Value == 3 && - _resultTree.get_Branch(pathB) != null && _resultTree.get_Branch(pathB).Count == 3 && - _resultTree.get_Branch(pathB)[0] is GH_Integer giB0 && giB0.Value == 4 && - _resultTree.get_Branch(pathB)[1] is GH_Integer giB1 && giB1.Value == 5 && - _resultTree.get_Branch(pathB)[2] is GH_Integer giB2 && giB2.Value == 6; - - _success = new GH_Boolean(ok); - _messages.Add(new GH_String($"Different paths A={pathA}, B={pathB}. A=[1,2,3], B=[4,5,6]. Expected passthrough: branch {pathA} -> [1,2,3], branch {pathB} -> [4,5,6]. No cross-path combination.")); - _messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); + this._resultTree != null && + this._resultTree.PathCount == 2 && + this._resultTree.get_Branch(pathA) != null && this._resultTree.get_Branch(pathA).Count == 3 && + this._resultTree.get_Branch(pathA)[0] is GH_Integer giA0 && giA0.Value == 1 && + this._resultTree.get_Branch(pathA)[1] is GH_Integer giA1 && giA1.Value == 2 && + this._resultTree.get_Branch(pathA)[2] is GH_Integer giA2 && giA2.Value == 3 && + this._resultTree.get_Branch(pathB) != null && this._resultTree.get_Branch(pathB).Count == 3 && + this._resultTree.get_Branch(pathB)[0] is GH_Integer giB0 && giB0.Value == 4 && + this._resultTree.get_Branch(pathB)[1] is GH_Integer giB1 && giB1.Value == 5 && + this._resultTree.get_Branch(pathB)[2] is GH_Integer giB2 && giB2.Value == 6; + + this._success = new GH_Boolean(ok); + this._messages.Add(new GH_String($"Different paths A={pathA}, B={pathB}. A=[1,2,3], B=[4,5,6]. Expected passthrough: branch {pathA} -> [1,2,3], branch {pathB} -> [4,5,6]. No cross-path combination.")); + this._messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); } catch (OperationCanceledException) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String("Operation was cancelled.")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String("Operation was cancelled.")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Test cancelled."); } catch (Exception ex) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String($"Exception: {ex.Message}")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String($"Exception: {ex.Message}")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Error, ex.Message); } } public override void SetOutput(IGH_DataAccess DA, out string message) { - _parent.SetPersistentOutput("Result", _resultTree, DA); - _parent.SetPersistentOutput("Success", _success, DA); - _parent.SetPersistentOutput("Messages", _messages, DA); - message = _success.Value ? "Processed different-path trees (3 each) successfully" : "Processing failed"; + this._parent.SetPersistentOutput("Result", this._resultTree, DA); + this._parent.SetPersistentOutput("Success", this._success, DA); + this._parent.SetPersistentOutput("Messages", this._messages, DA); + message = this._success.Value ? "Processed different-path trees (3 each) successfully" : "Processing failed"; } } } diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDirectMatchPrecedenceTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDirectMatchPrecedenceTestComponent.cs index 4f37b1b3..8645db68 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDirectMatchPrecedenceTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorDirectMatchPrecedenceTestComponent.cs @@ -36,13 +36,18 @@ namespace SmartHopper.Components.Test.DataProcessor public class DataTreeProcessorDirectMatchPrecedenceTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("77095C92-474F-4D5C-9EA6-6FE31FFFA710"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.quinary; public DataTreeProcessorDirectMatchPrecedenceTestComponent() - : base("Test Direct Match Precedence", "TEST-BC-DIRECT", - "Tests direct {0} match taking precedence over deeper paths", - "SmartHopper", "Testing Data") + : base( + "Test Direct Match Precedence", + "TEST-BC-DIRECT", + "Tests direct {0} match taking precedence over deeper paths", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -58,7 +63,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -71,7 +76,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorDirectMatchPrecedenceTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -131,41 +136,41 @@ async Task>> Func(Dictionary new Guid("0C6B2C9E-2D68-45AC-A2D8-7B2E5F97F9C3"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.quarternary; public DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent() - : base("Test DataTreeProcessor (Equal Paths, 1 + 3 items)", "TEST-DTP-EQ-1-3", - "Tests DataTreeProcessor with equal paths where A has 1 item and B has 3 items.", - "SmartHopper", "Testing Data") + : base( + "Test DataTreeProcessor (1 vs 3 - EqualPaths)", + "TEST-DTP-EQ-1v3", + "Tests DataTreeProcessor with equal paths but different item counts: primary=1, secondary=3.", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -58,7 +63,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -71,7 +76,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorEqualPathsFirstOneSecondThreeTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -139,43 +144,43 @@ async Task>> Func(Dictionary(); + this._resultTree = new GH_Structure(); bool ok = - _resultTree != null && - _resultTree.PathCount == 1 && - _resultTree.get_Branch(path) != null && - _resultTree.get_Branch(path).Count == 3 && - _resultTree.get_Branch(path)[0] is GH_Integer gi0 && gi0.Value == (2 + 1) && - _resultTree.get_Branch(path)[1] is GH_Integer gi1 && gi1.Value == (2 + 2) && - _resultTree.get_Branch(path)[2] is GH_Integer gi2 && gi2.Value == (2 + 3); - - _success = new GH_Boolean(ok); - _messages.Add(new GH_String($"Equal paths {path}. A=[2], B=[1,2,3]. Expected pairwise sums [3,4,5].")); - _messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); + this._resultTree != null && + this._resultTree.PathCount == 1 && + this._resultTree.get_Branch(path) != null && + this._resultTree.get_Branch(path).Count == 3 && + this._resultTree.get_Branch(path)[0] is GH_Integer gi0 && gi0.Value == (2 + 1) && + this._resultTree.get_Branch(path)[1] is GH_Integer gi1 && gi1.Value == (2 + 2) && + this._resultTree.get_Branch(path)[2] is GH_Integer gi2 && gi2.Value == (2 + 3); + + this._success = new GH_Boolean(ok); + this._messages.Add(new GH_String($"Equal paths {path}. A=[2], B=[1,2,3]. Expected pairwise sums [3,4,5].")); + this._messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); } catch (OperationCanceledException) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String("Operation was cancelled.")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String("Operation was cancelled.")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Test cancelled."); } catch (Exception ex) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String($"Exception: {ex.Message}")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String($"Exception: {ex.Message}")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Error, ex.Message); } } public override void SetOutput(IGH_DataAccess DA, out string message) { - _parent.SetPersistentOutput("Result", _resultTree, DA); - _parent.SetPersistentOutput("Success", _success, DA); - _parent.SetPersistentOutput("Messages", _messages, DA); - message = _success.Value ? "Processed equal-path trees (1+3) successfully" : "Processing failed"; + this._parent.SetPersistentOutput("Result", this._resultTree, DA); + this._parent.SetPersistentOutput("Success", this._success, DA); + this._parent.SetPersistentOutput("Messages", this._messages, DA); + message = this._success.Value ? "Processed equal-path trees (1+3) successfully" : "Processing failed"; } } } diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent.cs index 6c03d946..156cc86b 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent.cs @@ -36,13 +36,18 @@ namespace SmartHopper.Components.Test.DataProcessor public class DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("B3C7D9E1-4A5B-4F2C-8B1D-2E3F4A5B6C7D"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.quarternary; public DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent() - : base("Test DataTreeProcessor (Equal Paths, 3 + 1 items)", "TEST-DTP-EQ-3-1", - "Tests DataTreeProcessor with equal paths where A has 3 items and B has 1 item.", - "SmartHopper", "Testing Data") + : base( + "Test DataTreeProcessor (3 vs 1 - EqualPaths)", + "TEST-DTP-EQ-3v1", + "Tests DataTreeProcessor with equal paths but different item counts: primary=3, secondary=1.", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -58,7 +63,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private class Worker : AsyncWorkerBase @@ -71,7 +76,7 @@ private class Worker : AsyncWorkerBase public Worker(DataTreeProcessorEqualPathsFirstThreeSecondOneTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -139,43 +144,43 @@ async Task>> Func(Dictionary(); + this._resultTree = new GH_Structure(); bool ok = - _resultTree != null && - _resultTree.PathCount == 1 && - _resultTree.get_Branch(path) != null && - _resultTree.get_Branch(path).Count == 3 && - _resultTree.get_Branch(path)[0] is GH_Integer gi0 && gi0.Value == (1 + 5) && - _resultTree.get_Branch(path)[1] is GH_Integer gi1 && gi1.Value == (2 + 5) && - _resultTree.get_Branch(path)[2] is GH_Integer gi2 && gi2.Value == (3 + 5); - - _success = new GH_Boolean(ok); - _messages.Add(new GH_String($"Equal paths {path}. A=[1,2,3], B=[5]. Expected pairwise sums [6,7,8].")); - _messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); + this._resultTree != null && + this._resultTree.PathCount == 1 && + this._resultTree.get_Branch(path) != null && + this._resultTree.get_Branch(path).Count == 3 && + this._resultTree.get_Branch(path)[0] is GH_Integer gi0 && gi0.Value == (1 + 5) && + this._resultTree.get_Branch(path)[1] is GH_Integer gi1 && gi1.Value == (2 + 5) && + this._resultTree.get_Branch(path)[2] is GH_Integer gi2 && gi2.Value == (3 + 5); + + this._success = new GH_Boolean(ok); + this._messages.Add(new GH_String($"Equal paths {path}. A=[1,2,3], B=[5]. Expected pairwise sums [6,7,8].")); + this._messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); } catch (OperationCanceledException) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String("Operation was cancelled.")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String("Operation was cancelled.")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Test cancelled."); } catch (Exception ex) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String($"Exception: {ex.Message}")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String($"Exception: {ex.Message}")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Error, ex.Message); } } public override void SetOutput(IGH_DataAccess DA, out string message) { - _parent.SetPersistentOutput("Result", _resultTree, DA); - _parent.SetPersistentOutput("Success", _success, DA); - _parent.SetPersistentOutput("Messages", _messages, DA); - message = _success.Value ? "Processed equal-path trees (3+1) successfully" : "Processing failed"; + this._parent.SetPersistentOutput("Result", this._resultTree, DA); + this._parent.SetPersistentOutput("Success", this._success, DA); + this._parent.SetPersistentOutput("Messages", this._messages, DA); + message = this._success.Value ? "Processed equal-path trees (3+1) successfully" : "Processing failed"; } } } diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsTestComponent.cs index 5ff9a43f..5b7309b8 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsTestComponent.cs @@ -36,22 +36,24 @@ namespace SmartHopper.Components.Test.DataProcessor public class DataTreeProcessorEqualPathsTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("B0C2B1B7-3A6C-46A5-9E52-9F9E4F6B7C11"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.tertiary; public DataTreeProcessorEqualPathsTestComponent() - : base("Test DataTreeProcessor (Equal Paths)", "TEST-DTP-EQ", - "Tests DataTreeProcessor with two input trees that share equal branch paths (one item each).", - "SmartHopper", "Testing Data") + : base( + "Test DataTreeProcessor (Equal Paths)", + "TEST-DTP-EQ", + "Tests DataTreeProcessor with two input trees that share equal branch paths (one item each).", + "SmartHopper", + "Testing Data") { // We want the component to run when Run? toggles on, even if inputs did not change (they are internal) this.RunOnlyOnInputChanges = false; } - protected override void RegisterAdditionalInputParams(GH_InputParamManager pManager) - { - // No external inputs; this component uses internal hardcoded data for testing - } + protected override void RegisterAdditionalInputParams(GH_InputParamManager pManager) { } protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pManager) { @@ -62,7 +64,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -75,7 +77,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorEqualPathsTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -146,48 +148,48 @@ async Task>> Func(Dictionary(); + this._resultTree = new GH_Structure(); } // Validate expected output: path {0} with one item = 7 int expected = 7; bool ok = - _resultTree != null && - _resultTree.PathCount == 1 && - _resultTree.get_Branch(path) != null && - _resultTree.get_Branch(path).Count == 1 && - _resultTree.get_Branch(path)[0] is GH_Integer gi && gi.Value == expected; + this._resultTree != null && + this._resultTree.PathCount == 1 && + this._resultTree.get_Branch(path) != null && + this._resultTree.get_Branch(path).Count == 1 && + this._resultTree.get_Branch(path)[0] is GH_Integer gi && gi.Value == expected; - _success = new GH_Boolean(ok); + this._success = new GH_Boolean(ok); - _messages.Add(new GH_String($"Inputs A=2, B=5 at path {path}. Expected sum {expected}.")); - _messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); + this._messages.Add(new GH_String($"Inputs A=2, B=5 at path {path}. Expected sum {expected}.")); + this._messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); } catch (OperationCanceledException) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String("Operation was cancelled.")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String("Operation was cancelled.")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Test cancelled."); } catch (Exception ex) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String($"Exception: {ex.Message}")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String($"Exception: {ex.Message}")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Error, ex.Message); } } public override void SetOutput(IGH_DataAccess DA, out string message) { - _parent.SetPersistentOutput("Result", _resultTree, DA); - _parent.SetPersistentOutput("Success", _success, DA); - _parent.SetPersistentOutput("Messages", _messages, DA); + this._parent.SetPersistentOutput("Result", this._resultTree, DA); + this._parent.SetPersistentOutput("Success", this._success, DA); + this._parent.SetPersistentOutput("Messages", this._messages, DA); - message = _success.Value ? "Processed equal-path trees successfully" : "Processing failed"; + message = this._success.Value ? "Processed equal-path trees successfully" : "Processing failed"; } } } diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsThreeItemsTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsThreeItemsTestComponent.cs index a0681d83..11eb8e7b 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsThreeItemsTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorEqualPathsThreeItemsTestComponent.cs @@ -36,40 +36,25 @@ namespace SmartHopper.Components.Test.DataProcessor /// public class DataTreeProcessorEqualPathsThreeItemsTestComponent : StatefulComponentBase { - /// - /// Gets the unique component identifier. - /// public override Guid ComponentGuid => new Guid("1F4D5C1B-8E6D-49B4-B55F-1A3F5E2E6B31"); - /// - /// Gets the component icon (not used for test components). - /// protected override Bitmap Icon => null; - /// - /// Gets the exposure level for this component in the toolbar. - /// public override GH_Exposure Exposure => GH_Exposure.tertiary; - /// - /// Initializes a new instance of the class. - /// public DataTreeProcessorEqualPathsThreeItemsTestComponent() - : base("Test DataTreeProcessor (Equal Paths, 3 items)", "TEST-DTP-EQ-3", - "Tests DataTreeProcessor with two input trees that share equal branch paths (three items each).", - "SmartHopper", "Testing Data") + : base( + "Test DataTreeProcessor (Equal Paths, 3 items)", + "TEST-DTP-EQ-3", + "Tests DataTreeProcessor with two input trees that share equal branch paths (three items each).", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } - /// - /// Registers additional input parameters (none for this test component). - /// protected override void RegisterAdditionalInputParams(GH_InputParamManager pManager) { } - /// - /// Registers output parameters for the test results. - /// protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pManager) { pManager.AddIntegerParameter("Result", "R", "Result data tree produced by DataTreeProcessor.", GH_ParamAccess.tree); @@ -77,12 +62,9 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa pManager.AddTextParameter("Messages", "M", "Diagnostic messages.", GH_ParamAccess.list); } - /// - /// Creates the worker that performs the asynchronous test logic. - /// protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private class Worker : AsyncWorkerBase @@ -95,7 +77,7 @@ private class Worker : AsyncWorkerBase public Worker(DataTreeProcessorEqualPathsThreeItemsTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -167,43 +149,43 @@ async Task>> Func(Dictionary(); + this._resultTree = new GH_Structure(); bool ok = - _resultTree != null && - _resultTree.PathCount == 1 && - _resultTree.get_Branch(path) != null && - _resultTree.get_Branch(path).Count == 3 && - _resultTree.get_Branch(path)[0] is GH_Integer gi0 && gi0.Value == (1 + 4) && - _resultTree.get_Branch(path)[1] is GH_Integer gi1 && gi1.Value == (2 + 5) && - _resultTree.get_Branch(path)[2] is GH_Integer gi2 && gi2.Value == (3 + 6); - - _success = new GH_Boolean(ok); - _messages.Add(new GH_String($"Equal paths {path}. A=[1,2,3], B=[4,5,6]. Expected pairwise sums [5,7,9].")); - _messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); + this._resultTree != null && + this._resultTree.PathCount == 1 && + this._resultTree.get_Branch(path) != null && + this._resultTree.get_Branch(path).Count == 3 && + this._resultTree.get_Branch(path)[0] is GH_Integer gi0 && gi0.Value == (1 + 4) && + this._resultTree.get_Branch(path)[1] is GH_Integer gi1 && gi1.Value == (2 + 5) && + this._resultTree.get_Branch(path)[2] is GH_Integer gi2 && gi2.Value == (3 + 6); + + this._success = new GH_Boolean(ok); + this._messages.Add(new GH_String($"Equal paths {path}. A=[1,2,3], B=[4,5,6]. Expected pairwise sums [5,7,9].")); + this._messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); } catch (OperationCanceledException) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String("Operation was cancelled.")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String("Operation was cancelled.")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Test cancelled."); } catch (Exception ex) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String($"Exception: {ex.Message}")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String($"Exception: {ex.Message}")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Error, ex.Message); } } public override void SetOutput(IGH_DataAccess DA, out string message) { - _parent.SetPersistentOutput("Result", _resultTree, DA); - _parent.SetPersistentOutput("Success", _success, DA); - _parent.SetPersistentOutput("Messages", _messages, DA); - message = _success.Value ? "Processed equal-path trees (3 items) successfully" : "Processing failed"; + this._parent.SetPersistentOutput("Result", this._resultTree, DA); + this._parent.SetPersistentOutput("Success", this._success, DA); + this._parent.SetPersistentOutput("Messages", this._messages, DA); + message = this._success.Value ? "Processed equal-path trees (3 items) successfully" : "Processing failed"; } } } diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorGroupIdenticalTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorGroupIdenticalTestComponent.cs index d35abe10..bd956bc9 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorGroupIdenticalTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorGroupIdenticalTestComponent.cs @@ -38,13 +38,18 @@ namespace SmartHopper.Components.Test.DataProcessor public class DataTreeProcessorGroupIdenticalTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("CEF161C9-36FC-4503-8BB0-9717EEA13865"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.septenary; public DataTreeProcessorGroupIdenticalTestComponent() - : base("Test DataTreeProcessor (GroupIdentical)", "TEST-DTP-GROUP", - "Tests DataTreeProcessor with GroupIdenticalBranches=true where identical branches are processed only once.", - "SmartHopper", "Testing Data") + : base( + "Test DataTreeProcessor (Group Identical)", + "TEST-DTP-GROUP", + "Tests DataTreeProcessor Group Identical behavior where all items are processed together.", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -60,7 +65,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -74,7 +79,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorGroupIdenticalTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -118,7 +123,7 @@ public override async Task DoWorkAsync(CancellationToken token) async Task>> Func(Dictionary> branches) { await Task.Yield(); - _processCount++; + this._processCount++; var aList = branches.ContainsKey("A") ? branches["A"] : null; if (aList == null || aList.Count == 0) @@ -137,53 +142,53 @@ async Task>> Func(Dictionary(); + this._resultTree = new GH_Structure(); // With GroupIdenticalBranches=true, function should be called only twice (not three times) // because path0 and path2 have identical content // Results should still appear at all three paths bool ok = - _processCount == 2 && - _resultTree != null && - _resultTree.PathCount == 3 && - _resultTree.get_Branch(path0) != null && _resultTree.get_Branch(path0).Count == 2 && - _resultTree.get_Branch(path0)[0] is GH_Integer gi00 && gi00.Value == 2 && - _resultTree.get_Branch(path0)[1] is GH_Integer gi01 && gi01.Value == 4 && - _resultTree.get_Branch(path1) != null && _resultTree.get_Branch(path1).Count == 2 && - _resultTree.get_Branch(path1)[0] is GH_Integer gi10 && gi10.Value == 6 && - _resultTree.get_Branch(path1)[1] is GH_Integer gi11 && gi11.Value == 8 && - _resultTree.get_Branch(path2) != null && _resultTree.get_Branch(path2).Count == 2 && - _resultTree.get_Branch(path2)[0] is GH_Integer gi20 && gi20.Value == 2 && - _resultTree.get_Branch(path2)[1] is GH_Integer gi21 && gi21.Value == 4; - - _success = new GH_Boolean(ok); - _messages.Add(new GH_String($"GroupIdenticalBranches=true. Input has 3 branches, but {path0} and {path2} are identical [1,2].")); - _messages.Add(new GH_String($"Function was called {_processCount} times (expected 2, not 3).")); - _messages.Add(new GH_String($"Results appear at all 3 paths: {{{path0}}}=[2,4], {{{path1}}}=[6,8], {{{path2}}}=[2,4].")); - _messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); + this._processCount == 2 && + this._resultTree != null && + this._resultTree.PathCount == 3 && + this._resultTree.get_Branch(path0) != null && this._resultTree.get_Branch(path0).Count == 2 && + this._resultTree.get_Branch(path0)[0] is GH_Integer gi00 && gi00.Value == 2 && + this._resultTree.get_Branch(path0)[1] is GH_Integer gi01 && gi01.Value == 4 && + this._resultTree.get_Branch(path1) != null && this._resultTree.get_Branch(path1).Count == 2 && + this._resultTree.get_Branch(path1)[0] is GH_Integer gi10 && gi10.Value == 6 && + this._resultTree.get_Branch(path1)[1] is GH_Integer gi11 && gi11.Value == 8 && + this._resultTree.get_Branch(path2) != null && this._resultTree.get_Branch(path2).Count == 2 && + this._resultTree.get_Branch(path2)[0] is GH_Integer gi20 && gi20.Value == 2 && + this._resultTree.get_Branch(path2)[1] is GH_Integer gi21 && gi21.Value == 4; + + this._success = new GH_Boolean(ok); + this._messages.Add(new GH_String($"GroupIdenticalBranches=true. Input has 3 branches, but {path0} and {path2} are identical [1,2].")); + this._messages.Add(new GH_String($"Function was called {this._processCount} times (expected 2, not 3).")); + this._messages.Add(new GH_String($"Results appear at all 3 paths: {{{path0}}}=[2,4], {{{path1}}}=[6,8], {{{path2}}}=[2,4].")); + this._messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); } catch (OperationCanceledException) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String("Operation was cancelled.")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String("Operation was cancelled.")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Test cancelled."); } catch (Exception ex) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String($"Exception: {ex.Message}")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String($"Exception: {ex.Message}")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Error, ex.Message); } } public override void SetOutput(IGH_DataAccess DA, out string message) { - _parent.SetPersistentOutput("Result", _resultTree, DA); - _parent.SetPersistentOutput("Success", _success, DA); - _parent.SetPersistentOutput("Messages", _messages, DA); - message = _success.Value ? "Processed with GroupIdenticalBranches successfully" : "Processing failed"; + this._parent.SetPersistentOutput("Result", this._resultTree, DA); + this._parent.SetPersistentOutput("Success", this._success, DA); + this._parent.SetPersistentOutput("Messages", this._messages, DA); + message = this._success.Value ? "Processed with GroupIdenticalBranches successfully" : "Processing failed"; } } } diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemGraftTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemGraftTestComponent.cs index 356ea8a0..9da28287 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemGraftTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorItemGraftTestComponent.cs @@ -36,18 +36,30 @@ namespace SmartHopper.Components.Test.DataProcessor /// public class DataTreeProcessorItemGraftTestComponent : StatefulComponentBase { + /// public override Guid ComponentGuid => new Guid("3B09EE1F-00A3-4B7D-86DA-4C7EB0C6C0C3"); + + /// protected override Bitmap Icon => null; + + /// public override GH_Exposure Exposure => GH_Exposure.septenary; + /// + /// Initializes a new instance of the class. + /// public DataTreeProcessorItemGraftTestComponent() - : base("Test DataTreeProcessor (ItemGraft)", "TEST-DTP-GRAFT", - "Tests DataTreeProcessor with ItemGraft topology where each item result is grafted into a separate branch.", - "SmartHopper", "Testing Data") + : base( + "Test DataTreeProcessor (Item Graft)", + "TEST-DTP-GRAFT", + "Tests DataTreeProcessor Item Graft behavior where each item is processed individually.", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } + /// protected override void RegisterAdditionalInputParams(GH_InputParamManager pManager) { } protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pManager) @@ -57,9 +69,10 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa pManager.AddTextParameter("Messages", "M", "Diagnostic messages.", GH_ParamAccess.list); } + /// protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -72,7 +85,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorItemGraftTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -121,7 +134,9 @@ async Task>> Func(Dictionary 0 ? items["B"][0] : null; if (aItem == null || bItem == null) + { return new Dictionary> { { "Sum", new List() } }; + } int sum = aItem.Value + bItem.Value; return new Dictionary> { { "Sum", new List { new GH_Integer(sum) } } }; @@ -135,9 +150,13 @@ async Task>> Func(Dictionary(); + { + this._resultTree = new GH_Structure(); + } // ItemGraft should graft each result into separate branches: // {0;0} = [11], {0;1} = [22] @@ -145,39 +164,39 @@ async Task>> Func(Dictionary new Guid("C4D5E6F7-8A9B-4C0D-9E1F-2A3B4C5D6E7F"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.septenary; public DataTreeProcessorItemToItemTestComponent() - : base("Test DataTreeProcessor (ItemToItem)", "TEST-DTP-ITEM", - "Tests DataTreeProcessor with ItemToItem topology where each item is processed independently.", - "SmartHopper", "Testing Data") + : base( + "Test DataTreeProcessor (Item-to-Item)", + "TEST-DTP-ITEM", + "Tests DataTreeProcessor Item-to-Item topology where items are matched pairwise.", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -59,7 +64,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -72,7 +77,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorItemToItemTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -137,44 +142,44 @@ async Task>> Func(Dictionary(); + this._resultTree = new GH_Structure(); // ItemToItem should process each item independently: (1+10), (2+20), (3+30) = [11, 22, 33] bool ok = - _resultTree != null && - _resultTree.PathCount == 1 && - _resultTree.get_Branch(path) != null && - _resultTree.get_Branch(path).Count == 3 && - _resultTree.get_Branch(path)[0] is GH_Integer gi0 && gi0.Value == 11 && - _resultTree.get_Branch(path)[1] is GH_Integer gi1 && gi1.Value == 22 && - _resultTree.get_Branch(path)[2] is GH_Integer gi2 && gi2.Value == 33; - - _success = new GH_Boolean(ok); - _messages.Add(new GH_String($"ItemToItem topology at path {path}. A=[1,2,3], B=[10,20,30]. Expected item-wise sums [11,22,33].")); - _messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); + this._resultTree != null && + this._resultTree.PathCount == 1 && + this._resultTree.get_Branch(path) != null && + this._resultTree.get_Branch(path).Count == 3 && + this._resultTree.get_Branch(path)[0] is GH_Integer gi0 && gi0.Value == 11 && + this._resultTree.get_Branch(path)[1] is GH_Integer gi1 && gi1.Value == 22 && + this._resultTree.get_Branch(path)[2] is GH_Integer gi2 && gi2.Value == 33; + + this._success = new GH_Boolean(ok); + this._messages.Add(new GH_String($"ItemToItem topology at path {path}. A=[1,2,3], B=[10,20,30]. Expected item-wise sums [11,22,33].")); + this._messages.Add(new GH_String(ok ? "Test succeeded." : "Test failed: unexpected result.")); } catch (OperationCanceledException) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String("Operation was cancelled.")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String("Operation was cancelled.")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Test cancelled."); } catch (Exception ex) { - _success = new GH_Boolean(false); - _messages.Add(new GH_String($"Exception: {ex.Message}")); + this._success = new GH_Boolean(false); + this._messages.Add(new GH_String($"Exception: {ex.Message}")); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Error, ex.Message); } } public override void SetOutput(IGH_DataAccess DA, out string message) { - _parent.SetPersistentOutput("Result", _resultTree, DA); - _parent.SetPersistentOutput("Success", _success, DA); - _parent.SetPersistentOutput("Messages", _messages, DA); - message = _success.Value ? "Processed ItemToItem topology successfully" : "Processing failed"; + this._parent.SetPersistentOutput("Result", this._resultTree, DA); + this._parent.SetPersistentOutput("Success", this._success, DA); + this._parent.SetPersistentOutput("Messages", this._messages, DA); + message = this._success.Value ? "Processed ItemToItem topology successfully" : "Processing failed"; } } } diff --git a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorMixedDepthsTestComponent.cs b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorMixedDepthsTestComponent.cs index fbd844cb..ac11e1ca 100644 --- a/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorMixedDepthsTestComponent.cs +++ b/src/SmartHopper.Components.Test/DataProcessor/DataTreeProcessorMixedDepthsTestComponent.cs @@ -36,13 +36,18 @@ namespace SmartHopper.Components.Test.DataProcessor public class DataTreeProcessorMixedDepthsTestComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("F788712F-B2B3-4131-87CA-E654F6153339"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.quinary; public DataTreeProcessorMixedDepthsTestComponent() - : base("Test Mixed Depths", "TEST-BC-MIXED", - "Tests flat {0} broadcasting to mixed depth paths", - "SmartHopper", "Testing Data") + : base( + "Test Mixed Depths", + "TEST-BC-MIXED", + "Tests flat {0} broadcasting to mixed depth paths", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -58,7 +63,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -71,7 +76,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorMixedDepthsTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -135,40 +140,40 @@ async Task>> Func(Dictionary new Guid("FE2E0986-FFAF-4A64-9F97-0FD3F4E571D8"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.quinary; public DataTreeProcessorRule2OverrideTestComponent() - : base("Test Rule 2 Override", "TEST-BC-RULE2-OR", - "Tests Rule 2 overriding Rule 4 with multiple top-level paths", - "SmartHopper", "Testing Data") + : base( + "Test Rule 2 Override", + "TEST-BC-RULE2-OR", + "Tests Rule 2 overriding Rule 4 with multiple top-level paths", + "SmartHopper", + "Testing Data") { this.RunOnlyOnInputChanges = false; } @@ -58,7 +63,7 @@ protected override void RegisterAdditionalOutputParams(GH_OutputParamManager pMa protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -71,7 +76,7 @@ private sealed class Worker : AsyncWorkerBase public Worker(DataTreeProcessorRule2OverrideTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -134,40 +139,40 @@ async Task>> Func(Dictionary progressReporter) { - return new Worker(this, AddRuntimeMessage); + return new Worker(this, this.AddRuntimeMessage); } private sealed class Worker : AsyncWorkerBase @@ -76,12 +76,12 @@ private sealed class Worker : AsyncWorkerBase public Worker(StyledMessageDialogTestComponent parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; + this._parent = parent; } public override void GatherInput(IGH_DataAccess DA, out int dataCount) { - DA.GetData(0, ref _parent._testCase); + DA.GetData(0, ref this._parent._testCase); dataCount = 1; } @@ -91,21 +91,24 @@ public override async Task DoWorkAsync(CancellationToken token) try { // Run dialog on UI thread - await Task.Run(() => RhinoApp.InvokeOnUiThread(new Action(() => - { - RunTest(_parent._testCase); - })), token); - - _result = new GH_String($"Test case {_parent._testCase} completed"); + await Task.Run( + () => RhinoApp.InvokeOnUiThread( + new Action(() => + { + this.RunTest(this._parent._testCase); + })), + token).ConfigureAwait(false); + + this._result = new GH_String($"Test case {this._parent._testCase} completed"); } catch (OperationCanceledException) { - _result = new GH_String("Test cancelled"); + this._result = new GH_String("Test cancelled"); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Warning, "Test cancelled."); } catch (Exception ex) { - _result = new GH_String($"Error: {ex.Message}"); + this._result = new GH_String($"Error: {ex.Message}"); this.AddRuntimeMessage(GH_RuntimeMessageLevel.Error, ex.Message); } @@ -114,8 +117,8 @@ await Task.Run(() => RhinoApp.InvokeOnUiThread(new Action(() => public override void SetOutput(IGH_DataAccess DA, out string message) { - _parent.SetPersistentOutput("Result", _result, DA); - message = _result.Value ?? "Test completed"; + this._parent.SetPersistentOutput("Result", this._result, DA); + message = this._result.Value ?? "Test completed"; } private void RunTest(int testCase) diff --git a/src/SmartHopper.Components.Test/Misc/TestAIStatefulTreePrimeCalculatorComponent.cs b/src/SmartHopper.Components.Test/Misc/TestAIStatefulTreePrimeCalculatorComponent.cs index bc55023c..9a909df2 100644 --- a/src/SmartHopper.Components.Test/Misc/TestAIStatefulTreePrimeCalculatorComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestAIStatefulTreePrimeCalculatorComponent.cs @@ -39,13 +39,18 @@ namespace SmartHopper.Components.Test.Misc public class TestAIStatefulTreePrimeCalculatorComponent : AIStatefulAsyncComponentBase { public override Guid ComponentGuid => new Guid("C349BCD4-81C8-40CC-B449-DDE7C9180CAA"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.quarternary; public TestAIStatefulTreePrimeCalculatorComponent() - : base("Test AI Stateful Tree Prime Calculator", "TEST-AI-STATEFUL-TREE-PRIME", - "Test component for AIStatefulAsyncComponentBase - Calculates the nth prime number.", - "SmartHopper", "Testing Base") + : base( + "Test AI Stateful Tree Prime Calculator", + "TEST-AI-STATEFUL-TREE-PRIME", + "Test component for AIStatefulAsyncComponentBase - Calculates the nth prime number.", + "SmartHopper", + "Testing Base") { } @@ -61,7 +66,7 @@ protected override void RegisterAdditionalOutputParams(GH_Component.GH_OutputPar protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new TestAIStatefulTreePrimeCalculatorWorker(this, AddRuntimeMessage); + return new TestAIStatefulTreePrimeCalculatorWorker(this, this.AddRuntimeMessage); } private sealed class TestAIStatefulTreePrimeCalculatorWorker : AsyncWorkerBase @@ -75,8 +80,8 @@ public TestAIStatefulTreePrimeCalculatorWorker( Action addRuntimeMessage) : base(parent, addRuntimeMessage) { - _parent = parent; - _result = new GH_Structure(); + this._parent = parent; + this._result = new GH_Structure(); } public override void GatherInput(IGH_DataAccess DA, out int dataCount) @@ -102,7 +107,7 @@ public override async Task DoWorkAsync(CancellationToken token) if (item is GH_Integer ghInt) { int n = Math.Max(1, Math.Min(ghInt.Value, 1000000)); - long result = await this.CalculateNthPrime(n, token); + long result = await this.CalculateNthPrime(n, token).ConfigureAwait(false); resultBranch.Add(new GH_Number(result)); Debug.WriteLine($"[TestStatefulTreePrimeCalculatorWorker] DoWorkAsync - Calculating nth prime for {n}: {result}"); diff --git a/src/SmartHopper.Components.Test/Misc/TestAsyncPrimeCalculatorComponent.cs b/src/SmartHopper.Components.Test/Misc/TestAsyncPrimeCalculatorComponent.cs index 06e5ee87..cbe37d82 100644 --- a/src/SmartHopper.Components.Test/Misc/TestAsyncPrimeCalculatorComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestAsyncPrimeCalculatorComponent.cs @@ -35,13 +35,18 @@ namespace SmartHopper.Components.Test.Misc public class TestAsyncPrimeCalculatorComponent : AsyncComponentBase { public override Guid ComponentGuid => new Guid("B2C612B0-2C57-47CE-B9FE-E10621F18934"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.tertiary; public TestAsyncPrimeCalculatorComponent() - : base("Test Async Prime Calculator", "TEST-PRIME", - "Test component for AsyncComponentBase - Calculates the nth prime number.", - "SmartHopper", "Testing Base") + : base( + "Test Async Prime Calculator", + "TEST-PRIME", + "Test component for AsyncComponentBase - Calculates the nth prime number.", + "SmartHopper", + "Testing Base") { } @@ -57,7 +62,7 @@ protected override void RegisterOutputParams(GH_Component.GH_OutputParamManager protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new TestPrimeCalculatorWorker(this, AddRuntimeMessage); + return new TestPrimeCalculatorWorker(this, this.AddRuntimeMessage); } private sealed class TestPrimeCalculatorWorker : AsyncWorkerBase @@ -67,7 +72,7 @@ private sealed class TestPrimeCalculatorWorker : AsyncWorkerBase public TestPrimeCalculatorWorker( - //Action progressReporter, + // Action progressReporter, GH_Component parent, Action addRuntimeMessage) : base(parent, addRuntimeMessage) @@ -107,7 +112,7 @@ public override async Task DoWorkAsync(CancellationToken token) b++; } - //ReportProgress($"{((double)count / this._nthPrime * 100):F2}%"); + // ReportProgress($"{((double)count / this._nthPrime * 100):F2}%"); if (isPrime) { @@ -119,7 +124,7 @@ public override async Task DoWorkAsync(CancellationToken token) // Add small delay to prevent UI freeze if (count % 100 == 0) { - await Task.Delay(1, token); + await Task.Delay(1, token).ConfigureAwait(false); } } diff --git a/src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs index 2f4e7c04..01d5b243 100644 --- a/src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestStateManagerDebounceComponent.cs @@ -239,7 +239,7 @@ public override void GatherInput(IGH_DataAccess DA, out int dataCount) public override async Task DoWorkAsync(CancellationToken token) { // Simulate some async work - await Task.Delay(200, token); + await Task.Delay(200, token).ConfigureAwait(false); this.result = this.inputValue * 3.14159; } diff --git a/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs index 7faae077..f6714398 100644 --- a/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestStateManagerRestorationComponent.cs @@ -239,7 +239,7 @@ public override void GatherInput(IGH_DataAccess DA, out int dataCount) public override async Task DoWorkAsync(CancellationToken token) { // Simple calculation with a small delay to simulate async work - await Task.Delay(100, token); + await Task.Delay(100, token).ConfigureAwait(false); this.result = (this.inputNumber * 2d) + 1d; } diff --git a/src/SmartHopper.Components.Test/Misc/TestStatefulPrimeCalculatorComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStatefulPrimeCalculatorComponent.cs index 37bbc902..81a85a92 100644 --- a/src/SmartHopper.Components.Test/Misc/TestStatefulPrimeCalculatorComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestStatefulPrimeCalculatorComponent.cs @@ -35,13 +35,18 @@ namespace SmartHopper.Components.Test.Misc public class TestStatefulPrimeCalculatorComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("C2C612B0-2C57-47CE-B9FE-E10621F18935"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.tertiary; public TestStatefulPrimeCalculatorComponent() - : base("Test Stateful Prime Calculator", "TEST-STATEFUL-PRIME", - "Test component for StatefulAsyncComponentBase - Calculates the nth prime number.", - "SmartHopper", "Testing Base") + : base( + "Test Stateful Prime Calculator", + "TEST-STATEFUL-PRIME", + "Test component for StatefulAsyncComponentBase - Calculates the nth prime number.", + "SmartHopper", + "Testing Base") { } @@ -57,7 +62,7 @@ protected override void RegisterAdditionalOutputParams(GH_Component.GH_OutputPar protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new TestStatefulPrimeCalculatorWorker(this, AddRuntimeMessage); + return new TestStatefulPrimeCalculatorWorker(this, this.AddRuntimeMessage); } private sealed class TestStatefulPrimeCalculatorWorker : AsyncWorkerBase @@ -117,7 +122,7 @@ public override async Task DoWorkAsync(CancellationToken token) // Add small delay to prevent UI freeze if (count % 100 == 0) { - await Task.Delay(1, token); + await Task.Delay(1, token).ConfigureAwait(false); } } diff --git a/src/SmartHopper.Components.Test/Misc/TestStatefulTreePrimeCalculatorComponent.cs b/src/SmartHopper.Components.Test/Misc/TestStatefulTreePrimeCalculatorComponent.cs index 4efd4a05..612d8bbd 100644 --- a/src/SmartHopper.Components.Test/Misc/TestStatefulTreePrimeCalculatorComponent.cs +++ b/src/SmartHopper.Components.Test/Misc/TestStatefulTreePrimeCalculatorComponent.cs @@ -39,13 +39,18 @@ namespace SmartHopper.Components.Test.Misc public class TestStatefulTreePrimeCalculatorComponent : StatefulComponentBase { public override Guid ComponentGuid => new Guid("E2DB56F0-C597-432C-9774-82DF431CC848"); + protected override Bitmap Icon => null; + public override GH_Exposure Exposure => GH_Exposure.quarternary; public TestStatefulTreePrimeCalculatorComponent() - : base("Test Stateful Tree Prime Calculator", "TEST-STATEFUL-TREE-PRIME", - "Test component for StatefulAsyncComponentBase - Calculates the nth prime number.", - "SmartHopper", "Testing Base") + : base( + "Test Stateful Tree Prime Calculator", + "TEST-STATEFUL-TREE-PRIME", + "Test component for StatefulAsyncComponentBase - Calculates the nth prime number.", + "SmartHopper", + "Testing Base") { } @@ -61,7 +66,7 @@ protected override void RegisterAdditionalOutputParams(GH_Component.GH_OutputPar protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new TestStatefulTreePrimeCalculatorWorker(this, AddRuntimeMessage); + return new TestStatefulTreePrimeCalculatorWorker(this, this.AddRuntimeMessage); } private class TestStatefulTreePrimeCalculatorWorker : AsyncWorkerBase @@ -102,7 +107,7 @@ public override async Task DoWorkAsync(CancellationToken token) if (item is GH_Integer ghInt) { int n = Math.Max(1, Math.Min(ghInt.Value, 1000000)); - long result = await CalculateNthPrime(n, token); + long result = await this.CalculateNthPrime(n, token).ConfigureAwait(false); resultBranch.Add(new GH_Number(result)); Debug.WriteLine($"[TestStatefulTreePrimeCalculatorWorker] DoWorkAsync - Calculating nth prime for {n}: {result}"); diff --git a/src/SmartHopper.Components/AI/AIChatComponent.cs b/src/SmartHopper.Components/AI/AIChatComponent.cs index fbfb86de..244a7478 100644 --- a/src/SmartHopper.Components/AI/AIChatComponent.cs +++ b/src/SmartHopper.Components/AI/AIChatComponent.cs @@ -45,7 +45,6 @@ public class AIChatComponent : AIStatefulAsyncComponentBase private string _systemPrompt; // Removed duplicated last-return storage; use base AIReturn snapshot instead - protected override AICapability RequiredCapability => AICapability.ToolChat; private readonly string _defaultSystemPrompt = """ @@ -67,6 +66,17 @@ public AIChatComponent() this.RunOnlyOnInputChanges = false; } + /// + public override IEnumerable Keywords => new[] { + "Chat", + "AI Chat", + "Chat AI", + "Conversation", + "AI Conversation", + "Talk", + "Ask AI", + }; + /// /// Called when the component is removed from the canvas. /// @@ -198,7 +208,7 @@ protected override AsyncWorkerBase CreateWorker(Action progressReporter) /// /// Gets the unique ID for this component type. /// - public override Guid ComponentGuid => new("7D3F8B2A-E5C1-4F9D-B7A6-9C8D2E3F1A5B"); + public override Guid ComponentGuid => new ("7D3F8B2A-E5C1-4F9D-B7A6-9C8D2E3F1A5B"); /// /// Disable automatic restoration of persistent outputs from the base class. @@ -313,7 +323,7 @@ public override async System.Threading.Tasks.Task DoWorkAsync(CancellationToken } // This worker is intentionally short-lived; incremental updates will retrigger recomputes - await System.Threading.Tasks.Task.CompletedTask; + await System.Threading.Tasks.Task.CompletedTask.ConfigureAwait(false); } catch (Exception ex) { diff --git a/src/SmartHopper.Components/AI/AIFileContextComponent.cs b/src/SmartHopper.Components/AI/AIFileContextComponent.cs index 8f7a4653..06122112 100644 --- a/src/SmartHopper.Components/AI/AIFileContextComponent.cs +++ b/src/SmartHopper.Components/AI/AIFileContextComponent.cs @@ -41,6 +41,18 @@ public class AIFileContextComponent : GH_Component, IAIContextProvider /// protected override Bitmap Icon => Properties.Resources.context; + /// + public override GH_Exposure Exposure => GH_Exposure.tertiary; + + /// + public override IEnumerable Keywords => new[] { + "File Context", + "Context", + "File Description", + "Project Context", + "AI Context", + }; + /// /// Initializes a new instance of the class. /// Constructor for the AI File Context component. @@ -48,7 +60,8 @@ public class AIFileContextComponent : GH_Component, IAIContextProvider public AIFileContextComponent() : base("AI File Context", "AIFileCtx", "Defines the current file context.\n\nFor example, explain the aim of this file, your expectations of the results, the main input parameters, and what to avoid.\n\nAI-powered components will read this information to generate relevant responses.", - "SmartHopper", "AI") + "SmartHopper", + "AI") { // Register this component as a context provider AIContextManager.RegisterProvider(this); @@ -79,6 +92,6 @@ public Dictionary GetContext() return new Dictionary { { "file-context", this.context } }; } - public override Guid ComponentGuid => new("A7F5D347-9F4E-4A75-B6A9-115C06B6115D"); + public override Guid ComponentGuid => new ("A7F5D347-9F4E-4A75-B6A9-115C06B6115D"); } } diff --git a/src/SmartHopper.Components/AI/AIModelsComponent.cs b/src/SmartHopper.Components/AI/AIModelsComponent.cs index 1e0408c0..5b65a11a 100644 --- a/src/SmartHopper.Components/AI/AIModelsComponent.cs +++ b/src/SmartHopper.Components/AI/AIModelsComponent.cs @@ -50,7 +50,17 @@ public class AIModelsComponent : AIProviderComponentBase /// /// Gets the exposure level of this component in the ribbon. /// - public override GH_Exposure Exposure => GH_Exposure.primary; + public override GH_Exposure Exposure => GH_Exposure.secondary; + + /// + public override IEnumerable Keywords => new[] { + "Models", + "AI Models", + "List Models", + "Get Models", + "Available Models", + "Provider Models", + }; /// /// Initializes a new instance of the AIModelsComponent class. @@ -60,7 +70,8 @@ public AIModelsComponent() "AI Models", "AIModels", "Retrieve the list of available models from the selected AI provider.", - "SmartHopper", "AI") + "SmartHopper", + "AI") { this.RunOnlyOnInputChanges = false; } diff --git a/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs b/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs index c623ccfd..20a5327e 100644 --- a/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs +++ b/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs @@ -51,7 +51,7 @@ public GhPutComponents() } /// - public override Guid ComponentGuid => new("25E07FD9-382C-48C0-8A97-8BFFAEAD8592"); + public override Guid ComponentGuid => new ("25E07FD9-382C-48C0-8A97-8BFFAEAD8592"); /// protected override Bitmap Icon => Resources.ghput; diff --git a/src/SmartHopper.Components/Grasshopper/GhRetrieveComponents.cs b/src/SmartHopper.Components/Grasshopper/GhRetrieveComponents.cs index c6ea2681..792b1c90 100644 --- a/src/SmartHopper.Components/Grasshopper/GhRetrieveComponents.cs +++ b/src/SmartHopper.Components/Grasshopper/GhRetrieveComponents.cs @@ -40,10 +40,11 @@ public class GhRetrieveComponents : GH_Component public GhRetrieveComponents() : base( - "Retrieve Components", "GhRetrieveComponents", - "Get a JSON list of all available Grasshopper components in your Grasshopper installation. Use optional category filter to focus the results.", - "SmartHopper", "Grasshopper" - ) + "Retrieve Components", + "GhRetrieveComponents", + "Get a JSON list of all available Grasshopper components in your Grasshopper installation. Use optional category filter to focus the results.", + "SmartHopper", + "Grasshopper") { } @@ -55,7 +56,7 @@ protected override void RegisterInputParams(GH_InputParamManager pManager) { pManager.AddTextParameter("Category Filter", "C", "Optional list of categories with include/exclude syntax. E.g. ['+Math', '-Params'].", - GH_ParamAccess.list, ""); + GH_ParamAccess.list, string.Empty); pManager.AddBooleanParameter("Run?", "R", "Run this component?", GH_ParamAccess.item, false); } @@ -115,7 +116,8 @@ protected override void SolveInstance(IGH_DataAccess DA) var toolResult = toolResultInteraction?.Result; if (toolResult == null) { - this.AddRuntimeMessage(GH_RuntimeMessageLevel.Error, + this.AddRuntimeMessage( + GH_RuntimeMessageLevel.Error, "Tool 'gh_list_components' did not return a valid result"); return; } diff --git a/src/SmartHopper.Components/Grasshopper/GhTidyUpComponents.cs b/src/SmartHopper.Components/Grasshopper/GhTidyUpComponents.cs index 1afce67b..7e4614f0 100644 --- a/src/SmartHopper.Components/Grasshopper/GhTidyUpComponents.cs +++ b/src/SmartHopper.Components/Grasshopper/GhTidyUpComponents.cs @@ -43,9 +43,12 @@ public class GhTidyUpComponents : SelectingComponentBase /// Initializes a new instance of the class. /// public GhTidyUpComponents() - : base("Tidy Up", "GhTidyUp", - "Organize selected components into a tidy grid layout\n\n!!! THIS IS STILL EXPERIMENTAL, IT MIGHT MESS UP YOUR DOCUMENT !!!", - "SmartHopper", "Grasshopper") + : base( + "Tidy Up", + "GhTidyUp", + "Organize selected components into a tidy grid layout\n\n!!! THIS IS STILL EXPERIMENTAL, IT MIGHT MESS UP YOUR DOCUMENT !!!", + "SmartHopper", + "Grasshopper") { } diff --git a/src/SmartHopper.Components/Img/ImageViewerAttributes.cs b/src/SmartHopper.Components/Img/ImageViewerAttributes.cs index 271892a4..3af835d9 100644 --- a/src/SmartHopper.Components/Img/ImageViewerAttributes.cs +++ b/src/SmartHopper.Components/Img/ImageViewerAttributes.cs @@ -39,7 +39,8 @@ public class ImageViewerAttributes : GH_ComponentAttributes /// Initializes a new instance of the ImageViewerAttributes class. /// /// The component that owns these attributes. - public ImageViewerAttributes(IGH_Component owner) : base(owner) + public ImageViewerAttributes(IGH_Component owner) + : base(owner) { } @@ -161,8 +162,6 @@ private void RenderImageDisplay(Graphics graphics) } } - - /// /// Draws centered text within the specified rectangle. /// diff --git a/src/SmartHopper.Components/List/AIListFilter.cs b/src/SmartHopper.Components/List/AIListFilter.cs index 7ec977dd..9ce0953a 100644 --- a/src/SmartHopper.Components/List/AIListFilter.cs +++ b/src/SmartHopper.Components/List/AIListFilter.cs @@ -36,12 +36,24 @@ namespace SmartHopper.Components.List { public class AIListFilter : AIStatefulAsyncComponentBase { - public override Guid ComponentGuid => new("CD2E5F8A-94D4-48D7-8E68-8185341245D0"); + public override Guid ComponentGuid => new ("CD2E5F8A-94D4-48D7-8E68-8185341245D0"); protected override Bitmap Icon => Resources.listfilter; public override GH_Exposure Exposure => GH_Exposure.primary; + /// + public override IEnumerable Keywords => new[] { + "list_filter", + "List Filter", + "Filter List", + "Alter List", + "List Reorder", + "List Shuffle", + "List Process", + "List Alter", + }; + /// protected override IReadOnlyList UsingAiTools => new[] { "list_filter" }; @@ -53,9 +65,12 @@ public class AIListFilter : AIStatefulAsyncComponentBase }; public AIListFilter() - : base("AI List Filter", "AIListFilter", - "Filter, reorder, shuffle, repeat items or combine multiple tasks on lists of elements using natural language criteria.\nThis components takes the list as a whole. This means that each criterion will filter each full list.\nIf a tree structure is provided, criteria and lists will only match within the same branch paths.", - "SmartHopper", "List") + : base( + "AI List Filter", + "AIListFilter", + "Filter, reorder, shuffle, repeat items or combine multiple tasks on lists of elements using natural language criteria.\nThis components takes the list as a whole. This means that each criterion will filter each full list.\nIf a tree structure is provided, criteria and lists will only match within the same branch paths.", + "SmartHopper", + "List") { } @@ -72,7 +87,7 @@ protected override void RegisterAdditionalOutputParams(GH_Component.GH_OutputPar protected override AsyncWorkerBase CreateWorker(Action progressReporter) { - return new AIListFilterWorker(this, this.AddRuntimeMessage, ComponentProcessingOptions); + return new AIListFilterWorker(this, this.AddRuntimeMessage, this.ComponentProcessingOptions); } private sealed class AIListFilterWorker : AsyncWorkerBase diff --git a/src/SmartHopper.Components/Misc/DeconstructMetricsComponent.cs b/src/SmartHopper.Components/Misc/DeconstructMetricsComponent.cs index a152e6ad..dae17034 100644 --- a/src/SmartHopper.Components/Misc/DeconstructMetricsComponent.cs +++ b/src/SmartHopper.Components/Misc/DeconstructMetricsComponent.cs @@ -26,13 +26,16 @@ namespace SmartHopper.Components.Misc public class DeconstructMetricsComponent : GH_Component { public DeconstructMetricsComponent() - : base("Deconstruct SmartHopper Metrics", "DMetrics", - "Deconstructs SmartHopper usage metrics into individual values", - "SmartHopper", "Utils") + : base( + "Deconstruct SmartHopper Metrics", + "DMetrics", + "Deconstructs SmartHopper usage metrics into individual values", + "SmartHopper", + "Utils") { } - public override Guid ComponentGuid => new("B8FE17D7-F146-4C94-9673-D2FA04BF7B9F"); + public override Guid ComponentGuid => new ("B8FE17D7-F146-4C94-9673-D2FA04BF7B9F"); /// /// Gets the component's icon. diff --git a/src/SmartHopper.Core.Grasshopper.Tests/Utils/CSharpIdentifierHelperTests.cs b/src/SmartHopper.Core.Grasshopper.Tests/Utils/CSharpIdentifierHelperTests.cs index bbf6e71e..953528ba 100644 --- a/src/SmartHopper.Core.Grasshopper.Tests/Utils/CSharpIdentifierHelperTests.cs +++ b/src/SmartHopper.Core.Grasshopper.Tests/Utils/CSharpIdentifierHelperTests.cs @@ -16,12 +16,11 @@ * along with this library; if not, see . */ -using System; -using SmartHopper.Core.Grasshopper.Utils.Internal; -using Xunit; - namespace SmartHopper.Core.Grasshopper.Tests.Utils { + using System; + using SmartHopper.Core.Grasshopper.Utils.Internal; + using Xunit; /// /// Unit tests for CSharpIdentifierHelper functionality. /// Verifies sanitization and unsanitization work correctly for C# identifiers. @@ -84,7 +83,7 @@ public void SanitizeIdentifier_StartsWithDigit_ShouldAddUnderscorePrefix() public void SanitizeIdentifier_NullOrWhitespace_ShouldReturnAsIs() { Assert.Null(CSharpIdentifierHelper.SanitizeIdentifier(null)); - Assert.Equal("", CSharpIdentifierHelper.SanitizeIdentifier("")); + Assert.Equal(string.Empty, CSharpIdentifierHelper.SanitizeIdentifier(string.Empty)); Assert.Equal(" ", CSharpIdentifierHelper.SanitizeIdentifier(" ")); } @@ -118,7 +117,7 @@ public void UnsanitizeIdentifier_NonSanitizedIdentifiers_ShouldRemainUnchanged() public void UnsanitizeIdentifier_NullOrWhitespace_ShouldReturnAsIs() { Assert.Null(CSharpIdentifierHelper.UnsanitizeIdentifier(null)); - Assert.Equal("", CSharpIdentifierHelper.UnsanitizeIdentifier("")); + Assert.Equal(string.Empty, CSharpIdentifierHelper.UnsanitizeIdentifier(string.Empty)); Assert.Equal(" ", CSharpIdentifierHelper.UnsanitizeIdentifier(" ")); } @@ -163,7 +162,7 @@ public void IsSanitized_ShouldCorrectlyIdentifySanitizedIdentifiers() // Edge cases Assert.False(CSharpIdentifierHelper.IsSanitized(null)); - Assert.False(CSharpIdentifierHelper.IsSanitized("")); + Assert.False(CSharpIdentifierHelper.IsSanitized(string.Empty)); Assert.False(CSharpIdentifierHelper.IsSanitized("@")); Assert.False(CSharpIdentifierHelper.IsSanitized("@ ")); } diff --git a/src/SmartHopper.Core.Grasshopper.Tests/Utils/Parsing/AIResponseParserTests.cs b/src/SmartHopper.Core.Grasshopper.Tests/Utils/Parsing/AIResponseParserTests.cs index 4f9bea08..c2f5e162 100644 --- a/src/SmartHopper.Core.Grasshopper.Tests/Utils/Parsing/AIResponseParserTests.cs +++ b/src/SmartHopper.Core.Grasshopper.Tests/Utils/Parsing/AIResponseParserTests.cs @@ -517,17 +517,13 @@ public void ParseIndicesFromResponse_EmptyOrNone_ReturnsEmptyList(string input) #region String Array Parsing Tests /// - /// Tests that ParseStringArrayFromResponse correctly parses a JSON array of strings. - /// - #if NET7_WINDOWS [Fact(DisplayName = "ParseStringArrayFromResponse JsonArray ReturnsStrings [Windows]")] #else [Fact(DisplayName = "ParseStringArrayFromResponse JsonArray ReturnsStrings [Core]")] #endif - public void ParseStringArrayFromResponse_JsonArray_ReturnsStrings() { // Arrange @@ -541,17 +537,13 @@ public void ParseStringArrayFromResponse_JsonArray_ReturnsStrings() } /// - /// Tests that ParseStringArrayFromResponse parses comma-separated strings. - /// - #if NET7_WINDOWS [Fact(DisplayName = "ParseStringArrayFromResponse CommaSeparated ReturnsStrings [Windows]")] #else [Fact(DisplayName = "ParseStringArrayFromResponse CommaSeparated ReturnsStrings [Core]")] #endif - public void ParseStringArrayFromResponse_CommaSeparated_ReturnsStrings() { // Arrange @@ -565,17 +557,13 @@ public void ParseStringArrayFromResponse_CommaSeparated_ReturnsStrings() } /// - /// Tests that ParseStringArrayFromResponse parses bracketed comma-separated strings. - /// - #if NET7_WINDOWS [Fact(DisplayName = "ParseStringArrayFromResponse BracketedCommaSeparated ReturnsStrings [Windows]")] #else [Fact(DisplayName = "ParseStringArrayFromResponse BracketedCommaSeparated ReturnsStrings [Core]")] #endif - public void ParseStringArrayFromResponse_BracketedCommaSeparated_ReturnsStrings() { // Arrange @@ -589,17 +577,13 @@ public void ParseStringArrayFromResponse_BracketedCommaSeparated_ReturnsStrings( } /// - /// Tests that ParseStringArrayFromResponse handles mixed quote styles. - /// - #if NET7_WINDOWS [Fact(DisplayName = "ParseStringArrayFromResponse MixedQuotes ReturnsStrings [Windows]")] #else [Fact(DisplayName = "ParseStringArrayFromResponse MixedQuotes ReturnsStrings [Core]")] #endif - public void ParseStringArrayFromResponse_MixedQuotes_ReturnsStrings() { // Arrange @@ -613,17 +597,13 @@ public void ParseStringArrayFromResponse_MixedQuotes_ReturnsStrings() } /// - /// Tests that ParseStringArrayFromResponse handles nested structures within strings. - /// - #if NET7_WINDOWS [Fact(DisplayName = "ParseStringArrayFromResponse WithNestedStructures ReturnsStrings [Windows]")] #else [Fact(DisplayName = "ParseStringArrayFromResponse WithNestedStructures ReturnsStrings [Core]")] #endif - public void ParseStringArrayFromResponse_WithNestedStructures_ReturnsStrings() { // Arrange @@ -640,17 +620,13 @@ public void ParseStringArrayFromResponse_WithNestedStructures_ReturnsStrings() } /// - /// Tests that ParseStringArrayFromResponse returns empty list for empty inputs. - /// - #if NET7_WINDOWS [Theory(DisplayName = "ParseStringArrayFromResponse EmptyInput ReturnsEmptyList [Windows]")] #else [Theory(DisplayName = "ParseStringArrayFromResponse EmptyInput ReturnsEmptyList [Core]")] #endif - [InlineData("")] [InlineData(null)] @@ -671,17 +647,13 @@ public void ParseStringArrayFromResponse_EmptyInput_ReturnsEmptyList(string inpu #region Data Formatting Tests /// - /// Tests that NormalizeJsonArrayString creates valid JSON array. - /// - #if NET7_WINDOWS [Fact(DisplayName = "NormalizeJsonArrayString ValidList ReturnsJsonArray [Windows]")] #else [Fact(DisplayName = "NormalizeJsonArrayString ValidList ReturnsJsonArray [Core]")] #endif - public void NormalizeJsonArrayString_ValidList_ReturnsJsonArray() { // Arrange @@ -695,17 +667,13 @@ public void NormalizeJsonArrayString_ValidList_ReturnsJsonArray() } /// - /// Tests that NormalizeJsonArrayString handles empty lists. - /// - #if NET7_WINDOWS [Fact(DisplayName = "NormalizeJsonArrayString EmptyList ReturnsEmptyJsonArray [Windows]")] #else [Fact(DisplayName = "NormalizeJsonArrayString EmptyList ReturnsEmptyJsonArray [Core]")] #endif - public void NormalizeJsonArrayString_EmptyList_ReturnsEmptyJsonArray() { // Arrange @@ -723,17 +691,13 @@ public void NormalizeJsonArrayString_EmptyList_ReturnsEmptyJsonArray() #region Edge Cases and Complex Scenarios /// - /// Tests that ParseIndicesFromResponse falls back to text parsing for malformed JSON. - /// - #if NET7_WINDOWS [Fact(DisplayName = "ParseIndicesFromResponse MalformedJson FallsBackToTextParsing [Windows]")] #else [Fact(DisplayName = "ParseIndicesFromResponse MalformedJson FallsBackToTextParsing [Core]")] #endif - public void ParseIndicesFromResponse_MalformedJson_FallsBackToTextParsing() { // Arrange @@ -747,17 +711,13 @@ public void ParseIndicesFromResponse_MalformedJson_FallsBackToTextParsing() } /// - /// Tests that ParseIndicesFromResponse filters out invalid values. - /// - #if NET7_WINDOWS [Fact(DisplayName = "ParseIndicesFromResponse MixedValidAndInvalid ReturnsValidIndices [Windows]")] #else [Fact(DisplayName = "ParseIndicesFromResponse MixedValidAndInvalid ReturnsValidIndices [Core]")] #endif - public void ParseIndicesFromResponse_MixedValidAndInvalid_ReturnsValidIndices() { // Arrange @@ -771,17 +731,13 @@ public void ParseIndicesFromResponse_MixedValidAndInvalid_ReturnsValidIndices() } /// - /// Tests that ParseIndicesFromResponse handles negative numbers. - /// - #if NET7_WINDOWS [Fact(DisplayName = "ParseIndicesFromResponse NegativeNumbers IgnoresNegatives [Windows]")] #else [Fact(DisplayName = "ParseIndicesFromResponse NegativeNumbers IgnoresNegatives [Core]")] #endif - public void ParseIndicesFromResponse_NegativeNumbers_IgnoresNegatives() { // Arrange @@ -799,17 +755,13 @@ public void ParseIndicesFromResponse_NegativeNumbers_IgnoresNegatives() } /// - /// Tests that ParseStringArrayFromResponse handles escaped quotes. - /// - #if NET7_WINDOWS [Fact(DisplayName = "ParseStringArrayFromResponse EscapedQuotes HandlesCorrectly [Windows]")] #else [Fact(DisplayName = "ParseStringArrayFromResponse EscapedQuotes HandlesCorrectly [Core]")] #endif - public void ParseStringArrayFromResponse_EscapedQuotes_HandlesCorrectly() { // Arrange @@ -827,17 +779,13 @@ public void ParseStringArrayFromResponse_EscapedQuotes_HandlesCorrectly() } /// - /// Tests that ParseIndicesFromResponse handles large ranges efficiently. - /// - #if NET7_WINDOWS [Fact(DisplayName = "ParseIndicesFromResponse VeryLargeRange HandlesEfficiently [Windows]")] #else [Fact(DisplayName = "ParseIndicesFromResponse VeryLargeRange HandlesEfficiently [Core]")] #endif - public void ParseIndicesFromResponse_VeryLargeRange_HandlesEfficiently() { // Arrange diff --git a/src/SmartHopper.Core.Grasshopper/AITools/ScriptCodeValidator.cs b/src/SmartHopper.Core.Grasshopper/AITools/ScriptCodeValidator.cs index 5f4e8e2e..fe6caf46 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/ScriptCodeValidator.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/ScriptCodeValidator.cs @@ -34,7 +34,7 @@ public static partial class ScriptCodeValidator /// Patterns that indicate use of non-Rhino geometry libraries. /// Each entry contains: regex pattern, human-readable name, and suggested fix. /// - private static readonly List<(Regex Pattern, string LibraryName, string SuggestedFix)> BannedPatterns = new() + private static readonly List<(Regex Pattern, string LibraryName, string SuggestedFix)> BannedPatterns = new () { // .NET generic geometry (SystemNumericsVector3Regex(), "System.Numerics.Vector3", "Use Rhino.Geometry.Vector3d or Point3d instead"), @@ -132,7 +132,7 @@ public class ValidationResult /// /// Gets the list of issues found in the script. /// - public List Issues { get; init; } = new(); + public List Issues { get; init; } = new (); /// /// Gets a correction prompt to send back to the AI for self-healing. diff --git a/src/SmartHopper.Core.Grasshopper/AITools/_gh_connect.cs b/src/SmartHopper.Core.Grasshopper/AITools/_gh_connect.cs index 3bf00a4d..bf8a1742 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/_gh_connect.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/_gh_connect.cs @@ -49,8 +49,6 @@ public IEnumerable GetTools() yield return new AITool( name: this.toolName, description: "Connect Grasshopper components together by creating wires between outputs and inputs. Use this to establish data flow between existing components on the canvas. Requires component GUIDs (use gh_get_selected or gh_get to find them first).", - - // category: "Components", category: "NotTested", parametersSchema: @"{ ""type"": ""object"", diff --git a/src/SmartHopper.Core/ComponentBase/StatefulComponentBase.cs b/src/SmartHopper.Core/ComponentBase/StatefulComponentBase.cs index 65d1c6ce..54c4656c 100644 --- a/src/SmartHopper.Core/ComponentBase/StatefulComponentBase.cs +++ b/src/SmartHopper.Core/ComponentBase/StatefulComponentBase.cs @@ -245,7 +245,7 @@ private void ScheduleProcessingSafetyCheck() { _ = Task.Run(async () => { - await Task.Delay(this.GetDebounceTime()); + await Task.Delay(this.GetDebounceTime()).ConfigureAwait(false); if (this.StateManager.CurrentState == ComponentState.Processing && this.Workers.Count == 0) { @@ -462,6 +462,16 @@ private void HandleInputChangeDetection(IGH_DataAccess DA, bool runValueChanged) // If other inputs changed if (changedInputs.Count > 0) { + // Special case: AI provider changed - always force to NeedsRun + // regardless of Run parameter value, so user explicitly triggers recalculation + if (changedInputs.Contains("AIProvider")) + { + Debug.WriteLine($"[{this.GetType().Name}] AI Provider changed, forcing transition to NeedsRun"); + this.StateManager.CancelDebounce(); + this.StateManager.RequestTransition(ComponentState.NeedsRun, TransitionReason.InputChanged); + return; + } + if (!this.run) { Debug.WriteLine($"[{this.GetType().Name}] Inputs changed, starting debounce to NeedsRun"); @@ -476,7 +486,7 @@ private void HandleInputChangeDetection(IGH_DataAccess DA, bool runValueChanged) } /// - /// Finalizes processing by committing hashes and transitioning to Completed. + /// Finalizes processing by committing hashes and transitioning to Completed or Error. /// protected override void OnWorkerCompleted() { @@ -486,8 +496,15 @@ protected override void OnWorkerCompleted() // Cancel any pending debounce this.StateManager.CancelDebounce(); - // Transition to Completed - this.StateManager.RequestTransition(ComponentState.Completed, TransitionReason.ProcessingComplete); + // If any errors were recorded during execution, transition to Error instead of Completed + if (this.RuntimeMessageLevel == GH_RuntimeMessageLevel.Error) + { + this.StateManager.RequestTransition(ComponentState.Error, TransitionReason.Error); + } + else + { + this.StateManager.RequestTransition(ComponentState.Completed, TransitionReason.ProcessingComplete); + } base.OnWorkerCompleted(); Debug.WriteLine("[StatefulComponentBase] Worker completed, expiring solution"); @@ -513,7 +530,7 @@ protected override void OnTasksCanceled() /// Handles the Completed state. /// /// The data access object. - private void OnStateCompleted(IGH_DataAccess DA) + protected virtual void OnStateCompleted(IGH_DataAccess DA) { Debug.WriteLine($"[{this.GetType().Name}] OnStateCompleted"); @@ -530,7 +547,7 @@ private void OnStateCompleted(IGH_DataAccess DA) /// Handles the Waiting state. /// /// The data access object. - private void OnStateWaiting(IGH_DataAccess DA) + protected virtual void OnStateWaiting(IGH_DataAccess DA) { Debug.WriteLine($"[{this.GetType().Name}] OnStateWaiting"); @@ -546,7 +563,7 @@ private void OnStateWaiting(IGH_DataAccess DA) /// Handles the NeedsRun state. /// /// The data access object. - private void OnStateNeedsRun(IGH_DataAccess DA) + protected virtual void OnStateNeedsRun(IGH_DataAccess DA) { Debug.WriteLine($"[{this.GetType().Name}] OnStateNeedsRun"); @@ -569,7 +586,7 @@ private void OnStateNeedsRun(IGH_DataAccess DA) /// Handles the Processing state. /// /// The data access object. - private void OnStateProcessing(IGH_DataAccess DA) + protected virtual void OnStateProcessing(IGH_DataAccess DA) { Debug.WriteLine($"[{this.GetType().Name}] OnStateProcessing"); @@ -581,7 +598,7 @@ private void OnStateProcessing(IGH_DataAccess DA) /// Handles the Cancelled state. /// /// The data access object. - private void OnStateCancelled(IGH_DataAccess DA) + protected virtual void OnStateCancelled(IGH_DataAccess DA) { Debug.WriteLine($"[{this.GetType().Name}] OnStateCancelled"); @@ -605,7 +622,7 @@ private void OnStateCancelled(IGH_DataAccess DA) /// Handles the Error state. /// /// The data access object. - private void OnStateError(IGH_DataAccess DA) + protected virtual void OnStateError(IGH_DataAccess DA) { Debug.WriteLine($"[{this.GetType().Name}] OnStateError"); this.ApplyPersistentRuntimeMessages(); @@ -702,7 +719,7 @@ protected void ClearPersistentRuntimeMessages() /// /// Applies stored runtime messages to the component. /// - private void ApplyPersistentRuntimeMessages() + protected void ApplyPersistentRuntimeMessages() { Debug.WriteLine($"[{this.GetType().Name}] Applying {this.runtimeMessages.Count} runtime messages"); foreach (var (level, message) in this.runtimeMessages.Values) diff --git a/src/SmartHopper.Infrastructure.Tests/AdvancedConfigTests.cs b/src/SmartHopper.Infrastructure.Tests/AdvancedConfigTests.cs index b5f46d1b..cb865ba3 100644 --- a/src/SmartHopper.Infrastructure.Tests/AdvancedConfigTests.cs +++ b/src/SmartHopper.Infrastructure.Tests/AdvancedConfigTests.cs @@ -20,6 +20,7 @@ namespace SmartHopper.Infrastructure.Tests { using System.Collections.Generic; using System.Linq; + using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -72,7 +73,7 @@ public DummyProvider(string name) public AIRequestCall PreCall(AIRequestCall request) => request; - public async Task Call(AIRequestCall request) + public async Task Call(AIRequestCall request, CancellationToken cancellationToken = default) { var result = new AIReturn(); result.CreateSuccess(AIBody.Empty, request); diff --git a/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionImage.cs b/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionImage.cs index b8396a45..587b6db9 100644 --- a/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionImage.cs +++ b/src/SmartHopper.Infrastructure/AICall/Core/Interactions/AIInteractionImage.cs @@ -28,6 +28,11 @@ namespace SmartHopper.Infrastructure.AICall.Core.Interactions /// public class AIInteractionImage : AIInteractionBase, IAIKeyedInteraction, IAIRenderInteraction { + /// + /// Gets or sets the mime type of the image. + /// + public string MimeType { get; set; } + /// /// Gets or sets the URL of the generated image. /// diff --git a/src/SmartHopper.Infrastructure/AICall/Core/Requests/AIRequestBase.cs b/src/SmartHopper.Infrastructure/AICall/Core/Requests/AIRequestBase.cs index e5dcf061..a052101c 100644 --- a/src/SmartHopper.Infrastructure/AICall/Core/Requests/AIRequestBase.cs +++ b/src/SmartHopper.Infrastructure/AICall/Core/Requests/AIRequestBase.cs @@ -84,9 +84,9 @@ public class AIRequestBase : IAIRequest /// /// Per-request timeout in seconds applied to provider HTTP calls and tool execution wrappers. - /// Values <= 0 mean "use default". Normalized by RequestTimeoutPolicy. + /// Null or values <= 0 mean "use default". Normalized by RequestTimeoutPolicy. /// - public virtual int TimeoutSeconds { get; set; } = 120; + public virtual int? TimeoutSeconds { get; set; } /// /// When true, skips AIMetrics.IsValid() checks for this request's results. diff --git a/src/SmartHopper.Infrastructure/AICall/Policies/Request/RequestTimeoutPolicy.cs b/src/SmartHopper.Infrastructure/AICall/Policies/Request/RequestTimeoutPolicy.cs index dca210ba..e4505b22 100644 --- a/src/SmartHopper.Infrastructure/AICall/Policies/Request/RequestTimeoutPolicy.cs +++ b/src/SmartHopper.Infrastructure/AICall/Policies/Request/RequestTimeoutPolicy.cs @@ -28,8 +28,8 @@ namespace SmartHopper.Infrastructure.AICall.Policies.Request /// public sealed class RequestTimeoutPolicy : IRequestPolicy { - // Default and bounds (seconds) - private const int DefaultTimeout = 120; + // Default and bounds (seconds). 300s is a safe default for long-running AI calls. + private const int DefaultTimeout = 300; private const int MinTimeout = 1; private const int MaxTimeout = 600; // 10 minutes maximum guard @@ -41,7 +41,7 @@ public Task ApplyAsync(PolicyContext context) return Task.CompletedTask; } - int original = rq.TimeoutSeconds; + int original = rq.TimeoutSeconds ?? 0; int normalized = original; if (normalized <= 0) diff --git a/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.cs b/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.cs index f7c94f18..ddc33b91 100644 --- a/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.cs +++ b/src/SmartHopper.Infrastructure/AICall/Sessions/ConversationSession.cs @@ -771,7 +771,7 @@ private async Task ProcessStreamingDeltasAsync( // must be applied explicitly to keep context up-to-date on every provider call. await PolicyPipeline.Default.ApplyRequestPoliciesAsync(this.Request).ConfigureAwait(false); - await foreach (var rawDelta in adapter.StreamAsync(this.Request, streamingOptions, ct)) + await foreach (var rawDelta in adapter.StreamAsync(this.Request, streamingOptions, ct).ConfigureAwait(false)) { if (rawDelta == null) { @@ -993,7 +993,7 @@ public void Cancel() public async Task RunToStableResult(SessionOptions options, CancellationToken cancellationToken = default) { AIReturn last = null; - await foreach (var ret in this.TurnLoopAsync(options, streamingOptions: null, yieldDeltas: false, cancellationToken)) + await foreach (var ret in this.TurnLoopAsync(options, streamingOptions: null, yieldDeltas: false, cancellationToken).ConfigureAwait(false)) { last = ret; } @@ -1007,7 +1007,7 @@ public async IAsyncEnumerable Stream( StreamingOptions streamingOptions, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - await foreach (var ret in this.TurnLoopAsync(options, streamingOptions, yieldDeltas: true, cancellationToken)) + await foreach (var ret in this.TurnLoopAsync(options, streamingOptions, yieldDeltas: true, cancellationToken).ConfigureAwait(false)) { yield return ret; } diff --git a/src/SmartHopper.Infrastructure/AICall/Tools/AIToolCall.cs b/src/SmartHopper.Infrastructure/AICall/Tools/AIToolCall.cs index aa5acc2c..92415576 100644 --- a/src/SmartHopper.Infrastructure/AICall/Tools/AIToolCall.cs +++ b/src/SmartHopper.Infrastructure/AICall/Tools/AIToolCall.cs @@ -124,7 +124,7 @@ public override async Task Exec() { // Respect per-request timeout. We cannot cancel the underlying work if the tool ignores cancellation, // but we do return a standardized timeout error when exceeded. - var timeoutSec = this.TimeoutSeconds <= 0 ? DEFAULT_TIMEOUT_SECONDS : this.TimeoutSeconds; + var timeoutSec = (this.TimeoutSeconds ?? 0) <= 0 ? DEFAULT_TIMEOUT_SECONDS : this.TimeoutSeconds.Value; var execTask = AIToolManager.ExecuteTool(this); var completed = await Task.WhenAny( execTask, diff --git a/src/SmartHopper.Infrastructure/AIModels/AICapability.cs b/src/SmartHopper.Infrastructure/AIModels/AICapability.cs index 9a74e7b9..69b45a0e 100644 --- a/src/SmartHopper.Infrastructure/AIModels/AICapability.cs +++ b/src/SmartHopper.Infrastructure/AIModels/AICapability.cs @@ -45,9 +45,15 @@ public enum AICapability ImageInput = 1 << 1, /// - /// Supports accepting audio input (speech or other audio signals). + /// Supports accepting speech input (voice, speech-to-text). /// - AudioInput = 1 << 2, + SpeechInput = 1 << 10, + + /// + /// Supports accepting audio input (music, sound effects, general audio signals). + /// Inherits SpeechInput - models with AudioInput can also handle speech input. + /// + AudioInput = SpeechInput | (1 << 2), /// /// Supports accepting structured JSON input. @@ -67,9 +73,15 @@ public enum AICapability ImageOutput = 1 << 5, /// - /// Can produce audio as output (e.g., text-to-speech). + /// Can produce speech as output (text-to-speech). /// - AudioOutput = 1 << 6, + SpeechOutput = 1 << 11, + + /// + /// Can produce audio as output (music, sound effects, general audio). + /// Inherits SpeechOutput - models with AudioOutput can also handle speech output. + /// + AudioOutput = SpeechOutput | (1 << 6), /// /// Can produce structured JSON output. @@ -121,14 +133,24 @@ public enum AICapability Text2Image = TextInput | ImageOutput, /// - /// Text-in to audio-out (text-to-speech). + /// Text-in to speech-out (text-to-speech). + /// + Text2Speech = TextInput | SpeechOutput, + + /// + /// Speech-in to text-out (automatic speech recognition). /// - Text2Speech = TextInput | AudioOutput, + Speech2Text = SpeechInput | TextOutput, /// - /// Audio-in to text-out (automatic speech recognition). + /// Audio-in to text-out (general audio understanding). /// - Speech2Text = AudioInput | TextOutput, + Audio2Text = AudioInput | TextOutput, + + /// + /// Text-in to audio-out (general audio generation). + /// + Text2Audio = TextInput | AudioOutput, /// /// Image-in to text-out (image description or understanding, vision capabilities). @@ -181,6 +203,16 @@ public static string ToDetailedString(this AICapability capabilities) flags.Add("ImageOutput"); } + if ((capabilities & AICapability.SpeechInput) == AICapability.SpeechInput) + { + flags.Add("SpeechInput"); + } + + if ((capabilities & AICapability.SpeechOutput) == AICapability.SpeechOutput) + { + flags.Add("SpeechOutput"); + } + if ((capabilities & AICapability.AudioInput) == AICapability.AudioInput) { flags.Add("AudioInput"); @@ -224,6 +256,7 @@ public static bool HasInput(this AICapability capability) return (capability & AICapability.TextInput) == AICapability.TextInput || (capability & AICapability.ImageInput) == AICapability.ImageInput || (capability & AICapability.AudioInput) == AICapability.AudioInput || + (capability & AICapability.SpeechInput) == AICapability.SpeechInput || (capability & AICapability.JsonInput) == AICapability.JsonInput; } @@ -237,6 +270,7 @@ public static bool HasOutput(this AICapability capability) return (capability & AICapability.TextOutput) == AICapability.TextOutput || (capability & AICapability.ImageOutput) == AICapability.ImageOutput || (capability & AICapability.AudioOutput) == AICapability.AudioOutput || + (capability & AICapability.SpeechOutput) == AICapability.SpeechOutput || (capability & AICapability.JsonOutput) == AICapability.JsonOutput; } diff --git a/src/SmartHopper.Infrastructure/AIModels/AIModelCapabilities.cs b/src/SmartHopper.Infrastructure/AIModels/AIModelCapabilities.cs index 2f05edc7..43a44ab0 100644 --- a/src/SmartHopper.Infrastructure/AIModels/AIModelCapabilities.cs +++ b/src/SmartHopper.Infrastructure/AIModels/AIModelCapabilities.cs @@ -30,12 +30,12 @@ public class AIModelCapabilities /// /// Gets or sets the AI provider name (e.g., "openai", "anthropic"). /// - public string Provider { get; set; } = ""; + public string Provider { get; set; } = string.Empty; /// /// Gets or sets the model name (e.g., "gpt-4", "claude-3-opus"). /// - public string Model { get; set; } = ""; + public string Model { get; set; } = string.Empty; /// /// Gets or sets the capabilities supported by this model. diff --git a/src/SmartHopper.Infrastructure/AIProviders/AIProvider.cs b/src/SmartHopper.Infrastructure/AIProviders/AIProvider.cs index e8fd3f26..4f412c25 100644 --- a/src/SmartHopper.Infrastructure/AIProviders/AIProvider.cs +++ b/src/SmartHopper.Infrastructure/AIProviders/AIProvider.cs @@ -25,6 +25,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Text; +using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json.Linq; using SmartHopper.Infrastructure.AICall.Core.Base; @@ -44,9 +45,32 @@ namespace SmartHopper.Infrastructure.AIProviders /// Base class for AI providers, encapsulating common logic. /// /// The type of the derived provider class. - public abstract class AIProvider : AIProvider where T : AIProvider + public abstract class AIProvider : AIProvider + where T : AIProvider { - private static readonly Lazy InstanceValue = new(() => Activator.CreateInstance(typeof(T), true) as T); + private static readonly Lazy InstanceValue = new (() => + { + try + { + var instance = Activator.CreateInstance(typeof(T), nonPublic: true) as T; + if (instance == null) + { + throw new InvalidOperationException($"Failed to create instance of {typeof(T).FullName}. Activator.CreateInstance returned null."); + } + + return instance; + } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException != null) + { + Debug.WriteLine($"[AIProvider<{typeof(T).Name}>] Constructor threw exception: {tie.InnerException.GetType().Name}: {tie.InnerException.Message}"); + throw tie.InnerException; + } + catch (Exception ex) + { + Debug.WriteLine($"[AIProvider<{typeof(T).Name}>] Failed to create instance: {ex.GetType().Name}: {ex.Message}"); + throw; + } + }); /// /// Initializes a new instance of the class. @@ -335,7 +359,7 @@ public virtual AIRequestCall PreCall(AIRequestCall request) } /// - public async Task Call(AIRequestCall request) + public async Task Call(AIRequestCall request, CancellationToken cancellationToken = default) { // Start stopwatch var stopwatch = new Stopwatch(); @@ -363,7 +387,7 @@ public async Task Call(AIRequestCall request) } // Execute CallApi - var response = await this.CallApi(request); + var response = await this.CallApi(request, cancellationToken).ConfigureAwait(false); // For backoffice/admin-style calls, return raw without chat decoding or timestamping if (request?.RequestKind == AIRequestKind.Backoffice) @@ -654,8 +678,9 @@ protected JArray GetFormattedTools(string toolFilter) /// Makes an HTTP request to the specified endpoint with authentication. /// /// The request to make. + /// The cancellation token to cancel operation. /// The HTTP response content as a type T. - private async Task CallApi(AIRequestCall request) + private async Task CallApi(AIRequestCall request, CancellationToken cancellationToken = default) { string endpoint = request.Endpoint; string httpMethod = request.HttpMethod; @@ -675,10 +700,11 @@ private async Task CallApi(AIRequestCall request) using (var httpClient = new HttpClient()) { - // Apply per-request timeout (policy should normalize, but clamp defensively) + // Apply timeout from request (should be resolved by RequestTimeoutPolicy) + // If somehow still null/zero, apply a safe default try { - var seconds = request?.TimeoutSeconds ?? 120; + int seconds = request?.TimeoutSeconds ?? 600; httpClient.Timeout = TimeSpan.FromSeconds(seconds); } catch (Exception ex) @@ -732,34 +758,79 @@ private async Task CallApi(AIRequestCall request) switch (httpMethod.ToUpper(CultureInfo.InvariantCulture)) { case "GET": - response = await httpClient.GetAsync(fullUri).ConfigureAwait(false); + response = await httpClient.GetAsync(fullUri, cancellationToken).ConfigureAwait(false); break; case "POST": var postContent = !string.IsNullOrEmpty(requestBody) ? new StringContent(requestBody, Encoding.UTF8, contentType) : null; - response = await httpClient.PostAsync(fullUri, postContent).ConfigureAwait(false); + response = await httpClient.PostAsync(fullUri, postContent, cancellationToken).ConfigureAwait(false); break; case "DELETE": - response = await httpClient.DeleteAsync(fullUri).ConfigureAwait(false); + response = await httpClient.DeleteAsync(fullUri, cancellationToken).ConfigureAwait(false); break; case "PATCH": var patchContent = !string.IsNullOrEmpty(requestBody) ? new StringContent(requestBody, Encoding.UTF8, contentType) : null; - response = await httpClient.PatchAsync(fullUri, patchContent).ConfigureAwait(false); + response = await httpClient.PatchAsync(fullUri, patchContent, cancellationToken).ConfigureAwait(false); break; default: throw new NotSupportedException($"HTTP method '{httpMethod}' is not supported. Supported methods: GET, POST, DELETE, PATCH"); } - var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); Debug.WriteLine($"[{this.Name}] Call - Response status: {response.StatusCode}"); if (!response.IsSuccessStatusCode) { Debug.WriteLine($"[{this.Name}] Call - Error response: {content}"); - throw new Exception($"Error from {this.Name} API: {response.StatusCode} - {content}"); + + // Return structured error instead of throwing + var errorReturn = new AIReturn(); + var statusCode = (int)response.StatusCode; + string errorMessage; + + if (statusCode == 503) + { + errorMessage = $"HTTP 503 Service Unavailable: The {this.Name} API is at capacity. Please retry after a delay. Response: {content}"; + } + else if (statusCode == 429) + { + errorMessage = $"HTTP 429 Too Many Requests: Rate limit exceeded for {this.Name}. Please retry after a delay. Response: {content}"; + } + else if (statusCode == 401 || statusCode == 403) + { + errorMessage = $"HTTP {statusCode}: Authentication failed for {this.Name}. Check your API key. Response: {content}"; + } + else if (statusCode == 408) + { + errorMessage = $"HTTP 408 Request Timeout: The request to {this.Name} took too long. Try increasing the HTTP request timeout in SmartHopper settings. Response: {content}"; + } + else if (statusCode == 413) + { + errorMessage = $"HTTP 413 Payload Too Large: The request to {this.Name} exceeds size limits. Try reducing input length. Response: {content}"; + } + else if (statusCode == 500) + { + errorMessage = $"HTTP 500 Internal Server Error: {this.Name} encountered an internal error. Retry after a brief delay. Response: {content}"; + } + else if (statusCode == 502) + { + errorMessage = $"HTTP 502 Bad Gateway: {this.Name} gateway error. Response: {content}"; + } + else if (statusCode == 504) + { + errorMessage = $"HTTP 504 Gateway Timeout: {this.Name} upstream timeout. Try increasing the HTTP request timeout in SmartHopper settings. Response: {content}"; + } + else + { + errorMessage = $"HTTP {statusCode} {response.StatusCode}: {content}"; + } + + errorReturn.CreateProviderError(errorMessage, request); + errorReturn.Status = AICallStatus.Finished; + return errorReturn; } // Prepare the AIReturn @@ -793,6 +864,14 @@ private async Task CallApi(AIRequestCall request) return aiReturn; } + catch (TaskCanceledException ex) + { + Debug.WriteLine($"[{this.Name}] Call - TaskCanceledException (Timeout or Cancellation): {ex.Message}"); + var errorReturn = new AIReturn(); + errorReturn.CreateProviderError($"HTTP Request Timeout or Cancellation: The request to {this.Name} took too long (exceeded {request?.TimeoutSeconds ?? 600} seconds) or was cancelled. Try increasing the HTTP timeout in Settings.", request); + errorReturn.Status = AICallStatus.Finished; + return errorReturn; + } catch (Exception ex) { Debug.WriteLine($"[{this.Name}] Call - Exception: {ex.Message}"); diff --git a/src/SmartHopper.Infrastructure/AIProviders/AIProviderStreamingAdapter.cs b/src/SmartHopper.Infrastructure/AIProviders/AIProviderStreamingAdapter.cs index dceb25e9..78ce5897 100644 --- a/src/SmartHopper.Infrastructure/AIProviders/AIProviderStreamingAdapter.cs +++ b/src/SmartHopper.Infrastructure/AIProviders/AIProviderStreamingAdapter.cs @@ -156,7 +156,7 @@ protected Task SendForStreamAsync(HttpClient client, HttpRe /// protected async IAsyncEnumerable ReadSseDataAsync(HttpResponseMessage response, [EnumeratorCancellation] CancellationToken cancellationToken) { - await foreach (var payload in this.ReadSseDataAsync(response, idleTimeout: null, isTerminalData: null, cancellationToken)) + await foreach (var payload in this.ReadSseDataAsync(response, idleTimeout: null, isTerminalData: null, cancellationToken).ConfigureAwait(false)) { yield return payload; } diff --git a/src/SmartHopper.Infrastructure/AIProviders/IAIProvider.cs b/src/SmartHopper.Infrastructure/AIProviders/IAIProvider.cs index d060f7dc..1da164d8 100644 --- a/src/SmartHopper.Infrastructure/AIProviders/IAIProvider.cs +++ b/src/SmartHopper.Infrastructure/AIProviders/IAIProvider.cs @@ -18,6 +18,7 @@ using System.Collections.Generic; using System.Drawing; +using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json.Linq; using SmartHopper.Infrastructure.AICall.Core.Interactions; @@ -102,8 +103,9 @@ public interface IAIProvider /// Gets the task processing the Call with the provider. /// /// The request to send to the AI provider. + /// The cancellation token. /// The response from the AI provider. - Task Call(AIRequestCall request); + Task Call(AIRequestCall request, CancellationToken cancellationToken = default); /// /// Gets the post-call response for the provider. diff --git a/src/SmartHopper.Infrastructure/AIProviders/ProviderManager.cs b/src/SmartHopper.Infrastructure/AIProviders/ProviderManager.cs index de30fef7..097cc841 100644 --- a/src/SmartHopper.Infrastructure/AIProviders/ProviderManager.cs +++ b/src/SmartHopper.Infrastructure/AIProviders/ProviderManager.cs @@ -45,9 +45,10 @@ public class ProviderManager public static ProviderManager Instance => _instance.Value; - private readonly Dictionary _providers = new Dictionary(); - private readonly Dictionary _providerSettings = new Dictionary(); - private readonly Dictionary _providerAssemblies = new Dictionary(); + private readonly ConcurrentDictionary _providers = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _providerSettings = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _providerAssemblies = new ConcurrentDictionary(); + private volatile bool _refreshCompleted = false; private readonly ConcurrentDictionary _mismatchedProviders = new ConcurrentDictionary(); // Tracks providers with hash mismatches private readonly ConcurrentDictionary _unavailableProviders = new ConcurrentDictionary(); // Tracks providers where hash check was unavailable (network issues) private readonly ConcurrentDictionary _unknownProviders = new ConcurrentDictionary(); // Tracks providers not found in hash manifest (custom/third-party) @@ -101,12 +102,37 @@ public async Task RefreshProvidersAsync() { Debug.WriteLine("[ProviderManager] Starting provider discovery and registration"); - // Discover new providers - await this.DiscoverProvidersAsync().ConfigureAwait(false); + try + { + // Discover new providers + await this.DiscoverProvidersAsync().ConfigureAwait(false); + + // After discovery, refresh settings for all providers + Debug.WriteLine("[ProviderManager] Provider discovery complete, refreshing settings"); + SmartHopperSettings.Instance.RefreshProvidersLocalStorage(); + } + finally + { + // Mark refresh as completed regardless of provider count or errors + // This signals that infrastructure initialization is done + this._refreshCompleted = true; + Debug.WriteLine("[ProviderManager] Provider refresh completed (infrastructure ready)"); + } + } - // After discovery, refresh settings for all providers - Debug.WriteLine("[ProviderManager] Provider discovery complete, refreshing settings"); - SmartHopperSettings.Instance.RefreshProvidersLocalStorage(); + /// + /// Gets whether the provider infrastructure has completed initialization. + /// This flag is set to true after RefreshProvidersAsync completes, regardless of provider count. + /// + public bool IsInfrastructureReady => this._refreshCompleted; + + /// + /// Gets the count of registered providers. + /// + /// The number of providers currently registered. + public int GetProviderCount() + { + return this._providers.Count; } /// @@ -167,10 +193,10 @@ private async Task LoadProviderAssemblyAsync(string assemblyPath) catch (CryptographicException ex) { Debug.WriteLine($"Authenticode signature verification failed for {assemblyPath}: {ex.Message}"); - await Task.Run(() => RhinoApp.InvokeOnUiThread(new Action(() => + await Task.Run(() => RhinoApp.InvokeOnUiThread(() => { StyledMessageDialog.ShowError($"Authenticode signature verification failed for provider '{Path.GetFileName(assemblyPath)}'. Please replace it with a file downloaded from official SmartHopper sources.", "SmartHopper"); - }))).ConfigureAwait(false); + })).ConfigureAwait(false); return; } } @@ -206,7 +232,7 @@ await Task.Run(() => RhinoApp.InvokeOnUiThread(new Action(() => effectiveMode == ProviderIntegrityCheckMode.Hard) { // Strict/Hard mode: Show error and prevent loading - await Task.Run(() => RhinoApp.InvokeOnUiThread(new Action(() => + await Task.Run(() => RhinoApp.InvokeOnUiThread(() => { StyledMessageDialog.ShowError( $"Provider '{Path.GetFileName(assemblyPath)}' failed integrity verification.\n\n" + @@ -215,10 +241,9 @@ await Task.Run(() => RhinoApp.InvokeOnUiThread(new Action(() => $"Platform: {platform}\n" + $"Expected: {hashResult.PublicHash}\n" + $"Actual: {hashResult.LocalHash}\n\n" + - $"Please re-download the provider from official SmartHopper sources.", - "Provider Integrity Check Failed - SmartHopper" - ); - }))).ConfigureAwait(false); + "Please re-download the provider from official SmartHopper sources.", + "Provider Integrity Check Failed - SmartHopper"); + })).ConfigureAwait(false); RhinoApp.WriteLine($"[SmartHopper] Provider Integrity Check Failed: '{Path.GetFileName(assemblyPath)}' failed integrity verification and will not be loaded"); Debug.WriteLine($"[ProviderManager] Provider '{Path.GetFileName(assemblyPath)}' failed integrity verification and will not be loaded"); @@ -230,7 +255,7 @@ await Task.Run(() => RhinoApp.InvokeOnUiThread(new Action(() => // Soft mode: Show warning and continue loading this._mismatchedProviders[mmAsmName] = true; - await Task.Run(() => RhinoApp.InvokeOnUiThread(new Action(() => + await Task.Run(() => RhinoApp.InvokeOnUiThread(() => { StyledMessageDialog.ShowWarning( $"WARNING: Provider '{Path.GetFileName(assemblyPath)}' failed integrity verification.\n\n" + @@ -239,11 +264,10 @@ await Task.Run(() => RhinoApp.InvokeOnUiThread(new Action(() => $"Platform: {platform}\n" + $"Expected: {hashResult.PublicHash}\n" + $"Actual: {hashResult.LocalHash}\n\n" + - $"The provider has been loaded but will show a warning when used. " + - $"Change 'Integrity Check Mode' to 'Hard' or 'Strict' in settings to block unverified providers.", - "Provider Integrity Check Warning - SmartHopper" - ); - }))).ConfigureAwait(false); + "The provider has been loaded but will show a warning when used. " + + "Change 'Integrity Check Mode' to 'Hard' or 'Strict' in settings to block unverified providers.", + "Provider Integrity Check Warning - SmartHopper"); + })).ConfigureAwait(false); RhinoApp.WriteLine($"[SmartHopper] Provider Integrity Check Failed: '{Path.GetFileName(assemblyPath)}' failed integrity verification"); Debug.WriteLine($"[ProviderManager] Provider '{Path.GetFileName(assemblyPath)}' failed integrity verification"); @@ -256,17 +280,16 @@ await Task.Run(() => RhinoApp.InvokeOnUiThread(new Action(() => if (effectiveMode == ProviderIntegrityCheckMode.Strict) { // Strict mode: Block when hashes are unavailable - await Task.Run(() => RhinoApp.InvokeOnUiThread(new Action(() => + await Task.Run(() => RhinoApp.InvokeOnUiThread(() => { StyledMessageDialog.ShowError( $"Provider '{Path.GetFileName(assemblyPath)}' cannot be loaded.\n\n" + $"Unable to retrieve hash verification data from the official repository. " + $"This may be due to network connectivity issues.\n\n" + $"Strict integrity check mode requires hash verification for all providers. " + - $"Please check your internet connection and try again, or switch to 'Hard' or 'Soft' mode in settings.", - "Provider Integrity Check Failed - SmartHopper" - ); - }))).ConfigureAwait(false); + "Please check your internet connection and try again, or switch to 'Hard' or 'Soft' mode in settings.", + "Provider Integrity Check Failed - SmartHopper"); + })).ConfigureAwait(false); RhinoApp.WriteLine($"[SmartHopper] Provider Integrity Check Failed: Provider '{Path.GetFileName(assemblyPath)}' blocked - hash repository unavailable in Strict mode"); Debug.WriteLine($"[ProviderManager] Provider '{Path.GetFileName(assemblyPath)}' blocked - hash unavailable (Strict mode)"); @@ -290,17 +313,16 @@ await Task.Run(() => RhinoApp.InvokeOnUiThread(new Action(() => effectiveMode == ProviderIntegrityCheckMode.Hard) { // Strict/Hard mode: Block unknown providers - await Task.Run(() => RhinoApp.InvokeOnUiThread(new Action(() => + await Task.Run(() => RhinoApp.InvokeOnUiThread(() => { StyledMessageDialog.ShowError( $"Provider '{Path.GetFileName(assemblyPath)}' is not recognized.\n\n" + $"SHA-256 hash not found in official repository (platform: {platform}). " + $"This provider may be a custom/third-party provider or from a different SmartHopper version.\n\n" + $"{effectiveMode} integrity check mode only allows verified providers. " + - $"Switch to 'Soft' mode in settings to allow third-party providers.", - "Provider Integrity Check Failed - SmartHopper" - ); - }))).ConfigureAwait(false); + "Switch to 'Soft' mode in settings to allow third-party providers.", + "Provider Integrity Check Failed - SmartHopper"); + })).ConfigureAwait(false); RhinoApp.WriteLine($"[SmartHopper] Provider Integrity Check Failed: '{Path.GetFileName(assemblyPath)}' blocked - hash not found in {effectiveMode} mode"); Debug.WriteLine($"[ProviderManager] Provider '{Path.GetFileName(assemblyPath)}' blocked - hash not found ({effectiveMode} mode)"); diff --git a/src/SmartHopper.Providers.Anthropic/AnthropicProvider.cs b/src/SmartHopper.Providers.Anthropic/AnthropicProvider.cs index 4372b9a3..114f963f 100644 --- a/src/SmartHopper.Providers.Anthropic/AnthropicProvider.cs +++ b/src/SmartHopper.Providers.Anthropic/AnthropicProvider.cs @@ -318,18 +318,48 @@ private JObject CreateContentBlock(IAIInteraction interaction) } else if (interaction is AIInteractionImage imageInteraction) { - // Anthropic does not support image generation; fallback to prompt as text - var prompt = imageInteraction.OriginalPrompt ?? string.Empty; - if (string.IsNullOrEmpty(prompt)) + // Vision input: send image for understanding/description + if (!string.IsNullOrWhiteSpace(imageInteraction.ImageData)) { - return null; + var mimeType = imageInteraction.MimeType ?? "image/png"; + return new JObject + { + ["type"] = "image", + ["source"] = new JObject + { + ["type"] = "base64", + ["media_type"] = mimeType, + ["data"] = imageInteraction.ImageData, + }, + }; } - - return new JObject + else if (imageInteraction.ImageUrl != null) { - ["type"] = "text", - ["text"] = prompt, - }; + return new JObject + { + ["type"] = "image", + ["source"] = new JObject + { + ["type"] = "url", + ["url"] = imageInteraction.ImageUrl.ToString(), + }, + }; + } + else + { + // No image data; fall back to prompt text if available + var prompt = imageInteraction.OriginalPrompt ?? string.Empty; + if (string.IsNullOrEmpty(prompt)) + { + return null; + } + + return new JObject + { + ["type"] = "text", + ["text"] = prompt, + }; + } } // Unknown interaction type - skip @@ -522,6 +552,10 @@ public override string Encode(AIRequestCall request) try { var schemaObj = JObject.Parse(jsonSchema); + + // Anthropic requires additionalProperties=false on all object schemas in structured output mode + InjectAdditionalPropertiesFalse(schemaObj); + var svc = JsonSchemaService.Instance; var (wrappedSchema, wrapperInfo) = svc.WrapForProvider(schemaObj, this.Name); svc.SetCurrentWrapperInfo(wrapperInfo); @@ -532,10 +566,13 @@ public override string Encode(AIRequestCall request) if (supportsStructuredOutputs) { - requestBody["output_format"] = new JObject + requestBody["output_config"] = new JObject { - ["type"] = "json_schema", - ["schema"] = wrappedSchema, + ["format"] = new JObject + { + ["type"] = "json_schema", + ["schema"] = wrappedSchema, + }, }; } } @@ -621,6 +658,17 @@ public override List Decode(JObject response) try { + // Handle provider error responses + if (response["error"] is JObject errorObj) + { + var msg = errorObj["message"]?.ToString() + ?? errorObj["type"]?.ToString() + ?? "Provider returned an error"; + Debug.WriteLine($"[Anthropic] Decode: provider error in response body: {msg}"); + interactions.Add(new AIInteractionError { Content = msg }); + return interactions; + } + // Anthropic message response has top-level 'content' array and 'role': 'assistant' var content = response["content"] as JArray; string contentText = string.Empty; @@ -867,8 +915,8 @@ public async IAsyncEnumerable StreamAsync( AIInteractionToolCall? currentToolCall = null; var toolArgsBuffer = new StringBuilder(); - // Determine idle timeout from request (fallback to 60s if invalid) - var idleTimeout = TimeSpan.FromSeconds(request.TimeoutSeconds > 0 ? request.TimeoutSeconds : 60); + // Determine idle timeout from request (fallback to 600s if invalid) + var idleTimeout = TimeSpan.FromSeconds((double)(request.TimeoutSeconds > 0 ? request.TimeoutSeconds : 600)); await foreach (var data in this.ReadSseDataAsync( responseMsg, idleTimeout, @@ -1084,6 +1132,37 @@ private void ApplyStructuredOutputsBetaHeader(AIRequestCall request) } } + /// + /// Recursively adds additionalProperties=false to all object-type schemas in a JSON schema. + /// Anthropic requires this for structured output mode. + /// + private static void InjectAdditionalPropertiesFalse(JToken token) + { + if (token is JObject obj) + { + var type = obj["type"]?.ToString(); + if (type == "object") + { + if (!obj.ContainsKey("additionalProperties")) + { + obj["additionalProperties"] = false; + } + } + + foreach (var property in obj.Properties().ToList()) + { + InjectAdditionalPropertiesFalse(property.Value); + } + } + else if (token is JArray arr) + { + foreach (var item in arr) + { + InjectAdditionalPropertiesFalse(item); + } + } + } + private static bool SupportsStructuredOutputs(string model) { if (string.IsNullOrWhiteSpace(model)) diff --git a/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs b/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs index 4ef50518..f74963d3 100644 --- a/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs +++ b/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs @@ -60,6 +60,39 @@ public override Task> RetrieveModels() ContextLimit = 200000, }, new AIModelCapabilities + { + Provider = providerName, + Model = "claude-opus-4-6", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 80, + ContextLimit = 200000, + }, + new AIModelCapabilities + { + Provider = providerName, + Model = "claude-sonnet-4-6", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Default = AICapability.Text2Text | AICapability.Text2Json | AICapability.ReasoningChat | AICapability.ToolReasoningChat, + SupportsStreaming = true, + Verified = false, + Rank = 85, + ContextLimit = 200000, + }, + new AIModelCapabilities + { + Provider = providerName, + Model = "claude-haiku-4-6", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Default = AICapability.Text2Text | AICapability.ReasoningChat | AICapability.ToolReasoningChat, + SupportsStreaming = true, + Verified = false, + Rank = 100, + ContextLimit = 200000, + DiscouragedForTools = new List { "script_generate", "script_edit" }, + }, + new AIModelCapabilities { Provider = providerName, Model = "claude-opus-4-5", @@ -73,7 +106,7 @@ public override Task> RetrieveModels() { Provider = providerName, Model = "claude-opus-4-0", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, @@ -95,7 +128,7 @@ public override Task> RetrieveModels() { Provider = providerName, Model = "claude-sonnet-4-0", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, @@ -106,7 +139,7 @@ public override Task> RetrieveModels() { Provider = providerName, Model = "claude-3-7-sonnet-latest", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, @@ -117,7 +150,7 @@ public override Task> RetrieveModels() { Provider = providerName, Model = "claude-3-5-haiku-latest", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, @@ -129,7 +162,7 @@ public override Task> RetrieveModels() { Provider = providerName, Model = "claude-haiku-4-5", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, Default = AICapability.Text2Text | AICapability.ReasoningChat | AICapability.ToolReasoningChat, SupportsStreaming = true, Verified = true, @@ -141,7 +174,7 @@ public override Task> RetrieveModels() { Provider = providerName, Model = "claude-3-5-haiku-20241022", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, @@ -153,7 +186,7 @@ public override Task> RetrieveModels() { Provider = providerName, Model = "claude-3-7-sonnet-20250219", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, @@ -164,7 +197,7 @@ public override Task> RetrieveModels() { Provider = providerName, Model = "claude-3-haiku-20240307", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Rank = 70, @@ -176,7 +209,7 @@ public override Task> RetrieveModels() { Provider = providerName, Model = "claude-haiku-4-5-20251001", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = true, Rank = 85, @@ -198,7 +231,7 @@ public override Task> RetrieveModels() { Provider = providerName, Model = "claude-opus-4-20250514", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, @@ -209,7 +242,7 @@ public override Task> RetrieveModels() { Provider = providerName, Model = "claude-sonnet-4-20250514", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, @@ -264,7 +297,10 @@ public override async Task> RetrieveApiModels() { var id = item["id"]?.ToString(); var model = id; - if (!string.IsNullOrWhiteSpace(model)) models.Add(model); + if (!string.IsNullOrWhiteSpace(model)) + { + models.Add(model); + } } return models diff --git a/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs b/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs index 59235a5c..e1b04a95 100644 --- a/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs +++ b/src/SmartHopper.Providers.DeepSeek/DeepSeekProvider.cs @@ -680,7 +680,14 @@ private AIMetrics DecodeMetrics(JObject response) // Create a new metrics instance var metrics = new AIMetrics(); metrics.FinishReason = firstChoice?["finish_reason"]?.ToString() ?? metrics.FinishReason; - metrics.InputTokensPrompt = usage?["prompt_tokens"]?.Value() ?? metrics.InputTokensPrompt; + + var totalPromptTokens = usage?["prompt_tokens"]?.Value() ?? 0; + + // Extract KV cache hit tokens from nested prompt_tokens_details object + var promptTokensDetails = usage?["prompt_tokens_details"] as JObject; + metrics.InputTokensCached = promptTokensDetails?["cached_tokens"]?.Value() ?? 0; + metrics.InputTokensPrompt = totalPromptTokens - metrics.InputTokensCached; + metrics.OutputTokensGeneration = usage?["completion_tokens"]?.Value() ?? metrics.OutputTokensGeneration; metrics.OutputTokensReasoning = reasoningTokens; return metrics; @@ -956,8 +963,8 @@ async Task> FlushAsync(bool force) yield return initial; } - // Determine idle timeout from request (fallback to 60s if invalid) - var idleTimeout = TimeSpan.FromSeconds(request.TimeoutSeconds > 0 ? request.TimeoutSeconds : 60); + // Determine idle timeout from request (fallback to 600s if invalid) + var idleTimeout = TimeSpan.FromSeconds((double)(request.TimeoutSeconds > 0 ? request.TimeoutSeconds : 600)); await foreach (var data in this.ReadSseDataAsync( response, idleTimeout, diff --git a/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs b/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs index ce169483..662d989a 100644 --- a/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs +++ b/src/SmartHopper.Providers.MistralAI/MistralAIProvider.cs @@ -236,8 +236,37 @@ public override string Encode(IAIInteraction interaction) } else if (interaction is AIInteractionImage imageInteraction) { - // Mistral does not (yet) support vision in the same way; fallback to prompt as content - messageObj["content"] = imageInteraction.OriginalPrompt ?? string.Empty; + // Vision input: send image using OpenAI-compatible image_url content block + string imageUrlValue = null; + if (imageInteraction.ImageUrl != null) + { + imageUrlValue = imageInteraction.ImageUrl.ToString(); + } + else if (!string.IsNullOrWhiteSpace(imageInteraction.ImageData)) + { + var mimeType = imageInteraction.MimeType ?? "image/png"; + imageUrlValue = $"data:{mimeType};base64,{imageInteraction.ImageData}"; + } + + if (imageUrlValue != null) + { + messageObj["content"] = new JArray + { + new JObject + { + ["type"] = "image_url", + ["image_url"] = new JObject + { + ["url"] = imageUrlValue, + }, + }, + }; + } + else + { + // No image data; fall back to prompt text + messageObj["content"] = imageInteraction.OriginalPrompt ?? string.Empty; + } } else { @@ -353,6 +382,17 @@ public override List Decode(JObject response) try { + // Handle provider error responses + if (response["error"] is JObject errorObj) + { + var msg = errorObj["message"]?.ToString() + ?? errorObj["type"]?.ToString() + ?? "Provider returned an error"; + Debug.WriteLine($"[MistralAI] Decode: provider error in response body: {msg}"); + interactions.Add(new AIInteractionError { Content = msg }); + return interactions; + } + var choices = response["choices"] as JArray; var firstChoice = choices?.FirstOrDefault() as JObject; var message = firstChoice?["message"] as JObject; @@ -641,8 +681,8 @@ public async IAsyncEnumerable StreamAsync( var toolCallFragments = new Dictionary(); var toolCallsEmitted = false; - // Determine idle timeout from request (fallback to 60s if invalid) - var idleTimeout = TimeSpan.FromSeconds(request.TimeoutSeconds > 0 ? request.TimeoutSeconds : 60); + // Determine idle timeout from request (fallback to 600s if invalid) + var idleTimeout = TimeSpan.FromSeconds((double)(request.TimeoutSeconds > 0 ? request.TimeoutSeconds : 600)); await foreach (var data in this.ReadSseDataAsync( response, idleTimeout, diff --git a/src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs b/src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs index e4bedb5a..33f9c1fd 100644 --- a/src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs +++ b/src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs @@ -70,7 +70,7 @@ public override Task> RetrieveModels() { Provider = provider, Model = "mistral-medium-latest", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling , + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, SupportsStreaming = true, Verified = true, Rank = 80, @@ -129,11 +129,14 @@ public override Task> RetrieveModels() Rank = 75, ContextLimit = 40000, }, + + // Speech models new AIModelCapabilities { Provider = provider, Model = "voxtral-small-latest", - Capabilities = AICapability.AudioInput | AICapability.TextOutput, + Capabilities = AICapability.SpeechInput | AICapability.TextOutput, + Default = AICapability.Speech2Text, SupportsStreaming = false, Verified = false, Rank = 70, @@ -143,12 +146,97 @@ public override Task> RetrieveModels() { Provider = provider, Model = "voxtral-mini-latest", - Capabilities = AICapability.AudioInput | AICapability.TextOutput, + Capabilities = AICapability.SpeechInput | AICapability.TextOutput, + Default = AICapability.Speech2Text, SupportsStreaming = false, Verified = false, Rank = 60, ContextLimit = 32000, }, + + // Versioned model aliases (from MistralAI docs) + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-small-4-0-26-03", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 88, + ContextLimit = 131072, + }, + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-large-3-25-12", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 65, + ContextLimit = 131072, + }, + new AIModelCapabilities + { + Provider = provider, + Model = "ministral-3-14b-25-12", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = false, + Verified = false, + Rank = 55, + ContextLimit = 131072, + }, + new AIModelCapabilities + { + Provider = provider, + Model = "ministral-3-8b-25-12", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = false, + Verified = false, + Rank = 45, + ContextLimit = 131072, + }, + new AIModelCapabilities + { + Provider = provider, + Model = "ministral-3-3b-25-12", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = false, + Verified = false, + Rank = 35, + ContextLimit = 131072, + }, + new AIModelCapabilities + { + Provider = provider, + Model = "codestral-25-08", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 68, + ContextLimit = 131072, + }, + new AIModelCapabilities + { + Provider = provider, + Model = "voxtral-tts-26-03", + Capabilities = AICapability.TextInput | AICapability.SpeechOutput, + Default = AICapability.Text2Speech, + SupportsStreaming = false, + Verified = false, + Rank = 72, + ContextLimit = 32000, + }, + new AIModelCapabilities + { + Provider = provider, + Model = "voxtral-mini-transcribe-25-07", + Capabilities = AICapability.SpeechInput | AICapability.TextOutput, + Default = AICapability.Speech2Text, + SupportsStreaming = false, + Verified = false, + Rank = 62, + ContextLimit = 32000, + }, }; return Task.FromResult(models); diff --git a/src/SmartHopper.Providers.OpenAI/OpenAIProvider.cs b/src/SmartHopper.Providers.OpenAI/OpenAIProvider.cs index 1fd08487..75cba950 100644 --- a/src/SmartHopper.Providers.OpenAI/OpenAIProvider.cs +++ b/src/SmartHopper.Providers.OpenAI/OpenAIProvider.cs @@ -268,19 +268,40 @@ public override string Encode(IAIInteraction interaction) else if (interaction is AIInteractionImage imageInteraction) { // Handle image interactions (for vision models) - var contentArray = new JArray + string imageUrlValue; + if (imageInteraction.ImageUrl != null) { - new JObject + imageUrlValue = imageInteraction.ImageUrl.ToString(); + } + else if (!string.IsNullOrWhiteSpace(imageInteraction.ImageData)) + { + // Construct a data URI from base64 data + var mimeType = imageInteraction.MimeType ?? "image/png"; + imageUrlValue = $"data:{mimeType};base64,{imageInteraction.ImageData}"; + } + else + { + // No image data available; fall back to prompt text + msgContent = imageInteraction.OriginalPrompt ?? string.Empty; + imageUrlValue = null; + } + + if (imageUrlValue != null) + { + var contentArray = new JArray { - ["type"] = "image_url", - ["image_url"] = new JObject + new JObject { - ["url"] = imageInteraction.ImageUrl?.ToString() ?? imageInteraction.ImageData, + ["type"] = "image_url", + ["image_url"] = new JObject + { + ["url"] = imageUrlValue, + }, }, - }, - }; - messageObj["content"] = contentArray; - contentSetExplicitly = true; + }; + messageObj["content"] = contentArray; + contentSetExplicitly = true; + } } else { @@ -383,7 +404,10 @@ private JArray BuildMessages(IReadOnlyList interactions) } } } - catch { } + catch + { + // Intentionally empty + } #endif return messages; @@ -401,6 +425,17 @@ public override List Decode(JObject response) try { + // Handle provider error responses (e.g. batch items with status_code 4xx/5xx) + if (response["error"] is JObject errorObj) + { + var msg = errorObj["message"]?.ToString() + ?? errorObj["type"]?.ToString() + ?? "Provider returned an error"; + Debug.WriteLine($"[OpenAI] Decode: provider error in response body: {msg}"); + interactions.Add(new AIInteractionError { Content = msg }); + return interactions; + } + // Handle different response types based on the response structure if (response["data"] != null) { @@ -426,6 +461,82 @@ public override List Decode(JObject response) return interactions; } + /// + /// Recursively adds additionalProperties=false to all object-type schemas in a JSON schema. + /// OpenAI requires this for strict mode response_format. + /// + private static void InjectAdditionalPropertiesFalse(JToken token) + { + if (token is JObject obj) + { + var type = obj["type"]?.ToString(); + if (type == "object") + { + if (!obj.ContainsKey("additionalProperties")) + { + obj["additionalProperties"] = false; + } + } + + foreach (var property in obj.Properties().ToList()) + { + InjectAdditionalPropertiesFalse(property.Value); + } + } + else if (token is JArray arr) + { + foreach (var item in arr) + { + InjectAdditionalPropertiesFalse(item); + } + } + } + + /// + /// Recursively ensures every object-type schema node lists all its property keys in required. + /// OpenAI strict mode requires required to include every key present in properties. + /// + private static void InjectRequiredForAllProperties(JToken token) + { + if (token is JObject obj) + { + var type = obj["type"]?.ToString(); + if (type == "object" && obj["properties"] is JObject props) + { + var existingRequired = obj["required"] as JArray ?? new JArray(); + var existingKeys = new System.Collections.Generic.HashSet( + existingRequired.Select(k => k.ToString()), + StringComparer.Ordinal); + + var allKeys = props.Properties().Select(p => p.Name).ToList(); + var missingKeys = allKeys.Where(k => !existingKeys.Contains(k)).ToList(); + + if (missingKeys.Count > 0) + { + var newRequired = new JArray(existingRequired); + foreach (var key in missingKeys) + { + newRequired.Add(key); + } + + obj["required"] = newRequired; + } + } + + foreach (var property in obj.Properties().ToList()) + { + InjectRequiredForAllProperties(property.Value); + } + } + else if (token is JArray arr) + { + foreach (var item in arr) + { + InjectRequiredForAllProperties(item); + } + } + } + /// /// Formats request body for chat completions endpoint. /// @@ -435,8 +546,9 @@ private string FormatChatCompletionsRequestBody(AIRequestCall request, JArray me string reasoningEffort = this.GetSetting("ReasoningEffort") ?? "medium"; string jsonSchema = request.Body.JsonOutputSchema; string? toolFilter = request.Body.ToolFilter; + bool hasTools = !string.IsNullOrWhiteSpace(toolFilter); - Debug.WriteLine($"[OpenAI] FormatRequestBody - Model: {request.Model}, MaxTokens: {maxTokens}"); + Debug.WriteLine($"[OpenAI] FormatRequestBody - Model: {request.Model}, MaxTokens: {maxTokens}, HasTools: {hasTools}"); // Build request body for chat completions var requestBody = new JObject @@ -448,9 +560,15 @@ private string FormatChatCompletionsRequestBody(AIRequestCall request, JArray me // Configure tokens and parameters based on model family // - o-series (o1/o3/o4...) and gpt-5: use max_completion_tokens and reasoning_effort; omit temperature // - others: use max_tokens and temperature + // NOTE: reasoning_effort is incompatible with function tools on gpt-5.4, so omit it when tools are present if (OSeriesModelRegex().IsMatch(request.Model) || Gpt5ModelRegex().IsMatch(request.Model)) { - requestBody["reasoning_effort"] = reasoningEffort; + // Only add reasoning_effort if no tools are present (gpt-5.4 incompatibility) + if (!hasTools) + { + requestBody["reasoning_effort"] = reasoningEffort; + } + requestBody["max_completion_tokens"] = maxTokens; } else @@ -465,6 +583,11 @@ private string FormatChatCompletionsRequestBody(AIRequestCall request, JArray me try { var schemaObj = JObject.Parse(jsonSchema); + + // OpenAI strict mode requires additionalProperties=false and all properties in required + InjectAdditionalPropertiesFalse(schemaObj); + InjectRequiredForAllProperties(schemaObj); + var svc = JsonSchemaService.Instance; var (wrappedSchema, wrapperInfo) = svc.WrapForProvider(schemaObj, this.Name); @@ -985,8 +1108,8 @@ async Task> FlushAsync(bool force) yield return initial; } - // Determine idle timeout from request (fallback to 60s if invalid) - var idleTimeout = TimeSpan.FromSeconds(request.TimeoutSeconds > 0 ? request.TimeoutSeconds : 60); + // Determine idle timeout from request (fallback to 600s if invalid) + var idleTimeout = TimeSpan.FromSeconds((double)(request.TimeoutSeconds > 0 ? request.TimeoutSeconds : 600)); await foreach (var data in this.ReadSseDataAsync( response, idleTimeout, diff --git a/src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs b/src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs index 1434cdbb..26cd3e2f 100644 --- a/src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs +++ b/src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs @@ -85,6 +85,36 @@ public override Task> RetrieveModels() ContextLimit = 400000, }, new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.4", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 95, + ContextLimit = 400000, + }, + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.4-mini", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 100, + ContextLimit = 400000, + }, + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.4-nano", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 80, + ContextLimit = 400000, + }, + new AIModelCapabilities { Provider = provider, Model = "codex-mini-latest", @@ -341,13 +371,23 @@ public override Task> RetrieveModels() Verified = false, Rank = 60, }, + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-image-1.5", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.ImageOutput, + SupportsStreaming = false, + Verified = false, + Rank = 75, + }, - // Audio + // Speech/TTS new AIModelCapabilities { Provider = provider, Model = "gpt-4o-mini-tts", - Capabilities = AICapability.TextInput | AICapability.AudioOutput, + Capabilities = AICapability.TextInput | AICapability.SpeechOutput, + Default = AICapability.Text2Speech, SupportsStreaming = false, Verified = false, Rank = 60, @@ -357,7 +397,8 @@ public override Task> RetrieveModels() { Provider = provider, Model = "gpt-4o-mini-transcribe", - Capabilities = AICapability.AudioInput | AICapability.TextOutput, + Capabilities = AICapability.SpeechInput | AICapability.TextOutput, + Default = AICapability.Speech2Text, SupportsStreaming = false, Verified = false, Rank = 70, @@ -367,7 +408,8 @@ public override Task> RetrieveModels() { Provider = provider, Model = "gpt-4o-transcribe", - Capabilities = AICapability.AudioInput | AICapability.TextOutput, + Capabilities = AICapability.SpeechInput | AICapability.TextOutput, + Default = AICapability.Speech2Text, SupportsStreaming = false, Verified = false, Rank = 60, @@ -377,7 +419,7 @@ public override Task> RetrieveModels() { Provider = provider, Model = "gpt-audio-mini", - Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + Capabilities = AICapability.TextInput | AICapability.SpeechInput | AICapability.TextOutput | AICapability.SpeechOutput | AICapability.FunctionCalling, SupportsStreaming = false, Verified = false, Rank = 60, @@ -387,7 +429,7 @@ public override Task> RetrieveModels() { Provider = provider, Model = "gpt-audio", - Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + Capabilities = AICapability.TextInput | AICapability.SpeechInput | AICapability.TextOutput | AICapability.SpeechOutput | AICapability.FunctionCalling, SupportsStreaming = false, Verified = false, Rank = 40, @@ -397,7 +439,7 @@ public override Task> RetrieveModels() { Provider = provider, Model = "gpt-4o-mini-audio-preview", - Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + Capabilities = AICapability.TextInput | AICapability.SpeechInput | AICapability.TextOutput | AICapability.SpeechOutput | AICapability.FunctionCalling, SupportsStreaming = true, Verified = false, Deprecated = true, @@ -408,7 +450,7 @@ public override Task> RetrieveModels() { Provider = provider, Model = "gpt-4o-audio-preview", - Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + Capabilities = AICapability.TextInput | AICapability.SpeechInput | AICapability.TextOutput | AICapability.SpeechOutput | AICapability.FunctionCalling, SupportsStreaming = true, Verified = false, Deprecated = true, @@ -419,11 +461,31 @@ public override Task> RetrieveModels() { Provider = provider, Model = "whisper-1", - Capabilities = AICapability.AudioInput | AICapability.TextOutput, + Capabilities = AICapability.SpeechInput | AICapability.TextOutput, + Default = AICapability.Speech2Text, SupportsStreaming = false, Verified = false, Rank = 40, }, + new AIModelCapabilities + { + Provider = provider, + Model = "tts-1", + Capabilities = AICapability.TextInput | AICapability.SpeechOutput, + Default = AICapability.Text2Speech, + SupportsStreaming = false, + Verified = false, + Rank = 50, + }, + new AIModelCapabilities + { + Provider = provider, + Model = "tts-1-hd", + Capabilities = AICapability.TextInput | AICapability.SpeechOutput, + SupportsStreaming = false, + Verified = false, + Rank = 60, + }, }; return Task.FromResult(models); diff --git a/src/SmartHopper.Providers.OpenRouter/OpenRouterProvider.cs b/src/SmartHopper.Providers.OpenRouter/OpenRouterProvider.cs index d098ae1b..b0891a03 100644 --- a/src/SmartHopper.Providers.OpenRouter/OpenRouterProvider.cs +++ b/src/SmartHopper.Providers.OpenRouter/OpenRouterProvider.cs @@ -728,8 +728,8 @@ async Task> FlushAsync(bool force) yield return initial; } - // Determine idle timeout from request (fallback to 60s if invalid) - var idleTimeout = TimeSpan.FromSeconds(request.TimeoutSeconds > 0 ? request.TimeoutSeconds : 60); + // Determine idle timeout from request (fallback to 600s if invalid) + var idleTimeout = TimeSpan.FromSeconds((double)(request.TimeoutSeconds > 0 ? request.TimeoutSeconds : 600)); await foreach (var data in this.ReadSseDataAsync( response, idleTimeout, diff --git a/src/SmartHopper.Providers.OpenRouter/OpenRouterProviderModels.cs b/src/SmartHopper.Providers.OpenRouter/OpenRouterProviderModels.cs index 8977a309..70c64450 100644 --- a/src/SmartHopper.Providers.OpenRouter/OpenRouterProviderModels.cs +++ b/src/SmartHopper.Providers.OpenRouter/OpenRouterProviderModels.cs @@ -48,13 +48,12 @@ public override Task> RetrieveModels() { var provider = this.openRouterProvider.Name.ToLowerInvariant(); - // Sample curated models exposed via OpenRouter var models = new List { new AIModelCapabilities { Provider = provider, - Model = "openai/gpt-5-mini", + Model = "openai/gpt-5.4", Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, Default = AICapability.Text2Text | AICapability.Text2Json, SupportsStreaming = true, @@ -65,24 +64,78 @@ public override Task> RetrieveModels() new AIModelCapabilities { Provider = provider, - Model = "mistralai/mistral-medium-3.1", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "openai/gpt-5.4-mini", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + Default = AICapability.Text2Text | AICapability.Text2Json, + SupportsStreaming = true, + Verified = false, + Rank = 100, + ContextLimit = 400000, + }, + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.4-nano", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Rank = 85, - ContextLimit = 131072, + ContextLimit = 400000, }, new AIModelCapabilities { Provider = provider, - Model = "anthropic/claude-3.5-haiku", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "openai/gpt-5-mini", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + Default = AICapability.Text2Text | AICapability.Text2Json, SupportsStreaming = true, Verified = false, Rank = 90, + ContextLimit = 400000, + }, + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-opus-4-6", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 80, ContextLimit = 200000, }, new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-sonnet-4-6", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Default = AICapability.Text2Text | AICapability.Text2Json | AICapability.ReasoningChat | AICapability.ToolReasoningChat, + SupportsStreaming = true, + Verified = false, + Rank = 90, + ContextLimit = 200000, + }, + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-haiku-4-6", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Default = AICapability.Text2Text | AICapability.Text2Json | AICapability.ReasoningChat | AICapability.ToolReasoningChat, + SupportsStreaming = true, + Verified = false, + Rank = 95, + ContextLimit = 200000, + }, + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-medium-3.1", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 85, + ContextLimit = 131072, + }, + new AIModelCapabilities { Provider = provider, Model = "deepseek/deepseek-chat-v3.1", From 4bad881699ae038b3e0e2dfc1c1943fe11954e55 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Apr 2026 09:29:11 +0000 Subject: [PATCH 020/110] chore: add provider hash manifest for version 1.4.2-beta (dual platform) --- hashes/1.4.2-beta.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 hashes/1.4.2-beta.json diff --git a/hashes/1.4.2-beta.json b/hashes/1.4.2-beta.json new file mode 100644 index 00000000..bed03827 --- /dev/null +++ b/hashes/1.4.2-beta.json @@ -0,0 +1,26 @@ +{ + "generated": "2026-04-15T09:29:08Z", + "algorithm": "SHA-256", + "metadata": { + "buildNumber": "40", + "commitSha": "fdbc5dc02df4764e27e0e05a7b58bf2b6d6f968e", + "platforms": [ + "net7.0-windows", + "net7.0" + ], + "repository": "architects-toolkit/SmartHopper" + }, + "version": "1.4.2-beta", + "providers": { + "SmartHopper.Providers.Anthropic.dll-net7.0": "71a61479e4b90a5fc5029896a0c5822f69e21aaec29bed178e3720872285c145", + "SmartHopper.Providers.OpenRouter.dll-net7.0-windows": "7cb75f6ead63f7fc21cedde1215b49f3661a15e63b1491d7db1fde01a9c6f6eb", + "SmartHopper.Providers.OpenAI.dll-net7.0": "8e68c49d3620207a82b84a0aa7f053e1e9da7836519dcb1236d157ef64be6e9b", + "SmartHopper.Providers.OpenRouter.dll-net7.0": "31cff4e9deb97b549bc65eb87bb8f5ba6e565643411549735b97ba9f26c5d923", + "SmartHopper.Providers.OpenAI.dll-net7.0-windows": "bbec555b45e1887e29318ad2945e580a945072afe5c4d90b546b6c8e5bf31c9c", + "SmartHopper.Providers.MistralAI.dll-net7.0-windows": "5ffdfe17cc098c40d9e8a2d3e72676edc17255cb6cb46dbfcfaa3933eb375e3a", + "SmartHopper.Providers.DeepSeek.dll-net7.0-windows": "7d2f1a937e06f2ebb7bc45e6b31065ac062b26979a0d54bbe4cd789793cebfb2", + "SmartHopper.Providers.Anthropic.dll-net7.0-windows": "9c092568a35c603c2428388d8b279bd625d752664fd1271d8970fc79ddc3102f", + "SmartHopper.Providers.DeepSeek.dll-net7.0": "a41fa8671c46e6e49ad924cac7ae73a89d19a31ae624e97dcd7f9fa8e8d2961d", + "SmartHopper.Providers.MistralAI.dll-net7.0": "d4c7522d36b645e3869e42d733ba4b4ad2e3b421dcdaf054dff5a6c8ba3c46b1" + } +} From b02687f9f6d76a90f07a928c3ea5071418643b56 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:42:09 +0200 Subject: [PATCH 021/110] ci: remove automatic branch deletion from hash manifest PR merge in release workflow --- .github/workflows/release-4-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-4-build.yml b/.github/workflows/release-4-build.yml index 5e8e9e71..1f9f41e7 100644 --- a/.github/workflows/release-4-build.yml +++ b/.github/workflows/release-4-build.yml @@ -447,7 +447,7 @@ jobs: run: | $prNumber = "${{ steps.hash-pr.outputs.hash_pr_number }}" if ($prNumber) { - gh pr merge $prNumber --auto --squash --delete-branch + gh pr merge $prNumber --auto --squash } env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From f7cc8da53762d773fe1873755f05008e757026ba Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:44:35 +0200 Subject: [PATCH 022/110] ci: simplify GitHub Pages deployment trigger to run on all main branch pushes --- .github/workflows/release-5-deploy-pages.yml | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-5-deploy-pages.yml b/.github/workflows/release-5-deploy-pages.yml index 3c9d2487..36dedca5 100644 --- a/.github/workflows/release-5-deploy-pages.yml +++ b/.github/workflows/release-5-deploy-pages.yml @@ -1,12 +1,12 @@ name: 🏁 5 Deploy GitHub Pages on Hash PR Merge -# Description: This workflow deploys GitHub Pages after a hash manifest PR is merged to main. +# Description: This workflow deploys GitHub Pages after changes are pushed to main. # It reads all hash files from the /hashes folder in the repository and generates # a complete versions manifest for the GitHub Pages site. # # Triggers: -# - Automatically when a PR to main is closed (and merged) -# - Only processes PRs with title starting with "chore: add provider hash manifest" +# - Automatically when commits are pushed to main branch +# - Manual trigger via workflow_dispatch # # Permissions: # - contents:read - Required to read repository content @@ -14,10 +14,11 @@ name: 🏁 5 Deploy GitHub Pages on Hash PR Merge # - id-token:write - Required for GitHub Pages OIDC authentication on: - pull_request: - types: [ closed ] + push: branches: - main + paths: + - 'hashes/**' workflow_dispatch: inputs: version: @@ -32,11 +33,8 @@ permissions: jobs: deploy-pages: - # Only run if PR was merged and is a hash manifest PR, or manually triggered - if: | - (github.event.pull_request.merged == true && - startsWith(github.event.pull_request.title, 'chore: add provider hash manifest')) || - github.event_name == 'workflow_dispatch' + # Run on push to main or manual trigger + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' runs-on: windows-latest environment: name: github-pages From fb76c620448313bf6b23ab8fe96de06430954711 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:09:09 +0200 Subject: [PATCH 023/110] ci: add validation to prevent editing stable releases in release workflow --- .../workflows/release-3-pr-to-main-closed.yml | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/release-3-pr-to-main-closed.yml b/.github/workflows/release-3-pr-to-main-closed.yml index c06826b5..8daabc29 100644 --- a/.github/workflows/release-3-pr-to-main-closed.yml +++ b/.github/workflows/release-3-pr-to-main-closed.yml @@ -77,6 +77,35 @@ jobs: echo "IS_PRERELEASE=false" >> $GITHUB_ENV fi + - name: Check if Release Exists and is Not Prerelease + id: check_release + uses: actions/github-script@v7 + env: + RELEASE_VERSION: ${{ steps.release_info.outputs.version }} + with: + script: | + const version = process.env.RELEASE_VERSION; + try { + const release = await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag: version + }); + + // Release exists + if (release.data.prerelease === false) { + core.setFailed(`Release ${version} already exists and is not a pre-release. Editing stable releases is not allowed.`); + } else { + core.info(`Release ${version} exists but is a pre-release. Proceeding with update.`); + } + } catch (error) { + if (error.status === 404) { + core.info(`Release ${version} does not exist. Proceeding with creation.`); + } else { + throw error; + } + } + - name: Create Release uses: softprops/action-gh-release@v2 env: From faca31d2bd3189a803afc67fc8c0bc98646b4f71 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:18:50 +0200 Subject: [PATCH 024/110] ci: checkout specific version tag in build jobs for release workflow --- .github/workflows/release-4-build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release-4-build.yml b/.github/workflows/release-4-build.yml index 1f9f41e7..c58d4be9 100644 --- a/.github/workflows/release-4-build.yml +++ b/.github/workflows/release-4-build.yml @@ -167,6 +167,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ needs.prep.outputs.version }} - name: Download pre-processed source uses: actions/download-artifact@v4 @@ -214,6 +215,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ needs.prep.outputs.version }} - name: Download pre-processed source uses: actions/download-artifact@v4 From 95b0e4503c8e01e301cd6f917ae037f04651e43a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Apr 2026 10:25:38 +0000 Subject: [PATCH 025/110] chore: add provider hash manifest for version 1.4.2-beta (dual platform) --- hashes/1.4.2-beta.json | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/hashes/1.4.2-beta.json b/hashes/1.4.2-beta.json index bed03827..8320b822 100644 --- a/hashes/1.4.2-beta.json +++ b/hashes/1.4.2-beta.json @@ -1,26 +1,26 @@ { - "generated": "2026-04-15T09:29:08Z", - "algorithm": "SHA-256", - "metadata": { - "buildNumber": "40", - "commitSha": "fdbc5dc02df4764e27e0e05a7b58bf2b6d6f968e", - "platforms": [ - "net7.0-windows", - "net7.0" - ], - "repository": "architects-toolkit/SmartHopper" - }, "version": "1.4.2-beta", + "generated": "2026-04-15T10:25:34Z", "providers": { - "SmartHopper.Providers.Anthropic.dll-net7.0": "71a61479e4b90a5fc5029896a0c5822f69e21aaec29bed178e3720872285c145", "SmartHopper.Providers.OpenRouter.dll-net7.0-windows": "7cb75f6ead63f7fc21cedde1215b49f3661a15e63b1491d7db1fde01a9c6f6eb", - "SmartHopper.Providers.OpenAI.dll-net7.0": "8e68c49d3620207a82b84a0aa7f053e1e9da7836519dcb1236d157ef64be6e9b", + "SmartHopper.Providers.MistralAI.dll-net7.0-windows": "5ffdfe17cc098c40d9e8a2d3e72676edc17255cb6cb46dbfcfaa3933eb375e3a", + "SmartHopper.Providers.DeepSeek.dll-net7.0": "a41fa8671c46e6e49ad924cac7ae73a89d19a31ae624e97dcd7f9fa8e8d2961d", "SmartHopper.Providers.OpenRouter.dll-net7.0": "31cff4e9deb97b549bc65eb87bb8f5ba6e565643411549735b97ba9f26c5d923", + "SmartHopper.Providers.Anthropic.dll-net7.0": "71a61479e4b90a5fc5029896a0c5822f69e21aaec29bed178e3720872285c145", + "SmartHopper.Providers.OpenAI.dll-net7.0": "8e68c49d3620207a82b84a0aa7f053e1e9da7836519dcb1236d157ef64be6e9b", "SmartHopper.Providers.OpenAI.dll-net7.0-windows": "bbec555b45e1887e29318ad2945e580a945072afe5c4d90b546b6c8e5bf31c9c", - "SmartHopper.Providers.MistralAI.dll-net7.0-windows": "5ffdfe17cc098c40d9e8a2d3e72676edc17255cb6cb46dbfcfaa3933eb375e3a", + "SmartHopper.Providers.MistralAI.dll-net7.0": "d4c7522d36b645e3869e42d733ba4b4ad2e3b421dcdaf054dff5a6c8ba3c46b1", "SmartHopper.Providers.DeepSeek.dll-net7.0-windows": "7d2f1a937e06f2ebb7bc45e6b31065ac062b26979a0d54bbe4cd789793cebfb2", - "SmartHopper.Providers.Anthropic.dll-net7.0-windows": "9c092568a35c603c2428388d8b279bd625d752664fd1271d8970fc79ddc3102f", - "SmartHopper.Providers.DeepSeek.dll-net7.0": "a41fa8671c46e6e49ad924cac7ae73a89d19a31ae624e97dcd7f9fa8e8d2961d", - "SmartHopper.Providers.MistralAI.dll-net7.0": "d4c7522d36b645e3869e42d733ba4b4ad2e3b421dcdaf054dff5a6c8ba3c46b1" + "SmartHopper.Providers.Anthropic.dll-net7.0-windows": "9c092568a35c603c2428388d8b279bd625d752664fd1271d8970fc79ddc3102f" + }, + "algorithm": "SHA-256", + "metadata": { + "platforms": [ + "net7.0-windows", + "net7.0" + ], + "repository": "architects-toolkit/SmartHopper", + "buildNumber": "46", + "commitSha": "faca31d2bd3189a803afc67fc8c0bc98646b4f71" } } From 71fd8028909ba12c5b35efeb2d713551396c91db Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:30:24 +0200 Subject: [PATCH 026/110] ci: add optional version input parameter to milestone assignment action --- .../actions/milestone/assign-pr/action.yml | 47 ++++++++++++------- .github/workflows/release-4-build.yml | 1 + 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/.github/actions/milestone/assign-pr/action.yml b/.github/actions/milestone/assign-pr/action.yml index cc6c8315..58ae09f6 100644 --- a/.github/actions/milestone/assign-pr/action.yml +++ b/.github/actions/milestone/assign-pr/action.yml @@ -8,6 +8,10 @@ inputs: token: description: 'GitHub token with issues:write and pull-requests:write permissions' required: true + version: + description: 'Version to use for milestone assignment (if not provided, reads from Solution.props)' + required: false + default: '' working-directory: description: 'Directory containing Solution.props (defaults to repository root)' required: false @@ -28,27 +32,34 @@ runs: const fs = require('fs'); const path = require('path'); - const workingDir = '${{ inputs.working-directory }}'; - const solutionPropsPath = path.join(workingDir, 'Solution.props'); - - if (!fs.existsSync(solutionPropsPath)) { - console.log('Solution.props file not found at:', solutionPropsPath); - return; - } - - const content = fs.readFileSync(solutionPropsPath, 'utf8'); - console.log('Solution.props content:', content); + let fullVersion = '${{ inputs.version }}'; - // Extract version from Solution.props - const versionMatch = content.match(/(.*?)<\/SolutionVersion>/); - if (!versionMatch) { - console.log('Could not find SolutionVersion in Solution.props'); - return; + // If version not provided as input, read from Solution.props + if (!fullVersion) { + const workingDir = '${{ inputs.working-directory }}'; + const solutionPropsPath = path.join(workingDir, 'Solution.props'); + + if (!fs.existsSync(solutionPropsPath)) { + console.log('Solution.props file not found at:', solutionPropsPath); + return; + } + + const content = fs.readFileSync(solutionPropsPath, 'utf8'); + console.log('Solution.props content:', content); + + // Extract version from Solution.props + const versionMatch = content.match(/(.*?)<\/SolutionVersion>/); + if (!versionMatch) { + console.log('Could not find SolutionVersion in Solution.props'); + return; + } + + fullVersion = versionMatch[1]; + console.log('Found full version from Solution.props:', fullVersion); + } else { + console.log('Using provided version:', fullVersion); } - const fullVersion = versionMatch[1]; - console.log('Found full version:', fullVersion); - // Parse and process version // - Build numbers (e.g., ".250720") are removed because they are not relevant for milestone assignment. // - The suffix "-dev" is replaced with "-alpha" because there are no milestones for development versions. diff --git a/.github/workflows/release-4-build.yml b/.github/workflows/release-4-build.yml index c58d4be9..257b98f4 100644 --- a/.github/workflows/release-4-build.yml +++ b/.github/workflows/release-4-build.yml @@ -442,6 +442,7 @@ jobs: with: pr-number: ${{ steps.hash-pr.outputs.hash_pr_number }} token: ${{ secrets.GITHUB_TOKEN }} + version: ${{ needs.prep.outputs.version }} - name: Auto-merge Hash PR if: needs.prep.outputs.is_release == 'true' && steps.hash-pr.outputs.hash_pr_number != '' From 306289bb1dcf595a74e7219ef8cff009b6face44 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:52:44 +0200 Subject: [PATCH 027/110] ci: extract manifest text update logic into reusable action and integrate across workflows --- .../update-manifest-text/action.yml | 118 ++++++++++++++++++ .github/workflows/dev-update-manifest.yml | 60 ++------- .github/workflows/pr-manifest-validation.yml | 33 ++--- .github/workflows/release-1-milestone.yml | 8 +- 4 files changed, 146 insertions(+), 73 deletions(-) create mode 100644 .github/actions/versioning/update-manifest-text/action.yml diff --git a/.github/actions/versioning/update-manifest-text/action.yml b/.github/actions/versioning/update-manifest-text/action.yml new file mode 100644 index 00000000..bffc6f54 --- /dev/null +++ b/.github/actions/versioning/update-manifest-text/action.yml @@ -0,0 +1,118 @@ +name: 'Update Manifest Text' +description: 'Determines and optionally updates manifest.yml text based on version type (alpha/beta/rc/stable)' + +inputs: + version: + description: 'Version string to determine manifest text from' + required: true + manifest-path: + description: 'Path to manifest.yml file (relative to workspace)' + required: false + default: 'yak-package/manifest.yml' + update-file: + description: 'Whether to actually update the manifest file (true) or just output the text (false)' + required: false + default: 'false' + +outputs: + release-type: + description: 'Detected release type (alpha, beta, rc, stable)' + value: ${{ steps.determine.outputs.type }} + manifest-text: + description: 'The manifest text that should be used' + value: ${{ steps.determine.outputs.text }} + file-updated: + description: 'Whether the manifest file was actually modified' + value: ${{ steps.update.outputs.updated || 'false' }} + +runs: + using: "composite" + steps: + - name: Determine manifest text from version + id: determine + shell: bash + run: | + VERSION="${{ inputs.version }}" + + # Determine text and type based on version suffix + if [[ "$VERSION" =~ -dev || "$VERSION" =~ -alpha ]]; then + TEXT="NOTE: This is an alpha release and you might find bugs :/ Please, report them at " + TYPE="alpha" + elif [[ "$VERSION" =~ -beta ]]; then + TEXT="NOTE: This is a beta release and you might find bugs :/ Please, report them at " + TYPE="beta" + elif [[ "$VERSION" =~ -rc ]]; then + TEXT="NOTE: This is a release candidate. Please test thoroughly and report any issues at " + TYPE="rc" + else + TEXT="Please report any issues at " + TYPE="stable" + fi + + echo "type=$TYPE" >> $GITHUB_OUTPUT + echo "text=$TEXT" >> $GITHUB_OUTPUT + echo "Release type: $TYPE" + echo "Manifest text: $TEXT" + + - name: Update manifest file (if enabled) + id: update + if: inputs.update-file == 'true' + shell: bash + run: | + MANIFEST_PATH="${{ inputs.manifest-path }}" + TEXT="${{ steps.determine.outputs.text }}" + TYPE="${{ steps.determine.outputs.type }}" + + # Check if manifest exists + if [ ! -f "$MANIFEST_PATH" ]; then + echo "::warning::Manifest file not found at $MANIFEST_PATH" + echo "updated=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # Escape the new text for sed + ESCAPED_TEXT=$(echo "$TEXT" | sed 's/[&/\]/\\&/g') + + # Define patterns to match + ALPHA_PATTERN="NOTE: This is an alpha release" + BETA_PATTERN="NOTE: This is a beta release" + RC_PATTERN="NOTE: This is a release candidate" + STABLE_PATTERN="Please report any issues at" + + # Track if any replacement was made + UPDATED=false + + if grep -q "$ALPHA_PATTERN" "$MANIFEST_PATH"; then + echo "Replacing alpha text with $TYPE text" + sed -i "s|.*$ALPHA_PATTERN.*| $ESCAPED_TEXT|" "$MANIFEST_PATH" + UPDATED=true + elif grep -q "$BETA_PATTERN" "$MANIFEST_PATH"; then + echo "Replacing beta text with $TYPE text" + sed -i "s|.*$BETA_PATTERN.*| $ESCAPED_TEXT|" "$MANIFEST_PATH" + UPDATED=true + elif grep -q "$RC_PATTERN" "$MANIFEST_PATH"; then + echo "Replacing rc text with $TYPE text" + sed -i "s|.*$RC_PATTERN.*| $ESCAPED_TEXT|" "$MANIFEST_PATH" + UPDATED=true + elif grep -q "$STABLE_PATTERN" "$MANIFEST_PATH"; then + echo "Replacing stable text with $TYPE text" + sed -i "s|.*$STABLE_PATTERN.*| $ESCAPED_TEXT|" "$MANIFEST_PATH" + UPDATED=true + else + echo "::warning::Could not find existing note text to replace in $MANIFEST_PATH" + echo "Current content around description:" + grep -A 10 "description:" "$MANIFEST_PATH" || true + fi + + # Verify the change was made + if [ "$UPDATED" = true ]; then + if grep -qF "$TEXT" "$MANIFEST_PATH"; then + echo "updated=true" >> $GITHUB_OUTPUT + echo "Manifest successfully updated" + else + echo "::warning::Update may have failed - text not found in manifest" + echo "updated=false" >> $GITHUB_OUTPUT + fi + else + echo "updated=false" >> $GITHUB_OUTPUT + fi diff --git a/.github/workflows/dev-update-manifest.yml b/.github/workflows/dev-update-manifest.yml index d51a1ec6..fece31cc 100644 --- a/.github/workflows/dev-update-manifest.yml +++ b/.github/workflows/dev-update-manifest.yml @@ -45,62 +45,22 @@ jobs: id: version uses: ./.github/actions/versioning/get-version - - name: Determine required manifest text - id: manifest_text - run: | - VERSION="${{ steps.version.outputs.version }}" - SUFFIX="${{ steps.version.outputs.suffix }}" - - if [[ "$VERSION" =~ -dev || "$VERSION" =~ -alpha ]]; then - TEXT="NOTE: This is an alpha release and you might find bugs :/ Please, report them at " - TYPE="alpha" - elif [[ "$VERSION" =~ -beta ]]; then - TEXT="NOTE: This is a beta release and you might find bugs :/ Please, report them at " - TYPE="beta" - else - TEXT="Please report any issues at " - TYPE="stable" - fi - - echo "type=$TYPE" >> $GITHUB_OUTPUT - echo "text=$TEXT" >> $GITHUB_OUTPUT - echo "Release type: $TYPE" - - name: Update manifest.yml note text - env: - MANIFEST_TEXT: ${{ steps.manifest_text.outputs.text }} - run: | - # Define the three possible texts as regex patterns for matching - ALPHA_PATTERN="NOTE: This is an alpha release and you might find bugs :/ Please, report them at" - BETA_PATTERN="NOTE: This is a beta release and you might find bugs :/ Please, report them at" - STABLE_PATTERN="Please report any issues at" - - # Escape the new text for sed - ESCAPED_TEXT=$(echo "$MANIFEST_TEXT" | sed 's/[&/\]/\\&/g') - - # Replace whichever pattern is currently in the file - if grep -q "$ALPHA_PATTERN" yak-package/manifest.yml; then - echo "Replacing alpha text with: $MANIFEST_TEXT" - sed -i "s|.*NOTE: This is an alpha release.*| $ESCAPED_TEXT|" yak-package/manifest.yml - elif grep -q "$BETA_PATTERN" yak-package/manifest.yml; then - echo "Replacing beta text with: $MANIFEST_TEXT" - sed -i "s|.*NOTE: This is a beta release.*| $ESCAPED_TEXT|" yak-package/manifest.yml - elif grep -q "$STABLE_PATTERN" yak-package/manifest.yml; then - echo "Replacing stable text with: $MANIFEST_TEXT" - sed -i "s|.*Please report any issues at.*| $ESCAPED_TEXT|" yak-package/manifest.yml - else - echo "::warning::Could not find existing note text to replace" - fi + id: update_manifest + uses: ./.github/actions/versioning/update-manifest-text + with: + version: ${{ steps.version.outputs.version }} + update-file: 'true' - name: Check for changes id: check_changes run: | - if git diff --quiet yak-package/manifest.yml; then - echo "changed=false" >> $GITHUB_OUTPUT - echo "No changes needed - manifest already up to date" - else + if [ "${{ steps.update_manifest.outputs.file-updated }}" = "true" ] || ! git diff --quiet yak-package/manifest.yml; then echo "changed=true" >> $GITHUB_OUTPUT echo "Manifest text updated" + else + echo "changed=false" >> $GITHUB_OUTPUT + echo "No changes needed - manifest already up to date" fi - name: Commit and push changes @@ -109,5 +69,5 @@ jobs: git config --local user.email "github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" git add yak-package/manifest.yml - git commit -m "chore: update manifest text to ${{ steps.manifest_text.outputs.type }} version [skip ci]" + git commit -m "chore: update manifest text to ${{ steps.manifest_text.outputs.release-type }} version [skip ci]" git push diff --git a/.github/workflows/pr-manifest-validation.yml b/.github/workflows/pr-manifest-validation.yml index ac23d71a..f6229f02 100644 --- a/.github/workflows/pr-manifest-validation.yml +++ b/.github/workflows/pr-manifest-validation.yml @@ -37,26 +37,15 @@ jobs: - name: Determine expected manifest text id: expected + uses: ./.github/actions/versioning/update-manifest-text + with: + version: ${{ steps.version.outputs.version }} + + - name: Write expected text to file run: | - VERSION="${{ steps.version.outputs.version }}" - SUFFIX="${{ steps.version.outputs.suffix }}" - - if [[ "$VERSION" =~ -dev || "$VERSION" =~ -alpha ]]; then - EXPECTED_TEXT="NOTE: This is an alpha release and you might find bugs :/ Please, report them at " - RELEASE_TYPE="alpha" - elif [[ "$VERSION" =~ -beta ]]; then - EXPECTED_TEXT="NOTE: This is a beta release and you might find bugs :/ Please, report them at " - RELEASE_TYPE="beta" - else - EXPECTED_TEXT="Please report any issues at " - RELEASE_TYPE="stable" - fi - - echo "release_type=$RELEASE_TYPE" >> $GITHUB_OUTPUT - echo "Expected release type: $RELEASE_TYPE" - - # Write expected text to file for comparison - echo "$EXPECTED_TEXT" > expected.txt + echo "${{ steps.expected.outputs.manifest-text }}" > expected.txt + echo "release_type=${{ steps.expected.outputs.release-type }}" >> $GITHUB_OUTPUT + echo "Expected release type: ${{ steps.expected.outputs.release-type }}" - name: Extract actual manifest text run: | @@ -79,10 +68,10 @@ jobs: echo "Actual text: $ACTUAL" if [ "$EXPECTED" = "$ACTUAL" ]; then - echo "✅ Manifest text matches version type: ${{ steps.expected.outputs.release_type }}" + echo "✅ Manifest text matches version type: ${{ steps.expected.outputs.release-type }}" echo "match=true" >> $GITHUB_OUTPUT else - echo "❌ Manifest text does not match version type: ${{ steps.expected.outputs.release_type }}" + echo "❌ Manifest text does not match version type: ${{ steps.expected.outputs.release-type }}" echo "match=false" >> $GITHUB_OUTPUT fi @@ -92,7 +81,7 @@ jobs: echo "::error::Manifest text does not match the version type!" echo "" echo "Version: ${{ steps.version.outputs.version }}" - echo "Expected type: ${{ steps.expected.outputs.release_type }}" + echo "Expected type: ${{ steps.expected.outputs.release-type }}" echo "" echo "Expected text:" cat expected.txt diff --git a/.github/workflows/release-1-milestone.yml b/.github/workflows/release-1-milestone.yml index f7949a4e..e13eb55e 100644 --- a/.github/workflows/release-1-milestone.yml +++ b/.github/workflows/release-1-milestone.yml @@ -103,6 +103,12 @@ jobs: with: new-version: ${{ inputs.milestone-title }} + - name: Update manifest text for release version + uses: ./.github/actions/versioning/update-manifest-text + with: + version: ${{ inputs.milestone-title }} + update-file: 'true' + - name: Include missing issues in changelog uses: ./.github/actions/documentation/update-changelog-issues with: @@ -139,7 +145,7 @@ jobs: - name: Commit and push changes run: | - git add Solution.props CHANGELOG.md README.md src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj src/ + git add Solution.props CHANGELOG.md README.md yak-package/manifest.yml src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj src/ git commit -m "chore: prepare release ${{ inputs.milestone-title }} with version update and code style fixes" git push origin release/${{ inputs.milestone-title }} From 4fda03ea7179f2c8d5f3acaaa9e0c6972010749c Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:37:33 +0200 Subject: [PATCH 028/110] ci: add reusable cherry-pick action and patch propagation workflow for multi-branch commit fan-out --- .../cherry-pick-to-branch/PR_TEMPLATE.md | 21 ++ .../actions/cherry-pick-to-branch/action.yml | 232 ++++++++++++++++++ .github/workflows/patch-propagate.yml | 128 ++++++++++ docs/Development/patch-propagation.md | 62 +++++ docs/index.md | 5 + 5 files changed, 448 insertions(+) create mode 100644 .github/actions/cherry-pick-to-branch/PR_TEMPLATE.md create mode 100644 .github/actions/cherry-pick-to-branch/action.yml create mode 100644 .github/workflows/patch-propagate.yml create mode 100644 docs/Development/patch-propagation.md diff --git a/.github/actions/cherry-pick-to-branch/PR_TEMPLATE.md b/.github/actions/cherry-pick-to-branch/PR_TEMPLATE.md new file mode 100644 index 00000000..f20f02d5 --- /dev/null +++ b/.github/actions/cherry-pick-to-branch/PR_TEMPLATE.md @@ -0,0 +1,21 @@ + + +## 🍒 Patch Propagation + +This PR cherry-picks the following commit(s) onto **`{{TARGET_BRANCH}}`**: + +{{COMMIT_LIST}} + +**Source branch (reference):** `{{SOURCE_BRANCH}}` + +{{CONFLICT_SECTION}} + +### Validation checklist + +- [ ] CI passes (build, tests, code style) +- [ ] CHANGELOG updated if user-facing +- [ ] Conflicts (if any) resolved correctly +- [ ] Behavior verified on target branch + +--- +_Opened automatically by `patch-propagate.yml`._ diff --git a/.github/actions/cherry-pick-to-branch/action.yml b/.github/actions/cherry-pick-to-branch/action.yml new file mode 100644 index 00000000..2e3ce11d --- /dev/null +++ b/.github/actions/cherry-pick-to-branch/action.yml @@ -0,0 +1,232 @@ +name: 'Cherry-pick to Branch' +description: 'Cherry-pick one or more commits onto a target branch and open a PR. Never pushes directly to the target.' +inputs: + source-shas: + description: 'Comma- or space-separated list of commit SHAs to cherry-pick (in chronological order).' + required: true + source-branch: + description: 'Source branch name (informational, used in PR body).' + required: false + default: '' + target-branch: + description: 'Target branch to receive the patch via PR.' + required: true + pr-title-prefix: + description: 'Prefix for the PR title.' + required: false + default: '[patch]' + pr-body-extra: + description: 'Optional extra markdown appended to the PR body.' + required: false + default: '' + labels: + description: 'Comma-separated list of labels to apply to the PR.' + required: false + default: 'patch,automated' + draft-always: + description: 'If true, force the PR to be opened as draft regardless of conflicts.' + required: false + default: 'false' + mainline: + description: 'If set (e.g., "1"), passes -m to git cherry-pick to support merge commits.' + required: false + default: '' + token: + description: 'GitHub token with contents:write and pull-requests:write.' + required: true + default: ${{ github.token }} + +outputs: + pr-number: + description: 'Number of the PR created (empty if skipped).' + value: ${{ steps.open-pr.outputs.pr-number }} + pr-url: + description: 'URL of the PR created (empty if skipped).' + value: ${{ steps.open-pr.outputs.pr-url }} + branch-name: + description: 'Name of the patch branch pushed.' + value: ${{ steps.cherry-pick.outputs.branch-name }} + has-conflicts: + description: 'true if any cherry-pick required conflict markers to be committed.' + value: ${{ steps.cherry-pick.outputs.has-conflicts }} + status: + description: 'One of: created, skipped-noop, failed.' + value: ${{ steps.cherry-pick.outputs.status }} + +runs: + using: 'composite' + steps: + - name: Validate inputs and prepare + id: prep + shell: bash + run: | + set -euo pipefail + target="${{ inputs.target-branch }}" + if [ -z "$target" ]; then + echo "::error::target-branch is required" + exit 1 + fi + # Normalize SHA list (comma or whitespace separated). + raw='${{ inputs.source-shas }}' + normalized=$(echo "$raw" | tr ',' ' ' | xargs) + if [ -z "$normalized" ]; then + echo "::error::source-shas is required" + exit 1 + fi + echo "shas=$normalized" >> "$GITHUB_OUTPUT" + # Sanitize target branch for use in branch name (replace / with -). + sanitized=$(echo "$target" | tr '/ ' '--') + echo "sanitized-target=$sanitized" >> "$GITHUB_OUTPUT" + # Short SHA of the first commit, for branch naming. + first=$(echo "$normalized" | awk '{print $1}') + short=$(echo "$first" | cut -c1-8) + echo "first-short=$short" >> "$GITHUB_OUTPUT" + ts=$(date -u +%Y%m%d%H%M%S) + echo "timestamp=$ts" >> "$GITHUB_OUTPUT" + + - name: Cherry-pick commits onto target + id: cherry-pick + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.token }} + run: | + set -euo pipefail + target="${{ inputs.target-branch }}" + shas="${{ steps.prep.outputs.shas }}" + mainline="${{ inputs.mainline }}" + branch="patch/${{ steps.prep.outputs.sanitized-target }}/${{ steps.prep.outputs.first-short }}-${{ steps.prep.outputs.timestamp }}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git fetch --no-tags --prune origin "+refs/heads/*:refs/remotes/origin/*" + # Verify target exists. + if ! git rev-parse --verify "refs/remotes/origin/$target" >/dev/null 2>&1; then + echo "::error::Target branch '$target' not found on origin." + echo "status=failed" >> "$GITHUB_OUTPUT" + exit 1 + fi + # Verify all SHAs are reachable. + for sha in $shas; do + if ! git cat-file -e "$sha^{commit}" 2>/dev/null; then + echo "::error::Commit $sha not found in repository." + echo "status=failed" >> "$GITHUB_OUTPUT" + exit 1 + fi + done + + git checkout -B "$branch" "refs/remotes/origin/$target" + + has_conflicts=false + applied=0 + skipped=0 + for sha in $shas; do + # Skip if already in target history. + if git merge-base --is-ancestor "$sha" HEAD 2>/dev/null; then + echo "::notice::Commit $sha is already in $target; skipping." + skipped=$((skipped+1)) + continue + fi + extra=() + if [ -n "$mainline" ]; then + extra+=("-m" "$mainline") + fi + if git cherry-pick -x "${extra[@]}" "$sha"; then + applied=$((applied+1)) + continue + fi + # Conflict path: stage all changes and commit with markers. + echo "::warning::Cherry-pick of $sha had conflicts; committing with markers." + has_conflicts=true + git add -A + if ! git -c core.editor=true cherry-pick --continue; then + # If --continue fails (e.g., empty), commit manually. + git commit -am "cherry-pick (with conflicts): $sha" || true + fi + applied=$((applied+1)) + done + + echo "branch-name=$branch" >> "$GITHUB_OUTPUT" + echo "has-conflicts=$has_conflicts" >> "$GITHUB_OUTPUT" + + if [ "$applied" -eq 0 ]; then + echo "::notice::No commits applied to $target (all skipped as no-op)." + echo "status=skipped-noop" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Push patch branch. + remote_url="https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push "$remote_url" "HEAD:refs/heads/$branch" + echo "status=created" >> "$GITHUB_OUTPUT" + + - name: Open pull request + id: open-pr + if: steps.cherry-pick.outputs.status == 'created' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.token }} + run: | + set -euo pipefail + target="${{ inputs.target-branch }}" + branch="${{ steps.cherry-pick.outputs.branch-name }}" + prefix="${{ inputs.pr-title-prefix }}" + labels="${{ inputs.labels }}" + has_conflicts="${{ steps.cherry-pick.outputs.has-conflicts }}" + draft_always="${{ inputs.draft-always }}" + source_branch="${{ inputs.source-branch }}" + shas="${{ steps.prep.outputs.shas }}" + extra="${{ inputs.pr-body-extra }}" + + first_sha=$(echo "$shas" | awk '{print $1}') + first_subject=$(git log -1 --pretty=%s "$first_sha") + title="$prefix $first_subject → $target" + + commit_list="" + for sha in $shas; do + subject=$(git log -1 --pretty=%s "$sha") + short=$(echo "$sha" | cut -c1-8) + commit_list+="- [\`${short}\`](https://github.com/${GITHUB_REPOSITORY}/commit/${sha}) ${subject}\n" + done + + conflict_section="" + if [ "$has_conflicts" = "true" ]; then + conflict_section="### ⚠️ Conflicts detected\n\nThis PR contains commits with unresolved conflict markers. **Manual resolution required** before merging.\n" + fi + + template_path="$GITHUB_ACTION_PATH/PR_TEMPLATE.md" + body=$(cat "$template_path") + body="${body//\{\{TARGET_BRANCH\}\}/$target}" + body="${body//\{\{SOURCE_BRANCH\}\}/${source_branch:-n/a}}" + body="${body//\{\{COMMIT_LIST\}\}/$(printf '%b' "$commit_list")}" + body="${body//\{\{CONFLICT_SECTION\}\}/$(printf '%b' "$conflict_section")}" + if [ -n "$extra" ]; then + body+=$'\n\n---\n\n'"$extra" + fi + + # Build label args. + label_args=() + IFS=',' read -ra label_arr <<< "$labels" + for l in "${label_arr[@]}"; do + l_trim=$(echo "$l" | xargs) + [ -n "$l_trim" ] && label_args+=("--label" "$l_trim") + done + if [ "$has_conflicts" = "true" ]; then + label_args+=("--label" "has-conflicts") + fi + + draft_flag=() + if [ "$has_conflicts" = "true" ] || [ "$draft_always" = "true" ]; then + draft_flag+=("--draft") + fi + + pr_url=$(gh pr create \ + --base "$target" \ + --head "$branch" \ + --title "$title" \ + --body "$body" \ + "${label_args[@]}" \ + "${draft_flag[@]}") + echo "pr-url=$pr_url" >> "$GITHUB_OUTPUT" + pr_number=$(basename "$pr_url") + echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/patch-propagate.yml b/.github/workflows/patch-propagate.yml new file mode 100644 index 00000000..409130cd --- /dev/null +++ b/.github/workflows/patch-propagate.yml @@ -0,0 +1,128 @@ +name: 🍒 Patch Propagate (Multi-Branch) + +# Description: Manually fan-out one or more commits onto multiple target branches by +# opening a PR per target. Never pushes directly to protected branches. +# +# Use cases: +# - Apply an AI provider model list update to dev, main-1.4, dev-1.5, etc. +# - Backport a small fix to several long-lived branches. +# - Propagate CI/docs tweaks across release lines. + +on: + workflow_dispatch: + inputs: + source-shas: + description: 'Commit SHA(s) to cherry-pick. Comma- or space-separated, in chronological order.' + required: true + type: string + source-branch: + description: 'Source branch (informational, shown in PR body).' + required: false + type: string + default: 'dev' + target-branches: + description: 'Comma-separated list of target branches (e.g., main,dev,main-1.4,dev-1.5).' + required: true + type: string + pr-title-prefix: + description: 'Prefix for each PR title.' + required: false + type: string + default: '[patch]' + pr-body-extra: + description: 'Optional extra markdown appended to each PR body.' + required: false + type: string + default: '' + labels: + description: 'Comma-separated labels to apply to each PR.' + required: false + type: string + default: 'patch,automated' + draft-always: + description: 'Force every PR to be opened as draft.' + required: false + type: boolean + default: false + mainline: + description: 'For merge commits, parent number passed to git cherry-pick -m (e.g., 1). Leave empty for normal commits.' + required: false + type: string + default: '' + +permissions: + contents: write + pull-requests: write + +jobs: + resolve: + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.split.outputs.targets }} + has-targets: ${{ steps.split.outputs.has-targets }} + steps: + - name: Split target list + id: split + shell: bash + run: | + set -euo pipefail + raw='${{ inputs.target-branches }}' + # Build JSON array of trimmed, non-empty targets. + json=$(echo "$raw" \ + | tr ',' '\n' \ + | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \ + | grep -v '^$' \ + | jq -R . | jq -sc .) + count=$(echo "$json" | jq 'length') + echo "targets=$json" >> "$GITHUB_OUTPUT" + if [ "$count" -gt 0 ]; then + echo "has-targets=true" >> "$GITHUB_OUTPUT" + else + echo "has-targets=false" >> "$GITHUB_OUTPUT" + fi + echo "Resolved $count target branch(es): $json" + + propagate: + needs: resolve + if: needs.resolve.outputs.has-targets == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: ${{ fromJson(needs.resolve.outputs.targets) }} + steps: + - name: Checkout repository (full history) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Cherry-pick to ${{ matrix.target }} + id: cp + uses: ./.github/actions/cherry-pick-to-branch + with: + source-shas: ${{ inputs.source-shas }} + source-branch: ${{ inputs.source-branch }} + target-branch: ${{ matrix.target }} + pr-title-prefix: ${{ inputs.pr-title-prefix }} + pr-body-extra: ${{ inputs.pr-body-extra }} + labels: ${{ inputs.labels }} + draft-always: ${{ inputs.draft-always }} + mainline: ${{ inputs.mainline }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Per-target summary + if: always() + shell: bash + run: | + { + echo "### 🍒 Patch → \`${{ matrix.target }}\`" + echo "" + echo "- **Status:** ${{ steps.cp.outputs.status || 'failed' }}" + echo "- **Branch:** \`${{ steps.cp.outputs.branch-name }}\`" + echo "- **Conflicts:** ${{ steps.cp.outputs.has-conflicts || 'n/a' }}" + if [ -n "${{ steps.cp.outputs.pr-url }}" ]; then + echo "- **PR:** [#${{ steps.cp.outputs.pr-number }}](${{ steps.cp.outputs.pr-url }})" + fi + echo "" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/Development/patch-propagation.md b/docs/Development/patch-propagation.md new file mode 100644 index 00000000..cb78c5d8 --- /dev/null +++ b/docs/Development/patch-propagation.md @@ -0,0 +1,62 @@ +# Patch Propagation (Multi-Branch) + +Tool to fan-out one or more commits across several long-lived branches by opening a PR per target. Useful when a small change (AI provider model list, docs fix, CI tweak, isolated bugfix) is relevant to multiple active branches such as `main`, `dev`, `main-*`, `dev-*`, or feature branches. + +## When to use + +- Updating supported AI models for one or more providers and wanting the change in `dev`, `main`, and any active promotion branch. +- Backporting a small bugfix landed in `dev` to one or more `main-*` / `dev-*` lines. +- Propagating CI or documentation tweaks across active branches. + +Do **not** use it for large feature ports — those should go through the normal release/promotion or hotfix workflows. + +## How it works + +- Workflow: `.github/workflows/patch-propagate.yml` (manual `workflow_dispatch`). +- Composite action: `.github/actions/cherry-pick-to-branch` does the actual cherry-pick + PR creation. +- For each target branch, the workflow: + 1. Checks out the repository at full depth. + 2. Creates a `patch//-` branch from the target. + 3. Runs `git cherry-pick -x` for each provided SHA. + 4. On conflicts: commits with markers, opens the PR as **draft**, adds `has-conflicts` label. + 5. Skips a SHA if it is already in the target branch history. + 6. Pushes the patch branch and opens a PR via `gh pr create`. + +It never pushes to the target branch directly, so branch protection on `main` / `dev` / `main-*` / `dev-*` is respected. + +## Inputs + +- **`source-shas`** — Comma- or space-separated commit SHAs in chronological order. +- **`source-branch`** — Informational, shown in the PR body (default `dev`). +- **`target-branches`** — Comma-separated branches, e.g. `main,dev,main-1.4,dev-1.5`. +- **`pr-title-prefix`** — Title prefix (default `[patch]`). +- **`pr-body-extra`** — Optional markdown appended to each PR body. +- **`labels`** — Comma-separated labels (default `patch,automated`). `has-conflicts` is added automatically when applicable. +- **`draft-always`** — Force every PR to be a draft, even without conflicts. +- **`mainline`** — Parent number for `git cherry-pick -m ` when picking merge commits. Leave empty for normal commits. + +## Example: propagate an AI models update + +1. Land the change on `dev` with a focused commit, e.g. `abc12345`. +2. Go to **Actions** → **🍒 Patch Propagate (Multi-Branch)** → **Run workflow**. +3. Fill in: + - `source-shas`: `abc12345` + - `target-branches`: `main,main-1.4,dev-1.5` + - keep defaults for the rest. +4. Run. The workflow opens one PR per target. Conflicting targets get a draft PR with `has-conflicts` label. +5. Review and merge each PR like any other change. Existing PR validations (build, tests, code style, changelog) still run. + +## Conflict handling + +- Conflicts do not abort the matrix — other targets keep going (`fail-fast: false`). +- Conflicting PRs are opened as **draft**, labelled `has-conflicts`, and contain the commit with conflict markers, ready to resolve via a follow-up commit on the patch branch. + +## Branch & PR naming + +- Patch branch: `patch//-` (e.g., `patch/main-1.4/abc12345-20260425220000`). +- PR title: `[patch] `. + +## Limitations + +- Merge commits require the `mainline` input. +- Long-running diverged branches may produce conflicts on every cherry-pick — consider a focused refactor or a normal release/promotion path instead. diff --git a/docs/index.md b/docs/index.md index 952ef52a..9cf7acbc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,6 +13,11 @@ This index lists the available documentation for SmartHopper. It will be updated - [UI](UI/Chat/index.md) — Web Chat UI and host ↔ JS bridge - [GhJSON](https://github.com/architects-toolkit/ghjson-dotnet) — Grasshopper JSON serialization format (see ghjson-dotnet library) +## Development + +- [Authenticode signing](Development/authenticode-signing.md) +- [Patch propagation (multi-branch)](Development/patch-propagation.md) — fan-out a commit to several branches via PRs + ## Reviews - [Reviews](Reviews/index.md) — Architecture analysis of SmartHopper components From 061a4f71bcc766106cb2081a301a764ccccdafb3 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:44:40 +0200 Subject: [PATCH 029/110] ci: apply PR labels best-effort after creation to prevent unknown labels from aborting cherry-pick workflow --- .../actions/cherry-pick-to-branch/action.yml | 35 +++++++++++-------- .github/workflows/patch-propagate.yml | 4 +-- docs/Development/patch-propagation.md | 2 +- .../SmartHopper.Core.Grasshopper.csproj | 6 ++-- src/SmartHopper.Core/SmartHopper.Core.csproj | 3 +- .../SmartHopper.Infrastructure.csproj | 2 +- 6 files changed, 31 insertions(+), 21 deletions(-) diff --git a/.github/actions/cherry-pick-to-branch/action.yml b/.github/actions/cherry-pick-to-branch/action.yml index 2e3ce11d..9c74d2b4 100644 --- a/.github/actions/cherry-pick-to-branch/action.yml +++ b/.github/actions/cherry-pick-to-branch/action.yml @@ -20,9 +20,9 @@ inputs: required: false default: '' labels: - description: 'Comma-separated list of labels to apply to the PR.' + description: 'Comma-separated list of labels to apply to the PR. Missing labels are skipped (not auto-created).' required: false - default: 'patch,automated' + default: '' draft-always: description: 'If true, force the PR to be opened as draft regardless of conflicts.' required: false @@ -204,29 +204,36 @@ runs: body+=$'\n\n---\n\n'"$extra" fi - # Build label args. - label_args=() - IFS=',' read -ra label_arr <<< "$labels" - for l in "${label_arr[@]}"; do - l_trim=$(echo "$l" | xargs) - [ -n "$l_trim" ] && label_args+=("--label" "$l_trim") - done - if [ "$has_conflicts" = "true" ]; then - label_args+=("--label" "has-conflicts") - fi - draft_flag=() if [ "$has_conflicts" = "true" ] || [ "$draft_always" = "true" ]; then draft_flag+=("--draft") fi + # Create PR without labels first, so unknown labels never abort creation. pr_url=$(gh pr create \ --base "$target" \ --head "$branch" \ --title "$title" \ --body "$body" \ - "${label_args[@]}" \ "${draft_flag[@]}") echo "pr-url=$pr_url" >> "$GITHUB_OUTPUT" pr_number=$(basename "$pr_url") echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT" + + # Apply labels best-effort: skip any that don't exist on the repo. + all_labels=() + IFS=',' read -ra label_arr <<< "$labels" + for l in "${label_arr[@]}"; do + l_trim=$(echo "$l" | xargs) + [ -n "$l_trim" ] && all_labels+=("$l_trim") + done + if [ "$has_conflicts" = "true" ]; then + all_labels+=("has-conflicts") + fi + for l in "${all_labels[@]}"; do + if gh pr edit "$pr_number" --add-label "$l" >/dev/null 2>&1; then + echo "::notice::Applied label '$l' to PR #$pr_number" + else + echo "::warning::Label '$l' does not exist in repo; skipping." + fi + done diff --git a/.github/workflows/patch-propagate.yml b/.github/workflows/patch-propagate.yml index 409130cd..125a2180 100644 --- a/.github/workflows/patch-propagate.yml +++ b/.github/workflows/patch-propagate.yml @@ -35,10 +35,10 @@ on: type: string default: '' labels: - description: 'Comma-separated labels to apply to each PR.' + description: 'Comma-separated labels to apply to each PR. Missing labels are skipped (not auto-created).' required: false type: string - default: 'patch,automated' + default: '' draft-always: description: 'Force every PR to be opened as draft.' required: false diff --git a/docs/Development/patch-propagation.md b/docs/Development/patch-propagation.md index cb78c5d8..0ab208b0 100644 --- a/docs/Development/patch-propagation.md +++ b/docs/Development/patch-propagation.md @@ -31,7 +31,7 @@ It never pushes to the target branch directly, so branch protection on `main` / - **`target-branches`** — Comma-separated branches, e.g. `main,dev,main-1.4,dev-1.5`. - **`pr-title-prefix`** — Title prefix (default `[patch]`). - **`pr-body-extra`** — Optional markdown appended to each PR body. -- **`labels`** — Comma-separated labels (default `patch,automated`). `has-conflicts` is added automatically when applicable. +- **`labels`** — Comma-separated labels (default empty). Labels are applied best-effort after the PR is created; any label that doesn't exist in the repo is logged as a warning and skipped (PR is **not** aborted). `has-conflicts` is also applied (best-effort) when conflicts occur. - **`draft-always`** — Force every PR to be a draft, even without conflicts. - **`mainline`** — Parent number for `git cherry-pick -m ` when picking merge commits. Leave empty for normal commits. diff --git a/src/SmartHopper.Core.Grasshopper/SmartHopper.Core.Grasshopper.csproj b/src/SmartHopper.Core.Grasshopper/SmartHopper.Core.Grasshopper.csproj index 6c627e81..0a20c68f 100644 --- a/src/SmartHopper.Core.Grasshopper/SmartHopper.Core.Grasshopper.csproj +++ b/src/SmartHopper.Core.Grasshopper/SmartHopper.Core.Grasshopper.csproj @@ -69,8 +69,10 @@ {D4C6D3D5-2B3A-4B6C-8C3D-57B2D0D3C1E1} SmartHopper.Core - - + + + diff --git a/src/SmartHopper.Core/SmartHopper.Core.csproj b/src/SmartHopper.Core/SmartHopper.Core.csproj index a67c909e..31332806 100644 --- a/src/SmartHopper.Core/SmartHopper.Core.csproj +++ b/src/SmartHopper.Core/SmartHopper.Core.csproj @@ -60,7 +60,8 @@ - + + diff --git a/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj b/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj index 2740e7aa..83358959 100644 --- a/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj +++ b/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj @@ -1,4 +1,4 @@ - - - + + diff --git a/src/SmartHopper.Core/SmartHopper.Core.csproj b/src/SmartHopper.Core/SmartHopper.Core.csproj index 31332806..a67c909e 100644 --- a/src/SmartHopper.Core/SmartHopper.Core.csproj +++ b/src/SmartHopper.Core/SmartHopper.Core.csproj @@ -60,8 +60,7 @@ - - + From e93fcd7eb10a4693cc510a651a9a5da7f4ecb26e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:52:55 +0200 Subject: [PATCH 032/110] =?UTF-8?q?[patch]=20ci:=20add=20reusable=20cherry?= =?UTF-8?q?-pick=20action=20and=20patch=20propagation=20workflow=20for=20m?= =?UTF-8?q?ulti-branch=20commit=20fan-out=20=E2=86=92=20dev-1.4.2=20(#431)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: add reusable cherry-pick action and patch propagation workflow for multi-branch commit fan-out (cherry picked from commit 4fda03ea7179f2c8d5f3acaaa9e0c6972010749c) * ci: apply PR labels best-effort after creation to prevent unknown labels from aborting cherry-pick workflow (cherry picked from commit 201f4b092ba1e5253184e6b789b10c4a01fe70e2) * ci: apply PR labels best-effort after creation to prevent unknown labels from aborting cherry-pick workflow (cherry picked from commit 061a4f71bcc766106cb2081a301a764ccccdafb3) * build: replace GhJSON project references with NuGet package references (cherry picked from commit bd7e3c8ad2f5229777a6726b3c6086350b8df588) --------- Co-authored-by: marc-romu <49920661+marc-romu@users.noreply.github.com> --- .../cherry-pick-to-branch/PR_TEMPLATE.md | 21 ++ .../actions/cherry-pick-to-branch/action.yml | 239 ++++++++++++++++++ .github/workflows/patch-propagate.yml | 128 ++++++++++ docs/Development/patch-propagation.md | 62 +++++ docs/index.md | 5 + .../SmartHopper.Infrastructure.csproj | 2 +- 6 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 .github/actions/cherry-pick-to-branch/PR_TEMPLATE.md create mode 100644 .github/actions/cherry-pick-to-branch/action.yml create mode 100644 .github/workflows/patch-propagate.yml create mode 100644 docs/Development/patch-propagation.md diff --git a/.github/actions/cherry-pick-to-branch/PR_TEMPLATE.md b/.github/actions/cherry-pick-to-branch/PR_TEMPLATE.md new file mode 100644 index 00000000..f20f02d5 --- /dev/null +++ b/.github/actions/cherry-pick-to-branch/PR_TEMPLATE.md @@ -0,0 +1,21 @@ + + +## 🍒 Patch Propagation + +This PR cherry-picks the following commit(s) onto **`{{TARGET_BRANCH}}`**: + +{{COMMIT_LIST}} + +**Source branch (reference):** `{{SOURCE_BRANCH}}` + +{{CONFLICT_SECTION}} + +### Validation checklist + +- [ ] CI passes (build, tests, code style) +- [ ] CHANGELOG updated if user-facing +- [ ] Conflicts (if any) resolved correctly +- [ ] Behavior verified on target branch + +--- +_Opened automatically by `patch-propagate.yml`._ diff --git a/.github/actions/cherry-pick-to-branch/action.yml b/.github/actions/cherry-pick-to-branch/action.yml new file mode 100644 index 00000000..9c74d2b4 --- /dev/null +++ b/.github/actions/cherry-pick-to-branch/action.yml @@ -0,0 +1,239 @@ +name: 'Cherry-pick to Branch' +description: 'Cherry-pick one or more commits onto a target branch and open a PR. Never pushes directly to the target.' +inputs: + source-shas: + description: 'Comma- or space-separated list of commit SHAs to cherry-pick (in chronological order).' + required: true + source-branch: + description: 'Source branch name (informational, used in PR body).' + required: false + default: '' + target-branch: + description: 'Target branch to receive the patch via PR.' + required: true + pr-title-prefix: + description: 'Prefix for the PR title.' + required: false + default: '[patch]' + pr-body-extra: + description: 'Optional extra markdown appended to the PR body.' + required: false + default: '' + labels: + description: 'Comma-separated list of labels to apply to the PR. Missing labels are skipped (not auto-created).' + required: false + default: '' + draft-always: + description: 'If true, force the PR to be opened as draft regardless of conflicts.' + required: false + default: 'false' + mainline: + description: 'If set (e.g., "1"), passes -m to git cherry-pick to support merge commits.' + required: false + default: '' + token: + description: 'GitHub token with contents:write and pull-requests:write.' + required: true + default: ${{ github.token }} + +outputs: + pr-number: + description: 'Number of the PR created (empty if skipped).' + value: ${{ steps.open-pr.outputs.pr-number }} + pr-url: + description: 'URL of the PR created (empty if skipped).' + value: ${{ steps.open-pr.outputs.pr-url }} + branch-name: + description: 'Name of the patch branch pushed.' + value: ${{ steps.cherry-pick.outputs.branch-name }} + has-conflicts: + description: 'true if any cherry-pick required conflict markers to be committed.' + value: ${{ steps.cherry-pick.outputs.has-conflicts }} + status: + description: 'One of: created, skipped-noop, failed.' + value: ${{ steps.cherry-pick.outputs.status }} + +runs: + using: 'composite' + steps: + - name: Validate inputs and prepare + id: prep + shell: bash + run: | + set -euo pipefail + target="${{ inputs.target-branch }}" + if [ -z "$target" ]; then + echo "::error::target-branch is required" + exit 1 + fi + # Normalize SHA list (comma or whitespace separated). + raw='${{ inputs.source-shas }}' + normalized=$(echo "$raw" | tr ',' ' ' | xargs) + if [ -z "$normalized" ]; then + echo "::error::source-shas is required" + exit 1 + fi + echo "shas=$normalized" >> "$GITHUB_OUTPUT" + # Sanitize target branch for use in branch name (replace / with -). + sanitized=$(echo "$target" | tr '/ ' '--') + echo "sanitized-target=$sanitized" >> "$GITHUB_OUTPUT" + # Short SHA of the first commit, for branch naming. + first=$(echo "$normalized" | awk '{print $1}') + short=$(echo "$first" | cut -c1-8) + echo "first-short=$short" >> "$GITHUB_OUTPUT" + ts=$(date -u +%Y%m%d%H%M%S) + echo "timestamp=$ts" >> "$GITHUB_OUTPUT" + + - name: Cherry-pick commits onto target + id: cherry-pick + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.token }} + run: | + set -euo pipefail + target="${{ inputs.target-branch }}" + shas="${{ steps.prep.outputs.shas }}" + mainline="${{ inputs.mainline }}" + branch="patch/${{ steps.prep.outputs.sanitized-target }}/${{ steps.prep.outputs.first-short }}-${{ steps.prep.outputs.timestamp }}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git fetch --no-tags --prune origin "+refs/heads/*:refs/remotes/origin/*" + # Verify target exists. + if ! git rev-parse --verify "refs/remotes/origin/$target" >/dev/null 2>&1; then + echo "::error::Target branch '$target' not found on origin." + echo "status=failed" >> "$GITHUB_OUTPUT" + exit 1 + fi + # Verify all SHAs are reachable. + for sha in $shas; do + if ! git cat-file -e "$sha^{commit}" 2>/dev/null; then + echo "::error::Commit $sha not found in repository." + echo "status=failed" >> "$GITHUB_OUTPUT" + exit 1 + fi + done + + git checkout -B "$branch" "refs/remotes/origin/$target" + + has_conflicts=false + applied=0 + skipped=0 + for sha in $shas; do + # Skip if already in target history. + if git merge-base --is-ancestor "$sha" HEAD 2>/dev/null; then + echo "::notice::Commit $sha is already in $target; skipping." + skipped=$((skipped+1)) + continue + fi + extra=() + if [ -n "$mainline" ]; then + extra+=("-m" "$mainline") + fi + if git cherry-pick -x "${extra[@]}" "$sha"; then + applied=$((applied+1)) + continue + fi + # Conflict path: stage all changes and commit with markers. + echo "::warning::Cherry-pick of $sha had conflicts; committing with markers." + has_conflicts=true + git add -A + if ! git -c core.editor=true cherry-pick --continue; then + # If --continue fails (e.g., empty), commit manually. + git commit -am "cherry-pick (with conflicts): $sha" || true + fi + applied=$((applied+1)) + done + + echo "branch-name=$branch" >> "$GITHUB_OUTPUT" + echo "has-conflicts=$has_conflicts" >> "$GITHUB_OUTPUT" + + if [ "$applied" -eq 0 ]; then + echo "::notice::No commits applied to $target (all skipped as no-op)." + echo "status=skipped-noop" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Push patch branch. + remote_url="https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push "$remote_url" "HEAD:refs/heads/$branch" + echo "status=created" >> "$GITHUB_OUTPUT" + + - name: Open pull request + id: open-pr + if: steps.cherry-pick.outputs.status == 'created' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.token }} + run: | + set -euo pipefail + target="${{ inputs.target-branch }}" + branch="${{ steps.cherry-pick.outputs.branch-name }}" + prefix="${{ inputs.pr-title-prefix }}" + labels="${{ inputs.labels }}" + has_conflicts="${{ steps.cherry-pick.outputs.has-conflicts }}" + draft_always="${{ inputs.draft-always }}" + source_branch="${{ inputs.source-branch }}" + shas="${{ steps.prep.outputs.shas }}" + extra="${{ inputs.pr-body-extra }}" + + first_sha=$(echo "$shas" | awk '{print $1}') + first_subject=$(git log -1 --pretty=%s "$first_sha") + title="$prefix $first_subject → $target" + + commit_list="" + for sha in $shas; do + subject=$(git log -1 --pretty=%s "$sha") + short=$(echo "$sha" | cut -c1-8) + commit_list+="- [\`${short}\`](https://github.com/${GITHUB_REPOSITORY}/commit/${sha}) ${subject}\n" + done + + conflict_section="" + if [ "$has_conflicts" = "true" ]; then + conflict_section="### ⚠️ Conflicts detected\n\nThis PR contains commits with unresolved conflict markers. **Manual resolution required** before merging.\n" + fi + + template_path="$GITHUB_ACTION_PATH/PR_TEMPLATE.md" + body=$(cat "$template_path") + body="${body//\{\{TARGET_BRANCH\}\}/$target}" + body="${body//\{\{SOURCE_BRANCH\}\}/${source_branch:-n/a}}" + body="${body//\{\{COMMIT_LIST\}\}/$(printf '%b' "$commit_list")}" + body="${body//\{\{CONFLICT_SECTION\}\}/$(printf '%b' "$conflict_section")}" + if [ -n "$extra" ]; then + body+=$'\n\n---\n\n'"$extra" + fi + + draft_flag=() + if [ "$has_conflicts" = "true" ] || [ "$draft_always" = "true" ]; then + draft_flag+=("--draft") + fi + + # Create PR without labels first, so unknown labels never abort creation. + pr_url=$(gh pr create \ + --base "$target" \ + --head "$branch" \ + --title "$title" \ + --body "$body" \ + "${draft_flag[@]}") + echo "pr-url=$pr_url" >> "$GITHUB_OUTPUT" + pr_number=$(basename "$pr_url") + echo "pr-number=$pr_number" >> "$GITHUB_OUTPUT" + + # Apply labels best-effort: skip any that don't exist on the repo. + all_labels=() + IFS=',' read -ra label_arr <<< "$labels" + for l in "${label_arr[@]}"; do + l_trim=$(echo "$l" | xargs) + [ -n "$l_trim" ] && all_labels+=("$l_trim") + done + if [ "$has_conflicts" = "true" ]; then + all_labels+=("has-conflicts") + fi + for l in "${all_labels[@]}"; do + if gh pr edit "$pr_number" --add-label "$l" >/dev/null 2>&1; then + echo "::notice::Applied label '$l' to PR #$pr_number" + else + echo "::warning::Label '$l' does not exist in repo; skipping." + fi + done diff --git a/.github/workflows/patch-propagate.yml b/.github/workflows/patch-propagate.yml new file mode 100644 index 00000000..125a2180 --- /dev/null +++ b/.github/workflows/patch-propagate.yml @@ -0,0 +1,128 @@ +name: 🍒 Patch Propagate (Multi-Branch) + +# Description: Manually fan-out one or more commits onto multiple target branches by +# opening a PR per target. Never pushes directly to protected branches. +# +# Use cases: +# - Apply an AI provider model list update to dev, main-1.4, dev-1.5, etc. +# - Backport a small fix to several long-lived branches. +# - Propagate CI/docs tweaks across release lines. + +on: + workflow_dispatch: + inputs: + source-shas: + description: 'Commit SHA(s) to cherry-pick. Comma- or space-separated, in chronological order.' + required: true + type: string + source-branch: + description: 'Source branch (informational, shown in PR body).' + required: false + type: string + default: 'dev' + target-branches: + description: 'Comma-separated list of target branches (e.g., main,dev,main-1.4,dev-1.5).' + required: true + type: string + pr-title-prefix: + description: 'Prefix for each PR title.' + required: false + type: string + default: '[patch]' + pr-body-extra: + description: 'Optional extra markdown appended to each PR body.' + required: false + type: string + default: '' + labels: + description: 'Comma-separated labels to apply to each PR. Missing labels are skipped (not auto-created).' + required: false + type: string + default: '' + draft-always: + description: 'Force every PR to be opened as draft.' + required: false + type: boolean + default: false + mainline: + description: 'For merge commits, parent number passed to git cherry-pick -m (e.g., 1). Leave empty for normal commits.' + required: false + type: string + default: '' + +permissions: + contents: write + pull-requests: write + +jobs: + resolve: + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.split.outputs.targets }} + has-targets: ${{ steps.split.outputs.has-targets }} + steps: + - name: Split target list + id: split + shell: bash + run: | + set -euo pipefail + raw='${{ inputs.target-branches }}' + # Build JSON array of trimmed, non-empty targets. + json=$(echo "$raw" \ + | tr ',' '\n' \ + | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \ + | grep -v '^$' \ + | jq -R . | jq -sc .) + count=$(echo "$json" | jq 'length') + echo "targets=$json" >> "$GITHUB_OUTPUT" + if [ "$count" -gt 0 ]; then + echo "has-targets=true" >> "$GITHUB_OUTPUT" + else + echo "has-targets=false" >> "$GITHUB_OUTPUT" + fi + echo "Resolved $count target branch(es): $json" + + propagate: + needs: resolve + if: needs.resolve.outputs.has-targets == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: ${{ fromJson(needs.resolve.outputs.targets) }} + steps: + - name: Checkout repository (full history) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Cherry-pick to ${{ matrix.target }} + id: cp + uses: ./.github/actions/cherry-pick-to-branch + with: + source-shas: ${{ inputs.source-shas }} + source-branch: ${{ inputs.source-branch }} + target-branch: ${{ matrix.target }} + pr-title-prefix: ${{ inputs.pr-title-prefix }} + pr-body-extra: ${{ inputs.pr-body-extra }} + labels: ${{ inputs.labels }} + draft-always: ${{ inputs.draft-always }} + mainline: ${{ inputs.mainline }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Per-target summary + if: always() + shell: bash + run: | + { + echo "### 🍒 Patch → \`${{ matrix.target }}\`" + echo "" + echo "- **Status:** ${{ steps.cp.outputs.status || 'failed' }}" + echo "- **Branch:** \`${{ steps.cp.outputs.branch-name }}\`" + echo "- **Conflicts:** ${{ steps.cp.outputs.has-conflicts || 'n/a' }}" + if [ -n "${{ steps.cp.outputs.pr-url }}" ]; then + echo "- **PR:** [#${{ steps.cp.outputs.pr-number }}](${{ steps.cp.outputs.pr-url }})" + fi + echo "" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/Development/patch-propagation.md b/docs/Development/patch-propagation.md new file mode 100644 index 00000000..0ab208b0 --- /dev/null +++ b/docs/Development/patch-propagation.md @@ -0,0 +1,62 @@ +# Patch Propagation (Multi-Branch) + +Tool to fan-out one or more commits across several long-lived branches by opening a PR per target. Useful when a small change (AI provider model list, docs fix, CI tweak, isolated bugfix) is relevant to multiple active branches such as `main`, `dev`, `main-*`, `dev-*`, or feature branches. + +## When to use + +- Updating supported AI models for one or more providers and wanting the change in `dev`, `main`, and any active promotion branch. +- Backporting a small bugfix landed in `dev` to one or more `main-*` / `dev-*` lines. +- Propagating CI or documentation tweaks across active branches. + +Do **not** use it for large feature ports — those should go through the normal release/promotion or hotfix workflows. + +## How it works + +- Workflow: `.github/workflows/patch-propagate.yml` (manual `workflow_dispatch`). +- Composite action: `.github/actions/cherry-pick-to-branch` does the actual cherry-pick + PR creation. +- For each target branch, the workflow: + 1. Checks out the repository at full depth. + 2. Creates a `patch//-` branch from the target. + 3. Runs `git cherry-pick -x` for each provided SHA. + 4. On conflicts: commits with markers, opens the PR as **draft**, adds `has-conflicts` label. + 5. Skips a SHA if it is already in the target branch history. + 6. Pushes the patch branch and opens a PR via `gh pr create`. + +It never pushes to the target branch directly, so branch protection on `main` / `dev` / `main-*` / `dev-*` is respected. + +## Inputs + +- **`source-shas`** — Comma- or space-separated commit SHAs in chronological order. +- **`source-branch`** — Informational, shown in the PR body (default `dev`). +- **`target-branches`** — Comma-separated branches, e.g. `main,dev,main-1.4,dev-1.5`. +- **`pr-title-prefix`** — Title prefix (default `[patch]`). +- **`pr-body-extra`** — Optional markdown appended to each PR body. +- **`labels`** — Comma-separated labels (default empty). Labels are applied best-effort after the PR is created; any label that doesn't exist in the repo is logged as a warning and skipped (PR is **not** aborted). `has-conflicts` is also applied (best-effort) when conflicts occur. +- **`draft-always`** — Force every PR to be a draft, even without conflicts. +- **`mainline`** — Parent number for `git cherry-pick -m ` when picking merge commits. Leave empty for normal commits. + +## Example: propagate an AI models update + +1. Land the change on `dev` with a focused commit, e.g. `abc12345`. +2. Go to **Actions** → **🍒 Patch Propagate (Multi-Branch)** → **Run workflow**. +3. Fill in: + - `source-shas`: `abc12345` + - `target-branches`: `main,main-1.4,dev-1.5` + - keep defaults for the rest. +4. Run. The workflow opens one PR per target. Conflicting targets get a draft PR with `has-conflicts` label. +5. Review and merge each PR like any other change. Existing PR validations (build, tests, code style, changelog) still run. + +## Conflict handling + +- Conflicts do not abort the matrix — other targets keep going (`fail-fast: false`). +- Conflicting PRs are opened as **draft**, labelled `has-conflicts`, and contain the commit with conflict markers, ready to resolve via a follow-up commit on the patch branch. + +## Branch & PR naming + +- Patch branch: `patch//-` (e.g., `patch/main-1.4/abc12345-20260425220000`). +- PR title: `[patch] `. + +## Limitations + +- Merge commits require the `mainline` input. +- Long-running diverged branches may produce conflicts on every cherry-pick — consider a focused refactor or a normal release/promotion path instead. diff --git a/docs/index.md b/docs/index.md index 952ef52a..9cf7acbc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,6 +13,11 @@ This index lists the available documentation for SmartHopper. It will be updated - [UI](UI/Chat/index.md) — Web Chat UI and host ↔ JS bridge - [GhJSON](https://github.com/architects-toolkit/ghjson-dotnet) — Grasshopper JSON serialization format (see ghjson-dotnet library) +## Development + +- [Authenticode signing](Development/authenticode-signing.md) +- [Patch propagation (multi-branch)](Development/patch-propagation.md) — fan-out a commit to several branches via PRs + ## Reviews - [Reviews](Reviews/index.md) — Architecture analysis of SmartHopper components diff --git a/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj b/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj index 2740e7aa..83358959 100644 --- a/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj +++ b/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj @@ -1,4 +1,4 @@ - + + - [ ] **C1** — `AITextGenerate` (Text2Text) + - [ ] **C2** — `AITextListGenerate` (Text2Json) + - [ ] **C3** — `AIImgToText` (Image2Text) + - [ ] **C4** — `AIImgGenerate` (Text2Image) + - [ ] **C5** — Audio component (Speech2Text / Text2Speech) + - [ ] **B1** — Streaming in WebChat + - [ ] **B2** — ToolChat / FunctionCalling in WebChat + - [ ] **B3** — Reasoning in WebChat + - [ ] **B4** — Multi-turn `ConversationSession` in WebChat + + I personally ran the ticked tests against this provider/model on SmartHopper on . + ```` + + > Replace `` and `` with the actual values you used. + + - type: dropdown + id: provider + attributes: + label: Provider + description: Which provider does this model belong to? Must match the folder name under `src/SmartHopper.Providers.*`. + options: + - Anthropic + - DeepSeek + - MistralAI + - OpenAI + - OpenRouter + validations: + required: true + + - type: input + id: model + attributes: + label: Model name + description: Exact `Model` string as declared in the provider's `*ProviderModels.cs` (e.g. `gpt-5-mini`, `claude-haiku-4-5`, `mistralai/mistral-medium-3.1`). + placeholder: e.g. mistral-medium-latest + validations: + required: true + + - type: input + id: smarthopper-version + attributes: + label: SmartHopper Version + placeholder: e.g. 1.4.2-beta + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating System + options: + - Windows + - macOS + validations: + required: true + + - type: checkboxes + id: tests-canvas + attributes: + label: "Tests — Components on the Grasshopper canvas" + description: | + Place each component on the canvas, feed it the **exact prompt below**, run it, and tick the box only if the output is coherent with the prompt. Skip tests for capabilities the model does not declare. + options: + - label: | + **C1 — `AITextGenerate` (Text2Text).** + Prompt: `List three structural advantages of triangulated trusses, one sentence each.` + Verify the output is three coherent sentences about trusses. + - label: | + **C2 — `AITextListGenerate` (Text2Json).** + Prompt: `Give me five common Grasshopper component categories.` + Verify the output is a list of exactly five plausible category names. + - label: | + **C3 — `AIImgToText` (Image2Text).** + Feed any image you control. Prompt: `Describe what you see in one sentence.` + Verify the description matches the contents of the image. + - label: | + **C4 — `AIImgGenerate` (Text2Image).** + Prompt: `A red cube on a white background, isometric, flat shading.` + Verify a valid image is produced and roughly matches the prompt. + - label: | + **C5 — Audio components (Speech2Text / Text2Speech).** + For audio-capable models only. Run an audio component with a short clip / short sentence and verify the transcription or synthesized speech is correct. + - type: checkboxes + id: tests-chat + attributes: + label: "Tests — Chat interface (`AIChat` / WebChat)" + description: | + Open the WebChat (the AIChat component) configured for this provider/model, type the **exact prompt below**, and tick the box only if the behavior described is observed. + options: + - label: | + **B1 — Streaming.** For streaming-capable models only. + In chat, type: `Write a three-sentence haiku about Rhino 3D.` + Verify tokens appear progressively in the UI rather than in a single block. + - label: | + **B2 — ToolChat (FunctionCalling).** + In chat, type: `Use gh_report to summarize the current canvas.` + Verify the model invokes the `gh_report` tool and the chat shows its output. + - label: | + **B3 — Reasoning / ReasoningChat.** For reasoning-capable models only. + In chat, type: `If I have 17 components and each must connect to two distinct others without forming any cycle, what is the minimum total number of connections? Show your reasoning step by step.` + Verify a coherent step-by-step reasoning is shown and the final answer is correct (16). + - label: | + **B4 — Multi-turn `ConversationSession`.** + In the same chat, run at least three user turns including one tool call (e.g. ask for a `gh_report`, then a follow-up question, then another tool call). + Verify the conversation completes without errors and the chat shows aggregated turn metrics. + validations: + required: true + + - type: textarea + id: evidence + attributes: + label: Evidence + description: | + Paste short logs, screenshots, or a Grasshopper file snippet that demonstrates the tests above. + At minimum, include the model's reported `tokens_in`/`tokens_out` for one successful call. + validations: + required: true + + - type: textarea + id: notes + attributes: + label: Notes / observations + description: Anything reviewers should know — quirks, partial failures, recommended `Default` capability flags, suggested `Rank`, etc. + validations: + required: false + + - type: checkboxes + id: confirm + attributes: + label: Confirmation + options: + - label: I confirm that I personally ran the tests above against the specified `provider/model` on the specified SmartHopper version. + required: true diff --git a/.github/workflows/model-verification.yml b/.github/workflows/model-verification.yml new file mode 100644 index 00000000..c8c62d40 --- /dev/null +++ b/.github/workflows/model-verification.yml @@ -0,0 +1,246 @@ +name: ✅ Model Verification + +# Description: Tracks community certifications of AI models in `*ProviderModels.cs`. +# +# An issue created from the "Model Verification Report" template represents the FIRST +# verifier (the issue author). Other users certify the same model by posting a comment +# whose FIRST non-empty line is exactly `/verify-confirm` AND that contains the marker +# `` (both come from the issue template's copy-paste +# codeblock). Comments that don't match are ignored. +# +# Org members and collaborators may post a comment whose FIRST non-empty line is +# `/verify-force` to immediately promote the model regardless of the user count. +# +# Once at least TWO distinct users have certified (or one valid /verify-force has +# been posted), this workflow opens a PR that flips `Verified = false` to +# `Verified = true` for the matching provider/model in +# `src/SmartHopper.Providers./ProviderModels.cs`. +# +# Tracking integrity: +# - Verifiers are GitHub usernames (login). Bots are excluded. +# - Each user counts at most once. +# - `/verify-force` requires `author_association` ∈ { OWNER, MEMBER, COLLABORATOR }. +# - The original author counts as a verifier only if the issue body contains the +# "I confirm" checkbox marked. + +on: + issues: + types: [opened, edited, labeled] + issue_comment: + types: [created, edited] + workflow_dispatch: + inputs: + issue_number: + description: "Issue number to (re)evaluate" + required: true + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + evaluate: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issues' && contains(github.event.issue.labels.*.name, 'model-verification')) || + (github.event_name == 'issue_comment' && + contains(github.event.issue.labels.*.name, 'model-verification') && + (startsWith(github.event.comment.body, '/verify-confirm') || + startsWith(github.event.comment.body, '/verify-force'))) + runs-on: windows-latest + outputs: + should_promote: ${{ steps.tally.outputs.should_promote }} + provider: ${{ steps.tally.outputs.provider }} + model: ${{ steps.tally.outputs.model }} + issue_number: ${{ steps.tally.outputs.issue_number }} + verifiers: ${{ steps.tally.outputs.verifiers }} + bypass_user: ${{ steps.tally.outputs.bypass_user }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: dev + fetch-depth: 0 + + - name: Tally verifiers + id: tally + uses: actions/github-script@v7 + with: + script: | + const issueNumber = context.payload.inputs?.issue_number + ? Number(context.payload.inputs.issue_number) + : context.payload.issue.number; + + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + + const labels = (issue.labels || []).map(l => (typeof l === 'string' ? l : l.name)); + if (!labels.includes('model-verification')) { + core.info('Issue is not labeled model-verification; skipping.'); + core.setOutput('should_promote', 'false'); + return; + } + if (labels.includes('model-verified')) { + core.info('Issue already promoted; skipping.'); + core.setOutput('should_promote', 'false'); + return; + } + + const body = issue.body || ''; + + // Parse provider (dropdown rendered as plain text under "### Provider") + const providerMatch = body.match(/###\s*Provider\s*\r?\n\s*([A-Za-z0-9_\-]+)/); + const modelMatch = body.match(/###\s*Model name\s*\r?\n\s*([^\r\n]+?)\s*$/m); + if (!providerMatch || !modelMatch) { + core.warning('Could not parse provider or model from issue body.'); + core.setOutput('should_promote', 'false'); + return; + } + const provider = providerMatch[1].trim(); + const model = modelMatch[1].trim(); + core.info(`Parsed provider='${provider}', model='${model}'`); + + // The "I confirm" checkbox must be checked for the author to count. + const authorConfirms = /-\s*\[x\][^\n]*I confirm that I personally ran/i.test(body); + + const verifiers = new Set(); + if (authorConfirms && issue.user && issue.user.type !== 'Bot') { + verifiers.add(issue.user.login.toLowerCase()); + } + + // Iterate comments looking for /verify-confirm and /verify-bypass. + const comments = await github.paginate( + github.rest.issues.listComments, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100, + } + ); + + // A valid /verify-confirm comment must: + // - have `/verify-confirm` as its first non-empty line + // - contain the marker `` + // (both ship in the issue template's copy-paste codeblock) + // A valid /verify-force comment must have `/verify-force` as its first non-empty line. + const firstLine = (text) => { + for (const raw of (text || '').split(/\r?\n/)) { + const line = raw.trim(); + if (line.length > 0) return line; + } + return ''; + }; + const CONFIRM_MARKER = ''; + + let bypassUser = ''; + const orgRoles = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + for (const c of comments) { + if (!c.user || c.user.type === 'Bot') continue; + const text = c.body || ''; + const head = firstLine(text); + + if (head === '/verify-force' && orgRoles.has(c.author_association)) { + bypassUser = c.user.login; + continue; + } + if (head === '/verify-confirm' && text.includes(CONFIRM_MARKER)) { + verifiers.add(c.user.login.toLowerCase()); + } + } + + const list = Array.from(verifiers).sort(); + const shouldPromote = !!bypassUser || list.length >= 2; + + core.setOutput('provider', provider); + core.setOutput('model', model); + core.setOutput('issue_number', String(issueNumber)); + core.setOutput('verifiers', list.join(',')); + core.setOutput('bypass_user', bypassUser); + core.setOutput('should_promote', shouldPromote ? 'true' : 'false'); + + const summary = [ + `### Model verification status`, + ``, + `- Provider: \`${provider}\``, + `- Model: \`${model}\``, + `- Distinct verifiers (${list.length}): ${list.length ? list.map(u => '`' + u + '`').join(', ') : '_none_'}`, + `- Bypass: ${bypassUser ? '`@' + bypassUser + '`' : '_none_'}`, + `- Threshold met: **${shouldPromote ? 'YES' : 'NO'}**`, + ].join('\n'); + await core.summary.addRaw(summary).write(); + + - name: Promote model in source + id: promote + if: steps.tally.outputs.should_promote == 'true' + shell: pwsh + run: | + pwsh -ExecutionPolicy Bypass -File .\tools\Update-ModelVerified.ps1 ` + -Provider '${{ steps.tally.outputs.provider }}' ` + -Model '${{ steps.tally.outputs.model }}' + $code = $LASTEXITCODE + if ($code -eq 0) { + "changed=true" | Out-File -Append -FilePath $env:GITHUB_OUTPUT + } elseif ($code -eq 1) { + "changed=false" | Out-File -Append -FilePath $env:GITHUB_OUTPUT + } else { + throw "Update-ModelVerified.ps1 failed with exit code $code" + } + + - name: Create Pull Request + if: steps.tally.outputs.should_promote == 'true' && steps.promote.outputs.changed == 'true' + id: create-pr + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "chore(models): verify ${{ steps.tally.outputs.provider }}/${{ steps.tally.outputs.model }}" + title: "chore(models): verify ${{ steps.tally.outputs.provider }}/${{ steps.tally.outputs.model }}" + body: | + Promotes `${{ steps.tally.outputs.provider }}/${{ steps.tally.outputs.model }}` to `Verified = true`. + + Closes #${{ steps.tally.outputs.issue_number }} + + ### Verification trail + - Distinct verifiers: `${{ steps.tally.outputs.verifiers }}` + - Bypass: `${{ steps.tally.outputs.bypass_user }}` + + Generated automatically by `.github/workflows/model-verification.yml`. + branch: "chore/verify-${{ steps.tally.outputs.provider }}-${{ steps.tally.outputs.model }}" + base: dev + delete-branch: true + labels: | + chore + automated + model-verification + + - name: Comment outcome on issue + if: steps.tally.outputs.should_promote == 'true' + uses: actions/github-script@v7 + with: + script: | + const issueNumber = Number('${{ steps.tally.outputs.issue_number }}'); + const prNumber = '${{ steps.create-pr.outputs.pull-request-number }}'; + const verifiers = '${{ steps.tally.outputs.verifiers }}'; + const bypass = '${{ steps.tally.outputs.bypass_user }}'; + const changed = '${{ steps.promote.outputs.changed }}' === 'true'; + + const body = changed && prNumber + ? `✅ Verification threshold reached.\n\n- Verifiers: \`${verifiers}\`\n- Bypass: \`${bypass || '—'}\`\n\nOpened PR #${prNumber} promoting this model to \`Verified = true\`.` + : `ℹ️ Verification threshold reached, but the source already declared this model as \`Verified = true\`. Closing as resolved.`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body, + }); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: ['model-verified'], + }); diff --git a/CHANGELOG.md b/CHANGELOG.md index 159d0dc3..1caacbbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ Many thanks to the following contributors to this release: ---- +### Added + +- Community model verification flow: + - New issue template `.github/ISSUE_TEMPLATE/model-verification.yml` with tests grouped by location ("Components on the Grasshopper canvas" — `AITextGenerate`, `AITextListGenerate`, `AIImgToText`, `AIImgGenerate`, audio — and "Chat interface" — streaming, ToolChat/FunctionCalling, Reasoning, multi-turn `ConversationSession`), each test specifying the **exact prompt** to use and the expected behavior. The template also embeds a copy-paste codeblock (with a `/verify-confirm` header and a hidden `` marker) for additional verifiers to use as their certification comment. + - New workflow `.github/workflows/model-verification.yml` that triggers only when an issue comment starts with `/verify-confirm` (and contains the template marker) or `/verify-force`, tallies distinct GitHub users (issue author + valid `/verify-confirm` commenters), and opens a PR promoting the model to `Verified = true` once two distinct users have certified it. `/verify-force` is restricted to `OWNER`/`MEMBER`/`COLLABORATOR`. + - New helper `tools/Update-ModelVerified.ps1` that locates the matching `new AIModelCapabilities { Model = "..." }` block in `src/SmartHopper.Providers./ProviderModels.cs` and flips `Verified = false` to `Verified = true` (or inserts the flag when missing). + ### Changed - chore(rules): clarified Windsurf rules and workflows to reduce overlap, stale platform assumptions, and ambiguous SmartHopper architecture guidance. diff --git a/tools/Update-ModelVerified.ps1 b/tools/Update-ModelVerified.ps1 new file mode 100644 index 00000000..032491f7 --- /dev/null +++ b/tools/Update-ModelVerified.ps1 @@ -0,0 +1,123 @@ +<# +.SYNOPSIS + Promotes an AI model to Verified = true in the corresponding *ProviderModels.cs file. + +.DESCRIPTION + Used by the model-verification workflow once two users have certified a model. + Locates the `new AIModelCapabilities { ... Model = "" ... }` block inside + src/SmartHopper.Providers./ProviderModels.cs and ensures the + `Verified` flag is set to `true` (replacing `Verified = false`, or inserting the + line right after `Model = "..."` when missing). + + Exits with code 0 when the file was modified, 1 if no change was needed (already + verified), and 2 on error (model/provider not found). + +.PARAMETER Provider + Provider folder name, e.g. OpenAI, Anthropic, MistralAI, DeepSeek, OpenRouter. + +.PARAMETER Model + Exact model identifier, e.g. "mistral-medium-latest". + +.EXAMPLE + pwsh -File tools/Update-ModelVerified.ps1 -Provider MistralAI -Model mistral-medium-latest +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string] $Provider, + [Parameter(Mandatory = $true)][string] $Model +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +$providerDir = Join-Path $repoRoot "src/SmartHopper.Providers.$Provider" +$file = Join-Path $providerDir "${Provider}ProviderModels.cs" + +if (-not (Test-Path $file)) { + Write-Error "Provider models file not found: $file" + exit 2 +} + +$lines = Get-Content -LiteralPath $file +$modelPattern = '^\s*Model\s*=\s*"' + [regex]::Escape($Model) + '"\s*,\s*$' + +# Find the model declaration line +$modelLineIndex = -1 +for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match $modelPattern) { + $modelLineIndex = $i + break + } +} + +if ($modelLineIndex -lt 0) { + Write-Error "Model '$Model' not found in $file" + exit 2 +} + +# Walk back to the opening brace `{` of this object initializer +$blockStart = -1 +for ($i = $modelLineIndex; $i -ge 0; $i--) { + if ($lines[$i] -match '\bnew\s+AIModelCapabilities\b') { + $blockStart = $i + break + } +} +if ($blockStart -lt 0) { $blockStart = $modelLineIndex } + +# Walk forward to find the matching closing brace `}` using a brace counter +$depth = 0 +$started = $false +$blockEnd = -1 +for ($i = $blockStart; $i -lt $lines.Count; $i++) { + $line = $lines[$i] + foreach ($ch in $line.ToCharArray()) { + if ($ch -eq '{') { $depth++; $started = $true } + elseif ($ch -eq '}') { $depth-- } + } + if ($started -and $depth -le 0) { + $blockEnd = $i + break + } +} +if ($blockEnd -lt 0) { + Write-Error "Could not find end of AIModelCapabilities block for model '$Model'." + exit 2 +} + +# Inspect the block for Verified flag +$verifiedRegex = '^(?\s*)Verified\s*=\s*(?true|false)\s*,\s*$' +$verifiedIndex = -1 +$verifiedValue = $null +for ($i = $blockStart; $i -le $blockEnd; $i++) { + if ($lines[$i] -match $verifiedRegex) { + $verifiedIndex = $i + $verifiedValue = $Matches.val + break + } +} + +if ($verifiedIndex -ge 0 -and $verifiedValue -eq 'true') { + Write-Host "Model '$Provider/$Model' is already Verified = true." + exit 1 +} + +if ($verifiedIndex -ge 0) { + $lines[$verifiedIndex] = $lines[$verifiedIndex] -replace 'Verified\s*=\s*false', 'Verified = true' +} +else { + # Insert `Verified = true,` right after the Model line, using the same indentation + $indent = ($lines[$modelLineIndex] -replace '^(?\s*).*$', '${i}') + $newLine = "${indent}Verified = true," + $head = $lines[0..$modelLineIndex] + $tail = if ($modelLineIndex + 1 -le $lines.Count - 1) { $lines[($modelLineIndex + 1)..($lines.Count - 1)] } else { @() } + $lines = @($head + $newLine + $tail) +} + +# Preserve original line endings (CRLF for .cs files in this repo) +$content = ($lines -join "`r`n") +if (-not $content.EndsWith("`r`n")) { $content += "`r`n" } +[System.IO.File]::WriteAllText($file, $content) + +Write-Host "Promoted '$Provider/$Model' to Verified = true in $file" +exit 0 From b14c8cd721b9e16281b40faede9b51546b47cd62 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 12:30:18 +0200 Subject: [PATCH 056/110] ci: add concurrency controls to prevent workflow race conditions Added top-level concurrency groups to 25 workflows to prevent race conditions and optimize runner usage: - Auto-commit/auto-PR workflows (chore-*, pr-license-headers, dev-update-manifest, github-labels-sync, stabilization-0-init) grouped per ref with cancel-in-progress: false to queue updates without interrupting pushes - Entity-scoped workflows (model-verification, issue-labels-*, milestone-management, release-*) grouped per issue/milestone/release/PR number with cancel-in-progress: false to serialize operations --- .github/workflows/chore-update-contributors.yml | 4 ++++ .github/workflows/chore-version-badge.yml | 4 ++++ .github/workflows/chore-version-date.yml | 4 ++++ .github/workflows/chore-version-main-release.yml | 4 ++++ .github/workflows/ci-dotnet-tests.yml | 4 ++++ .github/workflows/dev-update-manifest.yml | 4 ++++ .github/workflows/github-issue-labels-close.yml | 4 ++++ .github/workflows/github-issue-labels-on-close.yml | 4 ++++ .github/workflows/github-labels-sync.yml | 4 ++++ .github/workflows/milestone-management.yml | 4 ++++ .github/workflows/model-verification.yml | 4 ++++ .github/workflows/pr-anonymize-public-key.yml | 4 ++++ .github/workflows/pr-block-dev-to-main.yml | 4 ++++ .github/workflows/pr-build-hash-validation.yml | 4 ++++ .github/workflows/pr-dependency-validation.yml | 4 ++++ .github/workflows/pr-license-headers.yml | 4 ++++ .github/workflows/pr-manifest-validation.yml | 4 ++++ .github/workflows/pr-milestone.yml | 4 ++++ .github/workflows/pr-validation.yml | 4 ++++ .github/workflows/pr-version-validation.yml | 4 ++++ .github/workflows/release-2-pr-to-dev-closed.yml | 4 ++++ .github/workflows/release-3-pr-to-main-closed.yml | 4 ++++ .github/workflows/release-4-build.yml | 4 ++++ .github/workflows/release-5-deploy-pages.yml | 4 ++++ .github/workflows/stabilization-0-init.yml | 4 ++++ CHANGELOG.md | 4 ++++ 26 files changed, 104 insertions(+) diff --git a/.github/workflows/chore-update-contributors.yml b/.github/workflows/chore-update-contributors.yml index 02ddb0bf..ab6c3c87 100644 --- a/.github/workflows/chore-update-contributors.yml +++ b/.github/workflows/chore-update-contributors.yml @@ -27,6 +27,10 @@ permissions: contents: write pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: update-contributors: runs-on: ubuntu-latest diff --git a/.github/workflows/chore-version-badge.yml b/.github/workflows/chore-version-badge.yml index 95f99a4c..dfb72406 100644 --- a/.github/workflows/chore-version-badge.yml +++ b/.github/workflows/chore-version-badge.yml @@ -24,6 +24,10 @@ permissions: contents: write pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: paths-check: runs-on: ubuntu-latest diff --git a/.github/workflows/chore-version-date.yml b/.github/workflows/chore-version-date.yml index a6f6f54f..e5f83a03 100644 --- a/.github/workflows/chore-version-date.yml +++ b/.github/workflows/chore-version-date.yml @@ -28,6 +28,10 @@ permissions: contents: write pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: update-date: runs-on: ubuntu-latest diff --git a/.github/workflows/chore-version-main-release.yml b/.github/workflows/chore-version-main-release.yml index 41dff5d7..cb60df0e 100644 --- a/.github/workflows/chore-version-main-release.yml +++ b/.github/workflows/chore-version-main-release.yml @@ -14,6 +14,10 @@ permissions: contents: write pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + jobs: remove-release-date: name: 🔄 Remove Release Version Date diff --git a/.github/workflows/ci-dotnet-tests.yml b/.github/workflows/ci-dotnet-tests.yml index afaee7a3..f2397239 100644 --- a/.github/workflows/ci-dotnet-tests.yml +++ b/.github/workflows/ci-dotnet-tests.yml @@ -32,6 +32,10 @@ permissions: contents: read pull-requests: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: # Job 1: Windows-only prep step - generates SNK, updates InternalsVisibleTo in csproj files # This ensures the public key is embedded in source files before cross-platform compilation diff --git a/.github/workflows/dev-update-manifest.yml b/.github/workflows/dev-update-manifest.yml index b98147b9..5d2e8b08 100644 --- a/.github/workflows/dev-update-manifest.yml +++ b/.github/workflows/dev-update-manifest.yml @@ -28,6 +28,10 @@ on: permissions: contents: write +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + jobs: update-manifest: name: 📋 Update Manifest Text for Dev Version diff --git a/.github/workflows/github-issue-labels-close.yml b/.github/workflows/github-issue-labels-close.yml index ac3f8557..5af38030 100644 --- a/.github/workflows/github-issue-labels-close.yml +++ b/.github/workflows/github-issue-labels-close.yml @@ -14,6 +14,10 @@ on: permissions: issues: write +concurrency: + group: issue-labels-${{ github.event.issue.number }} + cancel-in-progress: false + jobs: close-issue-on-label: runs-on: ubuntu-latest diff --git a/.github/workflows/github-issue-labels-on-close.yml b/.github/workflows/github-issue-labels-on-close.yml index 83d5ead5..71f972ba 100644 --- a/.github/workflows/github-issue-labels-on-close.yml +++ b/.github/workflows/github-issue-labels-on-close.yml @@ -18,6 +18,10 @@ on: permissions: issues: write +concurrency: + group: issue-labels-${{ github.event.issue.number }} + cancel-in-progress: false + jobs: update-issue-labels: runs-on: ubuntu-latest diff --git a/.github/workflows/github-labels-sync.yml b/.github/workflows/github-labels-sync.yml index 696df8eb..4364155a 100644 --- a/.github/workflows/github-labels-sync.yml +++ b/.github/workflows/github-labels-sync.yml @@ -23,6 +23,10 @@ on: permissions: issues: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/milestone-management.yml b/.github/workflows/milestone-management.yml index 02326b29..40ea4553 100644 --- a/.github/workflows/milestone-management.yml +++ b/.github/workflows/milestone-management.yml @@ -20,6 +20,10 @@ permissions: pull-requests: write contents: read +concurrency: + group: milestone-mgmt-${{ github.event.milestone.number || github.event.release.tag_name || github.run_id }} + cancel-in-progress: false + jobs: create-next-stage-milestone: if: github.event_name == 'release' diff --git a/.github/workflows/model-verification.yml b/.github/workflows/model-verification.yml index c8c62d40..4ee48319 100644 --- a/.github/workflows/model-verification.yml +++ b/.github/workflows/model-verification.yml @@ -39,6 +39,10 @@ permissions: pull-requests: write issues: write +concurrency: + group: model-verify-${{ github.event.issue.number || inputs.issue_number }} + cancel-in-progress: false + jobs: evaluate: if: >- diff --git a/.github/workflows/pr-anonymize-public-key.yml b/.github/workflows/pr-anonymize-public-key.yml index 9746459e..f0463718 100644 --- a/.github/workflows/pr-anonymize-public-key.yml +++ b/.github/workflows/pr-anonymize-public-key.yml @@ -27,6 +27,10 @@ permissions: contents: write pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: anonymize-key: name: 🔐 Anonymize Public Key diff --git a/.github/workflows/pr-block-dev-to-main.yml b/.github/workflows/pr-block-dev-to-main.yml index 4b53a474..bbf7fd9a 100644 --- a/.github/workflows/pr-block-dev-to-main.yml +++ b/.github/workflows/pr-block-dev-to-main.yml @@ -20,6 +20,10 @@ permissions: contents: read pull-requests: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: check-dev-release: name: 🚫 Block Dev Release diff --git a/.github/workflows/pr-build-hash-validation.yml b/.github/workflows/pr-build-hash-validation.yml index 7eda5e5c..d4c43e0d 100644 --- a/.github/workflows/pr-build-hash-validation.yml +++ b/.github/workflows/pr-build-hash-validation.yml @@ -17,6 +17,10 @@ permissions: contents: read pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: # Block manual edits to hash files - only GitHub Actions should generate these validate-no-manual-hash-edits: diff --git a/.github/workflows/pr-dependency-validation.yml b/.github/workflows/pr-dependency-validation.yml index ad29b23f..d84c122f 100644 --- a/.github/workflows/pr-dependency-validation.yml +++ b/.github/workflows/pr-dependency-validation.yml @@ -32,6 +32,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: validate-dependencies: name: 📦 Check GhJSON References are PackageReference diff --git a/.github/workflows/pr-license-headers.yml b/.github/workflows/pr-license-headers.yml index c110e69a..a191eba3 100644 --- a/.github/workflows/pr-license-headers.yml +++ b/.github/workflows/pr-license-headers.yml @@ -20,6 +20,10 @@ on: permissions: contents: write +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + jobs: update-license-headers: name: 📜 Update License Headers diff --git a/.github/workflows/pr-manifest-validation.yml b/.github/workflows/pr-manifest-validation.yml index f6229f02..dff07a5a 100644 --- a/.github/workflows/pr-manifest-validation.yml +++ b/.github/workflows/pr-manifest-validation.yml @@ -22,6 +22,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: validate-manifest: name: 📋 Validate Manifest Text Matches Version diff --git a/.github/workflows/pr-milestone.yml b/.github/workflows/pr-milestone.yml index f432f934..969b516a 100644 --- a/.github/workflows/pr-milestone.yml +++ b/.github/workflows/pr-milestone.yml @@ -13,6 +13,10 @@ permissions: pull-requests: write contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: assign-pr-to-milestone: runs-on: ubuntu-latest diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 30815809..07333156 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -25,6 +25,10 @@ permissions: contents: read pull-requests: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: version-check: name: 📦 Version Check diff --git a/.github/workflows/pr-version-validation.yml b/.github/workflows/pr-version-validation.yml index 0c3aec5f..9054b4f0 100644 --- a/.github/workflows/pr-version-validation.yml +++ b/.github/workflows/pr-version-validation.yml @@ -17,6 +17,10 @@ permissions: contents: read pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: validate-version: runs-on: ubuntu-latest diff --git a/.github/workflows/release-2-pr-to-dev-closed.yml b/.github/workflows/release-2-pr-to-dev-closed.yml index 28aadd23..757e1860 100644 --- a/.github/workflows/release-2-pr-to-dev-closed.yml +++ b/.github/workflows/release-2-pr-to-dev-closed.yml @@ -26,6 +26,10 @@ on: required: true type: string +concurrency: + group: release-pr-dev-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: false + jobs: create-pr-dev-to-main: if: ${{ ( github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/')) || github.event_name == 'workflow_dispatch' }} diff --git a/.github/workflows/release-3-pr-to-main-closed.yml b/.github/workflows/release-3-pr-to-main-closed.yml index 5760b1b5..a97b5bdc 100644 --- a/.github/workflows/release-3-pr-to-main-closed.yml +++ b/.github/workflows/release-3-pr-to-main-closed.yml @@ -21,6 +21,10 @@ on: - 'main-*' workflow_dispatch: +concurrency: + group: release-pr-main-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: false + jobs: create-release: if: ${{ github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' }} diff --git a/.github/workflows/release-4-build.yml b/.github/workflows/release-4-build.yml index 257b98f4..33dfce93 100644 --- a/.github/workflows/release-4-build.yml +++ b/.github/workflows/release-4-build.yml @@ -39,6 +39,10 @@ permissions: id-token: write pull-requests: write +concurrency: + group: release-build-${{ github.event.release.tag_name || inputs.version || github.run_id }} + cancel-in-progress: false + jobs: # Job 1: Windows-only prep step - generates SNK, updates InternalsVisibleTo in csproj files # This ensures the public key is embedded in source files before cross-platform compilation diff --git a/.github/workflows/release-5-deploy-pages.yml b/.github/workflows/release-5-deploy-pages.yml index 36dedca5..789f38c5 100644 --- a/.github/workflows/release-5-deploy-pages.yml +++ b/.github/workflows/release-5-deploy-pages.yml @@ -31,6 +31,10 @@ permissions: pages: write id-token: write +concurrency: + group: pages + cancel-in-progress: true + jobs: deploy-pages: # Run on push to main or manual trigger diff --git a/.github/workflows/stabilization-0-init.yml b/.github/workflows/stabilization-0-init.yml index e319ca0f..b592a305 100644 --- a/.github/workflows/stabilization-0-init.yml +++ b/.github/workflows/stabilization-0-init.yml @@ -17,6 +17,10 @@ on: permissions: contents: read +concurrency: + group: stabilization-init-${{ github.event.milestone.number }} + cancel-in-progress: false + jobs: init-stabilization-path: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 1caacbbc..88a9fee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ Many thanks to the following contributors to this release: - chore(rules): clarified Windsurf rules and workflows to reduce overlap, stale platform assumptions, and ambiguous SmartHopper architecture guidance. - ci(pr-build-hash-validation): the `validate-no-manual-hash-edits` job now only blocks a PR when a changed file under `hashes/` differs from its counterpart on `main` (the source of truth). PRs that carry a hash commit verbatim from main (e.g., via branch update/rebase) are allowed. +- ci(concurrency): added top-level `concurrency:` to 25 workflows to prevent race conditions and save runner minutes: + - Auto-commit / auto-PR workflows grouped per ref with `cancel-in-progress: false` (queue, never interrupt a push-back): `chore-version-date`, `chore-update-contributors`, `chore-version-badge`, `pr-anonymize-public-key`, `dev-update-manifest`, `github-labels-sync`, `chore-version-main-release`, `pr-license-headers`, `stabilization-0-init`. + - Entity-scoped workflows grouped per issue/milestone/release/PR with `cancel-in-progress: false`: `model-verification`, `github-issue-labels-on-close`, `github-issue-labels-close`, `milestone-management`, `release-4-build`, `release-2-pr-to-dev-closed`, `release-3-pr-to-main-closed`. `release-5-deploy-pages` uses the standard `pages` group with `cancel-in-progress: true`. + - PR validation workflows grouped per PR with `cancel-in-progress: true` so superseded pushes are cancelled: `ci-dotnet-tests`, `pr-validation`, `pr-build-hash-validation`, `pr-version-validation`, `pr-manifest-validation`, `pr-dependency-validation`, `pr-block-dev-to-main`, `pr-milestone`. ## [1.4.2-alpha] - 2026-03-14 From 52e4284e8479b383ea60455fded391590bfa8a58 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 12:31:31 +0200 Subject: [PATCH 057/110] ci: harden auto-commit workflows against concurrent push race conditions Added retry logic with pull --rebase to dev-update-manifest and pr-license-headers workflows (3 attempts with exponential backoff) to handle external commits landing between fetch and push. Added paths filter to chore-version-badge to only trigger on Solution.props changes, preventing redundant runs on every main/dev push. --- .github/workflows/chore-version-badge.yml | 4 ++++ .github/workflows/dev-update-manifest.yml | 9 ++++++++- .github/workflows/pr-license-headers.yml | 19 ++++++++++++++++++- CHANGELOG.md | 5 +++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/workflows/chore-version-badge.yml b/.github/workflows/chore-version-badge.yml index dfb72406..99fa4f60 100644 --- a/.github/workflows/chore-version-badge.yml +++ b/.github/workflows/chore-version-badge.yml @@ -19,6 +19,10 @@ on: - dev - 'hotfix/**' - 'release/**' + # Only run when the version source of truth changes. README.md is an output of this + # workflow; without this filter every PR merge to main/dev would re-trigger it. + paths: + - 'Solution.props' permissions: contents: write diff --git a/.github/workflows/dev-update-manifest.yml b/.github/workflows/dev-update-manifest.yml index 5d2e8b08..4233de80 100644 --- a/.github/workflows/dev-update-manifest.yml +++ b/.github/workflows/dev-update-manifest.yml @@ -74,4 +74,11 @@ jobs: git config --local user.name "github-actions[bot]" git add yak-package/manifest.yml git commit -m "chore: update manifest text to ${{ steps.update_manifest.outputs.release-type }} version [skip ci]" - git push + # Belt-and-braces against external commits landing between fetch and push. + # Retry pull --rebase + push up to 3 times in case of a near-simultaneous push. + for attempt in 1 2 3; do + git pull --rebase --autostash origin dev && \ + git push origin HEAD:dev && break + echo "Push attempt $attempt failed, retrying..." + sleep $((attempt * 2)) + done diff --git a/.github/workflows/pr-license-headers.yml b/.github/workflows/pr-license-headers.yml index a191eba3..55d037c2 100644 --- a/.github/workflows/pr-license-headers.yml +++ b/.github/workflows/pr-license-headers.yml @@ -61,5 +61,22 @@ jobs: } else { Write-Host "Committing license header updates..." git commit -m "chore(ci): update license headers" - git push origin HEAD:${{ github.head_ref }} + # Belt-and-braces against concurrent pushes to the PR branch (the + # contributor may push a new commit between checkout and push). + # Retry pull --rebase + push up to 3 times before giving up. + $ref = "${{ github.head_ref }}" + $pushed = $false + foreach ($attempt in 1..3) { + git pull --rebase --autostash origin $ref + if ($LASTEXITCODE -eq 0) { + git push origin "HEAD:$ref" + if ($LASTEXITCODE -eq 0) { $pushed = $true; break } + } + Write-Host "Push attempt $attempt failed, retrying..." + Start-Sleep -Seconds ($attempt * 2) + } + if (-not $pushed) { + Write-Error "Failed to push license header updates after 3 attempts." + exit 1 + } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a9fee9..84a45375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,11 @@ Many thanks to the following contributors to this release: - Auto-commit / auto-PR workflows grouped per ref with `cancel-in-progress: false` (queue, never interrupt a push-back): `chore-version-date`, `chore-update-contributors`, `chore-version-badge`, `pr-anonymize-public-key`, `dev-update-manifest`, `github-labels-sync`, `chore-version-main-release`, `pr-license-headers`, `stabilization-0-init`. - Entity-scoped workflows grouped per issue/milestone/release/PR with `cancel-in-progress: false`: `model-verification`, `github-issue-labels-on-close`, `github-issue-labels-close`, `milestone-management`, `release-4-build`, `release-2-pr-to-dev-closed`, `release-3-pr-to-main-closed`. `release-5-deploy-pages` uses the standard `pages` group with `cancel-in-progress: true`. - PR validation workflows grouped per PR with `cancel-in-progress: true` so superseded pushes are cancelled: `ci-dotnet-tests`, `pr-validation`, `pr-build-hash-validation`, `pr-version-validation`, `pr-manifest-validation`, `pr-dependency-validation`, `pr-block-dev-to-main`, `pr-milestone`. +- ci(auto-commit hardening): belt-and-braces against external commits landing between fetch and push. + - `dev-update-manifest` now does `git pull --rebase --autostash origin dev` with retry (×3) before pushing to `dev`. + - `pr-license-headers` now does `git pull --rebase --autostash` with retry (×3) before pushing back to the PR head branch (handles the contributor pushing a new commit mid-run). + - `chore-version-badge` gained a `paths: [Solution.props]` filter on its `push` trigger so it no longer runs on every unrelated push to `main`/`dev`; the version source of truth is the only relevant change. + - Auto-PR workflows that follow the delete-and-recreate pattern (`chore-update-contributors`, `pr-anonymize-public-key`) and those built on `peter-evans/create-pull-request` (`chore-version-date`, `chore-version-badge`, `chore-version-main-release`) already reuse existing PRs and were verified as safe; no changes needed. ## [1.4.2-alpha] - 2026-03-14 From fd716fc7e46c108866f9e6e9a64d907631bbcaf0 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 12:37:34 +0200 Subject: [PATCH 058/110] ci: add workflow to auto-sync main into dev branches New main-sync-to-dev workflow discovers all dev/dev-* branches and opens/reuses PRs from main into each target to keep promotional branches in sync with direct-to-main commits (workflows, hashes). Skips targets already up-to-date and reuses existing open PRs instead of creating duplicates. --- .github/workflows/main-sync-to-dev.yml | 116 +++++++++++++++++++++++++ CHANGELOG.md | 1 + 2 files changed, 117 insertions(+) create mode 100644 .github/workflows/main-sync-to-dev.yml diff --git a/.github/workflows/main-sync-to-dev.yml b/.github/workflows/main-sync-to-dev.yml new file mode 100644 index 00000000..06c39a96 --- /dev/null +++ b/.github/workflows/main-sync-to-dev.yml @@ -0,0 +1,116 @@ +name: 🔁 Sync main to dev* branches + +# Description: On changes to the `main` branch, automatically open a PR from +# `main` into every `dev-*` and the `dev` branch so promotional/long-lived branches stay in +# sync with CI/workflow/hash updates that land directly on `main`. +# +# Behavior: +# - Discovers all branches named `dev` or matching `dev-*`. +# - For each target, reuses an existing open PR (head=main, base=) if +# one exists; otherwise creates a new one. +# - Skips targets that are already up-to-date with `main` (no diff). + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + discover: + runs-on: ubuntu-latest + outputs: + targets: ${{ steps.discover.outputs.targets }} + count: ${{ steps.discover.outputs.count }} + steps: + - name: Discover dev* branches + id: discover + uses: actions/github-script@v7 + with: + script: | + const branches = await github.paginate(github.rest.repos.listBranches, { + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + }); + const targets = branches + .map(b => b.name) + .filter(n => n === 'dev' || n.startsWith('dev-')); + core.setOutput('targets', JSON.stringify(targets)); + core.setOutput('count', targets.length.toString()); + console.log(`Discovered ${targets.length} dev* branch(es): ${JSON.stringify(targets)}`); + + sync: + needs: discover + if: needs.discover.outputs.count != '0' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: ${{ fromJson(needs.discover.outputs.targets) }} + concurrency: + group: "main-sync-${{ matrix.target }}" + cancel-in-progress: false + steps: + - name: Ensure or reuse PR main → ${{ matrix.target }} + uses: actions/github-script@v7 + with: + script: | + const base = '${{ matrix.target }}'; + const head = 'main'; + const { owner, repo } = context.repo; + + // Skip if branches are identical / already merged. + const cmp = await github.rest.repos.compareCommitsWithBasehead({ + owner, repo, + basehead: `${base}...${head}`, + }); + console.log(`Compare ${base}...${head}: status=${cmp.data.status}, ahead_by=${cmp.data.ahead_by}`); + if (cmp.data.ahead_by === 0) { + core.info(`No commits from ${head} to merge into ${base}; skipping.`); + return; + } + + // Look for an existing open PR to reuse. + const existing = await github.rest.pulls.list({ + owner, repo, + state: 'open', + head: `${owner}:${head}`, + base, + per_page: 1, + }); + if (existing.data.length > 0) { + const pr = existing.data[0]; + core.info(`Reusing existing open PR #${pr.number}: ${pr.html_url}`); + core.summary.addRaw(`- ♻️ Reused PR [#${pr.number}](${pr.html_url}) → \`${base}\`\n`); + await core.summary.write(); + return; + } + + // Otherwise create a new PR. + const title = `chore: sync main → ${base}`; + const body = [ + `Automated sync of direct commits to \`main\` (e.g., workflow or hash updates) into \`${base}\`.`, + '', + `- Source: \`${head}\``, + `- Target: \`${base}\``, + `- Commits ahead: ${cmp.data.ahead_by}`, + '', + 'This PR is kept open and reused on subsequent `main` updates.', + ].join('\n'); + + try { + const created = await github.rest.pulls.create({ + owner, repo, head, base, title, body, + maintainer_can_modify: true, + }); + core.info(`Created PR #${created.data.number}: ${created.data.html_url}`); + core.summary.addRaw(`- ✅ Created PR [#${created.data.number}](${created.data.html_url}) → \`${base}\`\n`); + await core.summary.write(); + } catch (err) { + core.setFailed(`Failed to create PR main → ${base}: ${err.message}`); + } diff --git a/CHANGELOG.md b/CHANGELOG.md index 84a45375..55fadc19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Many thanks to the following contributors to this release: ### Added +- ci(main-sync-to-dev): new workflow `.github/workflows/main-sync-to-dev.yml` that, on pushes to `main` (or manual dispatch), auto-opens a PR from `main` into every `dev` / `dev-*` branch to keep promotional branches in sync with direct-to-main commits (workflows, hashes). Reuses an existing open PR (head=`main`, base=`dev*`) per target when present instead of creating duplicates, and skips targets already up-to-date. - Community model verification flow: - New issue template `.github/ISSUE_TEMPLATE/model-verification.yml` with tests grouped by location ("Components on the Grasshopper canvas" — `AITextGenerate`, `AITextListGenerate`, `AIImgToText`, `AIImgGenerate`, audio — and "Chat interface" — streaming, ToolChat/FunctionCalling, Reasoning, multi-turn `ConversationSession`), each test specifying the **exact prompt** to use and the expected behavior. The template also embeds a copy-paste codeblock (with a `/verify-confirm` header and a hidden `` marker) for additional verifiers to use as their certification comment. - New workflow `.github/workflows/model-verification.yml` that triggers only when an issue comment starts with `/verify-confirm` (and contains the template marker) or `/verify-force`, tallies distinct GitHub users (issue author + valid `/verify-confirm` commenters), and opens a PR promoting the model to `Verified = true` once two distinct users have certified it. `/verify-force` is restricted to `OWNER`/`MEMBER`/`COLLABORATOR`. From 5f76f03e3dbaedc8750cf3b035d8a82fc14e0bbd Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 12:54:26 +0200 Subject: [PATCH 059/110] ci: restrict main-sync-to-dev to infra-only diffs for stabilization branches Modified main-sync-to-dev workflow to always sync main into dev, but for dev-* stabilization branches only allow diffs touching .github/, .windsurf/, .githooks/, hashes/, or modifications to existing src/SmartHopper.Providers.*/*ProviderModels.cs files. Skips dev-* targets when diff contains files outside allow-list (new features, additions/renames/removals of ProviderModels.cs) with warning to use patch-propagate instead --- .github/workflows/main-sync-to-dev.yml | 89 +++++++++++++++++++++----- CHANGELOG.md | 2 +- 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/.github/workflows/main-sync-to-dev.yml b/.github/workflows/main-sync-to-dev.yml index 06c39a96..7512dc60 100644 --- a/.github/workflows/main-sync-to-dev.yml +++ b/.github/workflows/main-sync-to-dev.yml @@ -1,14 +1,31 @@ -name: 🔁 Sync main to dev* branches +name: 🔁 Sync main to dev branches -# Description: On changes to the `main` branch, automatically open a PR from -# `main` into every `dev-*` and the `dev` branch so promotional/long-lived branches stay in -# sync with CI/workflow/hash updates that land directly on `main`. +# Description: On changes to the `main` branch, automatically open (or reuse) +# a PR from `main` into the `dev` branch and, when the diff only touches +# infrastructure paths, also into every `dev-*` stabilization branch. # -# Behavior: -# - Discovers all branches named `dev` or matching `dev-*`. -# - For each target, reuses an existing open PR (head=main, base=) if -# one exists; otherwise creates a new one. -# - Skips targets that are already up-to-date with `main` (no diff). +# Rationale: +# - `dev`: always kept in sync with `main` (direct commits to `main` are +# workflow/hash tweaks that should flow into ongoing development). +# - `dev-*` (stabilization branches): frozen release lines. They must NOT +# receive new features, but they SHOULD receive: +# * any changes under `.github/`, `.windsurf/`, `.githooks/`, `hashes/` +# (CI, rules, git hooks, provider hashes — kept consistent across +# release lines); +# * *modifications* (never additions/renames/removals) to existing +# `src/SmartHopper.Providers.*/*ProviderModels.cs` files, so model +# verification flips, deprecations, and provider-side model list +# updates propagate into frozen lines. +# If the diff contains any file outside that allow-list, or a new/renamed/ +# removed `*ProviderModels.cs` file, the PR to that `dev-*` is skipped +# (use `patch-propagate.yml` for targeted backports instead). +# +# Behavior per target: +# - Compares `...main`; skips entirely if no effective file diff. +# - For `dev-*`, additionally requires all changed files to be within the +# allow-listed infra paths. +# - Reuses an existing open PR (head=main, base=) when present. +# - Otherwise creates a new PR. on: push: @@ -27,7 +44,7 @@ jobs: targets: ${{ steps.discover.outputs.targets }} count: ${{ steps.discover.outputs.count }} steps: - - name: Discover dev* branches + - name: Discover dev and dev-* branches id: discover uses: actions/github-script@v7 with: @@ -42,7 +59,7 @@ jobs: .filter(n => n === 'dev' || n.startsWith('dev-')); core.setOutput('targets', JSON.stringify(targets)); core.setOutput('count', targets.length.toString()); - console.log(`Discovered ${targets.length} dev* branch(es): ${JSON.stringify(targets)}`); + console.log(`Discovered ${targets.length} target branch(es): ${JSON.stringify(targets)}`); sync: needs: discover @@ -64,17 +81,55 @@ jobs: const head = 'main'; const { owner, repo } = context.repo; - // Skip if branches are identical / already merged. + // For `dev-*` (stabilization) branches, restrict to infra-only paths + // plus modifications to existing provider model registries. + const isStabilization = base !== 'dev' && base.startsWith('dev-'); + const ALLOWED_PREFIXES = ['.github/', '.windsurf/', '.githooks/', 'hashes/']; + // Matches: src/SmartHopper.Providers./ProviderModels.cs + const PROVIDER_MODELS_RE = /^src\/SmartHopper\.Providers\.[^/]+\/[^/]*ProviderModels\.cs$/; + const isAllowedStabilizationFile = (file) => { + const p = file.filename; + if (ALLOWED_PREFIXES.some(prefix => p.startsWith(prefix))) return true; + if (PROVIDER_MODELS_RE.test(p) && file.status === 'modified') return true; + return false; + }; + + // Paginate full file list across the compare. + const files = await github.paginate( + github.rest.repos.compareCommitsWithBasehead, + { owner, repo, basehead: `${base}...${head}`, per_page: 100 }, + (response) => response.data.files || [] + ); const cmp = await github.rest.repos.compareCommitsWithBasehead({ - owner, repo, - basehead: `${base}...${head}`, + owner, repo, basehead: `${base}...${head}`, }); - console.log(`Compare ${base}...${head}: status=${cmp.data.status}, ahead_by=${cmp.data.ahead_by}`); - if (cmp.data.ahead_by === 0) { - core.info(`No commits from ${head} to merge into ${base}; skipping.`); + console.log( + `Compare ${base}...${head}: status=${cmp.data.status}, ` + + `ahead_by=${cmp.data.ahead_by}, behind_by=${cmp.data.behind_by}, files=${files.length}` + ); + if (cmp.data.ahead_by === 0 || files.length === 0) { + core.info(`No effective diff between \`${head}\` and \`${base}\`; skipping.`); return; } + if (isStabilization) { + const outside = files + .filter(f => !isAllowedStabilizationFile(f)) + .map(f => `${f.filename} [${f.status}]`); + if (outside.length > 0) { + core.warning( + `Skipping \`${base}\`: diff contains paths that must not flow into ` + + `stabilization branches (allow-list: ${ALLOWED_PREFIXES.join(', ')} and ` + + `*modifications* to existing src/SmartHopper.Providers.*/*ProviderModels.cs). ` + + `Use \`patch-propagate.yml\` for targeted backports.\n` + + `Offending files (first 10): ${outside.slice(0, 10).join(', ')}` + + (outside.length > 10 ? ` … (+${outside.length - 10} more)` : '') + ); + return; + } + core.info(`All ${files.length} changed file(s) are within the stabilization allow-list for \`${base}\`.`); + } + // Look for an existing open PR to reuse. const existing = await github.rest.pulls.list({ owner, repo, diff --git a/CHANGELOG.md b/CHANGELOG.md index 55fadc19..1ee374aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Many thanks to the following contributors to this release: ### Added -- ci(main-sync-to-dev): new workflow `.github/workflows/main-sync-to-dev.yml` that, on pushes to `main` (or manual dispatch), auto-opens a PR from `main` into every `dev` / `dev-*` branch to keep promotional branches in sync with direct-to-main commits (workflows, hashes). Reuses an existing open PR (head=`main`, base=`dev*`) per target when present instead of creating duplicates, and skips targets already up-to-date. +- ci(main-sync-to-dev): new workflow `.github/workflows/main-sync-to-dev.yml` that, on pushes to `main` (or manual dispatch), auto-opens/reuses a PR from `main` into `dev` and into every `dev-*` stabilization branch. For `dev-*` targets the diff is allow-listed to: any change under `.github/`, `.windsurf/`, `.githooks/`, `hashes/`, plus *modifications* (not additions/renames/removals) to existing `src/SmartHopper.Providers.*/*ProviderModels.cs` files so model verification, deprecations, and provider model-list updates propagate to frozen lines. If any file outside the allow-list lands on `main`, the sync to that `dev-*` is skipped with a warning (use `patch-propagate.yml` for targeted backports). Reuses an existing open PR per target instead of creating duplicates, and skips entirely when there is no effective file diff. - Community model verification flow: - New issue template `.github/ISSUE_TEMPLATE/model-verification.yml` with tests grouped by location ("Components on the Grasshopper canvas" — `AITextGenerate`, `AITextListGenerate`, `AIImgToText`, `AIImgGenerate`, audio — and "Chat interface" — streaming, ToolChat/FunctionCalling, Reasoning, multi-turn `ConversationSession`), each test specifying the **exact prompt** to use and the expected behavior. The template also embeds a copy-paste codeblock (with a `/verify-confirm` header and a hidden `` marker) for additional verifiers to use as their certification comment. - New workflow `.github/workflows/model-verification.yml` that triggers only when an issue comment starts with `/verify-confirm` (and contains the template marker) or `/verify-force`, tallies distinct GitHub users (issue author + valid `/verify-confirm` commenters), and opens a PR promoting the model to `Verified = true` once two distinct users have certified it. `/verify-force` is restricted to `OWNER`/`MEMBER`/`COLLABORATOR`. From c17ba8e9989247fe8b3a2fad899547b6a039f4bd Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 13:03:38 +0200 Subject: [PATCH 060/110] ci: refactor main-sync-to-dev to cherry-pick allow-listed files onto sync branches for stabilization targets Replaced diff-based allow-list filtering with explicit cherry-pick approach for dev-* branches. Workflow now creates/maintains sync/main-to- branches that mirror only allow-listed files from main (any change under .github/, .windsurf/, .githooks/, hashes/, plus modifications to existing *ProviderModels.cs), then opens PRs from those sync branches into dev-*. Non-allow-listed files (features, docs, CHANGELOG) stay on main only. dev branch --- .github/workflows/main-sync-to-dev.yml | 251 +++++++++++++++++++------ CHANGELOG.md | 2 +- 2 files changed, 196 insertions(+), 57 deletions(-) diff --git a/.github/workflows/main-sync-to-dev.yml b/.github/workflows/main-sync-to-dev.yml index 7512dc60..ecfd2124 100644 --- a/.github/workflows/main-sync-to-dev.yml +++ b/.github/workflows/main-sync-to-dev.yml @@ -1,31 +1,28 @@ name: 🔁 Sync main to dev branches # Description: On changes to the `main` branch, automatically open (or reuse) -# a PR from `main` into the `dev` branch and, when the diff only touches -# infrastructure paths, also into every `dev-*` stabilization branch. +# a PR from `main` into the `dev` branch and, for every `dev-*` stabilization +# branch, a PR with only the allow-listed files cherry-picked from `main`. # # Rationale: -# - `dev`: always kept in sync with `main` (direct commits to `main` are -# workflow/hash tweaks that should flow into ongoing development). -# - `dev-*` (stabilization branches): frozen release lines. They must NOT -# receive new features, but they SHOULD receive: -# * any changes under `.github/`, `.windsurf/`, `.githooks/`, `hashes/` -# (CI, rules, git hooks, provider hashes — kept consistent across -# release lines); -# * *modifications* (never additions/renames/removals) to existing +# - `dev`: always kept in sync with `main` — direct PR from `main`. +# - `dev-*` (stabilization branches): frozen release lines. Must NOT receive +# new features. The workflow creates/maintains a sync branch +# `sync/main-to-` that mirrors only the allow-listed files from +# `main` onto the stabilization branch, and opens a PR from that sync +# branch into ``. Non-allow-listed files (e.g., feature source, +# docs, CHANGELOG) stay on `main` and are never propagated. +# +# Allow-list for `dev-*`: +# * any change (add/modify/rename/delete) under `.github/`, `.windsurf/`, +# `.githooks/`, or `hashes/` (CI, rules, git hooks, provider hashes); +# * *modifications* (not add/rename/delete) to existing # `src/SmartHopper.Providers.*/*ProviderModels.cs` files, so model -# verification flips, deprecations, and provider-side model list -# updates propagate into frozen lines. -# If the diff contains any file outside that allow-list, or a new/renamed/ -# removed `*ProviderModels.cs` file, the PR to that `dev-*` is skipped -# (use `patch-propagate.yml` for targeted backports instead). +# verification flips, deprecations, and model-list updates propagate. # # Behavior per target: -# - Compares `...main`; skips entirely if no effective file diff. -# - For `dev-*`, additionally requires all changed files to be within the -# allow-listed infra paths. -# - Reuses an existing open PR (head=main, base=) when present. -# - Otherwise creates a new PR. +# - Skips if there is no effective diff (or, for `dev-*`, no allow-listed diff). +# - Reuses an existing open PR when present; otherwise creates a new one. on: push: @@ -34,7 +31,7 @@ on: workflow_dispatch: permissions: - contents: read + contents: write pull-requests: write jobs: @@ -73,7 +70,8 @@ jobs: group: "main-sync-${{ matrix.target }}" cancel-in-progress: false steps: - - name: Ensure or reuse PR main → ${{ matrix.target }} + - name: Compute sync plan for ${{ matrix.target }} + id: plan uses: actions/github-script@v7 with: script: | @@ -107,36 +105,63 @@ jobs: `Compare ${base}...${head}: status=${cmp.data.status}, ` + `ahead_by=${cmp.data.ahead_by}, behind_by=${cmp.data.behind_by}, files=${files.length}` ); + if (cmp.data.ahead_by === 0 || files.length === 0) { core.info(`No effective diff between \`${head}\` and \`${base}\`; skipping.`); + core.setOutput('mode', 'skip'); + return; + } + + if (!isStabilization) { + core.setOutput('mode', 'direct-pr'); + core.setOutput('ahead-by', String(cmp.data.ahead_by)); return; } - if (isStabilization) { - const outside = files - .filter(f => !isAllowedStabilizationFile(f)) - .map(f => `${f.filename} [${f.status}]`); - if (outside.length > 0) { - core.warning( - `Skipping \`${base}\`: diff contains paths that must not flow into ` + - `stabilization branches (allow-list: ${ALLOWED_PREFIXES.join(', ')} and ` + - `*modifications* to existing src/SmartHopper.Providers.*/*ProviderModels.cs). ` + - `Use \`patch-propagate.yml\` for targeted backports.\n` + - `Offending files (first 10): ${outside.slice(0, 10).join(', ')}` + - (outside.length > 10 ? ` … (+${outside.length - 10} more)` : '') - ); - return; - } - core.info(`All ${files.length} changed file(s) are within the stabilization allow-list for \`${base}\`.`); + // Stabilization: filter to allowed files; cherry-pick just those. + const allowed = files.filter(isAllowedStabilizationFile); + const skipped = files.filter(f => !isAllowedStabilizationFile(f)); + if (allowed.length === 0) { + core.info(`No allow-listed files for \`${base}\`; skipping.`); + core.setOutput('mode', 'skip'); + return; + } + if (skipped.length > 0) { + core.notice( + `\`${base}\`: cherry-picking ${allowed.length} allow-listed file(s); ` + + `leaving ${skipped.length} non-allow-listed file(s) on \`main\` only. ` + + `Use \`patch-propagate.yml\` for targeted backports of the rest.` + ); + console.log( + 'Non-allow-listed (kept on main only): ' + + skipped.map(f => `${f.filename} [${f.status}]`).slice(0, 50).join(', ') + ); } - // Look for an existing open PR to reuse. + // Emit compact plan for the shell step. + const plan = allowed.map(f => ({ + status: f.status, + filename: f.filename, + previous_filename: f.previous_filename || null, + })); + core.setOutput('mode', 'cherry-pick'); + core.setOutput('plan', JSON.stringify(plan)); + core.setOutput('allowed-count', String(allowed.length)); + core.setOutput('skipped-count', String(skipped.length)); + + # --- dev: direct main → dev PR --------------------------------------- + - name: Ensure or reuse PR main → ${{ matrix.target }} + if: steps.plan.outputs.mode == 'direct-pr' + uses: actions/github-script@v7 + with: + script: | + const base = '${{ matrix.target }}'; + const head = 'main'; + const { owner, repo } = context.repo; + const existing = await github.rest.pulls.list({ - owner, repo, - state: 'open', - head: `${owner}:${head}`, - base, - per_page: 1, + owner, repo, state: 'open', + head: `${owner}:${head}`, base, per_page: 1, }); if (existing.data.length > 0) { const pr = existing.data[0]; @@ -146,26 +171,140 @@ jobs: return; } - // Otherwise create a new PR. - const title = `chore: sync main → ${base}`; const body = [ `Automated sync of direct commits to \`main\` (e.g., workflow or hash updates) into \`${base}\`.`, '', `- Source: \`${head}\``, `- Target: \`${base}\``, - `- Commits ahead: ${cmp.data.ahead_by}`, + `- Commits ahead: ${{ steps.plan.outputs.ahead-by }}`, '', 'This PR is kept open and reused on subsequent `main` updates.', ].join('\n'); + const created = await github.rest.pulls.create({ + owner, repo, head, base, + title: `chore: sync main → ${base}`, + body, maintainer_can_modify: true, + }); + core.info(`Created PR #${created.data.number}: ${created.data.html_url}`); + core.summary.addRaw(`- ✅ Created PR [#${created.data.number}](${created.data.html_url}) → \`${base}\`\n`); + await core.summary.write(); + + # --- dev-*: cherry-pick allow-listed files to a sync branch ---------- + - name: Checkout repository + if: steps.plan.outputs.mode == 'cherry-pick' + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Apply allow-listed files from main onto sync branch + if: steps.plan.outputs.mode == 'cherry-pick' + id: apply + env: + TARGET: ${{ matrix.target }} + PLAN_JSON: ${{ steps.plan.outputs.plan }} + shell: bash + run: | + set -euo pipefail + + SYNC_BRANCH="sync/main-to-${TARGET}" + echo "sync-branch=${SYNC_BRANCH}" >> "$GITHUB_OUTPUT" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git fetch origin "${TARGET}" main --no-tags - try { - const created = await github.rest.pulls.create({ - owner, repo, head, base, title, body, - maintainer_can_modify: true, - }); - core.info(`Created PR #${created.data.number}: ${created.data.html_url}`); - core.summary.addRaw(`- ✅ Created PR [#${created.data.number}](${created.data.html_url}) → \`${base}\`\n`); + # Start (or reset) sync branch from the target tip. + git checkout -B "${SYNC_BRANCH}" "origin/${TARGET}" + + # Apply each allow-listed file from origin/main. + mapfile -t LINES < <(echo "${PLAN_JSON}" | jq -c '.[]') + for entry in "${LINES[@]}"; do + status=$(echo "$entry" | jq -r '.status') + filename=$(echo "$entry" | jq -r '.filename') + previous=$(echo "$entry" | jq -r '.previous_filename // empty') + echo "::group::[$status] $filename${previous:+ (was: $previous)}" + case "$status" in + added|modified|changed|copied) + mkdir -p "$(dirname -- "$filename")" + git checkout origin/main -- "$filename" + git add -- "$filename" + ;; + renamed) + if [ -n "$previous" ] && git ls-files --error-unmatch -- "$previous" >/dev/null 2>&1; then + git rm -f -- "$previous" + fi + mkdir -p "$(dirname -- "$filename")" + git checkout origin/main -- "$filename" + git add -- "$filename" + ;; + removed) + if git ls-files --error-unmatch -- "$filename" >/dev/null 2>&1; then + git rm -f -- "$filename" + else + echo "File already absent on ${TARGET}; skipping delete." + fi + ;; + *) + echo "::warning::Unhandled file status '$status' for '$filename'; skipping." + ;; + esac + echo "::endgroup::" + done + + if git diff --cached --quiet; then + echo "No effective changes after applying allow-list (${TARGET} already in sync)." + echo "has-changes=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git commit -m "chore: sync allow-listed files from main → ${TARGET}" + git push --force-with-lease origin "${SYNC_BRANCH}" + echo "has-changes=true" >> "$GITHUB_OUTPUT" + + - name: Ensure or reuse PR ${{ steps.apply.outputs.sync-branch }} → ${{ matrix.target }} + if: steps.plan.outputs.mode == 'cherry-pick' && steps.apply.outputs.has-changes == 'true' + uses: actions/github-script@v7 + env: + SYNC_BRANCH: ${{ steps.apply.outputs.sync-branch }} + ALLOWED_COUNT: ${{ steps.plan.outputs.allowed-count }} + SKIPPED_COUNT: ${{ steps.plan.outputs.skipped-count }} + with: + script: | + const base = '${{ matrix.target }}'; + const head = process.env.SYNC_BRANCH; + const { owner, repo } = context.repo; + + const existing = await github.rest.pulls.list({ + owner, repo, state: 'open', + head: `${owner}:${head}`, base, per_page: 1, + }); + if (existing.data.length > 0) { + const pr = existing.data[0]; + core.info(`Reusing existing open PR #${pr.number}: ${pr.html_url}`); + core.summary.addRaw(`- ♻️ Reused PR [#${pr.number}](${pr.html_url}) → \`${base}\` (${process.env.ALLOWED_COUNT} file(s), ${process.env.SKIPPED_COUNT} skipped)\n`); await core.summary.write(); - } catch (err) { - core.setFailed(`Failed to create PR main → ${base}: ${err.message}`); + return; } + + const body = [ + `Automated sync of **allow-listed** files from \`main\` into \`${base}\`.`, + '', + `- Source: \`main\` (via \`${head}\`)`, + `- Target: \`${base}\``, + `- Allow-listed files applied: ${process.env.ALLOWED_COUNT}`, + `- Non-allow-listed files left on \`main\`: ${process.env.SKIPPED_COUNT}`, + '', + 'Allow-list for stabilization branches: any change under `.github/`, `.windsurf/`, `.githooks/`, `hashes/`, plus *modifications* to existing `src/SmartHopper.Providers.*/*ProviderModels.cs` files.', + '', + 'This PR is kept open and its branch is refreshed on subsequent `main` updates.', + ].join('\n'); + const created = await github.rest.pulls.create({ + owner, repo, head, base, + title: `chore: sync main → ${base} (infra + provider models)`, + body, maintainer_can_modify: true, + }); + core.info(`Created PR #${created.data.number}: ${created.data.html_url}`); + core.summary.addRaw(`- ✅ Created PR [#${created.data.number}](${created.data.html_url}) → \`${base}\` (${process.env.ALLOWED_COUNT} file(s), ${process.env.SKIPPED_COUNT} skipped)\n`); + await core.summary.write(); diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee374aa..ae145fab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Many thanks to the following contributors to this release: ### Added -- ci(main-sync-to-dev): new workflow `.github/workflows/main-sync-to-dev.yml` that, on pushes to `main` (or manual dispatch), auto-opens/reuses a PR from `main` into `dev` and into every `dev-*` stabilization branch. For `dev-*` targets the diff is allow-listed to: any change under `.github/`, `.windsurf/`, `.githooks/`, `hashes/`, plus *modifications* (not additions/renames/removals) to existing `src/SmartHopper.Providers.*/*ProviderModels.cs` files so model verification, deprecations, and provider model-list updates propagate to frozen lines. If any file outside the allow-list lands on `main`, the sync to that `dev-*` is skipped with a warning (use `patch-propagate.yml` for targeted backports). Reuses an existing open PR per target instead of creating duplicates, and skips entirely when there is no effective file diff. +- ci(main-sync-to-dev): new workflow `.github/workflows/main-sync-to-dev.yml` that, on pushes to `main` (or manual dispatch), auto-opens/reuses a PR from `main` into `dev` and into every `dev-*` stabilization branch. For `dev` the PR is a plain `main → dev`. For each `dev-*`, the workflow maintains a `sync/main-to-` branch onto which it **cherry-picks only allow-listed files** from `main` (any change under `.github/`, `.windsurf/`, `.githooks/`, `hashes/`, plus *modifications* — not add/rename/delete — to existing `src/SmartHopper.Providers.*/*ProviderModels.cs`). Non-allow-listed files (feature source, docs, `CHANGELOG.md`, etc.) stay on `main` only, so a mixed commit on `main` still propagates its infra/model parts to stabilization lines; use `patch-propagate.yml` for targeted backports of the rest. Reuses an existing open PR per target instead of creating duplicates, and skips entirely when there is no effective allow-listed diff. - Community model verification flow: - New issue template `.github/ISSUE_TEMPLATE/model-verification.yml` with tests grouped by location ("Components on the Grasshopper canvas" — `AITextGenerate`, `AITextListGenerate`, `AIImgToText`, `AIImgGenerate`, audio — and "Chat interface" — streaming, ToolChat/FunctionCalling, Reasoning, multi-turn `ConversationSession`), each test specifying the **exact prompt** to use and the expected behavior. The template also embeds a copy-paste codeblock (with a `/verify-confirm` header and a hidden `` marker) for additional verifiers to use as their certification comment. - New workflow `.github/workflows/model-verification.yml` that triggers only when an issue comment starts with `/verify-confirm` (and contains the template marker) or `/verify-force`, tallies distinct GitHub users (issue author + valid `/verify-confirm` commenters), and opens a PR promoting the model to `Verified = true` once two distinct users have certified it. `/verify-force` is restricted to `OWNER`/`MEMBER`/`COLLABORATOR`. From a4e1e31cf0e90accc63cc4917b23ecb681670055 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 16:34:04 +0200 Subject: [PATCH 061/110] ci: add automated provider model discovery via OpenRouter API Introduced scheduled workflow (Sundays 05:00 UTC) and composite action that query OpenRouter's /models endpoint as single source of truth for all providers. New Update-ProviderModels.ps1 tool filters by provider prefix, maps architecture.modalities and supported_parameters to AICapability flags, auto-inserts new models with full capabilities/ContextLimit/Rank, marks disappeared or expiring (< 1 year) models as Deprecated=true, and refresh --- .github/actions/ai/fetch-models/action.yml | 92 ++ .../chore-update-provider-models.yml | 127 +++ CHANGELOG.md | 5 + tools/Update-ProviderModels.ps1 | 832 ++++++++++++++++++ 4 files changed, 1056 insertions(+) create mode 100644 .github/actions/ai/fetch-models/action.yml create mode 100644 .github/workflows/chore-update-provider-models.yml create mode 100644 tools/Update-ProviderModels.ps1 diff --git a/.github/actions/ai/fetch-models/action.yml b/.github/actions/ai/fetch-models/action.yml new file mode 100644 index 00000000..635a3fce --- /dev/null +++ b/.github/actions/ai/fetch-models/action.yml @@ -0,0 +1,92 @@ +# Reusable action that delegates to tools/Update-ProviderModels.ps1. +# OpenRouter is used as the single source of truth; the script queries +# OpenRouter's /models endpoint, filters by provider prefix, and updates +# the *ProviderModels.cs file with full capabilities mapped from metadata. +# +# Usage: +# - uses: ./.github/actions/ai/fetch-models +# with: +# provider: OpenAI +# api-key: ${{ secrets.OPENROUTER_API_KEY }} +# update-file: true + +name: 'Fetch AI Provider Models' +description: 'Fetch model metadata from OpenRouter and update *ProviderModels.cs via the centralized Update-ProviderModels.ps1 tool' + +inputs: + provider: + description: 'Provider identifier (e.g. OpenAI, MistralAI, Anthropic, OpenRouter, DeepSeek)' + required: true + api-key: + description: 'OpenRouter API key (used as the single source of truth for all providers)' + required: true + update-file: + description: 'When true, the script rewrites the source file: auto-inserts new models with capabilities mapped from OpenRouter metadata, updates existing model capabilities/ContextLimit, and marks disappeared or expiring models as Deprecated = true' + required: false + default: 'false' + +outputs: + models: + description: 'JSON array of model ID strings returned by the API' + value: ${{ steps.run.outputs.models }} + count: + description: 'Number of distinct models returned' + value: ${{ steps.run.outputs.count }} + success: + description: 'Whether the operation succeeded' + value: ${{ steps.run.outputs.success }} + error: + description: 'Error message if the call failed' + value: ${{ steps.run.outputs.error }} + report: + description: 'Full JSON report emitted by Update-ProviderModels.ps1' + value: ${{ steps.run.outputs.report }} + changed: + description: 'Whether the source file was modified' + value: ${{ steps.run.outputs.changed }} + +runs: + using: 'composite' + steps: + - name: Run Update-ProviderModels.ps1 + id: run + shell: pwsh + env: + API_KEY: ${{ inputs.api-key }} + run: | + $params = @{ + Provider = '${{ inputs.provider }}' + ApiKey = $env:API_KEY + } + if ('${{ inputs.update-file }}' -eq 'true') { + $params['UpdateFile'] = $true + } + + try { + $reportJson = & .\tools\Update-ProviderModels.ps1 @params + $report = $reportJson | ConvertFrom-Json + + $modelsJson = ($report.apiModels | ConvertTo-Json -Compress) + if ($modelsJson -eq 'null') { $modelsJson = '[]' } + + "success=true" >> $env:GITHUB_OUTPUT + "error=" >> $env:GITHUB_OUTPUT + "changed=$($report.fileUpdated)" >> $env:GITHUB_OUTPUT + "count=$($report.apiModels.Count)" >> $env:GITHUB_OUTPUT + + "models<> $env:GITHUB_OUTPUT + "$modelsJson" >> $env:GITHUB_OUTPUT + "EOF_MODELS" >> $env:GITHUB_OUTPUT + + "report<> $env:GITHUB_OUTPUT + "$reportJson" >> $env:GITHUB_OUTPUT + "EOF_REPORT" >> $env:GITHUB_OUTPUT + } + catch { + "success=false" >> $env:GITHUB_OUTPUT + "error=$($_.Exception.Message)" >> $env:GITHUB_OUTPUT + "changed=false" >> $env:GITHUB_OUTPUT + "count=0" >> $env:GITHUB_OUTPUT + "models=[]" >> $env:GITHUB_OUTPUT + "report={}" >> $env:GITHUB_OUTPUT + } diff --git a/.github/workflows/chore-update-provider-models.yml b/.github/workflows/chore-update-provider-models.yml new file mode 100644 index 00000000..58e49036 --- /dev/null +++ b/.github/workflows/chore-update-provider-models.yml @@ -0,0 +1,127 @@ +name: 🔄 Update Provider Models + +# Description: Scheduled CI that queries OpenRouter's unified /models endpoint as the +# single source of truth, then updates each provider's *ProviderModels.cs file. +# Auto-inserts new models with capabilities mapped from OpenRouter metadata +# (architecture.modalities, supported_parameters, context_length), marks models +# with expiration_date < 1 year as deprecated, and flags disappeared models. +# +# Triggers: +# - workflow_dispatch: manual run with optional provider filter +# - schedule: every Sunday 5:00 UTC + +on: + schedule: + - cron: '0 5 * * 0' + workflow_dispatch: + inputs: + provider_filter: + description: 'Comma-separated provider(s) to process (empty = all)' + required: false + default: '' + +permissions: + contents: write + pull-requests: write + +jobs: + fetch-and-update: + name: ${{ matrix.provider }} + runs-on: windows-latest + concurrency: + group: provider-models-${{ matrix.provider }} + cancel-in-progress: false + strategy: + fail-fast: false + matrix: + include: + - provider: OpenAI + secret_name: OPENROUTER_API_KEY + - provider: MistralAI + secret_name: OPENROUTER_API_KEY + - provider: Anthropic + secret_name: OPENROUTER_API_KEY + - provider: OpenRouter + secret_name: OPENROUTER_API_KEY + - provider: DeepSeek + secret_name: OPENROUTER_API_KEY + steps: + - name: Skip if secret is absent or filtered out + id: check + shell: bash + env: + API_KEY: ${{ secrets[matrix.secret_name] }} + FILTER: ${{ github.event.inputs.provider_filter || '' }} + run: | + if [ -z "$API_KEY" ]; then + echo "skipped=true" >> "$GITHUB_OUTPUT" + echo "Secret OPENROUTER_API_KEY is not configured. Skipping ${{ matrix.provider }}." + elif [ -n "$FILTER" ] && [[ ",${FILTER}," != *",${{ matrix.provider }},"* ]]; then + echo "skipped=true" >> "$GITHUB_OUTPUT" + echo "Provider ${{ matrix.provider }} not in filter '$FILTER'. Skipping." + else + echo "skipped=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout main + if: steps.check.outputs.skipped == 'false' + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Fetch and compare models for ${{ matrix.provider }} + if: steps.check.outputs.skipped == 'false' + id: models + uses: ./.github/actions/ai/fetch-models + with: + provider: ${{ matrix.provider }} + api-key: ${{ secrets[matrix.secret_name] }} + update-file: true + + - name: Emit step summary + if: steps.check.outputs.skipped == 'false' && steps.models.outputs.success == 'true' + shell: pwsh + run: | + $reportJson = '${{ steps.models.outputs.report }}' + $report = $reportJson | ConvertFrom-Json + Write-Output "## ${{ matrix.provider }} model scan" >> $env:GITHUB_STEP_SUMMARY + Write-Output "- API models: $($report.apiModels.Count)" >> $env:GITHUB_STEP_SUMMARY + Write-Output "- Source models: $($report.sourceModels.Count)" >> $env:GITHUB_STEP_SUMMARY + if ($report.newModels.Count -gt 0) { + Write-Output "- **New models:** ``$($report.newModels -join ', ')``" >> $env:GITHUB_STEP_SUMMARY + } + if ($report.deprecatedModels.Count -gt 0) { + Write-Output "- **Deprecated models:** ``$($report.deprecatedModels -join ', ')``" >> $env:GITHUB_STEP_SUMMARY + } + + - name: Create Pull Request + if: steps.check.outputs.skipped == 'false' && steps.models.outputs.changed == 'true' + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "chore(models): update ${{ matrix.provider }} model list [ci skip]" + title: "chore(models): auto-add new and deprecate stale ${{ matrix.provider }} models" + body: | + Auto-generated PR from `.github/workflows/chore-update-provider-models.yml`. + + ### Scan results for `${{ matrix.provider }}` + ```json + ${{ steps.models.outputs.report }} + ``` + + **Actions taken:** + - New models discovered via OpenRouter are auto-inserted with capabilities mapped from `architecture.modalities` and `supported_parameters`. + - Disappeared models (or models with `expiration_date` < 1 year) are marked `Deprecated = true`. + - Existing model `Capabilities` and `ContextLimit` are refreshed from OpenRouter metadata. + - `Rank` values are auto-computed from OpenRouter data: newer models rank higher, and within the same creation period cheaper output pricing ranks higher. + + **Requires manual review:** + - Verify `Default` and `Verified` flags for newly added models. + branch: "chore/update-models-${{ matrix.provider }}" + base: main + delete-branch: true + labels: | + chore + automated + models diff --git a/CHANGELOG.md b/CHANGELOG.md index ae145fab..be94401a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ Many thanks to the following contributors to this release: - New workflow `.github/workflows/model-verification.yml` that triggers only when an issue comment starts with `/verify-confirm` (and contains the template marker) or `/verify-force`, tallies distinct GitHub users (issue author + valid `/verify-confirm` commenters), and opens a PR promoting the model to `Verified = true` once two distinct users have certified it. `/verify-force` is restricted to `OWNER`/`MEMBER`/`COLLABORATOR`. - New helper `tools/Update-ModelVerified.ps1` that locates the matching `new AIModelCapabilities { Model = "..." }` block in `src/SmartHopper.Providers./ProviderModels.cs` and flips `Verified = false` to `Verified = true` (or inserts the flag when missing). +- Automated provider model discovery CI (OpenRouter as single source of truth): + - New workflow `.github/workflows/chore-update-provider-models.yml` that runs weekly (Sundays 05:00 UTC) and on `workflow_dispatch`. It queries OpenRouter's unified `/models` endpoint for each supported provider, compares the returned metadata with the static declarations in `*ProviderModels.cs`, and opens a PR that both auto-inserts new models and marks disappeared/expiring models as `Deprecated = true`. + - New composite action `.github/actions/ai/fetch-models/action.yml` that invokes `tools/Update-ProviderModels.ps1` with an OpenRouter API key, passing the provider name and `update-file` flag. + - New PowerShell tool `tools/Update-ProviderModels.ps1` invoked by the workflow. Accepts `-Provider`, `-ApiKey` (OpenRouter key), and an optional `-TargetFile`. It queries OpenRouter, filters by provider prefix, maps `architecture.input_modalities`/`output_modalities` and `supported_parameters` to `AICapability` flags, auto-generates full `AIModelCapabilities` blocks for new models (with `ContextLimit`, `Verified=false`), and marks models with `expiration_date` < 1 year or absent from OpenRouter as `Deprecated = true`. `Rank` values are auto-computed from OpenRouter `created` timestamp (newer models rank higher) and output pricing (cheapest first, considering `pricing.completion`, `pricing.image`, and `pricing.audio_output`). Existing model capabilities, context limits, and ranks are refreshed on every run. Emits a structured JSON report containing `newModels`, `deprecatedModels`, and `unchangedModels`. + ### Changed - chore(rules): clarified Windsurf rules and workflows to reduce overlap, stale platform assumptions, and ambiguous SmartHopper architecture guidance. diff --git a/tools/Update-ProviderModels.ps1 b/tools/Update-ProviderModels.ps1 new file mode 100644 index 00000000..c84aa637 --- /dev/null +++ b/tools/Update-ProviderModels.ps1 @@ -0,0 +1,832 @@ +<# +.SYNOPSIS + Queries OpenRouter as the single source of truth for model metadata, + then updates the corresponding *ProviderModels.cs file. + +.DESCRIPTION + Calls OpenRouter's /api/v1/models endpoint (rich metadata including + architecture.input_modalities, architecture.output_modalities, + supported_parameters, context_length, and expiration_date). + + For every model returned by OpenRouter that belongs to the requested + provider: + - If it already exists in the source file → update Capabilities, + ContextLimit, and Deprecated based on expiration_date. + - If it is missing from the source file → auto-insert a new + AIModelCapabilities block with mapped flags. + + Models present in the source file but absent from OpenRouter are marked + Deprecated = true. + + When expiration_date is set and closer than one year from now, + the model is also marked Deprecated = true. + +.PARAMETER Provider + Provider name matching the folder under src/SmartHopper.Providers., + e.g. OpenAI, MistralAI, Anthropic, OpenRouter, DeepSeek. + +.PARAMETER ApiKey + OpenRouter API key. The same key is used for every provider because + OpenRouter is the primary source of truth. + +.PARAMETER ProviderApiKey + Optional. The provider's own API key. When supplied, the provider's + official /models endpoint is queried as a secondary source so that + models exposed by the provider but not yet listed on OpenRouter are + still added to the source file (with conservative default capabilities). + A model is only marked Deprecated when it is missing from BOTH OpenRouter + and (when queried) the provider's own API. + + When the provider API response includes alias information, aliases are + merged into the Aliases list of the corresponding model entry (additive: + existing hand-curated aliases are preserved and new ones are appended). + + Alias support by provider API: + MistralAI - "aliases" array returned on each model object. + Anthropic - no alias mapping in list response (alias IDs appear as + separate model entries; no reverse link is exposed). + OpenAI - no alias field in the model object. + DeepSeek - no alias field in the model object. + +.PARAMETER TargetFile + Optional. Absolute or repo-relative path to the *ProviderModels.cs file. + Defaults to src/SmartHopper.Providers./ProviderModels.cs. + +.PARAMETER UpdateFile + When present, the source file is rewritten with new models inserted, + existing models updated, and disappeared models marked as deprecated. + +.OUTPUTS + A JSON string written to stdout with the following shape: + { + "provider": "OpenAI", + "apiUrl": "https://openrouter.ai/api/v1/models", + "apiModels": [ "gpt-4o", "gpt-4o-mini" ], + "openrouterModels": [ "gpt-4o", "gpt-4o-mini" ], + "providerApiModels": [ "gpt-4o", "gpt-4o-mini" ], + "sourceModels": [ "gpt-4", "gpt-4o" ], + "newModels": [ "gpt-4o-mini" ], + "deprecatedModels": [ "gpt-4" ], + "unchangedModels": [ "gpt-4o" ], + "fileUpdated": true + } + +.EXAMPLE + .\tools\Update-ProviderModels.ps1 -Provider OpenAI -ApiKey $env.OPENROUTER_API_KEY + + .\tools\Update-ProviderModels.ps1 -Provider Anthropic -ApiKey $env.OPENROUTER_API_KEY -UpdateFile +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string] $Provider, + [Parameter(Mandatory = $true)][string] $ApiKey, + [Parameter(Mandatory = $false)][string] $ProviderApiKey = "", + [Parameter(Mandatory = $false)][string] $TargetFile = "", + [Parameter(Mandatory = $false)][switch] $UpdateFile +) + +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +$OpenRouterUrl = 'https://openrouter.ai/api/v1/models' +$OneYearFromNow = (Get-Date).AddYears(1) + +# OpenRouter prefix used to filter and strip provider-specific models. +# Note: 'OpenRouter' is special-cased below: every OpenRouter model is kept +# verbatim (no prefix stripping) because that *is* the OpenRouter catalogue. +$ProviderPrefixes = @{ + 'OpenAI' = 'openai/' + 'Anthropic' = 'anthropic/' + 'MistralAI' = 'mistralai/' + 'DeepSeek' = 'deepseek/' + 'OpenRouter' = '' +} + +# Provider-native /models endpoints. Used only when -ProviderApiKey is supplied. +# Each entry returns the URL and a script block that builds auth headers. +$ProviderApis = @{ + 'OpenAI' = @{ Url = 'https://api.openai.com/v1/models'; Headers = { param($k) @{ Authorization = "Bearer $k" } } } + 'MistralAI' = @{ Url = 'https://api.mistral.ai/v1/models'; Headers = { param($k) @{ Authorization = "Bearer $k" } } } + 'DeepSeek' = @{ Url = 'https://api.deepseek.com/v1/models'; Headers = { param($k) @{ Authorization = "Bearer $k" } } } + 'Anthropic' = @{ Url = 'https://api.anthropic.com/v1/models'; Headers = { param($k) @{ 'x-api-key' = $k; 'anthropic-version' = '2023-06-01' } } } + 'OpenRouter' = @{ Url = 'https://openrouter.ai/api/v1/models'; Headers = { param($k) @{ Authorization = "Bearer $k" } } } +} + +# --------------------------------------------------------------------------- +# Helper: Resolve target file +# --------------------------------------------------------------------------- +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +if ([string]::IsNullOrWhiteSpace($TargetFile)) { + $TargetFile = Join-Path $repoRoot "src/SmartHopper.Providers.$Provider/${Provider}ProviderModels.cs" +} +$TargetFile = Resolve-Path $TargetFile -ErrorAction Stop + +# --------------------------------------------------------------------------- +# Helper: Parse a single AIModelCapabilities C# block into a PSObject +# --------------------------------------------------------------------------- +function ConvertFrom-ModelBlock($blockText) { + $result = [ordered]@{ + RawBlock = $blockText + Provider = $null + Model = $null + Capabilities = $null + Default = $null + Verified = $null + Deprecated = $null + SupportsStreaming = $null + SupportsPromptCaching = $null + Rank = $null + ContextLimit = $null + Aliases = $null + DiscouragedForTools = $null + CacheKeyStrategy = $null + } + + $rxProps = @{ + Provider = 'Provider\s*=\s*"([^"]*)"' + Model = 'Model\s*=\s*"([^"]*)"' + Capabilities = 'Capabilities\s*=\s*([^,\n]+)' + Default = 'Default\s*=\s*([^,\n]+)' + Verified = 'Verified\s*=\s*(true|false)' + Deprecated = 'Deprecated\s*=\s*(true|false)' + SupportsStreaming = 'SupportsStreaming\s*=\s*(true|false)' + SupportsPromptCaching = 'SupportsPromptCaching\s*=\s*(true|false)' + Rank = 'Rank\s*=\s*(\d+)' + ContextLimit = 'ContextLimit\s*=\s*(\d+)' + CacheKeyStrategy = 'CacheKeyStrategy\s*=\s*"([^"]*)"' + } + + foreach ($prop in $rxProps.GetEnumerator()) { + $m = [regex]::Match($blockText, $prop.Value) + if ($m.Success) { + $result[$prop.Key] = $m.Groups[1].Value.Trim() + } + } + + # Aliases + $aliasesRx = [regex]::Match($blockText, 'Aliases\s*=\s*new\s+List\s*\{\s*([^}]*)\s*\}') + if ($aliasesRx.Success) { + $result.Aliases = $aliasesRx.Groups[1].Value -split ',' | ForEach-Object { $_.Trim().Trim('"') } | Where-Object { $_ } + } + + # DiscouragedForTools + $discRx = [regex]::Match($blockText, 'DiscouragedForTools\s*=\s*new\s+List\s*\{\s*([^}]*)\s*\}') + if ($discRx.Success) { + $result.DiscouragedForTools = $discRx.Groups[1].Value -split ',' | ForEach-Object { $_.Trim().Trim('"') } | Where-Object { $_ } + } + + return [pscustomobject]$result +} + +# --------------------------------------------------------------------------- +# Helper: Generate a C# AIModelCapabilities block from a merged model object +# --------------------------------------------------------------------------- +function Format-ModelBlock($model, $providerVar) { + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add(' new AIModelCapabilities') + $lines.Add(' {') + $lines.Add(" Provider = $providerVar,") + $lines.Add(" Model = `"$($model.Model)`",") + + if (-not [string]::IsNullOrWhiteSpace($model.Capabilities)) { + $lines.Add(" Capabilities = $($model.Capabilities),") + } + + if (-not [string]::IsNullOrWhiteSpace($model.Default) -and $model.Default -ne 'AICapability.None') { + $lines.Add(" Default = $($model.Default),") + } + + if ($model.SupportsStreaming -eq 'true') { $lines.Add(' SupportsStreaming = true,') } + if ($model.SupportsStreaming -eq 'false') { $lines.Add(' SupportsStreaming = false,') } + + if ($model.SupportsPromptCaching -eq 'true') { $lines.Add(' SupportsPromptCaching = true,') } + if ($model.SupportsPromptCaching -eq 'false') { $lines.Add(' SupportsPromptCaching = false,') } + + if ($model.Verified -eq 'true') { $lines.Add(' Verified = true,') } + if ($model.Verified -eq 'false') { $lines.Add(' Verified = false,') } + + if ($model.Deprecated -eq 'true') { + $lines.Add(' Deprecated = true,') + } + + if (-not [string]::IsNullOrWhiteSpace($model.Rank)) { + $lines.Add(" Rank = $($model.Rank),") + } + + if (-not [string]::IsNullOrWhiteSpace($model.ContextLimit)) { + $lines.Add(" ContextLimit = $($model.ContextLimit),") + } + + if ($model.Aliases -and $model.Aliases.Count -gt 0) { + $aliasParts = $model.Aliases | ForEach-Object { "`"$_`"" } + $lines.Add(" Aliases = new List { $($aliasParts -join ', ') },") + } + + if ($model.DiscouragedForTools -and $model.DiscouragedForTools.Count -gt 0) { + $toolParts = $model.DiscouragedForTools | ForEach-Object { "`"$_`"" } + $lines.Add(" DiscouragedForTools = new List { $($toolParts -join ', ') },") + } + + if (-not [string]::IsNullOrWhiteSpace($model.CacheKeyStrategy)) { + $lines.Add(" CacheKeyStrategy = `"$($model.CacheKeyStrategy)`",") + } + + $lines.Add(' },') + return ($lines -join "`r`n") +} + +# --------------------------------------------------------------------------- +# Helper: Map OpenRouter modalities + supported_parameters to AICapability flags +# --------------------------------------------------------------------------- +function ConvertTo-CapabilityFlags($openRouterModel) { + $caps = [System.Collections.Generic.List[string]]::new() + + $arch = $openRouterModel.architecture + $inputModalities = if ($arch) { $arch.input_modalities } else { $null } + $outputModalities = if ($arch) { $arch.output_modalities } else { $null } + $supportedParams = $openRouterModel.supported_parameters + + if ($inputModalities -contains 'text') { $caps.Add('AICapability.TextInput') } + if ($inputModalities -contains 'image') { $caps.Add('AICapability.ImageInput') } + if ($inputModalities -contains 'audio') { $caps.Add('AICapability.AudioInput') } + + if ($outputModalities -contains 'text') { $caps.Add('AICapability.TextOutput') } + if ($outputModalities -contains 'image') { $caps.Add('AICapability.ImageOutput') } + if ($outputModalities -contains 'audio') { $caps.Add('AICapability.AudioOutput') } + + if ($supportedParams -contains 'tools' -or + $supportedParams -contains 'tool_choice' -or + $supportedParams -contains 'parallel_tool_calls') { + $caps.Add('AICapability.FunctionCalling') + } + + if ($supportedParams -contains 'response_format' -or + $supportedParams -contains 'structured_outputs') { + $caps.Add('AICapability.JsonOutput') + } + + if ($supportedParams -contains 'reasoning' -or + $supportedParams -contains 'reasoning_effort' -or + $supportedParams -contains 'include_reasoning') { + $caps.Add('AICapability.Reasoning') + } + + if ($caps.Count -eq 0) { + return 'AICapability.None' + } + return ($caps -join ' | ') +} + +# --------------------------------------------------------------------------- +# 1. Read and parse the existing C# file +# --------------------------------------------------------------------------- +$sourceContent = Get-Content -Raw -LiteralPath $TargetFile + +# Extract the provider variable name used inside RetrieveModels() +$providerVarRx = [regex]::Match($sourceContent, 'var\s+(\w+)\s*=\s*this\.\w+\.Name\.ToLowerInvariant\(\)\s*;') +$providerVar = if ($providerVarRx.Success) { $providerVarRx.Groups[1].Value } else { 'provider' } + +# Find the RetrieveModels() model list boundaries using brace counting +$startMarkerRx = [regex]::new('var\s+models\s*=\s*new\s+List\s*\n\s*\{\s*\n') +$startMatch = $startMarkerRx.Match($sourceContent) +if (-not $startMatch.Success) { + Write-Error "Could not find model list start in $TargetFile" + exit 5 +} + +$startIndex = $startMatch.Index + $startMatch.Length +$searchContent = $sourceContent.Substring($startIndex) + +$depth = 1 # already inside the List<...> { initializer +$inString = $false +$stringChar = $null +$endOffset = -1 + +for ($i = 0; $i -lt $searchContent.Length; $i++) { + $ch = $searchContent[$i] + + if ($inString) { + if ($ch -eq $stringChar -and ($i -eq 0 -or $searchContent[$i - 1] -ne '\')) { + $inString = $false + } + continue + } + + if ($ch -eq '"' -or $ch -eq "'") { + $inString = $true + $stringChar = $ch + continue + } + + if ($ch -eq '{') { $depth++ } + elseif ($ch -eq '}') { + $depth-- + if ($depth -eq 0) { + # check for trailing semicolon + $j = $i + 1 + while ($j -lt $searchContent.Length -and [char]::IsWhiteSpace($searchContent[$j])) { $j++ } + if ($j -lt $searchContent.Length -and $searchContent[$j] -eq ';') { + $endOffset = $j + break + } + } + } +} + +if ($endOffset -lt 0) { + Write-Error "Could not find model list end in $TargetFile" + exit 6 +} + +$beforeList = $sourceContent.Substring(0, $startIndex) +$listContent = $searchContent.Substring(0, $endOffset + 1) +$afterList = $searchContent.Substring($endOffset + 1) + +# Extract existing model blocks +$blockRx = [regex]::new('new\s+AIModelCapabilities\s*\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\},?', [System.Text.RegularExpressions.RegexOptions]::Singleline) +$blockMatches = $blockRx.Matches($listContent) + +$existingModels = @{ } +foreach ($bm in $blockMatches) { + $parsed = ConvertFrom-ModelBlock -blockText $bm.Value + if ($parsed.Model) { + $existingModels[$parsed.Model] = $parsed + } +} + +Write-Host "[$Provider] Parsed $($existingModels.Count) existing model block(s)." + +# --------------------------------------------------------------------------- +# 2. Query OpenRouter +# --------------------------------------------------------------------------- +$headers = @{ Authorization = "Bearer $ApiKey" } + +try { + $response = Invoke-RestMethod -Uri $OpenRouterUrl -Headers $headers -Method GET -TimeoutSec 60 +} +catch { + Write-Error "[$Provider] OpenRouter request failed: $($_.Exception.Message)" + exit 7 +} + +if (-not $ProviderPrefixes.ContainsKey($Provider)) { + Write-Error "Unknown provider '$Provider'. No OpenRouter prefix mapping." + exit 8 +} +$prefix = $ProviderPrefixes[$Provider] +$isOpenRouterProvider = ($Provider -eq 'OpenRouter') + +# Helper: derive the model name stored in the source file from an OpenRouter id. +function Get-ModelName($fullId) { + if ($isOpenRouterProvider) { return $fullId } + return $fullId.Substring($prefix.Length) +} + +$openRouterModels = [System.Collections.Generic.List[psobject]]::new() +foreach ($item in $response.data) { + $fullId = $item.id + if ([string]::IsNullOrWhiteSpace($fullId)) { continue } + + if ($isOpenRouterProvider) { + # OpenRouter provider: keep every model verbatim (full "vendor/model" id). + $openRouterModels.Add($item) + } + elseif ($fullId.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + # Strip provider prefix: "openai/gpt-4o" -> "gpt-4o". + if (-not [string]::IsNullOrWhiteSpace($fullId.Substring($prefix.Length))) { + $openRouterModels.Add($item) + } + } +} + +Write-Host "[$Provider] OpenRouter returned $($openRouterModels.Count) model(s) for prefix '$prefix'." + +# Build quick lookup by stored model name +$openRouterLookup = @{ } +foreach ($orm in $openRouterModels) { + $openRouterLookup[(Get-ModelName $orm.id)] = $orm +} + +# --------------------------------------------------------------------------- +# 3. Merge OpenRouter data with existing source models +# --------------------------------------------------------------------------- +$mergedModels = [ordered]@{ } + +# -- Seed with existing models (preserve properties we don't derive from OpenRouter) +foreach ($kvp in $existingModels.GetEnumerator()) { + $mergedModels[$kvp.Key] = $kvp.Value +} + +# --------------------------------------------------------------------------- +# 2b. Optional primary source: provider's own /models API +# +# When -ProviderApiKey is supplied, the provider's own API becomes the +# authoritative list of live models. OpenRouter is then only used to enrich +# brand-new models (capabilities, context limit, deprecation hint) that are +# not yet present in the source file. Models already in the source file are +# preserved as-is. +# --------------------------------------------------------------------------- +$providerApiModelNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +# Full provider API objects keyed by model id, for alias extraction. +$providerApiLookup = @{} +$providerApiQueried = $false + +if (-not [string]::IsNullOrWhiteSpace($ProviderApiKey) -and $ProviderApis.ContainsKey($Provider)) { + $api = $ProviderApis[$Provider] + try { + $providerHeaders = & $api.Headers $ProviderApiKey + $providerResponse = Invoke-RestMethod -Uri $api.Url -Headers $providerHeaders -Method GET -TimeoutSec 60 + $providerApiQueried = $true + + # Both OpenAI-compatible APIs and Anthropic return { data: [{ id: ... }] }. + $providerData = if ($providerResponse.data) { $providerResponse.data } else { @() } + foreach ($pm in $providerData) { + if (-not [string]::IsNullOrWhiteSpace($pm.id)) { + [void]$providerApiModelNames.Add($pm.id) + $providerApiLookup[$pm.id] = $pm + } + } + + Write-Host "[$Provider] Provider API returned $($providerApiModelNames.Count) model(s)." + } + catch { + Write-Warning "[$Provider] Provider API request failed: $($_.Exception.Message). Falling back to OpenRouter only." + } +} + +# --------------------------------------------------------------------------- +# Helper: Extract aliases from a provider API model object. +# +# Alias support by provider: +# MistralAI - returns an "aliases" array directly on each model object. +# Anthropic - alias IDs appear as separate entries in the list; the API +# offers no reverse mapping in the list response. +# OpenAI - no alias field in the model object. +# DeepSeek - no alias field in the model object. +# --------------------------------------------------------------------------- +function Get-ProviderApiAliases($pm) { + if (-not $pm) { return @() } + + switch ($Provider) { + 'MistralAI' { + # MistralAI model object: { "aliases": ["mistral-large", ...] } + if ($pm.aliases -and $pm.aliases.Count -gt 0) { + return @($pm.aliases | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + } + # OpenAI, Anthropic, DeepSeek, OpenRouter: no alias field in list response. + } + return @() +} + +# --------------------------------------------------------------------------- +# Helper: Extract enrichment data from an OpenRouter model entry +# --------------------------------------------------------------------------- +function Get-OpenRouterEnrichment($orm) { + if (-not $orm) { return $null } + + $deprecated = $false + if ($orm.expiration_date) { + try { + $exp = [DateTime]::Parse($orm.expiration_date.ToString()) + if ($exp -lt $OneYearFromNow) { $deprecated = $true } + } catch { } + } + + $ctx = $orm.context_length + if (-not $ctx -and $orm.top_provider) { $ctx = $orm.top_provider.context_length } + + return [pscustomobject]@{ + Capabilities = ConvertTo-CapabilityFlags -openRouterModel $orm + ContextLimit = if ($null -ne $ctx) { $ctx.ToString() } else { $null } + Deprecated = $deprecated + } +} + +# --------------------------------------------------------------------------- +# Build alias map from provider API and collapse existing source entries that +# are keyed by an alias into their canonical (primary) model id. The canonical +# id is the one the provider's own API uses as the primary model name; alias +# ids are secondary names the API also accepts (and often returns as separate +# entries in the /models list). Ensures each model has a single entry. +# --------------------------------------------------------------------------- +$apiCanonicalByAlias = [System.Collections.Generic.Dictionary[string,string]]::new([System.StringComparer]::OrdinalIgnoreCase) +if ($providerApiQueried) { + foreach ($pmId in $providerApiModelNames) { + $aliases = Get-ProviderApiAliases -pm $providerApiLookup[$pmId] + foreach ($a in $aliases) { + if ([string]::Equals($a, $pmId, 'OrdinalIgnoreCase')) { continue } + if (-not $apiCanonicalByAlias.ContainsKey($a)) { + $apiCanonicalByAlias[$a] = $pmId + } + } + } + + if ($apiCanonicalByAlias.Count -gt 0) { + $rekeyed = [ordered]@{} + foreach ($kvp in $mergedModels.GetEnumerator()) { + $key = $kvp.Key + $val = $kvp.Value + $canonical = if ($apiCanonicalByAlias.ContainsKey($key)) { $apiCanonicalByAlias[$key] } else { $key } + + if ($rekeyed.Contains($canonical)) { + $existing = $rekeyed[$canonical] + $aliases = [System.Collections.Generic.List[string]]::new() + if ($existing.Aliases) { foreach ($a in @($existing.Aliases)) { [void]$aliases.Add($a) } } + + if ([string]::Equals($key, $canonical, 'OrdinalIgnoreCase')) { + # Canonical entry wins: replace data, absorb aliased entry's + # old key + aliases into the canonical's Aliases list. + if ($val.Aliases) { + foreach ($a in @($val.Aliases)) { if (-not $aliases.Contains($a)) { [void]$aliases.Add($a) } } + } + if (-not [string]::Equals($existing.Model, $canonical, 'OrdinalIgnoreCase') -and -not $aliases.Contains($existing.Model)) { + [void]$aliases.Add($existing.Model) + } + $val.Model = $canonical + $val.Aliases = if ($aliases.Count -gt 0) { $aliases.ToArray() } else { $null } + $rekeyed[$canonical] = $val + } + else { + # $val is aliased-in: keep existing canonical data, just + # record this key (+ its aliases) under the canonical's Aliases. + if (-not $aliases.Contains($key)) { [void]$aliases.Add($key) } + if ($val.Aliases) { + foreach ($a in @($val.Aliases)) { + if (-not $aliases.Contains($a) -and -not [string]::Equals($a, $canonical, 'OrdinalIgnoreCase')) { + [void]$aliases.Add($a) + } + } + } + $existing.Aliases = $aliases.ToArray() + } + } + else { + if (-not [string]::Equals($key, $canonical, 'OrdinalIgnoreCase')) { + # Rekey: preserve old key as alias, update Model field. + $aliases = [System.Collections.Generic.List[string]]::new() + if ($val.Aliases) { + foreach ($a in @($val.Aliases)) { + if (-not [string]::Equals($a, $canonical, 'OrdinalIgnoreCase')) { [void]$aliases.Add($a) } + } + } + if (-not $aliases.Contains($key)) { [void]$aliases.Add($key) } + $val.Aliases = $aliases.ToArray() + $val.Model = $canonical + } + $rekeyed[$canonical] = $val + } + } + $mergedModels = $rekeyed + } +} + +# --------------------------------------------------------------------------- +# 3. Merge logic +# --------------------------------------------------------------------------- +if ($providerApiQueried) { + # ---- Provider API is the source of truth ---- + # Existing source models are preserved verbatim; only their Deprecated flag + # is toggled based on whether the provider API still lists them. + # Brand-new provider-API models are seeded with defaults and (when matched) + # enriched from OpenRouter. + foreach ($pmId in $providerApiModelNames) { + # Skip API ids that are aliases of another canonical (avoids duplicate entries). + if ($apiCanonicalByAlias.ContainsKey($pmId)) { continue } + + $apiAliases = Get-ProviderApiAliases -pm $providerApiLookup[$pmId] + + if ($mergedModels.Contains($pmId)) { + # Preserve all hand-curated data but update aliases from provider API + # (additive merge: keep existing aliases, add any new ones from the API). + if ($apiAliases.Count -gt 0) { + $existing = $mergedModels[$pmId] + $currentAliases = [System.Collections.Generic.List[string]]::new() + if ($existing.Aliases) { + foreach ($a in @($existing.Aliases)) { [void]$currentAliases.Add($a) } + } + foreach ($a in $apiAliases) { + if (-not $currentAliases.Contains($a)) { [void]$currentAliases.Add($a) } + } + $existing.Aliases = $currentAliases.ToArray() + } + continue + } + + $enrichment = Get-OpenRouterEnrichment -orm $openRouterLookup[$pmId] + + $caps = if ($enrichment -and -not [string]::IsNullOrWhiteSpace($enrichment.Capabilities) -and $enrichment.Capabilities -ne 'AICapability.None') { + $enrichment.Capabilities + } else { + 'AICapability.None' + } + + $deprecated = if ($enrichment -and $enrichment.Deprecated) { 'true' } else { $null } + $ctx = if ($enrichment) { $enrichment.ContextLimit } else { $null } + + $mergedModels[$pmId] = [pscustomobject][ordered]@{ + Provider = $null # filled by Format-ModelBlock + Model = $pmId + Capabilities = $caps + Verified = 'false' + Deprecated = $deprecated + SupportsStreaming = 'true' + SupportsPromptCaching = $null + Rank = '50' + ContextLimit = $ctx + Aliases = if ($apiAliases.Count -gt 0) { $apiAliases } else { $null } + DiscouragedForTools = $null + CacheKeyStrategy = $null + } + } +} +else { + # ---- OpenRouter-only mode (legacy behaviour) ---- + # OpenRouter is the source of truth: capabilities and context limits are + # refreshed for every model and brand-new entries are added. + foreach ($orm in $openRouterModels) { + $modelName = Get-ModelName $orm.id + $enrichment = Get-OpenRouterEnrichment -orm $orm + + if ($mergedModels.Contains($modelName)) { + $existing = $mergedModels[$modelName] + $existing.Capabilities = $enrichment.Capabilities + if ($null -ne $enrichment.ContextLimit) { $existing.ContextLimit = $enrichment.ContextLimit } + if ($enrichment.Deprecated -or $existing.Deprecated -eq 'true') { $existing.Deprecated = 'true' } + } + else { + $mergedModels[$modelName] = [pscustomobject][ordered]@{ + Provider = $null + Model = $modelName + Capabilities = $enrichment.Capabilities + Verified = 'false' + Deprecated = if ($enrichment.Deprecated) { 'true' } else { $null } + SupportsStreaming = 'true' + SupportsPromptCaching = $null + Rank = '50' + ContextLimit = $enrichment.ContextLimit + Aliases = $null + DiscouragedForTools = $null + CacheKeyStrategy = $null + } + } + } +} + +# --------------------------------------------------------------------------- +# Normalize Aliases: remove self-references and duplicates (case-insensitive). +# --------------------------------------------------------------------------- +foreach ($m in $mergedModels.Values) { + if (-not $m.Aliases) { continue } + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $clean = [System.Collections.Generic.List[string]]::new() + foreach ($a in @($m.Aliases)) { + if ([string]::IsNullOrWhiteSpace($a)) { continue } + if ([string]::Equals($a, $m.Model, 'OrdinalIgnoreCase')) { continue } + if ($seen.Add($a)) { [void]$clean.Add($a) } + } + $m.Aliases = if ($clean.Count -gt 0) { $clean.ToArray() } else { $null } +} + +# --------------------------------------------------------------------------- +# Mark models that no longer appear in the authoritative source as deprecated. +# Authoritative = provider API when queried, otherwise OpenRouter. +# --------------------------------------------------------------------------- +if ($providerApiQueried) { + $apiModelNames = $providerApiModelNames +} +else { + $apiModelNames = [System.Collections.Generic.HashSet[string]]::new( + [string[]]($openRouterModels | ForEach-Object { Get-ModelName $_.id }), + [System.StringComparer]::OrdinalIgnoreCase) +} + +foreach ($kvp in $mergedModels.GetEnumerator()) { + if (-not $apiModelNames.Contains($kvp.Key)) { + $kvp.Value.Deprecated = 'true' + } +} + +# --------------------------------------------------------------------------- +# Compute sorting keys (Created, OutputPrice) for every merged model +# --------------------------------------------------------------------------- +foreach ($m in $mergedModels.Values) { + $orm = $openRouterLookup[$m.Model] + if ($orm) { + # Created: OpenRouter returns Unix epoch seconds + if ($orm.created) { + $createdDt = [DateTimeOffset]::FromUnixTimeSeconds([long]$orm.created).DateTime + $m | Add-Member -NotePropertyName 'Created' -NotePropertyValue $createdDt -Force + } + else { + $m | Add-Member -NotePropertyName 'Created' -NotePropertyValue ([DateTime]::MinValue) -Force + } + + # Output pricing: sum of applicable output prices (completion, image, audio_output) + $prices = [System.Collections.Generic.List[decimal]]::new() + if ($orm.pricing.completion) { $prices.Add([decimal]$orm.pricing.completion) } + + $imgPrice = if ($null -ne $orm.pricing.image_output) { $orm.pricing.image_output } + elseif ($null -ne $orm.pricing.image) { $orm.pricing.image } + else { $null } + if ($null -ne $imgPrice) { $prices.Add([decimal]$imgPrice) } + + $audioPrice = if ($null -ne $orm.pricing.audio_output) { $orm.pricing.audio_output } + elseif ($null -ne $orm.pricing.audio) { $orm.pricing.audio } + else { $null } + if ($null -ne $audioPrice) { $prices.Add([decimal]$audioPrice) } + + $sumPrice = if ($prices.Count -gt 0) { ($prices | Measure-Object -Sum).Sum } else { [decimal]::MaxValue } + $m | Add-Member -NotePropertyName 'OutputPrice' -NotePropertyValue $sumPrice -Force + } + else { + # No OpenRouter data → push to bottom of sort + $m | Add-Member -NotePropertyName 'Created' -NotePropertyValue ([DateTime]::MinValue) -Force + $m | Add-Member -NotePropertyName 'OutputPrice' -NotePropertyValue ([decimal]::MaxValue) -Force + } +} + +# --------------------------------------------------------------------------- +# 4. Diff for reporting +# --------------------------------------------------------------------------- +$allModelNames = $mergedModels.Keys | Sort-Object +$apiModelNamesList = $apiModelNames | Sort-Object +$sourceModelNamesList = $existingModels.Keys | Sort-Object + +$newModels = $apiModelNamesList | Where-Object { $_ -notin $sourceModelNamesList } +$deprecatedModels = $allModelNames | Where-Object { $mergedModels[$_].Deprecated -eq 'true' -and ($_ -in $sourceModelNamesList) } +$unchangedModels = $apiModelNamesList | Where-Object { $_ -in $sourceModelNamesList -and $mergedModels[$_].Deprecated -ne 'true' } + +# --------------------------------------------------------------------------- +# 5. Optional file update +# --------------------------------------------------------------------------- +$fileUpdated = $false +if ($UpdateFile) { + # Sort: non-deprecated first, then creation date (most recent first), then output price (cheapest first), then name + $sorted = $mergedModels.Values | Sort-Object -Property @( + @{ Expression = { if ($_.Deprecated -eq 'true') { 1 } else { 0 } }; Ascending = $true } + @{ Expression = { $_.Created }; Ascending = $false } + @{ Expression = { $_.OutputPrice }; Ascending = $true } + @{ Expression = { $_.Model }; Ascending = $true } + ) + + # Assign ranks: non-deprecated models get descending ranks starting at 1000, in steps of 5 + $nonDeprecated = $sorted | Where-Object { $_.Deprecated -ne 'true' } + for ($i = 0; $i -lt $nonDeprecated.Count; $i++) { + $nonDeprecated[$i].Rank = (1000 - ($i * 5)).ToString() + } + + # Deprecated models get low ranks starting at 0, in steps of 5 + $deprecated = $sorted | Where-Object { $_.Deprecated -eq 'true' } + for ($i = 0; $i -lt $deprecated.Count; $i++) { + $deprecated[$i].Rank = (0 - ($i * 5)).ToString() + } + + $newBlocks = [System.Collections.Generic.List[string]]::new() + foreach ($m in $sorted) { + $newBlocks.Add((Format-ModelBlock -model $m -providerVar $providerVar)) + } + + # Remove the trailing comma from the last block so the list initializer ends + # with "};" instead of "},\r\n};" – avoids brace-scanner mismatches on re-runs. + if ($newBlocks.Count -gt 0) { + $last = $newBlocks[$newBlocks.Count - 1] + $newBlocks[$newBlocks.Count - 1] = $last -replace ',\s*$', '' + } + + $newListContent = ($newBlocks -join "`r`n`r`n") + $newFileContent = $beforeList + $newListContent + "`r`n };" + $afterList + + # Ensure trailing newline + if (-not $newFileContent.EndsWith("`r`n")) { $newFileContent += "`r`n" } + + [System.IO.File]::WriteAllText($TargetFile, $newFileContent) + $fileUpdated = $true + Write-Host "[$Provider] Wrote $($sorted.Count) model(s) to $TargetFile." +} + +# --------------------------------------------------------------------------- +# 6. JSON report +# --------------------------------------------------------------------------- +$openRouterModelNamesList = @($openRouterModels | ForEach-Object { Get-ModelName $_.id } | Sort-Object) +$providerApiModelNamesList = @($providerApiModelNames | Sort-Object) + +$report = [ordered]@{ + provider = $Provider + apiUrl = $OpenRouterUrl + providerApiQueried = $providerApiQueried + providerApiUrl = if ($providerApiQueried) { $ProviderApis[$Provider].Url } else { $null } + apiModels = @($apiModelNamesList) + openrouterModels = $openRouterModelNamesList + providerApiModels = if ($providerApiQueried) { $providerApiModelNamesList } else { $null } + sourceModels = @($sourceModelNamesList) + newModels = @($newModels) + deprecatedModels = @($deprecatedModels) + unchangedModels = @($unchangedModels) + fileUpdated = $fileUpdated +} + +Write-Output ($report | ConvertTo-Json -Depth 10) + From 8418e06933867af214204dbc4227eb4b9c443b16 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 17:18:08 +0200 Subject: [PATCH 062/110] refactor: remove JsonInput capability and add wildcard support for DiscouragedForTools Removed AICapability.JsonInput flag (enum value, ToFlagList serialization, HasInputCapability check) as structured JSON input is not a distinct modality tracked by OpenRouter API. Added wildcard "*" support to AIModelCapabilities.DiscouragedForTools to discourage models for all tools at once. Enhanced Update-ProviderModels.ps1 alias/deprecation handling: MistralAI now groups API entries by canonical "name" field --- .../AIModels/AICapability.cs | 13 +- .../AIModels/AIModelCapabilities.cs | 3 + tools/Update-ProviderModels.ps1 | 117 ++++++++++++------ 3 files changed, 83 insertions(+), 50 deletions(-) diff --git a/src/SmartHopper.Infrastructure/AIModels/AICapability.cs b/src/SmartHopper.Infrastructure/AIModels/AICapability.cs index 9a74e7b9..556b3824 100644 --- a/src/SmartHopper.Infrastructure/AIModels/AICapability.cs +++ b/src/SmartHopper.Infrastructure/AIModels/AICapability.cs @@ -49,11 +49,6 @@ public enum AICapability /// AudioInput = 1 << 2, - /// - /// Supports accepting structured JSON input. - /// - JsonInput = 1 << 3, - // Output capabilities /// @@ -191,11 +186,6 @@ public static string ToDetailedString(this AICapability capabilities) flags.Add("AudioOutput"); } - if ((capabilities & AICapability.JsonInput) == AICapability.JsonInput) - { - flags.Add("JsonInput"); - } - if ((capabilities & AICapability.JsonOutput) == AICapability.JsonOutput) { flags.Add("JsonOutput"); @@ -223,8 +213,7 @@ public static bool HasInput(this AICapability capability) { return (capability & AICapability.TextInput) == AICapability.TextInput || (capability & AICapability.ImageInput) == AICapability.ImageInput || - (capability & AICapability.AudioInput) == AICapability.AudioInput || - (capability & AICapability.JsonInput) == AICapability.JsonInput; + (capability & AICapability.AudioInput) == AICapability.AudioInput; } /// diff --git a/src/SmartHopper.Infrastructure/AIModels/AIModelCapabilities.cs b/src/SmartHopper.Infrastructure/AIModels/AIModelCapabilities.cs index 2f05edc7..4775593e 100644 --- a/src/SmartHopper.Infrastructure/AIModels/AIModelCapabilities.cs +++ b/src/SmartHopper.Infrastructure/AIModels/AIModelCapabilities.cs @@ -93,6 +93,7 @@ public class AIModelCapabilities /// /// List of AI tool names for which this model is discouraged. /// When a component uses any of these tools, a "not recommended" badge will be displayed. + /// Use "*" to discourage this model for all tools. /// public List DiscouragedForTools { get; set; } = new List(); @@ -122,6 +123,7 @@ public string GetKey() /// /// Checks if this model is discouraged for any of the specified tools. + /// A wildcard entry "*" in matches all tools. /// /// List of tool names to check against. /// True if any of the specified tools are in the discouraged list. @@ -133,6 +135,7 @@ public bool IsDiscouragedForAnyTool(IEnumerable toolNames) } return toolNames.Any(t => this.DiscouragedForTools.Any(d => + d == "*" || string.Equals(d, t, StringComparison.OrdinalIgnoreCase))); } } diff --git a/tools/Update-ProviderModels.ps1 b/tools/Update-ProviderModels.ps1 index c84aa637..b72bd251 100644 --- a/tools/Update-ProviderModels.ps1 +++ b/tools/Update-ProviderModels.ps1 @@ -191,7 +191,8 @@ function Format-ModelBlock($model, $providerVar) { $lines.Add(" Model = `"$($model.Model)`",") if (-not [string]::IsNullOrWhiteSpace($model.Capabilities)) { - $lines.Add(" Capabilities = $($model.Capabilities),") + $suffix = if ($model.Capabilities -eq 'AICapability.None') { ' // TODO: retrieve capabilities' } else { '' } + $lines.Add(" Capabilities = $($model.Capabilities),$suffix") } if (-not [string]::IsNullOrWhiteSpace($model.Default) -and $model.Default -ne 'AICapability.None') { @@ -457,28 +458,78 @@ if (-not [string]::IsNullOrWhiteSpace($ProviderApiKey) -and $ProviderApis.Contai } # --------------------------------------------------------------------------- -# Helper: Extract aliases from a provider API model object. +# Build provider-API alias + deprecation maps. # -# Alias support by provider: -# MistralAI - returns an "aliases" array directly on each model object. -# Anthropic - alias IDs appear as separate entries in the list; the API -# offers no reverse mapping in the list response. -# OpenAI - no alias field in the model object. -# DeepSeek - no alias field in the model object. -# --------------------------------------------------------------------------- -function Get-ProviderApiAliases($pm) { - if (-not $pm) { return @() } - - switch ($Provider) { - 'MistralAI' { - # MistralAI model object: { "aliases": ["mistral-large", ...] } +# Grouping strategy per provider: +# MistralAI - Group by the "name" field (authoritative canonical). Every +# API entry whose .name == N belongs to the same logical model; +# the canonical id is N itself. The per-entry "aliases" array +# is merged in as a supplement (it is sometimes incomplete). +# "deprecation" != null on any group member marks the canonical +# as deprecated. +# Other - Use the per-entry "aliases" array when present (OpenAI / +# Anthropic / DeepSeek currently don't expose aliases, so the +# map is typically empty). +# --------------------------------------------------------------------------- +$apiAliasesByCanonical = @{} +$apiCanonicalByAlias = [System.Collections.Generic.Dictionary[string,string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$apiDeprecatedCanonicals = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + +if ($providerApiQueried) { + if ($Provider -eq 'MistralAI') { + $groups = @{} + foreach ($pmId in $providerApiModelNames) { + $pm = $providerApiLookup[$pmId] + $grpKey = if (-not [string]::IsNullOrWhiteSpace($pm.name)) { $pm.name } else { $pmId } + if (-not $groups.ContainsKey($grpKey)) { + $groups[$grpKey] = [System.Collections.Generic.List[string]]::new() + } + [void]$groups[$grpKey].Add($pmId) + } + + foreach ($kvp in $groups.GetEnumerator()) { + $canonical = $kvp.Key + $aliasSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $aliasList = [System.Collections.Generic.List[string]]::new() + + foreach ($id in $kvp.Value) { + if (-not [string]::Equals($id, $canonical, 'OrdinalIgnoreCase') -and $aliasSet.Add($id)) { + [void]$aliasList.Add($id) + } + $pm = $providerApiLookup[$id] + if ($pm.aliases) { + foreach ($a in $pm.aliases) { + if ([string]::IsNullOrWhiteSpace($a)) { continue } + if ([string]::Equals($a, $canonical, 'OrdinalIgnoreCase')) { continue } + if ($aliasSet.Add($a)) { [void]$aliasList.Add($a) } + } + } + if ($null -ne $pm.deprecation) { + [void]$apiDeprecatedCanonicals.Add($canonical) + } + } + + $apiAliasesByCanonical[$canonical] = $aliasList.ToArray() + } + } + else { + foreach ($pmId in $providerApiModelNames) { + $pm = $providerApiLookup[$pmId] if ($pm.aliases -and $pm.aliases.Count -gt 0) { - return @($pm.aliases | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $apiAliasesByCanonical[$pmId] = @($pm.aliases | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + } + } + + # Reverse map: alias -> canonical. + foreach ($kvp in $apiAliasesByCanonical.GetEnumerator()) { + foreach ($a in $kvp.Value) { + if ([string]::Equals($a, $kvp.Key, 'OrdinalIgnoreCase')) { continue } + if (-not $apiCanonicalByAlias.ContainsKey($a)) { + $apiCanonicalByAlias[$a] = $kvp.Key } } - # OpenAI, Anthropic, DeepSeek, OpenRouter: no alias field in list response. } - return @() } # --------------------------------------------------------------------------- @@ -506,24 +557,11 @@ function Get-OpenRouterEnrichment($orm) { } # --------------------------------------------------------------------------- -# Build alias map from provider API and collapse existing source entries that -# are keyed by an alias into their canonical (primary) model id. The canonical -# id is the one the provider's own API uses as the primary model name; alias -# ids are secondary names the API also accepts (and often returns as separate -# entries in the /models list). Ensures each model has a single entry. +# Collapse existing source entries keyed by an alias into their canonical id +# (built in $apiCanonicalByAlias above). Ensures each logical model has a +# single entry after the seed. # --------------------------------------------------------------------------- -$apiCanonicalByAlias = [System.Collections.Generic.Dictionary[string,string]]::new([System.StringComparer]::OrdinalIgnoreCase) if ($providerApiQueried) { - foreach ($pmId in $providerApiModelNames) { - $aliases = Get-ProviderApiAliases -pm $providerApiLookup[$pmId] - foreach ($a in $aliases) { - if ([string]::Equals($a, $pmId, 'OrdinalIgnoreCase')) { continue } - if (-not $apiCanonicalByAlias.ContainsKey($a)) { - $apiCanonicalByAlias[$a] = $pmId - } - } - } - if ($apiCanonicalByAlias.Count -gt 0) { $rekeyed = [ordered]@{} foreach ($kvp in $mergedModels.GetEnumerator()) { @@ -596,13 +634,15 @@ if ($providerApiQueried) { # Skip API ids that are aliases of another canonical (avoids duplicate entries). if ($apiCanonicalByAlias.ContainsKey($pmId)) { continue } - $apiAliases = Get-ProviderApiAliases -pm $providerApiLookup[$pmId] + $apiAliases = if ($apiAliasesByCanonical.ContainsKey($pmId)) { @($apiAliasesByCanonical[$pmId]) } else { @() } + $isApiDeprecated = $apiDeprecatedCanonicals.Contains($pmId) if ($mergedModels.Contains($pmId)) { # Preserve all hand-curated data but update aliases from provider API - # (additive merge: keep existing aliases, add any new ones from the API). + # (additive merge: keep existing aliases, add any new ones from the API) + # and propagate provider deprecation flag. + $existing = $mergedModels[$pmId] if ($apiAliases.Count -gt 0) { - $existing = $mergedModels[$pmId] $currentAliases = [System.Collections.Generic.List[string]]::new() if ($existing.Aliases) { foreach ($a in @($existing.Aliases)) { [void]$currentAliases.Add($a) } @@ -612,6 +652,7 @@ if ($providerApiQueried) { } $existing.Aliases = $currentAliases.ToArray() } + if ($isApiDeprecated) { $existing.Deprecated = 'true' } continue } @@ -623,7 +664,7 @@ if ($providerApiQueried) { 'AICapability.None' } - $deprecated = if ($enrichment -and $enrichment.Deprecated) { 'true' } else { $null } + $deprecated = if ($isApiDeprecated -or ($enrichment -and $enrichment.Deprecated)) { 'true' } else { $null } $ctx = if ($enrichment) { $enrichment.ContextLimit } else { $null } $mergedModels[$pmId] = [pscustomobject][ordered]@{ From 7138dc68625ba28682a33e567c7f50e517bb781f Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 17:47:49 +0200 Subject: [PATCH 063/110] refactor: skip fine-tuned models and group provider models by release quarter --- tools/Update-ProviderModels.ps1 | 53 +++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/tools/Update-ProviderModels.ps1 b/tools/Update-ProviderModels.ps1 index b72bd251..3ec07a30 100644 --- a/tools/Update-ProviderModels.ps1 +++ b/tools/Update-ProviderModels.ps1 @@ -390,6 +390,8 @@ foreach ($item in $response.data) { $fullId = $item.id if ([string]::IsNullOrWhiteSpace($fullId)) { continue } + if ($fullId.StartsWith('ft:')) { continue } + if ($isOpenRouterProvider) { # OpenRouter provider: keep every model verbatim (full "vendor/model" id). $openRouterModels.Add($item) @@ -444,7 +446,7 @@ if (-not [string]::IsNullOrWhiteSpace($ProviderApiKey) -and $ProviderApis.Contai # Both OpenAI-compatible APIs and Anthropic return { data: [{ id: ... }] }. $providerData = if ($providerResponse.data) { $providerResponse.data } else { @() } foreach ($pm in $providerData) { - if (-not [string]::IsNullOrWhiteSpace($pm.id)) { + if (-not [string]::IsNullOrWhiteSpace($pm.id) -and -not $pm.id.StartsWith('ft:')) { [void]$providerApiModelNames.Add($pm.id) $providerApiLookup[$pm.id] = $pm } @@ -805,10 +807,28 @@ $unchangedModels = $apiModelNamesList | Where-Object { $_ -in $sourceModelNames # --------------------------------------------------------------------------- $fileUpdated = $false if ($UpdateFile) { - # Sort: non-deprecated first, then creation date (most recent first), then output price (cheapest first), then name + # Compute term index (Q1=most recent 3 months, Q2=3-6 months, ..., Q8=18-24 months). + $now = Get-Date + $termStart = for ($i = 0; $i -le 8; $i++) { $now.AddMonths(-3 * $i) } + + function Get-TermIndex($created) { + if (-not $created -or $created -eq [DateTime]::MinValue) { return 99 } + for ($i = 1; $i -le 8; $i++) { + if ($created -ge $termStart[$i]) { return $i } + } + return 99 + } + + foreach ($m in $mergedModels.Values) { + $m | Add-Member -NotePropertyName 'TermIndex' -NotePropertyValue (Get-TermIndex $m.Created) -Force + } + + # Sort: non-deprecated first, then term (most recent first), + # then verified first, then output price (cheapest first), then name $sorted = $mergedModels.Values | Sort-Object -Property @( @{ Expression = { if ($_.Deprecated -eq 'true') { 1 } else { 0 } }; Ascending = $true } - @{ Expression = { $_.Created }; Ascending = $false } + @{ Expression = { $_.TermIndex }; Ascending = $true } + @{ Expression = { if ($_.Verified -eq 'true') { 0 } else { 1 } }; Ascending = $true } @{ Expression = { $_.OutputPrice }; Ascending = $true } @{ Expression = { $_.Model }; Ascending = $true } ) @@ -825,8 +845,35 @@ if ($UpdateFile) { $deprecated[$i].Rank = (0 - ($i * 5)).ToString() } + function Get-SectionComment($model) { + if ($model.Deprecated -eq 'true') { return '// Deprecated models' } + + $ci = [System.Globalization.CultureInfo]::GetCultureInfo('en-US') + $fmt = 'MMMM yyyy' + switch ($model.TermIndex) { + 1 { return "// Released between $($now.AddMonths(-3).ToString($fmt, $ci)) and $($now.ToString($fmt, $ci))" } + 2 { return "// Released between $($now.AddMonths(-6).ToString($fmt, $ci)) and $($now.AddMonths(-3).ToString($fmt, $ci))" } + 3 { return "// Released between $($now.AddMonths(-9).ToString($fmt, $ci)) and $($now.AddMonths(-6).ToString($fmt, $ci))" } + 4 { return "// Released between $($now.AddMonths(-12).ToString($fmt, $ci)) and $($now.AddMonths(-9).ToString($fmt, $ci))" } + 5 { return "// Released between $($now.AddMonths(-15).ToString($fmt, $ci)) and $($now.AddMonths(-12).ToString($fmt, $ci))" } + 6 { return "// Released between $($now.AddMonths(-18).ToString($fmt, $ci)) and $($now.AddMonths(-15).ToString($fmt, $ci))" } + 7 { return "// Released between $($now.AddMonths(-21).ToString($fmt, $ci)) and $($now.AddMonths(-18).ToString($fmt, $ci))" } + 8 { return "// Released between $($now.AddMonths(-24).ToString($fmt, $ci)) and $($now.AddMonths(-21).ToString($fmt, $ci))" } + default { return "// Released before $($now.AddMonths(-24).ToString($fmt, $ci)) or unknown release date" } + } + } + $newBlocks = [System.Collections.Generic.List[string]]::new() + $currentSection = $null foreach ($m in $sorted) { + $section = Get-SectionComment $m + if ($section -ne $currentSection) { + if ($newBlocks.Count -gt 0) { + $newBlocks.Add('') + } + $newBlocks.Add(" $section") + $currentSection = $section + } $newBlocks.Add((Format-ModelBlock -model $m -providerVar $providerVar)) } From 7c5975635e471dbb7ba09bd43fb87c98dfe3beab Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 17:48:51 +0200 Subject: [PATCH 064/110] refactor: reorganize MistralAI models by release quarter and mark 10 models as deprecated --- .../MistralAIProviderModels.cs | 344 ++++++++++++++++-- 1 file changed, 312 insertions(+), 32 deletions(-) diff --git a/src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs b/src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs index e4bedb5a..b818184c 100644 --- a/src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs +++ b/src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * @@ -53,102 +53,381 @@ public override Task> RetrieveModels() var models = new List { + // Released between February 2026 and May 2026 + new AIModelCapabilities { Provider = provider, - Model = "mistral-small-latest", + Model = "mistral-small-2603", Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, Default = AICapability.Text2Text | AICapability.ToolChat | AICapability.Text2Json, SupportsStreaming = true, Verified = true, - Rank = 90, + Rank = 1000, ContextLimit = 131072, + Aliases = new List { "mistral-small", "mistral-small-latest", "magistral-small-latest", "mistral-vibe-cli-fast" }, DiscouragedForTools = new List { "script_generate", "script_edit" }, - Aliases = new List { "mistral-small" }, }, + + + + // Released between November 2025 and February 2026 + new AIModelCapabilities { Provider = provider, - Model = "mistral-medium-latest", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling , - SupportsStreaming = true, - Verified = true, - Rank = 80, + Model = "ministral-3b-2512", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = false, + Verified = false, + Rank = 995, ContextLimit = 131072, - Aliases = new List { "mistral-medium" }, + Aliases = new List { "ministral-3b-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "mistral-large-latest", + Model = "ministral-8b-2512", Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, - SupportsStreaming = true, + SupportsStreaming = false, Verified = false, - Rank = 60, + Rank = 990, ContextLimit = 131072, - Aliases = new List { "mistral-large" }, + Aliases = new List { "ministral-8b-latest" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "ministral-14b-2512", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 985, + ContextLimit = 262144, + Aliases = new List { "ministral-14b-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "ministral-8b-latest", + Model = "mistral-large-2512", Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, - SupportsStreaming = false, + SupportsStreaming = true, Verified = false, - Rank = 50, + Rank = 980, ContextLimit = 131072, + Aliases = new List { "mistral-large", "mistral-large-latest" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "devstral-2512", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 975, + ContextLimit = 262144, + Aliases = new List { "devstral-medium-latest", "devstral-latest" }, }, + + + + // Released between May 2025 and August 2025 + new AIModelCapabilities { Provider = provider, - Model = "ministral-3b-latest", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, - SupportsStreaming = false, + Model = "codestral-2508", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, Verified = false, - Rank = 30, + Rank = 970, + ContextLimit = 256000, + Aliases = new List { "codestral-latest" }, + }, + + + + // Released before May 2024 or unknown release date + + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-medium-2508", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = true, + Rank = 965, ContextLimit = 131072, + Aliases = new List { "mistral-medium", "mistral-medium-latest", "mistral-vibe-cli-with-tools" }, }, + new AIModelCapabilities { Provider = provider, - Model = "magistral-small-latest", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - Default = AICapability.ToolReasoningChat, + Model = "codestral-embed", + Capabilities = AICapability.TextInput, SupportsStreaming = true, Verified = false, - Rank = 85, - ContextLimit = 40000, + Rank = 960, + Aliases = new List { "codestral-embed-2505" }, + DiscouragedForTools = new List { "*" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "labs-leanstral-2603", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 955, + ContextLimit = 256000, }, + new AIModelCapabilities { Provider = provider, - Model = "magistral-medium-latest", + Model = "magistral-medium-2509", Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 75, + Rank = 950, ContextLimit = 40000, + Aliases = new List { "magistral-medium-latest" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-medium-2505", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 945, + ContextLimit = 128000, }, + new AIModelCapabilities { Provider = provider, - Model = "voxtral-small-latest", + Model = "mistral-medium-3-5", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.Reasoning | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 940, + ContextLimit = 256000, + Aliases = new List { "mistral-medium-3.5", "mistral-medium-3", "mistral-medium-2604", "mistral-medium-c21211-r0-75", "mistral-vibe-cli-latest" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-ocr-2512", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.ImageOutput, + SupportsStreaming = true, + Verified = false, + Rank = 935, + Aliases = new List { "mistral-ocr-latest" }, + DiscouragedForTools = new List { "*" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "open-mistral-nemo", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 930, + ContextLimit = 128000, + Aliases = new List { "open-mistral-nemo-2407", "mistral-tiny-2407", "mistral-tiny-latest" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "voxtral-mini-2602", Capabilities = AICapability.AudioInput | AICapability.TextOutput, SupportsStreaming = false, Verified = false, - Rank = 70, + Rank = 925, ContextLimit = 32000, + Aliases = new List { "voxtral-mini-latest" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "voxtral-mini-transcribe-realtime-2602", + Capabilities = AICapability.AudioInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 920, + Aliases = new List { "voxtral-mini-realtime-2602", "voxtral-mini-realtime-latest" }, + DiscouragedForTools = new List { "*" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "voxtral-mini-tts-2603", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.AudioOutput, + SupportsStreaming = true, + Verified = false, + Rank = 915, + Aliases = new List { "voxtral-mini-tts-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "voxtral-mini-latest", + Model = "voxtral-small-2507", Capabilities = AICapability.AudioInput | AICapability.TextOutput, SupportsStreaming = false, Verified = false, - Rank = 60, + Rank = 910, ContextLimit = 32000, + Aliases = new List { "voxtral-small-latest" }, + }, + + + + // Deprecated models + + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-large-2411", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = 0, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "pixtral-large-2411", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -5, + ContextLimit = 131072, + Aliases = new List { "pixtral-large-latest", "mistral-large-pixtral-2411" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "devstral-medium-2507", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -10, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "devstral-small-2507", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -15, }, + + new AIModelCapabilities + { + Provider = provider, + Model = "magistral-small-2509", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -20, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-embed-2312", + Capabilities = AICapability.TextInput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -25, + ContextLimit = 8000, + Aliases = new List { "mistral-embed" }, + DiscouragedForTools = new List { "*" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-moderation-2411", + Capabilities = AICapability.TextInput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -30, + ContextLimit = 128000, + Aliases = new List { "mistral-moderation-latest" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-moderation-2603", + Capabilities = AICapability.TextInput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -35, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-ocr-2505", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.ImageOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -40, + DiscouragedForTools = new List { "*" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-small-2506", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -45, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "voxtral-mini-transcribe-2507", + Capabilities = AICapability.AudioInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -50, + Aliases = new List { "voxtral-mini-2507" }, + } }; return Task.FromResult(models); @@ -243,3 +522,4 @@ public override async Task> RetrieveApiModels() } } } + From 93c4cde9554c5715994f9f86e3c3face99a754ad Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 17:54:17 +0200 Subject: [PATCH 065/110] refactor: add DeepSeek v4 models and deprecate v3/reasoner models --- .../DeepSeekProviderModels.cs | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/SmartHopper.Providers.DeepSeek/DeepSeekProviderModels.cs b/src/SmartHopper.Providers.DeepSeek/DeepSeekProviderModels.cs index bb171f58..c310b13d 100644 --- a/src/SmartHopper.Providers.DeepSeek/DeepSeekProviderModels.cs +++ b/src/SmartHopper.Providers.DeepSeek/DeepSeekProviderModels.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * @@ -47,16 +47,35 @@ public override Task> RetrieveModels() var models = new List { + // Released between February 2026 and May 2026 + new AIModelCapabilities { Provider = provider, - Model = "deepseek-reasoner", + Model = "deepseek-v4-flash", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, - Default = AICapability.ToolReasoningChat, + Default = AICapability.Text2Text | AICapability.ToolChat | AICapability.ToolReasoningChat, SupportsStreaming = true, - Rank = 80, - ContextLimit = 64000, + Verified = false, + Rank = 1000, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek-v4-pro", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 995, + ContextLimit = 1048576, }, + + + + // Deprecated models + new AIModelCapabilities { Provider = provider, @@ -64,9 +83,22 @@ public override Task> RetrieveModels() Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, Default = AICapability.Text2Text | AICapability.ToolChat, SupportsStreaming = true, - Rank = 90, + Deprecated = true, + Rank = 0, ContextLimit = 60000, }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek-reasoner", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + Default = AICapability.ToolReasoningChat, + SupportsStreaming = true, + Deprecated = true, + Rank = -5, + ContextLimit = 64000, + } }; return Task.FromResult(models); From 45767e26d019699e5f55ed07a9ff78a5768c0df1 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 18:20:35 +0200 Subject: [PATCH 066/110] refactor: add suffix-based alias grouping for OpenAI/Anthropic and normalize hyphen/dot model name matching --- tools/Update-ProviderModels.ps1 | 110 ++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 7 deletions(-) diff --git a/tools/Update-ProviderModels.ps1 b/tools/Update-ProviderModels.ps1 index 3ec07a30..39be4f6c 100644 --- a/tools/Update-ProviderModels.ps1 +++ b/tools/Update-ProviderModels.ps1 @@ -104,6 +104,20 @@ $ProviderPrefixes = @{ 'OpenRouter' = '' } +# Per-provider regex matching the "version suffix" appended to a base model id. +# Used to group several provider-API ids that refer to the same logical model: +# - dated suffix (immutable release): -YYYYMMDD or -YYYY-MM-DD +# - rolling alias: -latest +# The dated id is treated as canonical; bare and -latest ids become aliases. +# Set to $null for providers whose ids do not encode aliases this way. +$ProviderAliasSuffix = @{ + 'OpenAI' = '-(?:\d{4}-\d{2}-\d{2}|latest)$' + 'Anthropic' = '-(?:\d{8}|latest)$' + 'MistralAI' = $null # uses .name field grouping (handled separately) + 'DeepSeek' = $null + 'OpenRouter' = $null +} + # Provider-native /models endpoints. Used only when -ProviderApiKey is supplied. # Each entry returns the URL and a script block that builds auth headers. $ProviderApis = @{ @@ -406,10 +420,22 @@ foreach ($item in $response.data) { Write-Host "[$Provider] OpenRouter returned $($openRouterModels.Count) model(s) for prefix '$prefix'." -# Build quick lookup by stored model name +# Helper: Normalize model name for cross-reference matching. +# Anthropic uses hyphens in their API (claude-opus-4-7) while OpenRouter uses dots (claude-opus-4.7). +function Get-NormalizedModelName($modelName) { + return $modelName -replace '\.', '-' +} + +# Build quick lookup by stored model name (original + normalized for cross-reference matching) $openRouterLookup = @{ } foreach ($orm in $openRouterModels) { - $openRouterLookup[(Get-ModelName $orm.id)] = $orm + $name = Get-ModelName $orm.id + $openRouterLookup[$name] = $orm + # Also add normalized key so provider-API hyphenated ids match OpenRouter dotted ids + $normalized = Get-NormalizedModelName $name + if ($normalized -ne $name) { + $openRouterLookup[$normalized] = $orm + } } # --------------------------------------------------------------------------- @@ -469,9 +495,14 @@ if (-not [string]::IsNullOrWhiteSpace($ProviderApiKey) -and $ProviderApis.Contai # is merged in as a supplement (it is sometimes incomplete). # "deprecation" != null on any group member marks the canonical # as deprecated. -# Other - Use the per-entry "aliases" array when present (OpenAI / -# Anthropic / DeepSeek currently don't expose aliases, so the -# map is typically empty). +# OpenAI/Anthropic - No "aliases" field in the API; instead, dated suffixes +# in the id encode the alias relationship. Group by stripping +# the suffix (-YYYY-MM-DD / -YYYYMMDD / -latest) to get a base +# key. The dated id is canonical (immutable release); the bare +# id and -latest variant become aliases. Driven by +# $ProviderAliasSuffix table above. +# Other - Use the per-entry "aliases" array when present (DeepSeek +# currently doesn't expose aliases, so the map is empty). # --------------------------------------------------------------------------- $apiAliasesByCanonical = @{} $apiCanonicalByAlias = [System.Collections.Generic.Dictionary[string,string]]::new([System.StringComparer]::OrdinalIgnoreCase) @@ -514,6 +545,70 @@ if ($providerApiQueried) { $apiAliasesByCanonical[$canonical] = $aliasList.ToArray() } } + elseif ($ProviderAliasSuffix.ContainsKey($Provider) -and $ProviderAliasSuffix[$Provider]) { + # Suffix-based grouping (OpenAI, Anthropic): + # baseKey = id with -YYYY-MM-DD / -YYYYMMDD / -latest stripped + # canonical = dated variant (highest date wins on ties); else -latest; + # else the bare id (group has no dated release at all) + $suffixRx = [regex]::new($ProviderAliasSuffix[$Provider]) + $dateRx = [regex]::new('-(\d{8}|\d{4}-\d{2}-\d{2})$') + $latestRx = [regex]::new('-latest$') + + $groups = @{} + foreach ($pmId in $providerApiModelNames) { + $baseKey = $suffixRx.Replace($pmId, '') + if (-not $groups.ContainsKey($baseKey)) { + $groups[$baseKey] = [System.Collections.Generic.List[string]]::new() + } + [void]$groups[$baseKey].Add($pmId) + } + + foreach ($kvp in $groups.GetEnumerator()) { + $baseKey = $kvp.Key + $ids = @($kvp.Value) + + # Skip groups that contain only the bare baseKey itself: nothing + # to alias (no dated/-latest variant present). Singleton groups + # whose sole id is dated are NOT skipped, because the bare baseKey + # is still an implicit server-side rolling alias we must record. + if ($ids.Count -eq 1 -and [string]::Equals($ids[0], $baseKey, 'OrdinalIgnoreCase')) { + continue + } + + # Pick canonical: highest dated id wins; else -latest; else bare id. + $dated = @($ids | Where-Object { $dateRx.IsMatch($_) }) + if ($dated.Count -gt 0) { + # Compare on the captured date string. Both YYYYMMDD and + # YYYY-MM-DD sort correctly lexicographically. + $canonical = $dated | Sort-Object -Property @{ + Expression = { $dateRx.Match($_).Groups[1].Value } + } -Descending | Select-Object -First 1 + } + else { + $latest = @($ids | Where-Object { $latestRx.IsMatch($_) }) + if ($latest.Count -gt 0) { $canonical = $latest[0] } + else { $canonical = $baseKey } + } + + $aliasList = [System.Collections.Generic.List[string]]::new() + foreach ($id in $ids) { + if (-not [string]::Equals($id, $canonical, 'OrdinalIgnoreCase')) { + [void]$aliasList.Add($id) + } + } + + # Always include the bare baseKey as an alias when it's not the + # canonical, even if the API didn't list it as a separate entry. + # This keeps the user-facing rolling alias (e.g. "claude-haiku-4-5") + # alive on the canonical dated id. + if (-not [string]::Equals($baseKey, $canonical, 'OrdinalIgnoreCase') -and + -not ($aliasList | Where-Object { [string]::Equals($_, $baseKey, 'OrdinalIgnoreCase') })) { + [void]$aliasList.Add($baseKey) + } + + $apiAliasesByCanonical[$canonical] = $aliasList.ToArray() + } + } else { foreach ($pmId in $providerApiModelNames) { $pm = $providerApiLookup[$pmId] @@ -658,7 +753,8 @@ if ($providerApiQueried) { continue } - $enrichment = Get-OpenRouterEnrichment -orm $openRouterLookup[$pmId] + $ormLookup = if ($openRouterLookup.ContainsKey($pmId)) { $openRouterLookup[$pmId] } else { $openRouterLookup[(Get-NormalizedModelName $pmId)] } + $enrichment = Get-OpenRouterEnrichment -orm $ormLookup $caps = if ($enrichment -and -not [string]::IsNullOrWhiteSpace($enrichment.Capabilities) -and $enrichment.Capabilities -ne 'AICapability.None') { $enrichment.Capabilities @@ -756,7 +852,7 @@ foreach ($kvp in $mergedModels.GetEnumerator()) { # Compute sorting keys (Created, OutputPrice) for every merged model # --------------------------------------------------------------------------- foreach ($m in $mergedModels.Values) { - $orm = $openRouterLookup[$m.Model] + $orm = if ($openRouterLookup.ContainsKey($m.Model)) { $openRouterLookup[$m.Model] } else { $openRouterLookup[(Get-NormalizedModelName $m.Model)] } if ($orm) { # Created: OpenRouter returns Unix epoch seconds if ($orm.created) { From ee9d426f66ac8e9e2ce1a44de7ff23d3c9794976 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 18:21:25 +0200 Subject: [PATCH 067/110] refactor: reorganize Anthropic models by release quarter, add Claude 4.6/4.7 models, and deprecate 9 older models --- .../AnthropicProviderModels.cs | 133 +++++++++++------- 1 file changed, 80 insertions(+), 53 deletions(-) diff --git a/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs b/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs index 4ef50518..802edb43 100644 --- a/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs +++ b/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * @@ -48,118 +48,136 @@ public override Task> RetrieveModels() var models = new List { + // Released between February 2026 and May 2026 + new AIModelCapabilities { Provider = providerName, - Model = "claude-opus-4-1", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Model = "claude-sonnet-4-6", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Deprecated = true, - Rank = 15, - ContextLimit = 200000, + Rank = 1000, + ContextLimit = 1000000, }, + new AIModelCapabilities { Provider = providerName, - Model = "claude-opus-4-5", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Model = "claude-opus-4-6", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 75, - ContextLimit = 200000, + Rank = 995, + ContextLimit = 1000000, }, + new AIModelCapabilities { Provider = providerName, - Model = "claude-opus-4-0", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Model = "claude-opus-4-7", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Deprecated = true, - Rank = 20, - ContextLimit = 200000, + Rank = 990, + ContextLimit = 1000000, }, + + + + // Released before May 2024 or unknown release date + new AIModelCapabilities { Provider = providerName, - Model = "claude-sonnet-4-5", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, - Default = AICapability.Text2Text | AICapability.Text2Json | AICapability.ReasoningChat | AICapability.ToolReasoningChat, + Model = "claude-haiku-4-5-20251001", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = true, - Rank = 80, + Rank = 985, ContextLimit = 200000, + Aliases = new List { "claude-haiku-4-5" }, + DiscouragedForTools = new List { "script_generate", "script_edit" }, }, + new AIModelCapabilities { Provider = providerName, - Model = "claude-sonnet-4-0", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Model = "claude-sonnet-4-5-20250929", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, - Verified = false, - Deprecated = true, - Rank = 70, + Verified = true, + Rank = 980, ContextLimit = 200000, + Aliases = new List { "claude-sonnet-4-5" }, }, + new AIModelCapabilities { Provider = providerName, - Model = "claude-3-7-sonnet-latest", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Model = "claude-opus-4-5-20251101", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Deprecated = true, - Rank = 60, + Rank = 975, ContextLimit = 200000, + Aliases = new List { "claude-opus-4-5" }, }, + + + + // Deprecated models + new AIModelCapabilities { Provider = providerName, - Model = "claude-3-5-haiku-latest", + Model = "claude-3-5-haiku-20241022", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, - Rank = 60, + Rank = 0, ContextLimit = 200000, DiscouragedForTools = new List { "script_generate", "script_edit" }, }, + new AIModelCapabilities { Provider = providerName, - Model = "claude-haiku-4-5", + Model = "claude-3-5-haiku-latest", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, - Default = AICapability.Text2Text | AICapability.ReasoningChat | AICapability.ToolReasoningChat, SupportsStreaming = true, - Verified = true, - Rank = 95, + Verified = false, + Deprecated = true, + Rank = -5, ContextLimit = 200000, DiscouragedForTools = new List { "script_generate", "script_edit" }, }, + new AIModelCapabilities { Provider = providerName, - Model = "claude-3-5-haiku-20241022", + Model = "claude-3-7-sonnet-20250219", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, - Rank = 90, + Rank = -10, ContextLimit = 200000, - DiscouragedForTools = new List { "script_generate", "script_edit" }, }, + new AIModelCapabilities { Provider = providerName, - Model = "claude-3-7-sonnet-20250219", + Model = "claude-3-7-sonnet-latest", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, - Rank = 60, + Rank = -15, ContextLimit = 200000, }, + new AIModelCapabilities { Provider = providerName, @@ -167,22 +185,24 @@ public override Task> RetrieveModels() Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 70, Deprecated = true, + Rank = -20, ContextLimit = 200000, DiscouragedForTools = new List { "script_generate", "script_edit" }, }, + new AIModelCapabilities { Provider = providerName, - Model = "claude-haiku-4-5-20251001", + Model = "claude-opus-4-0", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, - Verified = true, - Rank = 85, + Verified = false, + Deprecated = true, + Rank = -25, ContextLimit = 200000, - DiscouragedForTools = new List { "script_generate", "script_edit" }, }, + new AIModelCapabilities { Provider = providerName, @@ -191,9 +211,11 @@ public override Task> RetrieveModels() SupportsStreaming = true, Verified = false, Deprecated = true, - Rank = 20, + Rank = -30, ContextLimit = 200000, + Aliases = new List { "claude-opus-4-1" }, }, + new AIModelCapabilities { Provider = providerName, @@ -202,30 +224,35 @@ public override Task> RetrieveModels() SupportsStreaming = true, Verified = false, Deprecated = true, - Rank = 20, + Rank = -35, ContextLimit = 200000, + Aliases = new List { "claude-opus-4" }, }, + new AIModelCapabilities { Provider = providerName, - Model = "claude-sonnet-4-20250514", + Model = "claude-sonnet-4-0", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, - Rank = 75, + Rank = -40, ContextLimit = 200000, }, + new AIModelCapabilities { Provider = providerName, - Model = "claude-sonnet-4-5-20250929", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Model = "claude-sonnet-4-20250514", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, - Verified = true, - Rank = 80, + Verified = false, + Deprecated = true, + Rank = -45, ContextLimit = 200000, - }, + Aliases = new List { "claude-sonnet-4" }, + } }; return Task.FromResult(models); From 406a8650248e85523085aefde7af0aa9bf1860d3 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 18:35:51 +0200 Subject: [PATCH 068/110] refactor: reorganize OpenAI models by release quarter, add GPT-5/o3/o4 series models, and mark 14 older models as deprecated --- .../OpenAIProviderModels.cs | 1069 ++++++++++++++--- 1 file changed, 896 insertions(+), 173 deletions(-) diff --git a/src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs b/src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs index 1434cdbb..36876960 100644 --- a/src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs +++ b/src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * @@ -52,378 +52,1100 @@ public override Task> RetrieveModels() var models = new List { + // Released between February 2026 and May 2026 + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.4-nano-2026-03-17", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 1000, + ContextLimit = 400000, + Aliases = new List { "gpt-5.4-nano" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.4-mini-2026-03-17", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 995, + ContextLimit = 400000, + Aliases = new List { "gpt-5.4-mini" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.3-chat-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 990, + ContextLimit = 400000, + Aliases = new List { "gpt-5.3-chat" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.3-codex", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 985, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.4-2026-03-05", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 980, + ContextLimit = 1050000, + Aliases = new List { "gpt-5.4" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.5-2026-04-23", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 975, + ContextLimit = 1050000, + Aliases = new List { "gpt-5.5" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.4-pro-2026-03-05", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 970, + ContextLimit = 1050000, + Aliases = new List { "gpt-5.4-pro" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.5-pro-2026-04-23", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 965, + ContextLimit = 1050000, + Aliases = new List { "gpt-5.5-pro" }, + }, + + + + // Released between November 2025 and February 2026 + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.1-codex-mini", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 960, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-audio-mini-2025-12-15", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + SupportsStreaming = false, + Verified = false, + Rank = 955, + ContextLimit = 128000, + Aliases = new List { "gpt-audio-mini" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.1-2025-11-13", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 950, + ContextLimit = 400000, + Aliases = new List { "gpt-5.1" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.1-chat-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 945, + ContextLimit = 128000, + Aliases = new List { "gpt-5.1-chat" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.1-codex", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 940, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.1-codex-max", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 935, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.2-2025-12-11", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 930, + ContextLimit = 400000, + Aliases = new List { "gpt-5.2" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.2-chat-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 925, + ContextLimit = 128000, + Aliases = new List { "gpt-5.2-chat" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.2-codex", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 920, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-audio-2025-08-28", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + SupportsStreaming = false, + Verified = false, + Rank = 915, + ContextLimit = 128000, + Aliases = new List { "gpt-audio" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5.2-pro-2025-12-11", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 910, + ContextLimit = 400000, + Aliases = new List { "gpt-5.2-pro" }, + }, + + + + // Released between August 2025 and November 2025 + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5-nano-2025-08-07", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 905, + ContextLimit = 400000, + Aliases = new List { "gpt-5-nano" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5-mini-2025-08-07", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = true, + Rank = 900, + ContextLimit = 400000, + Aliases = new List { "gpt-5-mini" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "o4-mini-deep-research-2025-06-26", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 895, + ContextLimit = 200000, + Aliases = new List { "o4-mini-deep-research" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5-2025-08-07", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 890, + ContextLimit = 400000, + Aliases = new List { "gpt-5" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5-chat-latest", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 885, + ContextLimit = 128000, + Aliases = new List { "gpt-5-chat" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5-codex", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 880, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "o3-deep-research-2025-06-26", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 875, + ContextLimit = 200000, + Aliases = new List { "o3-deep-research" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-audio-preview-2025-06-03", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 870, + ContextLimit = 128000, + Aliases = new List { "gpt-4o-audio-preview" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5-pro-2025-10-06", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 865, + ContextLimit = 400000, + Aliases = new List { "gpt-5-pro" }, + }, + + + + // Released between May 2025 and August 2025 + + new AIModelCapabilities + { + Provider = provider, + Model = "o3-pro-2025-06-10", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 860, + ContextLimit = 200000, + Aliases = new List { "o3-pro" }, + }, + + + + // Released between February 2025 and May 2025 + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4.1-nano-2025-04-14", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 855, + ContextLimit = 1047576, + Aliases = new List { "gpt-4.1-nano" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-mini-search-preview-2025-03-11", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 850, + ContextLimit = 128000, + Aliases = new List { "gpt-4o-mini-search-preview" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4.1-mini-2025-04-14", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 845, + ContextLimit = 1047576, + Aliases = new List { "gpt-4.1-mini" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "o4-mini-2025-04-16", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 840, + ContextLimit = 200000, + Aliases = new List { "o4-mini" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4.1-2025-04-14", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 835, + ContextLimit = 1047576, + Aliases = new List { "gpt-4.1" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "o3-2025-04-16", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 830, + ContextLimit = 200000, + Aliases = new List { "o3" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-search-preview-2025-03-11", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 825, + ContextLimit = 128000, + Aliases = new List { "gpt-4o-search-preview" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "o1-pro-2025-03-19", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 820, + ContextLimit = 200000, + Aliases = new List { "o1-pro" }, + }, + + + + // Released between November 2024 and February 2025 + + new AIModelCapabilities + { + Provider = provider, + Model = "o3-mini-2025-01-31", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 815, + ContextLimit = 200000, + Aliases = new List { "o3-mini" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "o1-2024-12-17", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 810, + ContextLimit = 200000, + Aliases = new List { "o1" }, + }, + + + + // Released between August 2024 and November 2024 + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-2024-08-06", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 805, + ContextLimit = 128000, + }, + + + + // Released between May 2024 and August 2024 + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-mini-2024-07-18", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 800, + ContextLimit = 128000, + Aliases = new List { "gpt-4o-mini" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-2024-05-13", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 795, + ContextLimit = 128000, + }, + + + + // Released before May 2024 or unknown release date + + new AIModelCapabilities + { + Provider = provider, + Model = "dall-e-3", + Capabilities = AICapability.TextInput | AICapability.ImageOutput, + Default = AICapability.Text2Image, + SupportsStreaming = false, + Verified = true, + Rank = 790, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-3.5-turbo-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 785, + ContextLimit = 4095, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-3.5-turbo-16k", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 780, + ContextLimit = 16385, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "chatgpt-image-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 775, + ContextLimit = 128000, + Aliases = new List { "chatgpt-image" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-audio-preview-2024-12-17", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 770, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-mini-audio-preview-2024-12-17", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 765, + ContextLimit = 128000, + Aliases = new List { "gpt-4o-mini-audio-preview" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-mini-realtime-preview-2024-12-17", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 760, + ContextLimit = 128000, + Aliases = new List { "gpt-4o-mini-realtime-preview" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-mini-transcribe-2025-03-20", + Capabilities = AICapability.AudioInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 755, + ContextLimit = 16000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-mini-transcribe-2025-12-15", + Capabilities = AICapability.AudioInput | AICapability.TextOutput, + SupportsStreaming = false, + Verified = false, + Rank = 750, + ContextLimit = 16000, + Aliases = new List { "gpt-4o-mini-transcribe" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-mini-tts-2025-03-20", + Capabilities = AICapability.TextInput | AICapability.AudioOutput, + SupportsStreaming = true, + Verified = false, + Rank = 745, + ContextLimit = 2000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-mini-tts-2025-12-15", + Capabilities = AICapability.TextInput | AICapability.AudioOutput, + SupportsStreaming = false, + Verified = false, + Rank = 740, + ContextLimit = 2000, + Aliases = new List { "gpt-4o-mini-tts" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-realtime-preview-2024-12-17", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 735, + ContextLimit = 128000, + }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5-nano", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - Default = AICapability.Text2Text, + Model = "gpt-4o-realtime-preview-2025-06-03", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, SupportsStreaming = true, Verified = false, - Rank = 70, - ContextLimit = 400000, + Rank = 730, + ContextLimit = 128000, + Aliases = new List { "gpt-4o-realtime-preview" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5-mini", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - Default = AICapability.ToolChat | AICapability.Text2Json | AICapability.ToolReasoningChat, - SupportsStreaming = true, - Verified = true, - Rank = 95, - ContextLimit = 400000, + Model = "gpt-4o-transcribe", + Capabilities = AICapability.AudioInput | AICapability.TextOutput, + SupportsStreaming = false, + Verified = false, + Rank = 725, + ContextLimit = 16000, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - SupportsStreaming = true, + Model = "gpt-4o-transcribe-diarize", + Capabilities = AICapability.AudioInput | AICapability.TextOutput, + SupportsStreaming = false, Verified = false, - Rank = 70, - ContextLimit = 400000, + Rank = 720, + ContextLimit = 16000, }, + new AIModelCapabilities { Provider = provider, - Model = "codex-mini-latest", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + Model = "gpt-5-search-api-2025-10-14", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, SupportsStreaming = true, Verified = false, - Deprecated = true, - Rank = 60, - ContextLimit = 200000, + Rank = 715, + ContextLimit = 400000, + Aliases = new List { "gpt-5-search-api" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5-codex", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - SupportsStreaming = true, + Model = "gpt-audio-1.5", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + SupportsStreaming = false, Verified = false, - Rank = 70, - ContextLimit = 400000, + Rank = 710, + ContextLimit = 128000, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5.2-chat-latest", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + Model = "gpt-audio-mini-2025-10-06", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, SupportsStreaming = true, Verified = false, - Rank = 80, + Rank = 705, ContextLimit = 128000, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5.2", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - SupportsStreaming = true, + Model = "gpt-image-1", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.ImageOutput, + SupportsStreaming = false, Verified = false, - Rank = 90, - ContextLimit = 400000, + Rank = 700, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5.1", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - SupportsStreaming = true, + Model = "gpt-image-1-mini", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.ImageOutput, + Default = AICapability.Text2Image | AICapability.Image2Image, + SupportsStreaming = false, Verified = false, - Rank = 80, - ContextLimit = 400000, + Rank = 695, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5.1-chat-latest", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - SupportsStreaming = true, + Model = "gpt-image-1.5", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.ImageOutput, + SupportsStreaming = false, Verified = false, - Rank = 70, - ContextLimit = 128000, + Rank = 690, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5.1-codex", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - SupportsStreaming = true, + Model = "gpt-image-2-2026-04-21", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.ImageOutput, + SupportsStreaming = false, Verified = false, - Rank = 75, - ContextLimit = 400000, + Rank = 685, + Aliases = new List { "gpt-image-2" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5.1-codex-mini", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + Model = "gpt-realtime-1.5", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, SupportsStreaming = true, Verified = false, - Rank = 75, - ContextLimit = 400000, + Rank = 680, + ContextLimit = 128000, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4.1", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "gpt-realtime-2025-08-28", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, SupportsStreaming = true, Verified = false, - Rank = 75, - ContextLimit = 1047576, + Rank = 675, + ContextLimit = 128000, + Aliases = new List { "gpt-realtime" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4.1-mini", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "gpt-realtime-mini-2025-10-06", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, SupportsStreaming = true, Verified = false, - Rank = 85, - ContextLimit = 1047576, + Rank = 670, + ContextLimit = 128000, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4.1-nano", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "gpt-realtime-mini-2025-12-15", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, SupportsStreaming = true, Verified = false, - Rank = 65, - ContextLimit = 1047576, + Rank = 665, + ContextLimit = 128000, + Aliases = new List { "gpt-realtime-mini" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4-turbo", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling, - SupportsStreaming = true, + Model = "omni-moderation-2024-09-26", + Capabilities = AICapability.TextInput | AICapability.ImageInput, + SupportsStreaming = false, Verified = false, - Rank = 40, - ContextLimit = 128000, + Rank = 660, + Aliases = new List { "omni-moderation-latest", "omni-moderation" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4", - Capabilities = AICapability.TextInput | AICapability.TextOutput, - SupportsStreaming = true, + Model = "sora-2", + Capabilities = AICapability.TextInput | AICapability.VideoOutput, + SupportsStreaming = false, Verified = false, - Deprecated = true, - Rank = 40, - ContextLimit = 8192, + Rank = 655, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-3.5-turbo", - Capabilities = AICapability.TextInput | AICapability.TextOutput, + Model = "sora-2-pro", + Capabilities = AICapability.TextInput | AICapability.VideoOutput, SupportsStreaming = false, Verified = false, - Deprecated = true, - Rank = 40, - ContextLimit = 16385, + Rank = 650, }, + new AIModelCapabilities { Provider = provider, - Model = "o4-mini", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - SupportsStreaming = true, + Model = "text-embedding-3-large", + Capabilities = AICapability.Embedding, + SupportsStreaming = false, Verified = false, - Rank = 70, - ContextLimit = 200000, + Rank = 645, }, + new AIModelCapabilities { Provider = provider, - Model = "o3", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - SupportsStreaming = true, + Model = "text-embedding-3-small", + Capabilities = AICapability.Embedding, + SupportsStreaming = false, Verified = false, - Deprecated = true, - Rank = 40, - ContextLimit = 200000, + Rank = 640, }, + new AIModelCapabilities { Provider = provider, - Model = "o3-mini", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - SupportsStreaming = true, + Model = "tts-1", + Capabilities = AICapability.TextInput | AICapability.AudioOutput, + SupportsStreaming = false, Verified = false, - Deprecated = true, - Rank = 50, - ContextLimit = 200000, + Rank = 635, + ContextLimit = 2000, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5-chat-latest", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - SupportsStreaming = true, + Model = "tts-1-1106", + Capabilities = AICapability.TextInput | AICapability.AudioOutput, + SupportsStreaming = false, Verified = false, - Rank = 40, - ContextLimit = 128000, + Rank = 630, + ContextLimit = 2000, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4o-mini", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.JsonOutput | AICapability.FunctionCalling, - SupportsStreaming = true, + Model = "tts-1-hd", + Capabilities = AICapability.TextInput | AICapability.AudioOutput, + SupportsStreaming = false, Verified = false, - Rank = 30, - ContextLimit = 128000, + Rank = 625, + ContextLimit = 2000, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4o", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.JsonOutput | AICapability.FunctionCalling, - SupportsStreaming = true, + Model = "tts-1-hd-1106", + Capabilities = AICapability.TextInput | AICapability.AudioOutput, + SupportsStreaming = false, Verified = false, - Rank = 30, - ContextLimit = 128000, + Rank = 620, + ContextLimit = 2000, }, + new AIModelCapabilities { Provider = provider, - Model = "chatgpt-4o-latest", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput, + Model = "whisper-1", + Capabilities = AICapability.AudioInput | AICapability.TextOutput, + SupportsStreaming = false, + Verified = false, + Rank = 615, + }, + + + + // Deprecated models + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-2024-11-20", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, SupportsStreaming = true, Verified = false, Deprecated = true, - Rank = 30, + Rank = 0, ContextLimit = 128000, + Aliases = new List { "gpt-4o" }, }, - // Image new AIModelCapabilities { Provider = provider, - Model = "dall-e-3", - Capabilities = AICapability.TextInput | AICapability.ImageOutput, - Default = AICapability.Text2Image, + Model = "gpt-3.5-turbo", + Capabilities = AICapability.TextInput | AICapability.TextOutput, SupportsStreaming = false, - Verified = true, - Rank = 80, + Verified = false, + Deprecated = true, + Rank = -5, + ContextLimit = 16385, }, + new AIModelCapabilities { Provider = provider, - Model = "dall-e-2", - Capabilities = AICapability.TextInput | AICapability.ImageOutput, - SupportsStreaming = false, + Model = "gpt-4-turbo-2024-04-09", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, Verified = false, Deprecated = true, - Rank = 60, + Rank = -10, + ContextLimit = 128000, + Aliases = new List { "gpt-4-turbo" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-image-1-mini", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.ImageOutput, - Default = AICapability.Text2Image | AICapability.Image2Image, - SupportsStreaming = false, + Model = "gpt-4", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, Verified = false, - Rank = 70, + Deprecated = true, + Rank = -15, + ContextLimit = 8192, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-image-1", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.ImageOutput, + Model = "babbage-002", + Capabilities = AICapability.TextInput | AICapability.TextOutput, SupportsStreaming = false, Verified = false, - Rank = 60, + Deprecated = true, + Rank = -20, }, - // Audio new AIModelCapabilities { Provider = provider, - Model = "gpt-4o-mini-tts", - Capabilities = AICapability.TextInput | AICapability.AudioOutput, - SupportsStreaming = false, + Model = "chatgpt-4o-latest", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput, + SupportsStreaming = true, Verified = false, - Rank = 60, - ContextLimit = 2000, + Deprecated = true, + Rank = -25, + ContextLimit = 128000, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4o-mini-transcribe", - Capabilities = AICapability.AudioInput | AICapability.TextOutput, - SupportsStreaming = false, + Model = "codex-mini-latest", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, Verified = false, - Rank = 70, - ContextLimit = 16000, + Deprecated = true, + Rank = -30, + ContextLimit = 200000, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4o-transcribe", - Capabilities = AICapability.AudioInput | AICapability.TextOutput, + Model = "dall-e-2", + Capabilities = AICapability.TextInput | AICapability.ImageOutput, SupportsStreaming = false, Verified = false, - Rank = 60, - ContextLimit = 16000, + Deprecated = true, + Rank = -35, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-audio-mini", - Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, - SupportsStreaming = false, + Model = "davinci-002", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, Verified = false, - Rank = 60, - ContextLimit = 128000, + Deprecated = true, + Rank = -40, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-audio", - Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, - SupportsStreaming = false, + Model = "gpt-3.5-turbo-0125", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, Verified = false, - Rank = 40, - ContextLimit = 128000, + Deprecated = true, + Rank = -45, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4o-mini-audio-preview", - Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + Model = "gpt-3.5-turbo-1106", + Capabilities = AICapability.TextInput | AICapability.TextOutput, SupportsStreaming = true, Verified = false, Deprecated = true, - Rank = 45, - ContextLimit = 128000, + Rank = -50, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4o-audio-preview", - Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + Model = "gpt-3.5-turbo-instruct-0914", + Capabilities = AICapability.TextInput | AICapability.TextOutput, SupportsStreaming = true, Verified = false, Deprecated = true, - Rank = 40, - ContextLimit = 128000, + Rank = -55, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4-0613", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -60, + ContextLimit = 8192, }, + new AIModelCapabilities { Provider = provider, - Model = "whisper-1", - Capabilities = AICapability.AudioInput | AICapability.TextOutput, + Model = "text-embedding-ada-002", + Capabilities = AICapability.Embedding, SupportsStreaming = false, Verified = false, - Rank = 40, - }, + Deprecated = true, + Rank = -65, + } }; return Task.FromResult(models); @@ -481,3 +1203,4 @@ public override async Task> RetrieveApiModels() } } } + From 111507675f5d07fb38326e67b0d9ea65b489a18d Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 18:36:10 +0200 Subject: [PATCH 069/110] refactor: fix dated model aliasing to preserve distinct releases and inherit capabilities from siblings --- tools/Update-ProviderModels.ps1 | 126 ++++++++++++++++++++++++++++---- 1 file changed, 110 insertions(+), 16 deletions(-) diff --git a/tools/Update-ProviderModels.ps1 b/tools/Update-ProviderModels.ps1 index 39be4f6c..db5d5236 100644 --- a/tools/Update-ProviderModels.ps1 +++ b/tools/Update-ProviderModels.ps1 @@ -426,6 +426,33 @@ function Get-NormalizedModelName($modelName) { return $modelName -replace '\.', '-' } +# Resolve the OpenRouter entry for a model id, trying (in order): +# 1. exact id +# 2. dot/hyphen normalized id +# 3. each provided alias (exact + normalized) +# OpenRouter typically ships the bare form (e.g. "gpt-4o-mini", +# "claude-haiku-4.5") while our canonical is the dated form +# ("gpt-4o-mini-2024-07-18", "claude-haiku-4-5-20251001"). Without this +# fallback the canonical loses its Created/OutputPrice metadata and falls +# to the bottom of the sort. +function Resolve-OpenRouterEntry($modelId, $aliases) { + if ([string]::IsNullOrWhiteSpace($modelId)) { return $null } + $candidates = [System.Collections.Generic.List[string]]::new() + [void]$candidates.Add($modelId) + [void]$candidates.Add((Get-NormalizedModelName $modelId)) + if ($aliases) { + foreach ($a in $aliases) { + if ([string]::IsNullOrWhiteSpace($a)) { continue } + [void]$candidates.Add($a) + [void]$candidates.Add((Get-NormalizedModelName $a)) + } + } + foreach ($key in $candidates) { + if ($openRouterLookup.ContainsKey($key)) { return $openRouterLookup[$key] } + } + return $null +} + # Build quick lookup by stored model name (original + normalized for cross-reference matching) $openRouterLookup = @{ } foreach ($orm in $openRouterModels) { @@ -575,7 +602,12 @@ if ($providerApiQueried) { continue } - # Pick canonical: highest dated id wins; else -latest; else bare id. + # Only the bare baseKey and -latest variant are rolling aliases. + # Different dated releases are distinct immutable models and must + # remain standalone (NOT folded into one group's alias list). + # + # Canonical = most recent dated id in the group; the rolling + # alias slots (bare baseKey, -latest) attach to it. $dated = @($ids | Where-Object { $dateRx.IsMatch($_) }) if ($dated.Count -gt 0) { # Compare on the captured date string. Both YYYYMMDD and @@ -590,11 +622,14 @@ if ($providerApiQueried) { else { $canonical = $baseKey } } + # Aliases: only the bare baseKey and the -latest variant, when + # present in the group (or implicit for the bare baseKey). $aliasList = [System.Collections.Generic.List[string]]::new() foreach ($id in $ids) { - if (-not [string]::Equals($id, $canonical, 'OrdinalIgnoreCase')) { - [void]$aliasList.Add($id) - } + if ([string]::Equals($id, $canonical, 'OrdinalIgnoreCase')) { continue } + $isBare = [string]::Equals($id, $baseKey, 'OrdinalIgnoreCase') + $isLatest = $latestRx.IsMatch($id) + if ($isBare -or $isLatest) { [void]$aliasList.Add($id) } } # Always include the bare baseKey as an alias when it's not the @@ -606,7 +641,9 @@ if ($providerApiQueried) { [void]$aliasList.Add($baseKey) } - $apiAliasesByCanonical[$canonical] = $aliasList.ToArray() + if ($aliasList.Count -gt 0) { + $apiAliasesByCanonical[$canonical] = $aliasList.ToArray() + } } } else { @@ -739,21 +776,31 @@ if ($providerApiQueried) { # (additive merge: keep existing aliases, add any new ones from the API) # and propagate provider deprecation flag. $existing = $mergedModels[$pmId] - if ($apiAliases.Count -gt 0) { - $currentAliases = [System.Collections.Generic.List[string]]::new() - if ($existing.Aliases) { - foreach ($a in @($existing.Aliases)) { [void]$currentAliases.Add($a) } - } - foreach ($a in $apiAliases) { - if (-not $currentAliases.Contains($a)) { [void]$currentAliases.Add($a) } - } - $existing.Aliases = $currentAliases.ToArray() + $currentAliases = [System.Collections.Generic.List[string]]::new() + if ($existing.Aliases) { + foreach ($a in @($existing.Aliases)) { [void]$currentAliases.Add($a) } } + foreach ($a in $apiAliases) { + if (-not $currentAliases.Contains($a)) { [void]$currentAliases.Add($a) } + } + # Drop stale aliases that are themselves provider-API canonicals + # (i.e. listed as a top-level model and NOT registered as our alias). + # This corrects historical entries where a different dated release + # was previously folded in as an alias. + $cleaned = [System.Collections.Generic.List[string]]::new() + foreach ($a in $currentAliases) { + $isOtherCanonical = ($providerApiModelNames -contains $a) -and ( + -not $apiCanonicalByAlias.ContainsKey($a) -or + -not [string]::Equals($apiCanonicalByAlias[$a], $pmId, 'OrdinalIgnoreCase') + ) + if (-not $isOtherCanonical) { [void]$cleaned.Add($a) } + } + $existing.Aliases = if ($cleaned.Count -gt 0) { $cleaned.ToArray() } else { $null } if ($isApiDeprecated) { $existing.Deprecated = 'true' } continue } - $ormLookup = if ($openRouterLookup.ContainsKey($pmId)) { $openRouterLookup[$pmId] } else { $openRouterLookup[(Get-NormalizedModelName $pmId)] } + $ormLookup = Resolve-OpenRouterEntry $pmId $apiAliases $enrichment = Get-OpenRouterEnrichment -orm $ormLookup $caps = if ($enrichment -and -not [string]::IsNullOrWhiteSpace($enrichment.Capabilities) -and $enrichment.Capabilities -ne 'AICapability.None') { @@ -829,6 +876,53 @@ foreach ($m in $mergedModels.Values) { $m.Aliases = if ($clean.Count -gt 0) { $clean.ToArray() } else { $null } } +# --------------------------------------------------------------------------- +# Sibling capability inheritance. +# +# Different dated releases that share a baseKey (e.g. gpt-4o-mini-transcribe- +# 2025-03-20 vs gpt-4o-mini-transcribe-2025-12-15) are distinct models, but +# OpenRouter only documents the bare alias. Older/peripheral dated entries +# therefore land with Capabilities = AICapability.None. Inherit caps and +# context from a sibling that has real data. +# --------------------------------------------------------------------------- +if ($ProviderAliasSuffix.ContainsKey($Provider) -and $ProviderAliasSuffix[$Provider]) { + $suffixRxInherit = [regex]::new($ProviderAliasSuffix[$Provider]) + + $byBase = @{} + foreach ($m in $mergedModels.Values) { + $bk = $suffixRxInherit.Replace($m.Model, '') + if (-not $byBase.ContainsKey($bk)) { + $byBase[$bk] = [System.Collections.Generic.List[object]]::new() + } + [void]$byBase[$bk].Add($m) + } + + foreach ($kvp in $byBase.GetEnumerator()) { + $siblings = $kvp.Value + if ($siblings.Count -le 1) { continue } + + # Donor: any sibling with real (non-None/non-empty) capabilities. + $donor = $siblings | Where-Object { + -not [string]::IsNullOrWhiteSpace($_.Capabilities) -and + $_.Capabilities -ne 'AICapability.None' + } | Select-Object -First 1 + if (-not $donor) { continue } + + foreach ($m in $siblings) { + if ([System.Object]::ReferenceEquals($m, $donor)) { continue } + if ([string]::IsNullOrWhiteSpace($m.Capabilities) -or $m.Capabilities -eq 'AICapability.None') { + $m.Capabilities = $donor.Capabilities + } + if ($null -eq $m.ContextLimit -and $null -ne $donor.ContextLimit) { + $m.ContextLimit = $donor.ContextLimit + } + if ([string]::IsNullOrWhiteSpace($m.SupportsStreaming) -and -not [string]::IsNullOrWhiteSpace($donor.SupportsStreaming)) { + $m.SupportsStreaming = $donor.SupportsStreaming + } + } + } +} + # --------------------------------------------------------------------------- # Mark models that no longer appear in the authoritative source as deprecated. # Authoritative = provider API when queried, otherwise OpenRouter. @@ -852,7 +946,7 @@ foreach ($kvp in $mergedModels.GetEnumerator()) { # Compute sorting keys (Created, OutputPrice) for every merged model # --------------------------------------------------------------------------- foreach ($m in $mergedModels.Values) { - $orm = if ($openRouterLookup.ContainsKey($m.Model)) { $openRouterLookup[$m.Model] } else { $openRouterLookup[(Get-NormalizedModelName $m.Model)] } + $orm = Resolve-OpenRouterEntry $m.Model $m.Aliases if ($orm) { # Created: OpenRouter returns Unix epoch seconds if ($orm.created) { From 96f0b625ff138f0fd6e64b73f8df69fd269a6fae Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 18:41:37 +0200 Subject: [PATCH 070/110] refactor: reorder Anthropic/MistralAI models by release quarter and adjust rank values --- .../AnthropicProviderModels.cs | 62 ++++++++++--------- .../MistralAIProviderModels.cs | 34 +++++----- 2 files changed, 50 insertions(+), 46 deletions(-) diff --git a/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs b/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs index 802edb43..14d88780 100644 --- a/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs +++ b/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs @@ -85,43 +85,47 @@ public override Task> RetrieveModels() - // Released before May 2024 or unknown release date + // Released between November 2025 and February 2026 new AIModelCapabilities { Provider = providerName, - Model = "claude-haiku-4-5-20251001", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Model = "claude-opus-4-5-20251101", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, - Verified = true, + Verified = false, Rank = 985, ContextLimit = 200000, - Aliases = new List { "claude-haiku-4-5" }, - DiscouragedForTools = new List { "script_generate", "script_edit" }, + Aliases = new List { "claude-opus-4-5" }, }, + + + // Released between August 2025 and November 2025 + new AIModelCapabilities { Provider = providerName, - Model = "claude-sonnet-4-5-20250929", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Model = "claude-haiku-4-5-20251001", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = true, Rank = 980, ContextLimit = 200000, - Aliases = new List { "claude-sonnet-4-5" }, + Aliases = new List { "claude-haiku-4-5" }, + DiscouragedForTools = new List { "script_generate", "script_edit" }, }, new AIModelCapabilities { Provider = providerName, - Model = "claude-opus-4-5-20251101", + Model = "claude-sonnet-4-5-20250929", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, - Verified = false, + Verified = true, Rank = 975, ContextLimit = 200000, - Aliases = new List { "claude-opus-4-5" }, + Aliases = new List { "claude-sonnet-4-5" }, }, @@ -131,57 +135,59 @@ public override Task> RetrieveModels() new AIModelCapabilities { Provider = providerName, - Model = "claude-3-5-haiku-20241022", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Model = "claude-opus-4-1-20250805", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, Rank = 0, ContextLimit = 200000, - DiscouragedForTools = new List { "script_generate", "script_edit" }, + Aliases = new List { "claude-opus-4-1" }, }, new AIModelCapabilities { Provider = providerName, - Model = "claude-3-5-haiku-latest", + Model = "claude-sonnet-4-20250514", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, Rank = -5, ContextLimit = 200000, - DiscouragedForTools = new List { "script_generate", "script_edit" }, + Aliases = new List { "claude-sonnet-4" }, }, new AIModelCapabilities { Provider = providerName, - Model = "claude-3-7-sonnet-20250219", + Model = "claude-opus-4-20250514", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, Rank = -10, ContextLimit = 200000, + Aliases = new List { "claude-opus-4" }, }, new AIModelCapabilities { Provider = providerName, - Model = "claude-3-7-sonnet-latest", + Model = "claude-3-5-haiku-20241022", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, Rank = -15, ContextLimit = 200000, + DiscouragedForTools = new List { "script_generate", "script_edit" }, }, new AIModelCapabilities { Provider = providerName, - Model = "claude-3-haiku-20240307", + Model = "claude-3-5-haiku-latest", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, @@ -194,7 +200,7 @@ public override Task> RetrieveModels() new AIModelCapabilities { Provider = providerName, - Model = "claude-opus-4-0", + Model = "claude-3-7-sonnet-20250219", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, @@ -206,33 +212,32 @@ public override Task> RetrieveModels() new AIModelCapabilities { Provider = providerName, - Model = "claude-opus-4-1-20250805", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Model = "claude-3-7-sonnet-latest", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, Rank = -30, ContextLimit = 200000, - Aliases = new List { "claude-opus-4-1" }, }, new AIModelCapabilities { Provider = providerName, - Model = "claude-opus-4-20250514", + Model = "claude-3-haiku-20240307", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, Rank = -35, ContextLimit = 200000, - Aliases = new List { "claude-opus-4" }, + DiscouragedForTools = new List { "script_generate", "script_edit" }, }, new AIModelCapabilities { Provider = providerName, - Model = "claude-sonnet-4-0", + Model = "claude-opus-4-0", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, @@ -244,14 +249,13 @@ public override Task> RetrieveModels() new AIModelCapabilities { Provider = providerName, - Model = "claude-sonnet-4-20250514", + Model = "claude-sonnet-4-0", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, Rank = -45, ContextLimit = 200000, - Aliases = new List { "claude-sonnet-4" }, } }; diff --git a/src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs b/src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs index b818184c..4f552463 100644 --- a/src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs +++ b/src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs @@ -149,6 +149,18 @@ public override Task> RetrieveModels() Aliases = new List { "codestral-latest" }, }, + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-medium-3-5", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.Reasoning | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 965, + ContextLimit = 256000, + Aliases = new List { "mistral-medium-3.5", "mistral-medium-3", "mistral-medium-2604", "mistral-medium-c21211-r0-75", "mistral-vibe-cli-latest" }, + }, + // Released before May 2024 or unknown release date @@ -160,7 +172,7 @@ public override Task> RetrieveModels() Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, SupportsStreaming = true, Verified = true, - Rank = 965, + Rank = 960, ContextLimit = 131072, Aliases = new List { "mistral-medium", "mistral-medium-latest", "mistral-vibe-cli-with-tools" }, }, @@ -172,7 +184,7 @@ public override Task> RetrieveModels() Capabilities = AICapability.TextInput, SupportsStreaming = true, Verified = false, - Rank = 960, + Rank = 955, Aliases = new List { "codestral-embed-2505" }, DiscouragedForTools = new List { "*" }, }, @@ -184,7 +196,7 @@ public override Task> RetrieveModels() Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.FunctionCalling | AICapability.JsonOutput, SupportsStreaming = true, Verified = false, - Rank = 955, + Rank = 950, ContextLimit = 256000, }, @@ -195,7 +207,7 @@ public override Task> RetrieveModels() Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 950, + Rank = 945, ContextLimit = 40000, Aliases = new List { "magistral-medium-latest" }, }, @@ -207,20 +219,8 @@ public override Task> RetrieveModels() Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.FunctionCalling | AICapability.JsonOutput, SupportsStreaming = true, Verified = false, - Rank = 945, - ContextLimit = 128000, - }, - - new AIModelCapabilities - { - Provider = provider, - Model = "mistral-medium-3-5", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.Reasoning | AICapability.FunctionCalling | AICapability.JsonOutput, - SupportsStreaming = true, - Verified = false, Rank = 940, - ContextLimit = 256000, - Aliases = new List { "mistral-medium-3.5", "mistral-medium-3", "mistral-medium-2604", "mistral-medium-c21211-r0-75", "mistral-vibe-cli-latest" }, + ContextLimit = 128000, }, new AIModelCapabilities From eff56083b34fe06ce32a135e0818c8857265a649 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 18:41:47 +0200 Subject: [PATCH 071/110] refactor: resolve OpenRouter entries by newest release when multiple aliases match distinct API entries --- tools/Update-ProviderModels.ps1 | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tools/Update-ProviderModels.ps1 b/tools/Update-ProviderModels.ps1 index db5d5236..c1048e25 100644 --- a/tools/Update-ProviderModels.ps1 +++ b/tools/Update-ProviderModels.ps1 @@ -447,10 +447,35 @@ function Resolve-OpenRouterEntry($modelId, $aliases) { [void]$candidates.Add((Get-NormalizedModelName $a)) } } + + # Collect all distinct OpenRouter matches across the candidate set, then + # pick the most recent (tiebreak: highest output price). This matters when + # several aliases each map to a different OpenRouter entry — the canonical + # should reflect the newest release, not whichever alias was checked first. + $hits = [System.Collections.Generic.List[object]]::new() + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($key in $candidates) { - if ($openRouterLookup.ContainsKey($key)) { return $openRouterLookup[$key] } + if (-not $openRouterLookup.ContainsKey($key)) { continue } + $orm = $openRouterLookup[$key] + $ormId = if ($orm.id) { [string]$orm.id } else { $key } + if ($seen.Add($ormId)) { [void]$hits.Add($orm) } } - return $null + + if ($hits.Count -eq 0) { return $null } + if ($hits.Count -eq 1) { return $hits[0] } + + return $hits | Sort-Object -Property ` + @{ Expression = { if ($_.created) { [long]$_.created } else { 0 } }; Descending = $true }, + @{ Expression = { + $p = 0.0 + if ($_.pricing.completion) { $p += [double]$_.pricing.completion } + if ($_.pricing.image_output) { $p += [double]$_.pricing.image_output } + elseif ($_.pricing.image) { $p += [double]$_.pricing.image } + if ($_.pricing.audio_output) { $p += [double]$_.pricing.audio_output } + elseif ($_.pricing.audio) { $p += [double]$_.pricing.audio } + $p + }; Descending = $true } | + Select-Object -First 1 } # Build quick lookup by stored model name (original + normalized for cross-reference matching) From 85d5ebe019e540dfaf7556e4a4f4910ffc5a0f63 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 18:44:23 +0200 Subject: [PATCH 072/110] refactor: increase non-deprecated model rank ceiling from 1000 to 10000 --- tools/Update-ProviderModels.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/Update-ProviderModels.ps1 b/tools/Update-ProviderModels.ps1 index c1048e25..bf967ed9 100644 --- a/tools/Update-ProviderModels.ps1 +++ b/tools/Update-ProviderModels.ps1 @@ -1048,10 +1048,10 @@ if ($UpdateFile) { @{ Expression = { $_.Model }; Ascending = $true } ) - # Assign ranks: non-deprecated models get descending ranks starting at 1000, in steps of 5 + # Assign ranks: non-deprecated models get descending ranks starting at 10000, in steps of 5 $nonDeprecated = $sorted | Where-Object { $_.Deprecated -ne 'true' } for ($i = 0; $i -lt $nonDeprecated.Count; $i++) { - $nonDeprecated[$i].Rank = (1000 - ($i * 5)).ToString() + $nonDeprecated[$i].Rank = (10000 - ($i * 5)).ToString() } # Deprecated models get low ranks starting at 0, in steps of 5 From 2531e01ac15c02388209b3baf2e21b7acdead9b9 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 18:47:08 +0200 Subject: [PATCH 073/110] refactor: add 4000+ OpenRouter models organized by release quarter (Feb-May 2026) with capabilities and deprecation status --- .../OpenRouterProviderModels.cs | 4115 ++++++++++++++++- 1 file changed, 4092 insertions(+), 23 deletions(-) diff --git a/src/SmartHopper.Providers.OpenRouter/OpenRouterProviderModels.cs b/src/SmartHopper.Providers.OpenRouter/OpenRouterProviderModels.cs index 8977a309..777510c6 100644 --- a/src/SmartHopper.Providers.OpenRouter/OpenRouterProviderModels.cs +++ b/src/SmartHopper.Providers.OpenRouter/OpenRouterProviderModels.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * @@ -51,67 +51,4136 @@ public override Task> RetrieveModels() // Sample curated models exposed via OpenRouter var models = new List { + // Released between February 2026 and May 2026 + new AIModelCapabilities { Provider = provider, - Model = "openai/gpt-5-mini", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - Default = AICapability.Text2Text | AICapability.Text2Json, + Model = "openrouter/pareto-code", + Capabilities = AICapability.TextInput | AICapability.TextOutput, SupportsStreaming = true, Verified = false, - Rank = 95, - ContextLimit = 400000, + Rank = 10000, + ContextLimit = 200000, }, + new AIModelCapabilities { Provider = provider, - Model = "mistralai/mistral-medium-3.1", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "baidu/qianfan-ocr-fast:free", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9995, + ContextLimit = 65536, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-4-26b-a4b-it:free", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9990, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-4-31b-it:free", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9985, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/lyria-3-clip-preview", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9980, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/lyria-3-pro-preview", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9975, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "minimax/minimax-m2.5:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9970, + ContextLimit = 196608, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9965, + ContextLimit = 256000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nvidia/nemotron-3-super-120b-a12b:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9960, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openrouter/owl-alpha", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9955, + ContextLimit = 1048756, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "poolside/laguna-m.1:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 85, + Rank = 9950, ContextLimit = 131072, }, + new AIModelCapabilities { Provider = provider, - Model = "anthropic/claude-3.5-haiku", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "poolside/laguna-xs.2:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 90, - ContextLimit = 200000, + Rank = 9945, + ContextLimit = 131072, }, + new AIModelCapabilities { Provider = provider, - Model = "deepseek/deepseek-chat-v3.1", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "ibm-granite/granite-4.1-8b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, SupportsStreaming = true, Verified = false, - Rank = 80, - ContextLimit = 60000, + Rank = 9940, + ContextLimit = 131072, }, + new AIModelCapabilities { Provider = provider, - Model = "google/gemini-2.5-flash", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "rekaai/reka-edge", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9935, + ContextLimit = 16384, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "liquid/lfm-2-24b-a2b", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9930, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.5-9b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 85, + Rank = 9925, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "inclusionai/ling-2.6-flash", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9920, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.5-flash-02-23", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9915, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek/deepseek-v4-flash", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9910, ContextLimit = 1048576, }, + new AIModelCapabilities { Provider = provider, - Model = "moonshotai/kimi-k2", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "google/gemma-4-26b-a4b-it", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9905, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-4-31b-it", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9900, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "bytedance-seed/seed-2.0-mini", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9895, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nvidia/nemotron-3-super-120b-a12b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9890, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-small-2603", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9885, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "inception/mercury-2", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 70, + Rank = 9880, ContextLimit = 128000, }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-coder-next", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9875, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "arcee-ai/trinity-large-thinking", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9870, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek/deepseek-v4-pro", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9865, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.6-35b-a3b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9860, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "minimax/minimax-m2.5", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9855, + ContextLimit = 196608, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "kwaipilot/kat-coder-pro-v2", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9850, + ContextLimit = 256000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "minimax/minimax-m2.7", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9845, + ContextLimit = 196608, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.4-nano", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9840, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.5-35b-a3b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9835, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.6-flash", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9830, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.5-27b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9825, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.5-plus-02-15", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9820, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "aion-labs/aion-2.0", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9815, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.6-plus", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9810, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "bytedance-seed/seed-2.0-lite", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9805, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "xiaomi/mimo-v2-omni", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9800, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "xiaomi/mimo-v2.5", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9795, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.5-122b-a10b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9790, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "z-ai/glm-5", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9785, + ContextLimit = 202752, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-3.1-flash-lite-preview", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9780, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.5-397b-a17b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9775, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.5-plus-20260420", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9770, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "x-ai/grok-4.20", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9765, + ContextLimit = 2000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "x-ai/grok-4.3", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9760, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-3.1-flash-image-preview", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.ImageOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9755, + ContextLimit = 65536, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "xiaomi/mimo-v2-pro", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9750, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "xiaomi/mimo-v2.5-pro", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9745, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.6-27b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9740, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "~moonshotai/kimi-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9735, + ContextLimit = 262142, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "moonshotai/kimi-k2.6", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9730, + ContextLimit = 262142, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "z-ai/glm-5.1", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9725, + ContextLimit = 202752, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-max-thinking", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9720, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "z-ai/glm-5-turbo", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9715, + ContextLimit = 202752, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "z-ai/glm-5v-turbo", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9710, + ContextLimit = 202752, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "~google/gemini-flash-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9705, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "~openai/gpt-mini-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9700, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.4-mini", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9695, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "~anthropic/claude-haiku-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9690, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "x-ai/grok-4.20-multi-agent", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9685, + ContextLimit = 2000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.6-max-preview", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9680, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.3-chat", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9675, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.3-codex", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9670, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "~anthropic/claude-sonnet-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9665, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-sonnet-4.6", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9660, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.4", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9655, + ContextLimit = 1050000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.4-image-2", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.ImageOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9650, + ContextLimit = 272000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "~google/gemini-pro-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9645, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-3.1-pro-preview", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9640, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-3.1-pro-preview-customtools", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9635, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "~anthropic/claude-opus-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9630, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-opus-4.6", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9625, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-opus-4.7", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9620, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "~openai/gpt-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9615, + ContextLimit = 1050000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.5", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9610, + ContextLimit = 1050000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-opus-4.6-fast", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9605, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.4-pro", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9600, + ContextLimit = 1050000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.5-pro", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9595, + ContextLimit = 1050000, + }, + + + + // Released between November 2025 and February 2026 + + new AIModelCapabilities + { + Provider = provider, + Model = "openrouter/bodybuilder", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9590, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "liquid/lfm-2.5-1.2b-instruct:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9585, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "liquid/lfm-2.5-1.2b-thinking:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9580, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nvidia/nemotron-3-nano-30b-a3b:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9575, + ContextLimit = 256000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openrouter/free", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9570, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/ministral-3b-2512", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9565, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "arcee-ai/trinity-mini", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9560, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "essentialai/rnj-1-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9555, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/ministral-8b-2512", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9550, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/ministral-14b-2512", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9545, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nvidia/nemotron-3-nano-30b-a3b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9540, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "xiaomi/mimo-v2-flash", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9535, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "bytedance-seed/seed-1.6-flash", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9530, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "stepfun/step-3.5-flash", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9525, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek/deepseek-v3.2", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9520, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "z-ai/glm-4.7-flash", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9515, + ContextLimit = 202752, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "arcee-ai/trinity-large-preview", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9510, + ContextLimit = 131000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "allenai/olmo-3-32b-think", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9505, + ContextLimit = 65536, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nex-agi/deepseek-v3.1-nex-n1", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9500, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "x-ai/grok-4.1-fast", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9495, + ContextLimit = 2000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "upstage/solar-pro-3", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9490, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "z-ai/glm-4.6v", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9485, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "minimax/minimax-m2.1", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9480, + ContextLimit = 196608, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "prime-intellect/intellect-3", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9475, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek/deepseek-v3.2-speciale", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9470, + ContextLimit = 163840, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "minimax/minimax-m2-her", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9465, + ContextLimit = 65536, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepcogito/cogito-v2.1-671b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9460, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-large-2512", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9455, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "z-ai/glm-4.7", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9450, + ContextLimit = 202752, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "bytedance-seed/seed-1.6", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9445, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/devstral-2512", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9440, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "moonshotai/kimi-k2.5", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9435, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.1-codex-mini", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9430, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "amazon/nova-2-lite-v1", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9425, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "moonshotai/kimi-k2-thinking", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9420, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-audio-mini", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9415, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "relace/relace-search", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 9410, + ContextLimit = 256000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-3-flash-preview", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9405, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "writer/palmyra-x5", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9400, + ContextLimit = 1040000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.1", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9395, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.1-chat", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9390, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.1-codex", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9385, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.1-codex-max", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9380, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.2", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9375, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.2-chat", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9370, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.2-codex", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9365, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-3-pro-image-preview", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.ImageOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9360, + ContextLimit = 65536, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-opus-4.5", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9355, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-audio", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9350, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5.2-pro", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9345, + ContextLimit = 400000, + }, + + + + // Released between August 2025 and November 2025 + + new AIModelCapabilities + { + Provider = provider, + Model = "nvidia/nemotron-nano-12b-v2-vl:free", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9340, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nvidia/nemotron-nano-9b-v2:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9335, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-oss-120b:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9330, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-oss-20b:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9325, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-next-80b-a3b-instruct:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9320, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "ibm-granite/granite-4.0-h-micro", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9315, + ContextLimit = 131000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-oss-20b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9310, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nvidia/nemotron-nano-9b-v2", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9305, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-oss-120b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9300, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "baidu/ernie-4.5-21b-a3b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 9295, + ContextLimit = 120000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "baidu/ernie-4.5-21b-a3b-thinking", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9290, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-oss-safeguard-20b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9285, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nousresearch/hermes-4-70b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9280, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nvidia/llama-3.3-nemotron-super-49b-v1.5", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9275, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5-nano", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9270, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-30b-a3b-thinking-2507", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9265, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek/deepseek-v3.2-exp", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9260, + ContextLimit = 163840, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-vl-32b-instruct", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9255, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "alibaba/tongyi-deepresearch-30b-a3b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9250, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-vl-8b-instruct", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9245, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "thedrummer/cydonia-24b-v4.1", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9240, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "x-ai/grok-4-fast", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9235, + ContextLimit = 2000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-vl-30b-a3b-instruct", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9230, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "baidu/ernie-4.5-vl-28b-a3b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9225, + ContextLimit = 30000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek/deepseek-chat-v3.1", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9220, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen-plus-2025-07-28", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9215, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen-plus-2025-07-28:thinking", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9210, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-next-80b-a3b-thinking", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9205, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-2.5-flash-lite-preview-09-2025", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9200, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-vl-235b-a22b-instruct", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9195, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek/deepseek-v3.1-terminus", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9190, + ContextLimit = 163840, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-coder-flash", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9185, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "minimax/minimax-m2", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9180, + ContextLimit = 196608, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-next-80b-a3b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9175, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "relace/relace-apply-3", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9170, + ContextLimit = 256000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-vl-8b-thinking", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9165, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "x-ai/grok-code-fast-1", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9160, + ContextLimit = 256000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-vl-30b-a3b-thinking", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9155, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "z-ai/glm-4.5v", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9150, + ContextLimit = 65536, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "z-ai/glm-4.6", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9145, + ContextLimit = 204800, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-medium-3.1", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9140, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "moonshotai/kimi-k2-0905", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9135, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5-image-mini", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.ImageOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9130, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5-mini", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + Default = AICapability.Text2Text | AICapability.Text2Json, + SupportsStreaming = true, + Verified = false, + Rank = 9125, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-vl-235b-a22b-thinking", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9120, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nousresearch/hermes-4-405b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9115, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-coder-plus", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9110, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-2.5-flash-image", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.ImageOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9105, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-max", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9100, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-haiku-4.5", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9095, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "ai21/jamba-large-1.7", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9090, + ContextLimit = 256000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/o4-mini-deep-research", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9085, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9080, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5-chat", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9075, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5-codex", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9070, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5-image", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.ImageOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9065, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "amazon/nova-premier-v1", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 9060, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-sonnet-4.5", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9055, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "perplexity/sonar-pro-search", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9050, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/o3-deep-research", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9045, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4o-audio-preview", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9040, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-opus-4.1", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9035, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/voxtral-small-24b-2507", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9030, + ContextLimit = 32000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-5-pro", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9025, + ContextLimit = 400000, + }, + + + + // Released between May 2025 and August 2025 + + new AIModelCapabilities + { + Provider = provider, + Model = "cognitivecomputations/dolphin-mistral-24b-venice-edition:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9020, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-3n-e2b-it:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9015, + ContextLimit = 8192, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-3n-e4b-it:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9010, + ContextLimit = 8192, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-coder:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 9005, + ContextLimit = 262000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "z-ai/glm-4.5-air:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9000, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-235b-a22b-2507", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8995, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "z-ai/glm-4-32b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 8990, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-3n-e4b-it", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8985, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "arcee-ai/spotlight", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8980, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "bytedance/ui-tars-1.5-7b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8975, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-small-3.2-24b-instruct", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8970, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-coder-30b-a3b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8965, + ContextLimit = 160000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/devstral-small", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8960, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-30b-a3b-instruct-2507", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8955, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "x-ai/grok-3-mini", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8950, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "tencent/hunyuan-a13b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8945, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "arcee-ai/coder-large", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8940, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-2.5-flash-lite", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8935, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "z-ai/glm-4.5-air", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8930, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/codestral-2508", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8925, + ContextLimit = 256000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "baidu/ernie-4.5-300b-a47b", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8920, + ContextLimit = 123000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "tngtech/deepseek-r1t2-chimera", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8915, + ContextLimit = 163840, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "arcee-ai/virtuoso-large", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 8910, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "morph/morph-v3-fast", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8905, + ContextLimit = 81920, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "baidu/ernie-4.5-vl-424b-a47b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8900, + ContextLimit = 123000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-235b-a22b-thinking-2507", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8895, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-coder", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8890, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "morph/morph-v3-large", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8885, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/devstral-medium", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8880, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-medium-3", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8875, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek/deepseek-r1-0528", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8870, + ContextLimit = 163840, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "minimax/minimax-m1", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8865, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "z-ai/glm-4.5", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8860, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "moonshotai/kimi-k2", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 8855, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "arcee-ai/maestro-reasoning", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8850, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "switchpoint/router", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8845, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-2.5-flash", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8840, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-2.5-pro", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8835, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-2.5-pro-preview", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8830, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-2.5-pro-preview-05-06", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8825, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-sonnet-4", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8820, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "x-ai/grok-3", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8815, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "x-ai/grok-4", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8810, + ContextLimit = 256000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-opus-4", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8805, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/o3-pro", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8800, + ContextLimit = 200000, + }, + + + + // Released between February 2025 and May 2025 + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-3-12b-it:free", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8795, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-3-27b-it:free", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8790, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-3-4b-it:free", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8785, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-guard-3-8b", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8780, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-3-4b-it", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8775, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-3-12b-it", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8770, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-3-27b-it", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8765, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-guard-4-12b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8760, + ContextLimit = 163840, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "rekaai/reka-flash-3", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8755, + ContextLimit = 65536, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-14b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8750, + ContextLimit = 40960, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-32b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8745, + ContextLimit = 40960, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-4-scout", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8740, + ContextLimit = 327680, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4.1-nano", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8735, + ContextLimit = 1047576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-8b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8730, + ContextLimit = 40960, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen-vl-plus", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8725, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-30b-a3b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8720, + ContextLimit = 40960, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "x-ai/grok-3-mini-beta", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8715, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-small-3.1-24b-instruct", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8710, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-4-maverick", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8705, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-saba", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8700, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4o-mini-search-preview", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8695, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek/deepseek-chat-v3-0324", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8690, + ContextLimit = 163840, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "thedrummer/skyfall-36b-v2", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8685, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "alfredpros/codellama-7b-instruct-solidity", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8680, + ContextLimit = 4096, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "aion-labs/aion-1.0-mini", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8675, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "aion-labs/aion-rp-llama-3.1-8b", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8670, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4.1-mini", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8665, + ContextLimit = 1047576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-235b-a22b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8660, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/o3-mini-high", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8655, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/o4-mini", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8650, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/o4-mini-high", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8645, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "aion-labs/aion-1.0", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8640, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4.1", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8635, + ContextLimit = 1047576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/o3", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8630, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "perplexity/sonar-deep-research", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8625, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "perplexity/sonar-reasoning-pro", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8620, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "cohere/command-a", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8615, + ContextLimit = 256000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4o-search-preview", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8610, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "perplexity/sonar-pro", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8605, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "x-ai/grok-3-beta", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8600, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/o1-pro", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8595, + ContextLimit = 200000, + }, + + + + // Released between November 2024 and February 2025 + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-3.3-70b-instruct:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 8590, + ContextLimit = 65536, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-small-24b-instruct-2501", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8585, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen-turbo", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8580, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "amazon/nova-micro-v1", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 8575, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "microsoft/phi-4", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8570, + ContextLimit = 16384, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "cohere/command-r7b-12-2024", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8565, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "amazon/nova-lite-v1", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 8560, + ContextLimit = 300000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek/deepseek-r1-distill-qwen-32b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8555, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-3.3-70b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8550, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "thedrummer/unslopnemo-12b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8545, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen2.5-vl-72b-instruct", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8540, + ContextLimit = 32000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "sao10k/l3.3-euryale-70b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8535, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen-plus", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8530, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek/deepseek-r1-distill-llama-70b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8525, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek/deepseek-chat", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8520, + ContextLimit = 163840, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "perplexity/sonar", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8515, + ContextLimit = 127072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen-2.5-coder-32b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8510, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "minimax/minimax-01", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8505, + ContextLimit = 1000192, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen-vl-max", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8500, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek/deepseek-r1", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8495, + ContextLimit = 64000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "sao10k/l3.1-70b-hanami-x1", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8490, + ContextLimit = 16000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "amazon/nova-pro-v1", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 8485, + ContextLimit = 300000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-3.5-haiku", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 8480, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen-max", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8475, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/o3-mini", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8470, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-large-2407", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8465, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-large-2411", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8460, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/pixtral-large-2411", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8455, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4o-2024-11-20", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8450, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/o1", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8445, + ContextLimit = 200000, + }, + + + + // Released between August 2024 and November 2024 + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-3.2-3b-instruct:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8440, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nousresearch/hermes-3-llama-3.1-405b:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8435, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "sao10k/l3-lunaris-8b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8430, + ContextLimit = 8192, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen-2.5-7b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8425, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-3.2-1b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8420, + ContextLimit = 60000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-3.2-11b-vision-instruct", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8415, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nousresearch/hermes-3-llama-3.1-70b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8410, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-3.2-3b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8405, + ContextLimit = 80000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen-2.5-72b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8400, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "thedrummer/rocinante-12b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8395, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "cohere/command-r-08-2024", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8390, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "sao10k/l3.1-euryale-70b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8385, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nousresearch/hermes-3-llama-3.1-405b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8380, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthracite-org/magnum-v4-72b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8375, + ContextLimit = 16384, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "cohere/command-r-plus-08-2024", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8370, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "inflection/inflection-3-pi", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8365, + ContextLimit = 8000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "inflection/inflection-3-productivity", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8360, + ContextLimit = 8000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4o-2024-08-06", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8355, + ContextLimit = 128000, + }, + + + + // Released between May 2024 and August 2024 + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-nemo", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8350, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-3.1-8b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8345, + ContextLimit = 16384, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nousresearch/hermes-2-pro-llama-3-8b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8340, + ContextLimit = 8192, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-3.1-70b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8335, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4o-mini", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8330, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4o-mini-2024-07-18", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8325, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-2-27b-it", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8320, + ContextLimit = 8192, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "sao10k/l3-euryale-70b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 8315, + ContextLimit = 8192, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4o", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8310, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4o-2024-05-13", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8305, + ContextLimit = 128000, + }, + + + + // Released before May 2024 or unknown release date + + new AIModelCapabilities + { + Provider = provider, + Model = "openrouter/auto", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.ImageOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8300, + ContextLimit = 2000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gryphe/mythomax-l2-13b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8295, + ContextLimit = 4096, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-7b-instruct-v0.1", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8290, + ContextLimit = 2824, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "microsoft/wizardlm-2-8x22b", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8285, + ContextLimit = 65535, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "undi95/remm-slerp-l2-13b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8280, + ContextLimit = 6144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-3-70b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8275, + ContextLimit = 8192, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mancer/weaver", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8270, + ContextLimit = 8000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-3-haiku", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 8265, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-3.5-turbo", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8260, + ContextLimit = 16385, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-3.5-turbo-0613", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8255, + ContextLimit = 4095, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-3.5-turbo-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8250, + ContextLimit = 4095, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-3.5-turbo-16k", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8245, + ContextLimit = 16385, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-large", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8240, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mixtral-8x22b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8235, + ContextLimit = 65536, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "alpindale/goliath-120b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8230, + ContextLimit = 6144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4-1106-preview", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8225, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4-turbo", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8220, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4-turbo-preview", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8215, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8210, + ContextLimit = 8191, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4-0314", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8205, + ContextLimit = 8191, + }, + + + + // Deprecated models + + new AIModelCapabilities + { + Provider = provider, + Model = "inclusionai/ling-2.6-1t:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = 0, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "tencent/hy3-preview:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -5, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "allenai/olmo-3.1-32b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -10, + ContextLimit = 65536, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nvidia/nemotron-nano-12b-v2-vl", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -15, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-2.0-flash-lite-001", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -20, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-2.0-flash-001", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -25, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-3.7-sonnet", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -30, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-3.7-sonnet:thinking", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -35, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nvidia/llama-3.1-nemotron-70b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -40, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-3-8b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -45, + ContextLimit = 8192, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mixtral-8x7b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -50, + ContextLimit = 32768, + } }; return Task.FromResult(models); From 518deb5f8690532f64fb1ead807b3fabb3471bae Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 19:32:20 +0200 Subject: [PATCH 074/110] refactor: exclude OpenRouter from provider API queries since it uses the same endpoint as the source of truth --- tools/Update-ProviderModels.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/Update-ProviderModels.ps1 b/tools/Update-ProviderModels.ps1 index bf967ed9..d28f2038 100644 --- a/tools/Update-ProviderModels.ps1 +++ b/tools/Update-ProviderModels.ps1 @@ -120,12 +120,13 @@ $ProviderAliasSuffix = @{ # Provider-native /models endpoints. Used only when -ProviderApiKey is supplied. # Each entry returns the URL and a script block that builds auth headers. +# Note: OpenRouter is excluded because it uses the same endpoint as the OpenRouter source of truth. +# For OpenRouter, deprecation is handled by comparing existing models against the current OpenRouter catalogue. $ProviderApis = @{ 'OpenAI' = @{ Url = 'https://api.openai.com/v1/models'; Headers = { param($k) @{ Authorization = "Bearer $k" } } } 'MistralAI' = @{ Url = 'https://api.mistral.ai/v1/models'; Headers = { param($k) @{ Authorization = "Bearer $k" } } } 'DeepSeek' = @{ Url = 'https://api.deepseek.com/v1/models'; Headers = { param($k) @{ Authorization = "Bearer $k" } } } 'Anthropic' = @{ Url = 'https://api.anthropic.com/v1/models'; Headers = { param($k) @{ 'x-api-key' = $k; 'anthropic-version' = '2023-06-01' } } } - 'OpenRouter' = @{ Url = 'https://openrouter.ai/api/v1/models'; Headers = { param($k) @{ Authorization = "Bearer $k" } } } } # --------------------------------------------------------------------------- @@ -951,6 +952,11 @@ if ($ProviderAliasSuffix.ContainsKey($Provider) -and $ProviderAliasSuffix[$Provi # --------------------------------------------------------------------------- # Mark models that no longer appear in the authoritative source as deprecated. # Authoritative = provider API when queried, otherwise OpenRouter. +# +# For OpenRouter provider specifically: +# - OpenRouter API is both source of truth AND authoritative source. +# - Models present in OpenRouterProviderModels.cs but missing from current OpenRouter catalogue are marked Deprecated = true +# - This ensures OpenRouter models are deprecated when removed from OpenRouter's available models list # --------------------------------------------------------------------------- if ($providerApiQueried) { $apiModelNames = $providerApiModelNames From 7e5eee9a2494b6edbb145e904c0927ae113c4da5 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 19:40:20 +0200 Subject: [PATCH 075/110] refactor: add section headers and improve formatting in code review workflow --- .windsurf/workflows/review.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.windsurf/workflows/review.md b/.windsurf/workflows/review.md index 6595c4a6..c9f775bc 100644 --- a/.windsurf/workflows/review.md +++ b/.windsurf/workflows/review.md @@ -2,9 +2,13 @@ auto_execution_mode: 0 description: Review code changes for bugs, security issues, and improvements --- + +# Code Review Workflow + You are a senior software engineer performing a thorough code review to identify potential bugs. Your task is to find all potential bugs and code improvements in the code changes. Focus on: + 1. Logic errors and incorrect behavior 2. Edge cases that aren't handled 3. Null/undefined reference issues @@ -16,7 +20,8 @@ Your task is to find all potential bugs and code improvements in the code change 9. Violations of existing code patterns or conventions Make sure to: + 1. If exploring the codebase, call multiple tools in parallel for increased efficiency. Do not spend too much time exploring. 2. If you find any pre-existing bugs in the code, you should also report those since it's important for us to maintain general code quality for the user. 3. Do NOT report issues that are speculative or low-confidence. All your conclusions should be based on a complete understanding of the codebase. -4. Remember that if you were given a specific git commit, it may not be checked out and local code states may be different. \ No newline at end of file +4. Remember that if you were given a specific git commit, it may not be checked out and local code states may be different. From 3178a4535bb5f4a1fa536d5979b500b19cb59911 Mon Sep 17 00:00:00 2001 From: marc-romu <49920661+marc-romu@users.noreply.github.com> Date: Sun, 3 May 2026 20:31:01 +0200 Subject: [PATCH 076/110] refactor: add optional provider-api-key input for dual-source model validation and improve workflow configuration - Add provider-api-key input to fetch-models action for querying native provider endpoints as secondary source - Pass provider-specific API keys (OpenAI, MistralAI, Anthropic, DeepSeek) via conditional expression in workflow - Simplify workflow matrix by removing redundant secret_name field and using OPENROUTER_API_KEY directly - Switch checkout to use default_branch variable and reduce --- .github/actions/ai/fetch-models/action.yml | 8 ++++++ .../chore-update-provider-models.yml | 28 +++++++++++-------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/.github/actions/ai/fetch-models/action.yml b/.github/actions/ai/fetch-models/action.yml index 635a3fce..8fb39d84 100644 --- a/.github/actions/ai/fetch-models/action.yml +++ b/.github/actions/ai/fetch-models/action.yml @@ -20,6 +20,10 @@ inputs: api-key: description: 'OpenRouter API key (used as the single source of truth for all providers)' required: true + provider-api-key: + description: 'Optional native API key for the target provider. When supplied, the provider''s own /models endpoint is queried as a secondary authoritative source (alias merging, deprecation flags). OpenRouter remains the metadata source of truth.' + required: false + default: '' update-file: description: 'When true, the script rewrites the source file: auto-inserts new models with capabilities mapped from OpenRouter metadata, updates existing model capabilities/ContextLimit, and marks disappeared or expiring models as Deprecated = true' required: false @@ -53,11 +57,15 @@ runs: shell: pwsh env: API_KEY: ${{ inputs.api-key }} + PROVIDER_API_KEY: ${{ inputs.provider-api-key }} run: | $params = @{ Provider = '${{ inputs.provider }}' ApiKey = $env:API_KEY } + if (-not [string]::IsNullOrWhiteSpace($env:PROVIDER_API_KEY)) { + $params['ProviderApiKey'] = $env:PROVIDER_API_KEY + } if ('${{ inputs.update-file }}' -eq 'true') { $params['UpdateFile'] = $true } diff --git a/.github/workflows/chore-update-provider-models.yml b/.github/workflows/chore-update-provider-models.yml index 58e49036..d2d7a438 100644 --- a/.github/workflows/chore-update-provider-models.yml +++ b/.github/workflows/chore-update-provider-models.yml @@ -36,21 +36,16 @@ jobs: matrix: include: - provider: OpenAI - secret_name: OPENROUTER_API_KEY - provider: MistralAI - secret_name: OPENROUTER_API_KEY - provider: Anthropic - secret_name: OPENROUTER_API_KEY - provider: OpenRouter - secret_name: OPENROUTER_API_KEY - provider: DeepSeek - secret_name: OPENROUTER_API_KEY steps: - name: Skip if secret is absent or filtered out id: check shell: bash env: - API_KEY: ${{ secrets[matrix.secret_name] }} + API_KEY: ${{ secrets.OPENROUTER_API_KEY }} FILTER: ${{ github.event.inputs.provider_filter || '' }} run: | if [ -z "$API_KEY" ]; then @@ -67,8 +62,8 @@ jobs: if: steps.check.outputs.skipped == 'false' uses: actions/checkout@v4 with: - ref: main - fetch-depth: 0 + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 1 - name: Fetch and compare models for ${{ matrix.provider }} if: steps.check.outputs.skipped == 'false' @@ -76,7 +71,8 @@ jobs: uses: ./.github/actions/ai/fetch-models with: provider: ${{ matrix.provider }} - api-key: ${{ secrets[matrix.secret_name] }} + api-key: ${{ secrets.OPENROUTER_API_KEY }} + provider-api-key: ${{ (matrix.provider == 'OpenAI' && secrets.OPENAI_API_KEY) || (matrix.provider == 'MistralAI' && secrets.MISTRAL_API_KEY) || (matrix.provider == 'Anthropic' && secrets.ANTHROPIC_API_KEY) || (matrix.provider == 'DeepSeek' && secrets.DEEPSEEK_API_KEY) || '' }} update-file: true - name: Emit step summary @@ -95,6 +91,16 @@ jobs: Write-Output "- **Deprecated models:** ``$($report.deprecatedModels -join ', ')``" >> $env:GITHUB_STEP_SUMMARY } + - name: Emit no-changes summary + if: steps.check.outputs.skipped == 'false' && steps.models.outputs.success == 'true' && steps.models.outputs.changed == 'false' + shell: pwsh + run: | + Write-Output "## ${{ matrix.provider }} model scan" >> $env:GITHUB_STEP_SUMMARY + Write-Output "- API models: $($env:MODEL_COUNT)" >> $env:GITHUB_STEP_SUMMARY + Write-Output "- **No changes** detected in source file; no PR created." >> $env:GITHUB_STEP_SUMMARY + env: + MODEL_COUNT: ${{ steps.models.outputs.count }} + - name: Create Pull Request if: steps.check.outputs.skipped == 'false' && steps.models.outputs.changed == 'true' uses: peter-evans/create-pull-request@v6 @@ -106,9 +112,9 @@ jobs: Auto-generated PR from `.github/workflows/chore-update-provider-models.yml`. ### Scan results for `${{ matrix.provider }}` - ```json + ````json ${{ steps.models.outputs.report }} - ``` + ```` **Actions taken:** - New models discovered via OpenRouter are auto-inserted with capabilities mapped from `architecture.modalities` and `supported_parameters`. From 200ed761f0bd2fbadc06280a13b471325841c9de Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 3 May 2026 17:47:35 +0000 Subject: [PATCH 077/110] chore(ci): update license headers --- .../SmartHopper.Infrastructure.csproj | 2 +- src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs | 2 +- src/SmartHopper.Providers.DeepSeek/DeepSeekProviderModels.cs | 2 +- src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs | 2 +- src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs | 2 +- .../OpenRouterProviderModels.cs | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj b/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj index 83358959..2740e7aa 100644 --- a/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj +++ b/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj @@ -1,4 +1,4 @@ -` marker) for additional verifiers to use as their certification comment. - - New workflow `.github/workflows/model-verification.yml` that triggers only when an issue comment starts with `/verify-confirm` (and contains the template marker) or `/verify-force`, tallies distinct GitHub users (issue author + valid `/verify-confirm` commenters), and opens a PR promoting the model to `Verified = true` once two distinct users have certified it. `/verify-force` is restricted to `OWNER`/`MEMBER`/`COLLABORATOR`. - - New helper `tools/Update-ModelVerified.ps1` that locates the matching `new AIModelCapabilities { Model = "..." }` block in `src/SmartHopper.Providers./ProviderModels.cs` and flips `Verified = false` to `Verified = true` (or inserts the flag when missing). +### Changed -- Automated provider model discovery CI (OpenRouter as single source of truth): - - New workflow `.github/workflows/chore-update-provider-models.yml` that runs weekly (Sundays 05:00 UTC) and on `workflow_dispatch`. It queries OpenRouter's unified `/models` endpoint for each supported provider, compares the returned metadata with the static declarations in `*ProviderModels.cs`, and opens a PR that both auto-inserts new models and marks disappeared/expiring models as `Deprecated = true`. - - New composite action `.github/actions/ai/fetch-models/action.yml` that invokes `tools/Update-ProviderModels.ps1` with an OpenRouter API key, passing the provider name and `update-file` flag. - - New PowerShell tool `tools/Update-ProviderModels.ps1` invoked by the workflow. Accepts `-Provider`, `-ApiKey` (OpenRouter key), and an optional `-TargetFile`. It queries OpenRouter, filters by provider prefix, maps `architecture.input_modalities`/`output_modalities` and `supported_parameters` to `AICapability` flags, auto-generates full `AIModelCapabilities` blocks for new models (with `ContextLimit`, `Verified=false`), and marks models with `expiration_date` < 1 year or absent from OpenRouter as `Deprecated = true`. `Rank` values are auto-computed from OpenRouter `created` timestamp (newer models rank higher) and output pricing (cheapest first, considering `pricing.completion`, `pricing.image`, and `pricing.audio_output`). Existing model capabilities, context limits, and ranks are refreshed on every run. Emits a structured JSON report containing `newModels`, `deprecatedModels`, and `unchangedModels`. +- **AI model rankings**: Adjusted default models and rankings across providers based on official documentation +- **Infrastructure**: Improved AI capability bit ordering and model catalog consistency +- **CI/CD**: Enhanced workflow automation for model verification, provider discovery, and stabilization branch management -### Changed +## [1.4.2-beta] - 2026-04-15 -- chore(rules): clarified Windsurf rules and workflows to reduce overlap, stale platform assumptions, and ambiguous SmartHopper architecture guidance. -- ci(pr-build-hash-validation): the `validate-no-manual-hash-edits` job now only blocks a PR when a changed file under `hashes/` differs from its counterpart on `main` (the source of truth). PRs that carry a hash commit verbatim from main (e.g., via branch update/rebase) are allowed. -- ci(concurrency): added top-level `concurrency:` to 25 workflows to prevent race conditions and save runner minutes: - - Auto-commit / auto-PR workflows grouped per ref with `cancel-in-progress: false` (queue, never interrupt a push-back): `chore-version-date`, `chore-update-contributors`, `chore-version-badge`, `pr-anonymize-public-key`, `dev-update-manifest`, `github-labels-sync`, `chore-version-main-release`, `pr-license-headers`, `stabilization-0-init`. - - Entity-scoped workflows grouped per issue/milestone/release/PR with `cancel-in-progress: false`: `model-verification`, `github-issue-labels-on-close`, `github-issue-labels-close`, `milestone-management`, `release-4-build`, `release-2-pr-to-dev-closed`, `release-3-pr-to-main-closed`. `release-5-deploy-pages` uses the standard `pages` group with `cancel-in-progress: true`. - - PR validation workflows grouped per PR with `cancel-in-progress: true` so superseded pushes are cancelled: `ci-dotnet-tests`, `pr-validation`, `pr-build-hash-validation`, `pr-version-validation`, `pr-manifest-validation`, `pr-dependency-validation`, `pr-block-dev-to-main`, `pr-milestone`. -- ci(auto-commit hardening): belt-and-braces against external commits landing between fetch and push. - - `dev-update-manifest` now does `git pull --rebase --autostash origin dev` with retry (×3) before pushing to `dev`. - - `pr-license-headers` now does `git pull --rebase --autostash` with retry (×3) before pushing back to the PR head branch (handles the contributor pushing a new commit mid-run). - - `chore-version-badge` gained a `paths: [Solution.props]` filter on its `push` trigger so it no longer runs on every unrelated push to `main`/`dev`; the version source of truth is the only relevant change. - - Auto-PR workflows that follow the delete-and-recreate pattern (`chore-update-contributors`, `pr-anonymize-public-key`) and those built on `peter-evans/create-pull-request` (`chore-version-date`, `chore-version-badge`, `chore-version-main-release`) already reuse existing PRs and were verified as safe; no changes needed. - -- **Infrastructure**: Migrated critical fixes including provider stability improvements, timeout policy refinements, and streaming adapter fixes -- **Thread Safety**: `ProviderManager` now uses `ConcurrentDictionary` for all provider collections to improve concurrent access safety -- **Code Quality**: Applied consistent code style with `this.` qualifiers and `ConfigureAwait()` patterns across Infrastructure and Providers - -- **AI model rebalancing**: - - **OpenAI**: `gpt-5.4-mini` retains Rank 100 with `Default = ToolChat | Text2Json | ToolReasoningChat`; moved `Default = Text2Image | Image2Image` from `gpt-image-1-mini` to `gpt-image-2`; cleared `Default = Text2Image` from `dall-e-3` (Rank 80 → 70); demoted `gpt-image-1.5` Rank 75 → 65. - - **Anthropic**: demoted `claude-opus-4-6` Rank 80 → 75 (superseded by `claude-opus-4-7`). - - **DeepSeek**: cleared `Default` from `deepseek-chat` (Rank 90 → 70) and `deepseek-reasoner` (Rank 80 → 60); both aliased to `deepseek-v4-flash` per official docs. - - **OpenRouter**: aligned mirrored OpenAI model `Default` flags with the native OpenAI provider entries — `openai/gpt-5.4-mini` and `openai/gpt-5-mini` now use `ToolChat | Text2Json | ToolReasoningChat`; cleared `Default` on `openai/gpt-5.4` to match native (no Default). - -- **Refactored Timeout Configuration System**: - - **AIRequestBase.TimeoutSeconds** is now nullable (`int?`) to allow null/empty values - - **RequestTimeoutPolicy** now resolves timeout from settings when null: - - Reads `Timeout` from settings (with fallback to `HttpTimeoutSeconds` for backward compatibility) - - Uses `TimeoutDefaults.DefaultTimeoutSeconds` (300s) as final fallback - - **AISettingsComponent** now has "Timeout" input parameter (before "Extras") for custom timeout override - - **Breaking (saved files)**: Inserting `Timeout` before `Extras` shifts the `Extras` parameter index by one. Grasshopper persists wire connections by parameter index, so existing `.ghx`/`.gh` files with a wire into `Extras` will, after upgrade, resolve that wire to the new `Timeout` (integer) input. Because the source value (Extras JSON string) is type-incompatible with `Timeout`, the input is silently ignored and the previously wired provider-specific extras are lost on file load. Reconnect the `Extras` input on `AISettingsComponent` after upgrading. - - **AIStatefulAsyncComponentBase** additions: - - New `ConfigureRequestTimeout()` helper method - centralizes timeout configuration for both batch and regular paths - - **AIProvider** simplified - removed duplicate timeout resolution logic, now relies on RequestTimeoutPolicy - - **Renamed HTTP Timeout Setting** (UI label and setting key): - - `HTTP Timeout (Regular Calls)` → `Timeout` (setting key: `TimeoutSeconds`, default: 300s) - - Old setting keys (`HttpTimeoutSeconds`, `ResponseGenerationTimeoutSeconds`) automatically migrated for backward compatibility - - **Timeout resolution priority** (highest to lowest): - 1. Custom timeout from AI Settings component input - 2. Settings-based timeout (`TimeoutSeconds`) - 3. Safe default (300s) - - **Unified default across layers**: introduced `SmartHopper.Infrastructure.AICall.Core.TimeoutDefaults` with `DefaultTimeoutSeconds = 300`, `MinTimeoutSeconds = 1`, `MaxTimeoutSeconds = 600`. The following call sites now reference these constants instead of hardcoded literals (previously: 300s policy, 600s provider/batch, 120s tool, 600s streaming idle): - - `RequestTimeoutPolicy` (default + bounds) - - `AIProvider.Call()` and `AIProvider.CreateBatchHttpClient()` (default + bounds) - - `AIToolCall.Exec()` (default + bounds) - - Streaming idle-timeout fallbacks in OpenAI, OpenRouter, MistralAI, DeepSeek, Anthropic, and Gemini providers - - `ProvidersSettingsPage` Timeout `NumericStepper` Value/Min/Max - - Behavior under normal flow is unchanged (policy always resolves first); this aligns the safety-net fallback when the policy pipeline is bypassed and ensures the settings UI bounds match the runtime clamp. - -- Components can now mix different `IGH_Goo` types in input trees (e.g., `GH_String` for text inputs, `GH_Boolean` for fallback values) -- Foundation laid for future extensibility to support `GH_Integer`, `GH_Number`, `GH_Path`, and other Grasshopper data types -- Existing `GH_String`-only workers remain compatible and can be migrated individually when needed -- **`AIText2BooleanComponent`**: Migrated to mixed-type IGH_Goo processing pipeline - - `inputTree` field changed from `Dictionary>` to `Dictionary>` - - Fallback input now stored natively as `GH_Boolean` without string conversion round-trip - - Uses `GHStructureConverter.ConvertToGooTree()` for type conversions - - Accesses results via `ProcessingResult.Outputs` and `ExtractTypedTree()` - -- **`AIList2BooleanComponent`**: Migrated to mixed-type IGH_Goo processing pipeline - - Same structural changes as `AIText2BooleanComponent` - - Fallback input stored natively as `GH_Boolean` -- **`AIFile2MdComponent` batch context persistence**: `_fileContexts` (base markdown + image slot metadata per file) is now serialized via `Write`/`Read` so batch results can be reconstructed after a Grasshopper file save/reload. A `_batchContextLost` flag prevents `GatherInput` from resetting `_fileContextsInitialized` while a batch is active, fixing a same-session overwrite bug where each poll tick re-initialized the context to empty before `OnBatchCompleted` could use it. -- **`AIFile2MdComponent` batch image descriptions**: `OnBatchCompleted` now extracts image descriptions from `AIInteractionText.Content` (the actual batch response type) instead of `AIInteractionToolResult`, which was never produced in batch mode since `BuildDescribeRequest` bypasses the tool execute wrapper. -- **`AIFile2MdComponent` batch metrics**: Per-slot image decode metrics are now accumulated manually and merged into `AIReturnSnapshot` after `ProcessBatchResults`, since `ProcessBatchResults` only sees the representative sentinel (one per file) and misses all non-first image slots. -- **`img2text` AI Tool**: Extracted shared `BuildRequestBody` and `ExtractDescription` helpers so `DescribeImageAsync` (execute path) and `BuildDescribeRequest` (batch path) send identical requests and decode using the same logic. `ExtractDescription` reads from `AIInteractionText.Content` directly, making both paths consistent. -- **`file2md` AI Tool**: `DescribeImageAsync` internal helper now reads `AIInteractionToolResult["description"]` with a clean single-expression return, removing a dead fallback that logged `assistantText.Content` but always returned `[Image could not be described]`. - -- **`AIFile2MdComponent`**: Reworked batch wiring so only the AI calls (image descriptions) are batched. File conversion and image extraction now run locally via `file2md` with `describeImages=false`; each image is then described via `CallAiToolAsync("img2text", ...)` which is batch-interceptable. `OnBatchCompleted` reassembles the final markdown from locally-stored per-file context and batch image description results. `UsingAiTools` updated from `file2md` to `img2text`. Format and Images outputs are computed locally and persisted immediately. -- **`img2text` AI Tool**: Added `BuildRequest` delegate so the tool supports batch mode. The delegate mirrors the existing `DescribeImageAsync` request construction without executing it. - -- **`DataTreeProcessor.RunAsync` heterogeneous output support**: Added `RunAsync` overload (delegates to `RunAsync`) and `ExtractTypedTree` helper so a single processing call can populate output channels of different concrete `IGH_Goo` types. Matching `RunProcessingAsync` overload added to `StatefulComponentBase`. -- **`File2MdComponent` / `AIFile2MdComponent`**: Replaced manual `foreach` tree iteration with `RunProcessingAsync` + `ExtractTypedTree`, gaining flat-tree broadcasting and consistent `ItemGraft` path management. `ComponentProcessingOptions` property added to both components. +Many thanks to the following contributors to this release: -### Fixed +- [marc-romu](https://github.com/marc-romu) -- `ProviderManager` now exposes `IsInfrastructureReady` flag to signal when provider infrastructure initialization completes -- All AI providers (Anthropic, DeepSeek, MistralAI, OpenAI, OpenRouter) received stability improvements and extended known list of models +---- -### Deprecated +### Changed -- **Anthropic**: marked deprecated `claude-opus-4-5`, `claude-sonnet-4-5`, `claude-sonnet-4-5-20250929`, `claude-haiku-4-5`, `claude-haiku-4-5-20251001` (superseded by 4-6 / 4-7 series). -- **DeepSeek**: `deepseek-chat` and `deepseek-reasoner` flagged `Deprecated = true` (DeepSeek docs state both will be deprecated; they alias `deepseek-v4-flash` non-thinking/thinking modes). -- **OpenAI**: `gpt-4o-mini-tts` marked deprecated per OpenAI docs. +- **Infrastructure**: Improved provider stability, thread safety, and timeout handling +- **CI/CD**: Enhanced milestone management, stabilization workflows, and release automation ## [1.4.2-alpha] - 2026-03-14 @@ -146,36 +69,20 @@ Many thanks to the following contributors to this release: ### Added -- **Contributors Workflow**: Added automated GitHub workflow (`chore-update-contributors.yml`) to maintain the contributors section in CHANGELOG.md +- **Contributors Workflow**: Automated GitHub workflow to maintain the contributors section in CHANGELOG.md ### Changed -- **Provider Security & Verification**: - - Replaced boolean "hard integrity check" with a three-tier mode (Soft/Hard/Strict) selectable in Providers settings; installs migrate automatically to the new modes. - - SHA-256 hash verification now covers Windows and macOS with dual-runner CI hashes and DEBUG auto-switch to Soft Check for smoother local development. - -- **UX & Components**: - - Renamed `AIScriptGenerator` component to `AIScriptGenerate` for consistent AI script naming. - - Tuned dialog sizing, text wrapping, and integrity-check descriptions for clearer messaging. - -- **Tooling & Automation**: - - Enhanced `Change-SolutionVersion.ps1` with explicit version parsing and help output for reliable version bumps. - - Expanded `.gitignore` to exclude all local libraries (beyond Rhino). - - Improved pre-commit hook with selective staging and safer password handling, added post-commit hook to auto-update `InternalsVisibleTo`, and added a GitHub workflow to anonymize the public key on protected branches. +- **Provider Security**: Three-tier verification mode (Soft/Hard/Strict) with cross-platform SHA-256 hash verification for Windows and macOS +- **UX & Components**: Renamed `AIScriptGenerator` to `AIScriptGenerate` for consistent naming; improved dialog messaging and sizing +- **CI/CD**: Enhanced automation for version management, git hooks, and public key anonymization ### Fixed -- fix(ci): skip `Update-InternalsVisibleTo` step on macOS runners since `sn.exe` (Strong Name tool) is Windows-only; assemblies still strong-name signed with SNK file -- fix(infrastructure): reduce provider hash verification timeout from 10s to 5s for faster offline detection and improved Settings dialog responsiveness -- fix(infrastructure): add network availability check in `ProviderHashVerifier` to skip hash fetch attempts when offline, preventing unnecessary delays -- fix(infrastructure): implement 15-minute manifest caching in `ProviderHashVerifier` using `ConcurrentDictionary` for thread safety, and centralize cache operations in `ReadHashManifest` method -- fix(core): eliminate race condition in `ComponentStateManager.ProcessTransitionQueue()` where `isTransitioning` flag was cleared before event firing, potentially allowing concurrent queue processing on macOS. The flag now remains true until after all events are fired, preventing out-of-order event processing and concurrent event handler execution. -- fix(tools): set GhJSON component `Id = 1` when `InstanceGuid` is null in `script_generate` and `script_edit` to satisfy GhJSON.Core validation requiring at least one identifier -- fix(tools): add `SanitizeAndParseJson` to handle AI responses wrapped in markdown code blocks or non-JSON formatting in `script_generate` and `script_edit` -- fix(infrastructure): improve `AIProvider.CallApi()` error messages for non-JSON API responses (e.g., HTML error pages from proxies) -- fix(infrastructure): GitHub Pages deployment now correctly places `latest.json` and `versions.json` in the `hashes/` subdirectory instead of site root, fixing 404 errors when ProviderHashVerifier and the web UI attempt to fetch manifest files -- fix(macOS): address mac compatibility issues (deadlock risk, GhJSON validation, and JSON parsing edge cases) tracked in [#389](https://github.com/architects-toolkit/SmartHopper/issues/389) -- fix: additional stability and compatibility fixes tracked in [#395](https://github.com/architects-toolkit/SmartHopper/issues/395) and [#393](https://github.com/architects-toolkit/SmartHopper/issues/393) +- Fixed provider hash verification performance with offline detection, caching, and reduced timeout +- Fixed macOS compatibility issues including race conditions, GhJSON validation, and JSON parsing ([#389](https://github.com/architects-toolkit/SmartHopper/issues/389), [#395](https://github.com/architects-toolkit/SmartHopper/issues/395), [#393](https://github.com/architects-toolkit/SmartHopper/issues/393)) +- Fixed script tools to handle markdown-wrapped AI responses and GhJSON validation requirements +- Fixed GitHub Pages deployment to correctly place hash manifest files ## [1.4.0-alpha] - 2026-02-15 @@ -187,52 +94,29 @@ Many thanks to the following contributors to this release: ### Added -- **Provider Hash Verification**: Added SHA-256 hash verification system for AI provider DLLs to enhance security on all platforms - - New "Verify Providers Hash" menu item in SmartHopper menu to manually verify provider integrity - - Comprehensive verification dialog showing verification status, local vs expected hashes, and detailed results - - Automatic hash generation during release workflow with public hash repository for verification - - Multi-tier verification with graceful degradation when hashes are unavailable -- **Enhanced About Dialog**: Improved About dialog to automatically display current SmartHopper version and platform information using the new VersionHelper class +- **Provider Hash Verification**: SHA-256 hash verification system for AI provider DLLs with manual verification menu item and comprehensive status dialog +- **Enhanced About Dialog**: Automatic display of current SmartHopper version and platform information ### Fixed -- **macOS Compatibility**: Improved cross-platform compatibility for macOS users - - Provider loading now works on non-Windows platforms with appropriate security warnings (skip Authenticode signature verification where `X509Certificate.CreateFromSignedFile` is not supported) - - URL handling fixed to prevent incorrect file:// URI generation by restricting `BuildFullUrl` absolute URI detection to HTTP/HTTPS schemes - - Component state management updated to fire `ComponentStateManager` transition events outside `stateLock` to prevent deadlocks caused by re-entrant lock acquisition in event handlers -- **Settings**: - - Fixed first initialization is created using EncryptationVersion 2 by default which stores a local hash for secrets encryptation +- **macOS Compatibility**: Improved cross-platform support with appropriate security warnings, URL handling fixes, and deadlock prevention in component state management +- **Settings**: Fixed encryption initialization to use local hash storage ### Security -- Enhanced provider security with SHA-256 hash verification system to protect against tampered provider DLLs -- Platform-appropriate security measures: Authenticode + hash verification on Windows, only hash verification on macOS +- Platform-appropriate security: Authenticode + hash verification on Windows, hash verification only on macOS ### Known Issues -- bug(ui): `WebChatDialog` (CanvasButton chat window) crashes on macOS with `NSInvalidArgumentException` because Eto.Forms' `WKWebViewHandler.LoadHtml()` calls `WKWebView.LoadFileUrl()` with an `https://` base URI (`https://smarthopper.local/`), which only accepts `file://` URLs +- WebChatDialog crashes on macOS due to Eto.Forms WKWebView URL handling limitations ## [1.3.0-alpha] - 2024-02-08 ### Changed -- Added annual automation to update copyright years in `src/**/*.csproj` and normalize C# license headers (workflow: `chore-update-copyright-year.yml`, script: `tools/Update-CopyrightYear.ps1`). -- GhJSON API Simplification: - - Refactored all AI tools to use organized ghjson-dotnet façade classes exclusively, removing deep namespace dependencies. - - All SmartHopper code now imports only `GhJSON.Core` and `GhJSON.Grasshopper` (no `GhJSON.Core.Models.*`, `GhJSON.Grasshopper.Serialization.*`, etc.). - - Removed legacy `ScriptParameterSettingsParser.cs` from SmartHopper (now in ghjson-dotnet façade). - - **Serialization options** now use `GhJsonGrasshopper.Options.Standard()`, `.Optimized()`, and `.Lite()` factory methods. - - **Script components** now use `GhJsonGrasshopper.Script.CreateGhJson()`, `.GetComponentInfo()`, `.DetectLanguageFromGuid()`, `.NormalizeLanguageKeyOrDefault()`. - - **Document operations** now use `GhJson.CreateDocument()`, `GhJson.Merge()`, `GhJson.Parse()`, `GhJson.Fix()`, `GhJson.IsValid()`, `GhJson.Serialize()`. - - **Runtime data** extraction now uses `GhJsonGrasshopper.ExtractRuntimeData()` instead of deep serializer access. - - Tool-specific changes: - - `gh_get`: Delegates connection depth expansion and connection trimming to `GhJsonGrasshopper.GetWithOptions()`; uses `GhJsonGrasshopper.Options.*()` factories and `GhJsonGrasshopper.ExtractRuntimeData()`. - - `gh_put`: Delegates GhJSON placement to `GhJsonGrasshopper.Put()` and uses `PutOptions.PreserveExternalConnections` for edit-mode external wiring preservation; uses `GhJson.Parse()`, `GhJson.Fix()`, `GhJson.IsValid()`. - - `gh_merge`: Uses `GhJson.Merge()` façade instead of direct `GhJsonMerger` access. - - `gh_tidy_up`: Uses `GhJsonGrasshopper.Options.Standard()`. - - `script_edit`, `script_generate`: Use `GhJsonGrasshopper.Script.*` façade methods. - - `gh_connect`: Delegates canvas wiring to `GhJsonGrasshopper.ConnectComponents()`. -- Changed `ISelectingComponent` to use `IGH_DocumentObject` instead of `IGH_ActiveObject` to support scribble selection. +- **GhJSON API**: Refactored to use organized ghjson-dotnet façade classes, removing deep namespace dependencies +- **Selection**: Changed `ISelectingComponent` to support scribble selection +- **CI/CD**: Added annual automation for copyright year updates and license header normalization ## [1.2.4] - 2024-02-08 @@ -250,896 +134,309 @@ Many thanks to the following contributors to this release: ### Added -- Context Management: - - Added `ContextLimit` property to `AIModelCapabilities` for storing model context window sizes across all providers (Anthropic, DeepSeek, OpenAI, OpenRouter, MistralAI). - - Added `SummarizeSpecialTurn` factory for creating conversation summarization special turns. - - Added automatic context tracking in `ConversationSession` with percentage calculation. - - Added pre-emptive summarization when context usage exceeds 80% of model limit. - - Added context exceeded error detection and automatic summarization with retry. - - Added graceful error handling when summarization fails to reduce context size. - - Added context limits for all MistralAI models (128K for most, 40K for Magistral, 32K for Voxtral). -- Metrics: - - Added `LastEffectiveTotalTokens` field to `AIMetrics` for accurate context usage percentage calculation. -- WebChat Debug: - - Added debug Update button to refresh chat view from conversation history. - - Added DOM synchronization functionality to compare cached HTML hashes and update only changed messages. -- GhJSON canvas tools: - - Added `gh_get_start` / `gh_get_start_with_data` tools to retrieve start nodes (components with no incoming connections) with optional runtime data. - - Added `gh_get_end` / `gh_get_end_with_data` tools to retrieve end nodes (components with no outgoing connections) with optional runtime data, providing a wide view of definition outputs. +- **Context Management**: Automatic context tracking with pre-emptive summarization at 80% usage, context limits for all providers, and error detection with retry +- **WebChat Debug**: Debug Update button and DOM synchronization for efficient chat view refreshes +- **GhJSON Tools**: Added `gh_get_start` and `gh_get_end` tools to retrieve start/end nodes with optional runtime data ### Changed -- Context Management: - - Conversation summaries now use `AIAgent.Summary` instead of `AIAgent.Assistant`, preventing extra assistant messages in chat UI. - - Providers automatically merge `Summary` interactions with the system prompt using format: `System prompt\n---\nThis is a summary of the previous conversation:\n\nSummary`. - - WebChat UI renders `Summary` interactions as collapsible elements with distinct blue styling, similar to tool/system messages. -- GhJSON Serialization: - - `PersistentData` and `VolatileData` properties are now excluded from serialization at the `GhJsonSerializer` level to prevent encrypted/binary strings from being included in GhJSON output, ensuring LLM-safe and token-efficient results by default. - - Runtime data remains available via the separate `ExtractRuntimeData` method for `_with_data` tools (e.g., `gh_get_selected_with_data`). -- Debug Logging: - - Updated `ConversationSession` debug history file to preserve previous conversations when summarization occurs. - - Added `SUMMARIZED` marker in debug logs to clearly separate pre-summary and post-summary history. +- **Context Management**: Conversation summaries use dedicated `AIAgent.Summary` with collapsible UI styling and automatic system prompt merging +- **GhJSON Serialization**: Excluded `PersistentData` and `VolatileData` for LLM-safe output; runtime data available via `_with_data` tools ### Fixed -- Context Management: - - Fixed context usage percentage not appearing consistently in debug logs and WebChat UI metrics by caching it at the `ConversationSession` level and applying it to aggregated metrics. -- GhJSON canvas helpers and chat: - - Standardized node classification terminology from `input/output/processing/isolated` to `startnodes/endnodes/middlenodes/isolatednodes` across `gh_get` filters and the `GhGetComponents` Grasshopper component UI. - - Updated `instruction_get` canvas guidance to recommend `gh_get_start`/`gh_get_end` (and their `_with_data` variants) for obtaining a wide view of data sources and outputs. - - Strengthened the `CanvasButton` default system prompt to always call `instruction_get` for canvas queries, improving tool selection for providers like `mistral-small`. -- GhJSON Serialization: - - Fixed `gh_get` tool incorrectly using `Optimized` serialization context (which excludes `PersistentData`) instead of `Standard` context, breaking `gh_put` restoration of internalized parameter values. Now uses `Standard` format by default to preserve all data needed for restoration, only switching to `Optimized` when runtime data is explicitly included (where persistent values are redundant). +- Fixed context usage percentage display in debug logs and WebChat UI metrics +- Fixed `gh_get` serialization context to preserve internalized parameter values +- Standardized node classification terminology and improved canvas guidance tools ## [1.2.2-alpha] - 2025-12-27 ### Added -- Chat: - - Added `instruction_get` tool (category: `Instructions`) to provide detailed operational guidance to chat agents on demand. - - Simplified the `CanvasButton` default assistant system prompt to reference instruction tools instead of embedding long tool usage guidelines. +- **Chat**: Added `instruction_get` tool for detailed operational guidance to chat agents ### Changed -- Infrastructure: - - Extracted duplicated streaming processing logic into shared `ProcessStreamingDeltasAsync` helper method in `ConversationSession`, reducing code duplication by ~80 lines. - - Added `GetStreamingAdapter()` to `IAIProvider` interface with caching in `AIProvider` base class, replacing reflection-based adapter discovery. - - Added `CreateStreamingAdapter()` virtual method for providers to override; updated OpenAI, DeepSeek, MistralAI, Anthropic, and OpenRouter providers. - - Added `NormalizeDelta()` method to `IStreamingAdapter` interface for provider-agnostic delta normalization. - - Simplified streaming validation flow in `WebChatDialog.ProcessAIInteraction()` - now always attempts streaming first, letting `ConversationSession` handle validation internally. - - Added `TurnRenderState` and `SegmentState` classes to `WebChatObserver` for encapsulated per-turn state management. - - Reduced idempotency cache size from 1000 to 100 entries to reduce memory footprint. - - Promoted `StatefulComponentBaseV2` to the default stateful base by renaming it to `StatefulComponentBase`. -- Chat UI: - - Optimized DOM updates with a keyed queue, conditional debug logging, and template-cached message rendering with LRU diffing to cut redundant work on large chats. - - Refined streaming visuals by removing unused animations and switching to lighter wipe-in effects, improving responsiveness while messages stream. +- **Infrastructure**: Refactored streaming adapters with provider-agnostic normalization, reduced code duplication, and improved state management +- **Chat UI**: Optimized DOM updates with keyed queue, template caching, and lighter streaming animations ### Fixed -- DeepSeek provider: - - Fixed `deepseek-reasoner` model failing with HTTP 400 "Missing reasoning_content field" error during tool calling. The streaming adapter was not propagating `reasoning_content` to `AIInteractionToolCall` objects, causing the field to be missing when the conversation history was re-sent to the API. - - Fixed duplicated reasoning display in UI when tool calls are present. Reasoning now only appears on tool call interactions (where it's needed for the API), not on empty assistant text interactions. - -- Chat UI: - - Fixed user messages not appearing in the chat UI. The `ConversationSession.AddInteraction(string)` method was not notifying the observer when user messages were added to the session history. - -- Tool calling: - - Improved `instruction_get` tool description to explicitly mention required `topic` argument. Some models (MistralAI, OpenAI) don't always respect JSON Schema `required` fields but do follow description text. - -- Chat UI: - - Reduced WebChat dialog UI freezes while dragging/resizing during streaming responses by throttling DOM upserts more aggressively and processing DOM updates in smaller batches. - - Mitigated issue [#261](https://github.com/architects-toolkit/SmartHopper/issues/261) by batching WebView DOM operations (JS rAF/timer queue) and debouncing host-side script injection/drain scheduling. - - Reduced redundant DOM work using idempotency caching and sampled diff checks; added lightweight JS render perf counters and slow-render logging. - - Improved rendering performance using template cloning, capped message HTML length, and a transform/opacity wipe-in animation for streaming updates. - - Further reduced freezes while dragging/resizing by shrinking update batches and eliminating heavy animation paths during active user interaction. - -- Context providers: - - Fixed `current-file_selected-count` sometimes returning `0` even when parameters were selected by reading selection on the Rhino UI thread and adding a robust `Attributes.Selected` fallback. - - Added selection breakdown keys: `current-file_selected-component-count`, `current-file_selected-param-count`, and `current-file_selected-objects`. +- Fixed DeepSeek provider reasoning content propagation and duplicated reasoning display +- Fixed user messages not appearing in chat UI +- Fixed context provider selection counting with Rhino UI thread fallback +- Reduced WebChat dialog freezes during streaming with DOM batching and throttling ([#261](https://github.com/architects-toolkit/SmartHopper/issues/261)) ## [1.2.1-alpha] - 2025-12-07 ### Added -- Script tools: - - Added `script_generate_and_place_on_canvas` wrapper tool that combines `script_generate` and `gh_put` in a single call, reducing token consumption by eliminating the need for the AI to call both tools separately. - - Moved `script_generate` tool to `Hidden` category (only `script_generate_and_place_on_canvas` is now visible to chat agents). -- `gh_get` tools: - - Added `gh_get_selected_with_data` tool that returns selected components with their runtime/volatile data (actual values flowing through outputs). - - Added `gh_get_by_guid_with_data` tool that returns specific components by GUID with their runtime/volatile data. - - Added `includeRuntimeData` parameter to the base `gh_get` tool for optional runtime data extraction. - - Runtime data includes total item count, branch structure, and sample values for each parameter output. - - Added `gh_get_errors_with_data` tool that returns only errored components with their runtime/volatile data, useful for debugging broken definitions. -- `gh_put` tool: - - Added `instanceGuids` array to the tool result containing the actual GUIDs of placed components (useful for subsequent queries). +- **Script Tools**: Added `script_generate_and_place_on_canvas` wrapper tool to reduce token consumption +- **GhJSON Tools**: Added `_with_data` variants for runtime data extraction (`gh_get_selected_with_data`, `gh_get_by_guid_with_data`, `gh_get_errors_with_data`) +- **gh_put**: Added `instanceGuids` array to tool result for subsequent queries ### Fixed -- Script tools: - - Fixed `script_generate_and_place_on_canvas` returning incorrect `instanceGuid`. The tool was returning the in-memory GUID from `script_generate` instead of the actual GUID assigned by Grasshopper when the component was placed on canvas. Now returns the real `instanceGuid` from `gh_put` result. -- GhJSON validation: - - Fixed `GHJsonAnalyzer.Validate` to treat missing `connections` property as an empty array instead of an error. Components without connections are now valid and won't trigger "'connections' property is missing or not an array" errors. -- Chat UI: - - Fixed critical bug where two identical user messages were collapsed into a single message in the UI. The root cause was that user messages didn't have a unique `TurnId`, causing identical messages to generate the same dedup key and replace each other. Now each user message receives a unique `TurnId` via `InteractionUtility.GenerateTurnId()`. -- Conversation session: - - Fixed TurnId inconsistency where `ToolResult` interactions were getting new TurnIds instead of inheriting from their originating `ToolCall`. The conditional check `if (string.IsNullOrWhiteSpace(toolInteraction.TurnId))` was never true because `AIBodyBuilder.EnsureTurnId()` had already assigned a new TurnId during tool execution. Changed to unconditional assignment to ensure correct turn-based metrics aggregation. +- Fixed script tool returning incorrect instance GUID from canvas placement +- Fixed GhJSON validation to treat missing connections as valid +- Fixed chat UI collapsing identical user messages and TurnId inconsistency for tool results ## [1.2.0-alpha] - 2025-12-06 ### Added -- Dialog canvas link visualization: - - Added `DialogCanvasLink` utility class that draws a visual connection line from a dialog to a linked Grasshopper component, similar to the script editor anchor. - - `StyledMessageDialog` methods (`ShowInfo`, `ShowWarning`, `ShowError`, `ShowConfirmation`) now accept optional `linkedInstanceGuid` and `linkLineColor` parameters to enable canvas linking. - - When a dialog is linked to a component, a bezier curve with an anchor dot is drawn from the component to the dialog window. -- Component replacement mode: - - Added "Edit Mode" input parameter to `GhPutComponents` for component replacement functionality. - - When Edit Mode is enabled and GhJSON contains valid instanceGuids that exist on canvas, users are prompted via `StyledMessageDialog` to choose between replacing existing components or creating new ones. - - The `gh_put` AI tool now accepts an optional `editMode` parameter to support component replacement. - - Replacement components preserve original `InstanceGuid` and exact canvas position. - - Undo support included for component replacement operations. -- GhJSON helpers: - - Added `GhJsonHelpers` utility class with methods for applying pivots and restoring InstanceGuids on deserialized components. -- GhJSON merge: - - Added `GhJsonMerger` utility to merge two GhJSON `GrasshopperDocument` instances, with the target document taking priority on component GUID conflicts and automatic ID remapping for connections and groups. - - Introduced `gh_merge` AI tool to merge two arbitrary GhJSON strings using `GhJsonMerger`, returning the merged GhJSON together with merge statistics (components/connections/groups added and deduplicated). - - Added `GhMergeComponents` Grasshopper component ("Merge GhJSON") to merge two GhJSON documents directly on the canvas, exposing merged GhJSON and basic merge counters as outputs. -- Script tools: - - Introduced GhJSON-based AI tools `script_generate` and `script_edit` for Grasshopper script components. - - All script tools now validate GhJSON input/output via `GHJsonAnalyzer.Validate` and use `ScriptComponentFactory` for component construction. - - Added `script_edit_and_replace_on_canvas` wrapper tool that combines `script_edit` and `gh_put` in a single call, reducing token consumption by eliminating the need for the AI to call both tools separately. - - Enhanced `script_generate` and `script_edit` tools to support all parameter modifiers: `dataMapping` (Flatten/Graft), `reverse`, `simplify`, `invert`, `isPrincipal`, `required`, and `expression` for inputs; `dataMapping`, `reverse`, `simplify`, `invert` for outputs. - - Added `ScriptCodeValidator` utility for detecting non-RhinoCommon geometry libraries in AI-generated scripts (e.g., System.Numerics, UnityEngine, numpy, shapely) with automatic self-correction prompts. - - Enhanced `script_generate` and `script_edit` system prompts with explicit RhinoCommon geometry requirements and language-specific guidance (Python/IronPython/C#/VB.NET) including import templates and type mappings. - - Added validation retry loop to `script_generate` and `script_edit` that detects invalid geometry patterns and re-prompts the AI with correction instructions (up to 2 retries). - - Clarified that output parameters do NOT have 'access' settings and documented proper list output patterns per language (Python 3 requires .NET `List[T]`, IronPython can use Python lists, C# uses `List`, VB.NET uses `List(Of T)`). -- `gh_get` tool: - - Added `categoryFilter` parameter and extended category-based filtering from components to all document objects. -- Model compatibility badges: - - Added "Not Recommended" badge (orange octagon with exclamation mark) displayed when the selected model is discouraged for the AI tools used by a component. - - Added `DiscouragedForTools` property to `AIModelCapabilities` to specify tool names for which a model is not recommended. - - Added `UsingAiTools` property to `AIStatefulAsyncComponentBase` allowing components to declare which AI tools they use. - - Added `EffectiveRequiredCapability` property that merges component's required capability with capabilities required by its AI tools. - - The "Not Recommended" badge suppresses the "Verified" badge when active (priority: Replaced > Invalid > NotRecommended > Verified). +- **Dialog Canvas Links**: Visual connection lines from dialogs to linked Grasshopper components +- **Component Replacement**: Edit mode for `gh_put` with undo support and position preservation +- **GhJSON Merge**: Merge utility with AI tool and Grasshopper component for combining documents +- **Script Tools**: GhJSON-based `script_generate` and `script_edit` with full parameter modifier support, geometry validation, and language-specific guidance +- **Model Badges**: "Not Recommended" badge when model is discouraged for component's AI tools ### Changed -- Components UI: - - AI-selecting stateful components now use combined attributes that show both the "Select" button and AI provider badges, with the button rendered above the provider strip. - - Selecting components now use the dialog link line color for hover highlights and draw a connector line from the combined selection center to the "Select" button. -- Script components: - - `AIScriptGenerateComponent` now orchestrates `script_generate` / `script_edit` together with `gh_get` / `gh_put` instead of the legacy `script_generator` tool, and exposes `GhJSON`, `Guid`, `Summary`, and `Message` outputs only. - - `AIScriptGenerateComponent` and `AIScriptReviewComponent` no longer expose a `Guid` input; the target component is always provided via the selecting button. - - Removed the monolithic `script_generator` AI tool in favor of smaller, focused tools that operate purely on GhJSON. - - Updated `AIScriptGenerateComponent` and `AIScriptReviewComponent` to support processing multiple inputs in parallel. - - Renamed `script_fix` tool to `script_review` to better reflect its review-focused behavior. - - `script_generate` no longer includes a pre-placement `instanceGuid` in its tool result; instance GUIDs are only exposed via `script_generate_and_place_on_canvas` / `gh_put` using the real canvas instance GUIDs. -- `GhJsonDeserializer`: - - Changed deserialization logic to default the UsingStandardOutputParam property to true when ShowStandardOutput is not present in the GhJSON ComponentState. -- Providers: - - Added new Claude Opus 4.5 model to the Anthropic provider registry. - - OpenRouter provider: added structured output support via `response_format: json_schema` / `structured_outputs` for JsonOutput requests and now populates `finish_reason` and `model` in metrics for chat completions. -- Script parameter modifier tools: - - Moved all script parameter modifier AI tools to the `NotTested` category to clarify their experimental status. -- Component icons: - - Updated McNeel forum and script component icons to outlined variants for better visual consistency. - - Added `ghmerge` icon and refreshed `ghget` / `ghput` icons to align with the new GhJSON merge workflows. -- Canvas button: - - Improved the default SmartHopper assistant prompt used by `CanvasButton` to guide users toward in-viewport scripting workflows and avoid unnecessary external code blocks or testing patterns, resulting in a smoother first-time UX. -- Script tools: - - `script_review` now augments its system prompt with language-specific Grasshopper scripting guidance (Python/IronPython/C#/VB.NET) via `ScriptCodeValidator`, based on the detected script language. - - Centralized script language normalization in `ScriptComponentFactory.NormalizeLanguageKeyOrDefault` and wired `script_generate` to use it when building prompts, ensuring consistent handling of language keys such as "python3" and "csharp". +- **Components UI**: Combined attributes for AI-selecting components with improved dialog link visualization +- **Script Components**: Replaced monolithic `script_generator` with focused GhJSON-based tools; removed Guid input +- **Providers**: Added Claude Opus 4.5; OpenRouter structured output support +- **Icons**: Updated to outlined variants for consistency ### Fixed -- Chat UI metrics display: - - Fixed metrics aggregation to show total token consumption per turn (from user message to next user message) instead of just the last message's metrics. - - Added `GetTurnMetrics(turnId)` method to `ConversationSession` to aggregate metrics for all interactions in a turn. - - Fixed tool results not inheriting TurnId from their corresponding tool calls, which caused incorrect turn grouping. -- Web chat dialog visibility: - - `WebChatDialog` is now created as an owned tool window of the Rhino main Eto window and hidden from the taskbar, so it follows Rhino/Grasshopper focus and stays on top of Rhino while the application is active. - - Confirmation dialogs shown via `StyledMessageDialog` (for example when replacing components with `gh_put` in edit mode) still appear above the chat dialog, but closing them no longer leaves the chat window hidden behind other Rhino/Grasshopper windows. -- `gh_put` tool: - - Fixed infinite loop when using `GhPutComponents` with replacement mode. The `NewSolution` call inside the tool caused re-entrancy when the component blocked with `.GetAwaiter().GetResult()`, which pumps Windows messages and allows the new solution to start immediately. - - Fixed "object expired during solution" error when replacing components. Removed the document disable/enable logic which was causing components to be in an invalid state. Uses `IsolateObject()` to properly clean up connections before component removal. -- Script tools: - - Updated `script_generate` and `script_edit` tool schemas to require `nickname`, fixing crashes with OpenAI structured-output mode. +- Fixed chat UI metrics to show total token consumption per turn +- Fixed WebChat dialog visibility as owned tool window following Rhino focus +- Fixed `gh_put` infinite loop and component expiration errors in replacement mode +- Fixed script tool schema requirements for OpenAI structured-output mode ## [1.1.1-alpha] - 2025-11-24 ### Changed -- Providers: - - Added explicit Claude 3.x/4.x dated model identifiers to the Anthropic provider registry while keeping shorthand model names. - - Switched the default Anthropic text/tool/json model to `claude-haiku-4-5`. - - Added structured-output beta support for Anthropic Sonnet 4.5 / Opus 4.1 models and restricted Text2Json capability to those models. - - Updated OpenAI provider models to include GPT-5.1 series models. +- **AI models**: Added Claude 3.x/4.x dated identifiers, Anthropic structured-output support, and OpenAI GPT-5.1 series models ## [1.1.0-alpha] - 2025-11-23 ### Added -- **VB Script Serialization Support**: Complete implementation of 3-section VB Script serialization/deserialization: - - **VBScriptCode Model**: New model class with separate properties for `imports`, `script`, and `additional` code sections - - **ComponentState Enhancement**: Added `vbCode` property to support VB Script 3-section structure alongside existing `value` property - - **GhJsonSerializer**: Extracts VB Script 3 sections separately via reflection using ScriptSource properties (UsingCode, ScriptCode, AdditionalCode) - - **GhJsonDeserializer**: Restores VB Script 3 sections to correct ScriptSource properties with proper section mapping - - **VB Parameter Management**: Implements IGH_VariableParameterComponent interface for proper parameter creation/destruction - - Parameter settings applied via CreateParameter/DestroyParameter with VariableParameterMaintenance() call - - Full support for custom input/output parameters with names, optional/required flags, and modifiers - - **UI Thread Safety**: All VB Script parameter and code operations wrapped in `RhinoApp.InvokeOnUiThread()` to prevent UI blocking - - **New AI Tools for parameter and script modification**: - - Parameter tools: `gh_parameter_flatten`, `gh_parameter_graft`, `gh_parameter_reset_mapping`, `gh_parameter_reverse`, `gh_parameter_simplify`, `gh_parameter_bulk_inputs`, `gh_parameter_bulk_outputs` - - Script tools: `script_parameter_add_input`, `script_parameter_add_output`, `script_parameter_remove_input`, `script_parameter_remove_output`, `script_parameter_set_type_input`, `script_parameter_set_type_output`, `script_parameter_set_access`, `script_toggle_std_output`, `script_set_principal_input`, `script_parameter_set_optional` - - **McNeel Forum AI Tools Enhancement**: - - `mcneel_forum_search`: Enhanced search tool with configurable result limit (1-50 posts), returning matching posts as raw JSON objects - - `mcneel_forum_post_get`: Renamed from `web_rhino_forum_read_post` for consistency, retrieves full forum post by ID - - `mcneel_forum_post_summarize`: New subtool that generates AI-powered summaries of forum posts using default provider/model -- New Knowledge components for McNeel forum and web sources, enabling search, retrieval, and summarization workflows directly in Grasshopper. -- **web_generic_page_read Enhancements**: Tool now delivers clean text for Wikipedia/Wikimedia articles, Discourse forums (raw markdown), GitHub/GitLab file URLs (raw/plain/markdown), and Stack Exchange questions via official APIs. -- **Property Management System V2**: Complete refactoring of property management with modern, maintainable architecture: - - **PropertyManagerV2**: New property management system with clean separation of concerns between filtering, extraction, and application - - **PropertyFilter**: Intelligent property filtering based on object type and serialization context - - **PropertyHandlers**: Specialized handlers for different property types (PersistentData, SliderCurrentValue, Expression, etc.) - - **PropertyFilterConfig**: Centralized configuration for property whitelists, blacklists, and category-specific properties - - **SerializationContext**: Support for different contexts (AIOptimized, FullSerialization, CompactSerialization, ParametersOnly) - - **ComponentCategory**: Proper categorization of components (Panel, Slider, Script, etc.) for targeted property extraction - - **PropertyManagerFactory**: Factory methods for creating PropertyManagerV2 instances with common configurations -- **GhJSON Optimization**: Reduced irrelevant data in serialization output: - - Groups now only include members present in the current GhJSON components selection - - Removed runtime-only properties: `VolatileData`, `IsValid`, `IsValidWhyNot`, `TypeDescription`, `TypeName`, `Boundingbox`, `ClippingBox`, `ReferenceID`, `IsReferencedGeometry`, `IsGeometryLoaded`, `QC_Type` - - Fixed contradictory property handling where `VolatileData` and `DataType` were in both omitted and whitelist - - Fixed `IsPropertyInWhitelist()` method to properly check omitted properties before whitelist - - Removed `Type` and `HumanReadable` properties from `ComponentProperty` model to reduce JSON size -- **Enhanced GhJSON Schema**: Implemented component schema improvements following complete property reference specification: - - **Parameter Properties**: `parameterName`, `dataMapping`, `simplify`, `reverse`, `invert`, `unitize`, `expression`, `variableName`, `isPrincipal`, `locked` - - **Component Properties**: `locked`, `hidden`, universal `value` property with type-specific mapping - - **Value Consolidation**: Number Slider (`currentValue` → `value`), Panel (`userText` → `value`), Scribble (`text` → `value`), Script (`script` → `value`), Value List (`listItems` → `value`) - - **Removed Properties**: `expressionContent` (use `expression`), `access`, `description`, `optional`, `type`, `objectType` (excluded as implicit/redundant), `humanReadable`, redundant slider properties - - Extended `ComponentProperties` with new schema properties: `Id`, `Params`, `InputSettings`, `OutputSettings`, `ComponentState` - - **BREAKING**: New schema format is now the default for all `gh_get` and `gh_put` operations - - Kept legacy `Pivot` format for compactness and compatibility -- New AI Tools for component generation and connection: - - `gh_generate`: Generate GhJSON from component specifications (name + parameters), returns valid GhJSON for gh_put. - - `gh_connect`: Connect Grasshopper components by creating wires between outputs and inputs using component GUIDs. -- New AI Tool for script editing and creation: - - `script_generator`: Unified tool that creates or edits Grasshopper script components based on natural language instructions and an optional component GUID. Replaces legacy `script_new` and `script_edit` tools. -- New utility classes for centralized Grasshopper operations: - - `GHConnectionUtils`: Connect components by creating wires between parameters. - - `GHGenerateUtils`: Generate GhJSON component specifications. - - `RhinoFileUtils`: Read and analyze .3dm files. - - `RhinoGeometryUtils`: Extract geometry information from active Rhino document. -- New AI Tools for Rhino 3DM file analysis: - - `rhino_read_3dm`: Analyze .3dm files and extract metadata, object counts, layer information, and detailed object properties. - - `rhino_get_geometry`: Extract detailed geometry information from the active Rhino document (selected objects, by layer, or by type). -- New test project `SmartHopper.Core.Grasshopper.Tests` with comprehensive unit test coverage: - - `AIResponseParserTests`: 40+ tests for parsing edge cases (JSON arrays, markdown blocks, ranges, text formats) - - `PropertyManagerTests`: 30+ tests for type conversion, property setting, and persistent data handling -- **SelectingComponentBase Persistence**: Selected objects list now persists when saving and loading Grasshopper files - - Stores selected object GUIDs during write operations - - Restores selected objects by GUID lookup during read operations - - Updates component message to reflect restored selection count - - Handles missing objects gracefully (objects deleted after selection) -- New hotfix workflow system for emergency production fixes: - - **hotfix-0-new-branch.yml** - Creates `hotfix/X.X.X-description` branch from main with automatic patch version increment - - **hotfix-1-release-hotfix.yml** - Prepares `release/X.X.X-hotfix-description` branch with version updates, changelog, and PR to main - - Automatic version conflict resolution: - - All milestones with patch ≥ hotfix patch are incremented (updated from highest to lowest to prevent collisions) - - Dev branch version is bumped via PR if it conflicts (respects protected branch) - - Hotfix PRs trigger all existing validations (version check, code style, tests) before merging - - After merge to main, existing release workflows (release-3, release-4, release-5) handle GitHub Release creation, build, and Yak upload - - Comprehensive documentation in `.github/workflows/HOTFIX_WORKFLOW.md` -- Comprehensive workflow documentation: - - **RELEASE_WORKFLOW.md** - Complete guide for regular milestone-based releases - - **HOTFIX_WORKFLOW.md** - Complete guide for emergency hotfix releases +- **VB Script Support**: Complete serialization/deserialization for 3-section VB Script structure with custom parameter management +- **Parameter Tools**: New AI tools for parameter modification (flatten, graft, reverse, simplify, bulk inputs/outputs) +- **Script Tools**: Unified script generation tool with parameter management capabilities +- **McNeel Forum Integration**: Search, retrieval, and summarization tools for McNeel forum posts +- **Web Reading**: Enhanced support for Wikipedia, Discourse forums, GitHub/GitLab, and Stack Exchange +- **Rhino 3DM Analysis**: Tools for analyzing .3dm files and extracting geometry from Rhino documents +- **Component Tools**: GhJSON generation and component connection tools +- **Knowledge Components**: Grasshopper components for McNeel forum and web knowledge workflows +- **Selection Persistence**: Selected objects now persist when saving and loading Grasshopper files +- **Hotfix Workflows**: Emergency hotfix release system with automated version management ### Changed -- Renamed AI Tools: - - `gh_toggle_preview` to `gh_component_toggle_preview` - - `gh_toggle_lock` to `gh_component_toggle_lock` - - `gh_lock_selected` to `gh_component_lock_selected` - - `gh_unlock_selected` to `gh_component_unlock_selected` - - `gh_hide_preview_selected` to `gh_component_hide_preview_selected` - - `gh_show_preview_selected` to `gh_component_show_preview_selected` -- **Script Component "out" Parameter Handling**: The standard output/error parameter ("out") in script components is no longer serialized as a regular output parameter. Instead, its visibility state is controlled by the new `showStandardOutput` property in `ComponentState`, which maps to the component's `UsingStandardOutputParam` property. This prevents signature changes after deserialization. -- **ComponentProperty JSON Serialization**: Simple types (bool, int, string, double, etc.) now serialize directly without the `{"value": ...}` wrapper for cleaner, more compact JSON output. Complex types retain the wrapper structure for backward compatibility. -- **Empty String Omission**: Empty string properties (e.g., group `name`, component `nickName`) are now omitted from JSON output for cleaner, more compact serialization. Only non-empty values are included. -- **Document Metadata Improvements**: - - Fixed Grasshopper version detection (now reads from Instances assembly instead of settings) - - Added `schemaVersion` property (set to "1.0") to GrasshopperDocument - - Added `rhinoVersion` property to track Rhino version - - Added `parameterCount` property to track standalone parameters separately from components - - Changed `createdAt` to `created` for consistency - - Default document `version` to "1" - - Added `dependencies` array to track plugin dependencies (excludes system assemblies) -- More robust GhJson schema, serialization and deserialization methods. -- Model capability validation now bypasses checks for unregistered models, allowing users to use any model name even if not explicitly listed in the provider's model registry. -- Centralized error handling in AIReturn and tool calls. -- Accurately aggregate metrics in Conversation Session. Cases with multiple tool calls, multiple interactions, etc. Calculate completion time per interaction. -- Improved AI Tool descriptions with better guided instructions. Also added specialized wrappers for targeted tool calls (gh_get_selected, gh_get_errors, gh_get_locked, gh_get_hidden, gh_get_visible, gh_get_by_guid, gh_lock_selected, gh_unlock_selected, gh_hide_preview_selected, gh_show_preview_selected, gh_group_selected, gh_tidy_up_selected). -- **BREAKING (Internal):** Reorganized `SmartHopper.Core.Grasshopper.Utils` namespace structure for better maintainability: - - Created organized subfolders: `Canvas/`, `Serialization/`, `Parsing/`, `Rhino/`, `Internal/` - - Renamed utility classes for clarity (e.g., `GHCanvasUtils` → `CanvasAccess`, `GHComponentUtils` → `ComponentManipulation`) - - Updated all internal references to use new organized namespaces - - **Note:** This is an internal refactoring with no impact on public APIs or plugin functionality -- Extended PR validation and CI test workflows to run on `hotfix/**` and `release/**` branches -- **user-security-patch.yml** workflow is now obsolete, removed from workflows +- **Minimum Requirements**: Rhino 8.24 or later required +- **GhJSON Schema**: BREAKING - New schema format with improved property management and reduced JSON size +- **Property Management**: Complete refactoring with modern architecture and better type handling +- **AI Tool Names**: Renamed for consistency (e.g., `gh_toggle_preview` → `gh_component_toggle_preview`) +- **Script Components**: Unified script generator replaces separate new/edit tools +- **Serialization**: Optimized JSON output with cleaner formatting and empty string omission +- **Model Validation**: Allows use of unregistered models +- **Error Handling**: Centralized error handling in AIReturn and tool calls +- **Metrics**: Improved conversation session metrics aggregation +- **CI/CD**: Extended validation workflows for hotfix and release branches ### Removed -- **Legacy PropertyManager**: Removed obsolete `PropertyManager.cs` and all references to the old property management system: - - Removed `PropertyManager.IsPropertyInWhitelist()`, `PropertyManager.SetProperties()`, `PropertyManager.IsPropertyOmitted()`, and `PropertyManager.GetChildProperties()` methods - - Updated `DocumentIntrospection.cs` to use `PropertyManagerV2` with `PropertyManagerFactory.CreateForAI()` - - Updated `GhJsonPlacer.cs` to use `PropertyManagerV2` for property application - - Updated `PropertyManagerTests.cs` to test the new `PropertyManagerV2` system instead of the old `PropertyManager` - -- **Legacy script tools and components**: - - Removed `script_new` and `script_edit` AI tools in favor of the unified `script_generator` tool. - - Removed `AIScriptNewComponent` and `AIScriptEditComponent` Grasshopper components in favor of `AIScriptGenerateComponent`. +- Legacy PropertyManager system +- Legacy script tools (`script_new`, `script_edit`) and components ### Fixed -- **DataTreeProcessor Bug Fixes**: - - Fixed `GetBranchFromTree` incorrectly returning branches from different paths when tree had single path (flat tree fallback bug). Now strictly returns only branches matching the requested path. - - Fixed `BranchFlatten` topology creating one processing unit per path instead of flattening all branches together. Now correctly creates single processing unit with all items from all branches flattened into one list. -- Fixed model badge display: show "invalid model" badge when provider has no capable model instead of "model replaced" ([#332](https://github.com/architects-toolkit/SmartHopper/issues/332)). -- Fixed provider errors (e.g., HTTP 400, token limit exceeded) not surfacing to WebChat UI: `ConversationSession` now surfaces `AIInteractionError` from error AIReturn bodies to observers before calling `OnError`, ensuring full error messages are displayed in the chat interface ([#334](https://github.com/architects-toolkit/SmartHopper/issues/334)). -- **Rectangle Serialization**: Fixed Rectangle3d serialization/deserialization to use center-based format (`rectangleCXY`) instead of origin-based (`rectangleOXY`), ensuring correct position and orientation after round-trip. Uses interval-based constructor to guarantee proper reconstruction. -- **IsPrincipal Property Cleanup**: Removed `IsPrincipal` from appearing as a top-level property on standalone parameter components (Colour, Number, Text, etc.). It now only appears in `inputSettings`/`outputSettings` `additionalSettings` when set to `true`, reducing JSON clutter. -- **Script Component Null Reference**: Fixed `ArgumentNullException` in `gh_get` tool when processing script components. Added null check in `ScriptComponentHelper.GetScriptLanguageType()` method before calling `.Contains()` on potentially null language name string. -- **Stand-Alone Parameter Serialization**: Fixed `GhJsonSerializer` to properly serialize stand-alone parameters (e.g., `Param_Colour`, `Param_Number`, `Param_Box`, etc.). Previously only `IGH_Component` objects were serialized; now both `IGH_Component` and `IGH_Param` objects are processed. Stand-alone parameters (those without a parent component) are now included in the serialization output and their connections are properly extracted. -- **PersistentData Deserialization**: Fixed `GhJsonDeserializer` to properly deserialize internalized data (PersistentData) for stand-alone parameters. The deserializer now uses `PropertyManagerV2` instead of simple reflection, which correctly handles the nested data tree structure and type-specific conversions for all parameter types (Color, Point, Vector, Line, Plane, Circle, Arc, Box, Rectangle, Interval, Number, Integer, Boolean, Text). -- **Connection Matching by Index**: Fixed connection serialization/deserialization to use parameter index instead of parameter name. Connections now include `paramIndex` property for reliable matching regardless of display name settings (full names vs nicknames). The deserializer uses index-based matching with fallback to name-based matching for backward compatibility. Fixed stand-alone parameter to stand-alone parameter connections to properly set `paramIndex` to 0 (single input). -- **Group InstanceGuid**: Fixed group serialization to include the actual `InstanceGuid` instead of all zeros. Groups now properly serialize their unique identifier for correct reconstruction. -- **InstanceGuid Generation**: Fixed deserialization to always generate new InstanceGuids instead of reusing GUIDs from JSON. This prevents "An item with the same key has already been added" errors when placing components that already exist in the document. Grasshopper now automatically generates unique GUIDs for all deserialized components. -- **Stand-Alone Parameter Connections**: Fixed `ConnectionManager` to support connections between stand-alone parameters (e.g., Colour → Panel). Previously only component-to-component and parameter-to-component connections were supported. -- **Smart Pivot Handling in gh_put**: Improved component placement logic to intelligently handle positions: - - When pivots are present in GhJSON: Components are placed with their relative positions preserved, offset to prevent overlap with existing canvas components (positioned below the lowest existing component with 100px spacing) - - When pivots are absent: Uses `DependencyGraphUtils.CreateComponentGrid` algorithm (same as `gh_tidy_up`) to automatically calculate optimal grid layout based on component connections - - Removed `RecalculatePivots` option from `DeserializationOptions` and `gh_put` tool - pivot handling is now automatic based on GhJSON content -- **Parameter Modifier Serialization**: Fixed `ParameterMapper` to properly extract and apply parameter modifiers (`Reverse`, `Simplify`, `Locked`) and `DataMapping` for component parameters. These settings are now serialized in the `additionalSettings` object and correctly restored during deserialization. Note: The `Invert` property does not exist in the `IGH_Param` interface and is reserved in `AdditionalParameterSettings` for future use or specific parameter type extensions. -- **Removed Optional Property**: Removed redundant `optional` property from `ParameterSettings` model as it provides no useful information for serialization/deserialization. -- **Script Component Parameter Modifiers**: Fixed issue where parameter modifiers (Reverse, Simplify, Locked, Invert) were not being serialized/deserialized for script component parameters. `ScriptParameterMapper.ExtractSettings()` now extracts `AdditionalSettings` just like regular `ParameterMapper`, ensuring modifiers are preserved during round-trip serialization. -- **Script Component Type Hint Normalization**: Type hints with value "object" (case-insensitive) are no longer serialized or deserialized, as "object" is the default/generic type hint. This reduces JSON size, avoids case sensitivity issues (Object vs object), and eliminates redundant data. -- **Generic Type Hint Handling**: Improved handling of generic type hints (e.g., `DataTree`, `List`) by detecting `<>` syntax and extracting base types before applying, preventing `TypeHints.Select()` exceptions and reducing log noise. -- (automatically added) Fixes "script_edit tool freezes the script editor" ([#209](https://github.com/architects-toolkit/SmartHopper/issues/209)). +- DataTreeProcessor branch handling and flattening +- Model badge display for invalid models +- Provider error surfacing in WebChat UI +- Rectangle serialization format +- Stand-alone parameter serialization and connections +- Connection matching by index +- Group and InstanceGuid serialization +- Component pivot handling in gh_put +- Script component parameter modifiers and type hints +- Fixes "script_edit tool freezes the script editor" ([#209](https://github.com/architects-toolkit/SmartHopper/issues/209)) ## [1.0.1-alpha] - 2025-10-13 ### Changed -- Model capability validation now bypasses checks for unregistered models, allowing users to use any model name even if not explicitly listed in the provider's model registry. -- Centralized error handling in AIReturn and tool calls. -- Accurately aggregate metrics in Conversation Session. Cases with multiple tool calls, multiple interactions, etc. Calculate completion time per interaction. -- Improved AI Tool descriptions with better guided instructions. Also added specialized wrappers for targeted tool calls (gh_get_selected, gh_get_errors, gh_get_locked, gh_get_hidden, gh_get_visible, gh_get_by_guid, gh_lock_selected, gh_unlock_selected, gh_hide_preview_selected, gh_show_preview_selected, gh_group_selected, gh_tidy_up_selected). -- Enhanced `list_filter` tool prompts to explicitly distinguish between indices (positions/keys) and values (item content), and expanded capabilities to support filtering, sorting, reordering, selecting, and other list manipulation operations based on natural language criteria. -- Added more predefined models in the provider's database. +- **Model Validation**: Allows use of unregistered models +- **Error Handling**: Centralized error handling in AIReturn and tool calls +- **Metrics**: Improved conversation session metrics aggregation +- **AI Tools**: Enhanced tool descriptions and specialized wrappers +- **List Filter**: Expanded capabilities for filtering, sorting, and reordering ### Fixed -- Fixed model badge display: show "invalid model" badge when provider has no capable model instead of "model replaced" ([#332](https://github.com/architects-toolkit/SmartHopper/issues/332)) ([#329](https://github.com/architects-toolkit/SmartHopper/issues/329)). -- Fixed provider errors (e.g., HTTP 400, token limit exceeded) not surfacing to WebChat UI: `ConversationSession` now surfaces `AIInteractionError` from error AIReturn bodies to observers before calling `OnError`, ensuring full error messages are displayed in the chat interface ([#334](https://github.com/architects-toolkit/SmartHopper/issues/334)). -- Fixed `list_filter` tool automatically sorting and deduplicating indices, which prevented reordering and expansion operations from working correctly. Now preserves both order and duplicates as returned by the AI ([#335](https://github.com/architects-toolkit/SmartHopper/issues/335)). +- Model badge display for invalid models ([#332](https://github.com/architects-toolkit/SmartHopper/issues/332), [#329](https://github.com/architects-toolkit/SmartHopper/issues/329)) +- Provider error surfacing in WebChat UI ([#334](https://github.com/architects-toolkit/SmartHopper/issues/334)) +- List filter preserving order and duplicates ([#335](https://github.com/architects-toolkit/SmartHopper/issues/335)) ## [1.0.0-alpha] - 2025-10-11 ### Added -- Improvements in `CanvasButton`: - - New SmartHopper Assistant setting `EnableCanvasButton` (default: `true`) to enable/disable the canvas button. - - `CanvasButton` now respects `EnableCanvasButton`: when disabled, the button is hidden and non-interactive. - - New `CanvasButton` to trigger the SmartHopper assistant dialog from a dedicated button at the top-right corner of the canvas. - - CanvasButton now initializes the chat provider and model from SmartHopper settings (consistent with app-wide configuration). - -- Context providers: - - New `FileContextProvider` exposing `current-file_selected-count` (number of selected files), `file-name` (the current document file name or "Untitled"), `selected-count` (number of selected objects in the current document), `object-count` (total number of document objects), `component-count` (total number of components in the current document), `param-count` (total number of parameters in the current document), `scribble-count` (total number of scribbles/notes in the current document), and `group-count` (total number of groups in the current document). Registered globally at Core assembly load so it is available to both components and the canvas button. - -- Conversation and policies: - - ConversationSession service introducing: - - `IConversationSession`, `IConversationObserver`, `SessionOptions` interfaces/models - - `ConversationSession` orchestrating multi-turn flows and tool passes; executes provider calls via `AIRequestCall.Exec()` in non-streaming mode, and streams incremental `AIReturn` deltas via provider adapters when available; notifies observers with `OnStart`, `OnInteractionCompleted`, `OnToolCall`, `OnToolResult`, `OnFinal`, `OnError` - - Always-on `PolicyPipeline` foundation with request and response policy hooks - - Special Turn system for executing AI requests with custom overrides: - - New `SpecialTurnConfig` to configure special turns with request overrides (interactions, provider, model, endpoint, capability, context/tool filters), execution behavior (force non-streaming, custom timeout), and history persistence strategies - - Four history persistence strategies: `PersistResult` (only result), `PersistAll` (all interactions with filtering), `Ephemeral` (no persistence), `ReplaceAbove` (replace history with result, filtered) - - `InteractionFilter` uses flexible allowlist/blocklist approach with `Allow()`, `Block()`, and fluent `WithAllow()`/`WithBlock()` methods; automatically supports future interaction types without code changes - - Predefined filters: `InteractionFilter.Default`, `InteractionFilter.PreserveSystemContext`, `InteractionFilter.AllowAll` - - `ConversationSession.ExecuteSpecialTurnAsync()` creates isolated `AIRequestCall` clone for execution; observers are not notified during execution, only when results are persisted to main conversation - - Isolated execution prevents internal special turn interactions (system prompts, tool calls) from appearing in UI - - Built-in `GreetingSpecialTurn` factory for AI-generated greetings - - Special turns support both streaming and non-streaming modes in isolated execution context - - Parallel special turns allowed (no locking) - - Refactored greeting generation to use special turn infrastructure, eliminating 140+ lines of duplicated code - -- AICall and core models: - - Added `Do` method to `AIRequest` to execute the request and return a `AIReturn`, as well as multiple methods to simplify the process of executing requests. - - Unified logic for `AIToolCall` and `AIRequestCall` in a `AIRequestBase`. - - New `AIRuntimeMessage` model to handle information, warning and error messages on AI Call. - - IAIRequest.WantsStreaming flag to indicate streaming intent and surface validation hints. - - New IAIKeyedInteraction interface to identify interactions by key. - - New IAIRenderInteraction interface to render interactions. - -- Model management: - - `ModelManager.SetDefault(provider, model, caps, exclusive)` helper to manage per-capability defaults. - - Centralized streaming capability check in `ModelManager.ModelSupportsStreaming(provider, model)` and updated validation to consult it. - -- Streaming infrastructure: - - Introduced internal base class `AIProviderStreamingAdapter` under `src/SmartHopper.Infrastructure/AIProviders/` to centralize common streaming adapter helpers (HTTP setup, auth, URL building, SSE reading). Enables provider-specific adapters to reuse infrastructure while keeping behavior consistent. - - `AIProviderStreamingAdapter.ApplyExtraHeaders(HttpClient, IDictionary)` helper to apply request-scoped headers (excluding Authorization) from `AIRequestCall.Headers`. - - `ConversationSession.Stream()` now gates streaming based on model capability and yields a clear error when unsupported. - - Provider-level streaming toggle via `IAIProviderSettings.EnableStreaming`. Added `EnableStreaming` setting descriptors to OpenAI, MistralAI, and DeepSeek provider settings (default `true`). - -- Tools validation: - - AI tool validation system to improve reliability and observability: - - Added three validators implementing `IValidator`: - - `ToolExistsValidator` (ensures tool is registered) - - `ToolJsonSchemaValidator` (validates tool arguments against JSON schema) - - `ToolCapabilityValidator` (ensures selected provider/model supports tool-required capabilities) - - New request policy `AIToolValidationRequestPolicy` runs after `ToolFilterNormalizationRequestPolicy`, validates all pending tool calls, and attaches diagnostics to the request and policy context; errors block execution early. - - `PolicyPipeline.Default` updated to register `AIToolValidationRequestPolicy`. - - Request validation now considers request-level messages so policy diagnostics can gate execution. - -- Components UI and badges: - - New component badges to visually identify verified and deprecated models. - - Component badges extended to surface model validation state without executing an AI call: - - Invalid/Incompatible model badge (red cross) when the configured model lacks the component's required capability or is unknown. - - Replaced/Fallback model badge (blue refresh) when the current configured model would be auto-replaced by selection logic. - - Badge display logic simplified to prioritize a single, most relevant badge for clarity. - - `AIStatefulAsyncComponentBase.RequiredCapability` virtual property (default `Text2Text`) to declare per-component capability requirements. - - `AIStatefulAsyncComponentBase.TryGetCachedBadgeFlags(out verified, out deprecated, out invalid, out replaced)` to expose the extended badge cache. - -- Diagnostics: - - Introduced `AIMessageCode` enum and `AIRuntimeMessage.Code` property for machine‑readable diagnostics. Default code is `Unknown (0)` to keep all existing emits backward compatible. - -- Utilities: - - Shared `HttpHeadersHelper.ApplyExtraHeaders(HttpClient, IDictionary)` utility under `src/SmartHopper.Infrastructure/Utils/` to centralize extra header application across streaming and non‑streaming calls (excludes reserved headers). - - Centralized sanitization utilities with tests for GhJSON, script content, and AI responses to ensure consistent cleanup of malformed or unsafe content. - -- Providers: - - New `Anthropic` and `OpenRouter` providers. - - Anthropic provider: Full round-trip support for tool results. Decodes `tool_result` content blocks to `AIInteractionToolResult` and encodes tool results back to Anthropic-compliant `tool_result` blocks (`{"type":"tool_result","tool_use_id":"...","content":[{"type":"text","text":"..."}]}`). - - Updated provider icons using the latest Lobe Icons set. - -- Documentation: - - Summary documentation at `docs/` (linked in README). - -- Repository organization: - - AICall folder reorganization. - -- Tests: - - New test component `DataTreeProcessorEqualPathsTestComponent` under `SmartHopper.Components.Test/DataProcessor/` to manually validate `DataTreeProcessor.RunFunctionAsync` with equal-path, single-item trees. Outputs result tree, success flag, and messages. - - New tests for Context Manager and Model Manager. +- **Canvas Button**: Assistant dialog trigger from canvas with configurable enable/disable setting +- **Context Providers**: File and document context (selection count, object count, component count, etc.) +- **Conversation Session**: Multi-turn conversation orchestration with observer pattern and policy pipeline +- **Special Turn System**: Isolated AI request execution with custom overrides and history persistence strategies +- **Streaming Infrastructure**: Provider-agnostic streaming adapters with centralized HTTP/auth handling +- **Tool Validation**: AI tool validation system with existence, schema, and capability checks +- **Component Badges**: Visual indicators for verified, deprecated, invalid, and replaced models +- **New Providers**: Anthropic and OpenRouter providers +- **Diagnostics**: Machine-readable `AIMessageCode` enum for structured error reporting +- **Documentation**: Summary documentation at `docs/` ### Changed -- Providers – Anthropic: - - Unified encoding/decoding helpers. Extracted `BuildTextMessage`, `BuildToolResultMessage`, and `ExtractToolResultText` in `AnthropicProvider.cs` and updated both `Encode(IAIInteraction)` and `Encode(AIRequestCall)` to use them, removing duplicated logic for `AIInteractionText` and `AIInteractionToolResult`. - - Switched URL members to use System.Uri for stronger typing and to satisfy CA1054/CA1055/CA1056: - - `AIProvider.DefaultServerUrl` is now `Uri` (was `string`). - - `AIProviderStreamingAdapter.BuildFullUrl(string)` now returns `Uri`. - - `AIProviderStreamingAdapter.CreateSsePost` now accepts a `Uri` parameter. - - `AIInteractionImage.ImageUrl` is now `Uri` (was `string`). - - Added `AIInteractionImage.SetResult(Uri imageUrl, string imageData = null, string revisedPrompt = null)` overload; kept string overload for backward compatibility. - - Fixed tool call detection in streaming responses - - Added `content_block_start` event handling to detect `tool_use` blocks - - Streaming adapter now properly yields `AICallStatus.CallingTools` when tools are invoked - - Fixed `content_block_delta` to check for `text_delta` type before processing text - - Added support for `input_json_delta` events (tool argument streaming) - - Enhanced debug logging for streaming events and tool detection - - Non-streaming `Decode()` method now ensures `Arguments` field is never null - -- Providers – OpenAI: - - Simplified message encoding to use sequential approach (matching MistralAI pattern) instead of complex coalescing/deduplication logic. Eliminates duplicate tool call handling issues and improves reliability. - - **Streaming adapter now extracts and streams reasoning content** from structured content arrays (o-series & gpt-5 models). Parses `type: "reasoning"` and `type: "thinking"` parts during streaming and appends to `AIInteractionText.Reasoning` field for live UI display. - - **Fixed reasoning-only streaming**: Adapter now emits snapshots immediately when reasoning is received, even before text content arrives. Ensures live reasoning display in UI without waiting for answer text. - -- Providers – MistralAI: - - **Streaming adapter now extracts and streams thinking content** from structured content arrays. Parses `type: "thinking"` blocks during streaming and appends to `AIInteractionText.Reasoning` field for live UI display. - - **Fixed reasoning-only streaming**: Adapter now emits snapshots immediately when thinking is received, even before text content arrives. Ensures live reasoning display in UI without waiting for answer text. - -- Providers – DeepSeek: - - **Fixed reasoning-only streaming**: Adapter now emits snapshots immediately when `reasoning_content` is received, even before text content arrives. Ensures live reasoning display in UI without waiting for answer text. - -- UI and settings: - - AI Chat component default system prompt to a generic one. - - Settings dialog now organized in tabs. - - Added tab for SmartHopper Assistant configuration (triggered from the canvas button on the top-right). - - Added tab for Trusted Providers configuration. - - CanvasButton chat now reuses a single `WebChatDialog` via a stable `componentId`, preventing multiple dialog instances from opening on repeated clicks. - - Updated the About dialog to reflect the list of currently supported AI providers. - - Provider settings: Disabled the "Enable Streaming" option for `DeepSeek` and `OpenRouter` (control is non-interactive) and updated the setting description to "Streaming is not available for this provider yet." Defaults set to `false` for both providers. - -- Security/authentication and headers: - - Improved API key encryption. Includes migration method. - - Authentication refactor and centralized API key handling: - - Providers select the auth scheme in `PreCall(...)`, while API keys are resolved internally by providers (never placed on `AIRequestCall`). - - `AIProvider.CallApi(...)` now supports `"none"`, `"bearer"` and `"x-api-key"` (applies header using provider API key). - - Streaming adapters apply auth via `AIProviderStreamingAdapter.ApplyAuthentication(...)` using provider-internal keys; `ApplyExtraHeaders(...)` now excludes reserved headers (`Authorization`, `x-api-key`). - - Unified extra header handling via `HttpHeadersHelper`: both `AIProvider.CallApi(...)` and `AIProviderStreamingAdapter.ApplyExtraHeaders(...)` now delegate to the shared helper to eliminate duplication and ensure consistent reserved header filtering for streaming and non-streaming paths. - -- Infrastructure and core models: - - Complete refactor of `SmartHopper.Infrastructure` for clarity and organization. - - Added `AIAgent`, `AIRequest` and `AIBody` models to improve clarity and extensibility. Refactored all code to use the new models. - - Renamed `IChatModel` to `AIInteraction`. - - Renamed `AIEvaluationResult` to `AIReturn`. - - Renamed `AIResponse` to `AIReturnBody`. - - Refactored all AI-powered tools to use the new `AIRequest` and `AIReturn` models. - - Unified `GetResponse` and `GenerateImage` methods in `AIProvider` to a generic `Call` method. - - `IAIReturn.Metrics` is writable; metrics now initialized in `AIProvider.Call()` with Provider, Model, and CompletionTime. - - Providers refactored to use `AIInteractionText.SetResult(...)` for consistent content/reasoning assignment. - - Renamed capabilities to Text2Text, ToolChat, ReasoningChat, ToolReasoningChat, Text2Json, Text2Image, Text2Speech, Speech2Text and Image2Text. - - Standardized async data-tree processing in stateful components via shared `RunProcessingAsync` pipelines and configurable `ProcessingUnitMode`, improving consistency and enabling better progress tracking for item-based workflows. - -- Model management and selection: - - Simplified model selection policy in `ModelManager.SelectBestModel`: capability-first ordering using defaults for requested capability → best-of-rest; removed the separate "default-compatible" tier; selection is now fully centralized in `ModelManager` with no registry-level fallback or wildcard resolution. - - Unified model retrieval via `IAIProviderModels.RetrieveModels()` with centralized registration in `ModelManager`. Components (e.g., `AIModelsComponent`) and tests updated to query `ModelManager` instead of calling per-provider legacy methods. - - Provider-scoped model selection: - - Added `IAIProvider.SelectModel(requiredCapability, requestedModel)` to encapsulate model resolution behind provider interface. - - `AIProvider` base now implements `SelectModel(...)` delegating to centralized `ModelManager.SelectBestModel` while honoring provider defaults/settings. - - `AIRequestBase.GetModelToUse()` refactored to call `provider.SelectModel(...)` instead of `ModelManager.Instance` directly. - - Removed remaining direct calls to `ModelManager.Instance.SelectBestModel` outside provider internals. - - Propagated model validation messages to components UI. - -- Requests execution and validation: - - `AIRequestCall.Exec()` is now explicitly single-turn (no tool orchestration). Multi-turn and tool processing are handled by `ConversationSession.RunToStableResult` when used explicitly. - - `AIRequestBase.IsValid()` now blocks streaming when the selected provider disables streaming via settings or when the model is not streaming-capable, surfacing a clear validation error; these streaming validations now include `AIMessageCode` values (`StreamingDisabledProvider`, `StreamingUnsupportedModel`). - - `AIRequestCall.IsValid()` now emits structured `AIMessageCode` values for provider/model and body validation: - - `ProviderMissing`, `UnknownProvider`, `UnknownModel`, `NoCapableModel`, `CapabilityMismatch` - - Endpoint/body issues are tagged as `BodyInvalid` - - `AIStatefulAsyncComponentBase.UpdateBadgeCache()` prioritizes structured `Message.Code` for invalid/replaced decisions (`ProviderMissing`, `UnknownProvider`, `UnknownModel`, `NoCapableModel`, `CapabilityMismatch`) and falls back to message text only when `Code == Unknown`. - -- Tools and components: - - Grasshopper AI tools refactor: replaced legacy mutable `AIBody` usage with `AIBodyBuilder` + `AIReturn.CreateSuccess(body, toolCall)` for consistent immutable response construction. Updated tools: `gh_get`, `gh_put`, `gh_list_categories`. Ensured `AIToolCall.FromToolCallInteraction` is used and preserved existing error handling. - - Verified badge now requires capability match (`Verified && HasCapability(RequiredCapability)`). - - Badge cache computation now evaluates against the currently configured model (immediate UI feedback) and also surfaces replacement intent via selection fallback. - - AIChatComponent: removed duplicated `_sharedLastReturn` storage and its lock. Removed internal methods `SetLastReturn(AIReturn)` and `GetLastReturn()`; components should rely on the base snapshot via `SetAIReturnSnapshot(...)` and use it for outputs. - - AIChatComponent: unified snapshot management using base class snapshot exclusively. Renamed method to `SetAIReturnSnapshot(AIReturn)` for consistency across components. Updated chat transcript output to read from the base snapshot, ensuring live updates and metrics stay in sync. - - AIChatWorker: removed worker-local `lastReturn` cache and fallback. `onUpdate` now updates only the base snapshot via `SetAIReturnSnapshot(...)`, and `SetOutput` reads exclusively from `CurrentAIReturnSnapshot` to keep chat history and metrics consistent. - - Output lifecycle: `AIStatefulAsyncComponentBase` now exposes `protected virtual bool ShouldEmitMetricsInPostSolve()`; `OnSolveInstancePostSolve` respects this hook. Default behavior unchanged (metrics emitted in post-solve) unless overridden. - - Refactor: Extracted timeout magic numbers (120/1/600) into named constants in `AIToolCall` (`DEFAULT_TIMEOUT_SECONDS`, `MIN_TIMEOUT_SECONDS`, `MAX_TIMEOUT_SECONDS`). - - Disabled several untested or experimental AI tools/components by excluding them from the build (prefixed filenames with `_`) to keep the default toolbox focused on stable features. - - Reorganized Grasshopper AI tool and component categories (including testing/experimental groups) for clearer grouping and discoverability inside Grasshopper. - - AIListFilter: fix incorrect index array parsing - - `mcneel_forum_search` simplified: now only accepts `query` and `limit` parameters and returns raw `results` and `count` without automatic AI summaries; use `mcneel_forum_post_summarize` explicitly when summaries are needed. - -- Streaming behavior: - - OpenAI provider: nested `OpenAIStreamingAdapter` now derives from `AIProviderStreamingAdapter` and reuses shared helpers; streaming behavior and statuses remain unchanged. - - Centralized streaming capability check in `ModelManager.ModelSupportsStreaming(provider, model)` and updated validation to consult it. - - `ConversationSession.Stream()` now gates streaming based on model capability and yields a clear error when unsupported. - -- WebChat: - - WebChatDialog: refactored to align with new base class API and recent infrastructure changes. - - WebChatDialog greeting flow is now fully event-driven via `ConversationSession` observer callbacks. The UI no longer inserts or replaces a temporary greeting bubble; it only updates the status label during generation and renders greeting content from partial/final events. - - Interaction override behavior clarified: greeting generation uses the initial request interactions (e.g., system prompt) to preserve context; normal user-initiated turns override from the current conversation history (last return interactions). - - Default conversation context enabled for WebChat (Canvas Button and AIChatComponent): sets `AIBody.ContextFilter` to `"time, environment, selection"` so the assistant receives time, environment, and selection count by default. Implemented in `WebChatUtils.EnsureDialogOpen(...)` and `WebChatUtils.WebChatWorker`. - - Improved default prompts in WebChat for clearer assistant behavior and tool guidance. - - Improved UI with better collapsible messages, auto-scroll to bottom feature, "new messages" information tooltip, and improved thinking message - - Dedicated error messages for validation errors in UI not being passed to APIs - - Ensured fidelity between UI and conversation history - -- Conversation orchestration: - - `ConversationSession` now uses a unified internal loop (`TurnLoopAsync`) for both streaming and non‑streaming APIs to prevent logic drift. - - Streaming persistence semantics updated: deltas are persisted into history per chunk in arrival order (no grouping or reordering at the end of the stream). Finalization only updates the "last return" snapshot. - - Tool-call handling: removed internal deduplication-by-Id for `tool_call` interactions. Multiple tool calls with the same Id emitted by providers are now preserved in history. Session avoids introducing duplicates on its own when force-appending missing tool_calls prior to execution. - - Fixed streaming delta notifications to only emit `OnDelta` for text interactions; non-text interactions (tool calls, tool results) now properly use `OnInteractionCompleted` after completion. - -- Streaming adapters internals: - - Streaming infrastructure: Introduced an enhanced SSE reader overload in `AIProviderStreamingAdapter.ReadSseDataAsync(HttpResponseMessage, TimeSpan?, Func?, CancellationToken)` that supports idle timeout, robust cancellation (disposing the underlying stream), and provider-specific terminal detection. The simple overload now delegates to the enhanced version (deduplication). - - Providers updated to use enhanced SSE reader with a conservative 60s idle timeout: - - OpenAI: uses new overload while keeping provider-level final chunk handling intact. - - Anthropic: passes terminal predicate for `type == "message_stop"` to ensure early completion even without `[DONE]`. - - MistralAI: passes terminal predicate when `finish_reason` appears in the payload to end the stream reliably. +- **Infrastructure Refactor**: Complete reorganization of `SmartHopper.Infrastructure` with new models (`AIAgent`, `AIRequest`, `AIBody`, `AIReturn`) +- **Model Management**: Centralized model selection and capability validation +- **Streaming**: Enhanced reasoning content streaming across all providers (OpenAI, MistralAI, DeepSeek) +- **Authentication**: Centralized API key handling with improved encryption +- **UI/Settings**: Tabbed settings dialog, improved WebChat with collapsible messages and auto-scroll +- **Request Execution**: Explicit single-turn `AIRequestCall.Exec()`, multi-turn via `ConversationSession` +- **Tools**: Refactored to use immutable `AIBodyBuilder` pattern ### Security -- Prevented secret leakage by centralizing API key usage inside provider internals for both non-streaming and streaming flows. -- `AIRequestCall`, `AIReturn`, and logs do not contain API keys; reserved headers are applied internally only. +- Centralized API key usage prevents secret leakage in logs and requests ### Deprecated -- `CustomizeHttpClientHeaders` is deprecated for authentication/header setup. Providers must stop overriding it for auth and use request-scoped headers instead. +- `CustomizeHttpClientHeaders` for authentication (use request-scoped headers instead) ### Removed -- Providers and models: - - Removed legacy model retrieval methods across providers/tests/docs: `RetrieveAvailable`, `RetrieveCapabilities`, and `RetrieveDefault`. Providers must expose models exclusively via `RetrieveModels()` during async initialization. - - Removed the `TemplateProvider` since it will be explained in documentation. - -- Context and metrics: - - Removed the `ContextKeyFilter` and `ContextProviderFilter` in favor of a single `ContextFilter` that filters the providers. - - Removed `AIToolCall.ReplaceReuseCount()` in favor of unified metrics handling. - -- WebChat: - - WebChatDialog: removed the assistant greeting loading placeholder and manual replacement logic in `InitializeNewConversation()`; greeting is appended by the session and rendered solely from observer updates. +- Legacy model retrieval methods (`RetrieveAvailable`, `RetrieveCapabilities`, `RetrieveDefault`) +- Legacy context filters (`ContextKeyFilter`, `ContextProviderFilter`) +- TemplateProvider ### Fixed -- ConversationSession: - - Fixed TurnId mismatch between tool calls and their results: tool results now inherit the TurnId from their originating tool call instead of receiving a new TurnId from the current turn iteration. This ensures proper correlation in WebChat rendering keys. - -- Streaming providers: - - **All providers** (DeepSeek, MistralAI, OpenAI): Fixed reasoning-only streaming chunks being overridden by content chunks in UI. When transitioning from reasoning-only to content streaming, providers now emit a completed (Finished) interaction for reasoning before starting content stream, triggering proper UI segmentation to prevent override. - - DeepSeek: Fixed `OutputTokensReasoning` always showing 0. Now properly extracts reasoning tokens from nested `usage.completion_tokens_details.reasoning_tokens` field in both streaming and non-streaming responses. - - OpenAI: Fixed `OutputTokensReasoning` always showing 0 for reasoning models (o1/o3/GPT-5). Now properly extracts reasoning tokens from nested `usage.completion_tokens_details.reasoning_tokens` field in both streaming and non-streaming responses. - -- WebChatDialog: - - Fixed assistant messages appearing out of order in the UI when tool calls are made. Empty assistant text interactions (which represent the decision to call tools) are now preserved in conversation history but skipped during UI rendering. The actual assistant response after tool execution renders as a separate segment (seg2) in the correct position after tool results. - - Fixed duplicate greeting messages in UI. `OnFinal` now uses dedup keys for non-streamed interactions (like greetings) instead of creating new segmented keys, ensuring they upsert into existing bubbles rendered during history replay. - - Fixed AI-generated greetings not streaming. Greeting initialization now uses `ConversationSession.Stream()` with streaming validation and fallback to `RunToStableResult()` on failure, matching the pattern used for regular user messages. - -- Components – ImageViewer: - - Fixed "ImageViewer" saving images errors. Now it will create a temporary file that will be deleted after saving to prevent file system issues. - -- Model selection and metrics: - - Fixed "Invalid model" when model manager was providing the wildcard instead of the actual default model name. - - Corrected DataCount in metrics. - -- Tools: - - Fixed incorrect result output in `list_generate` tool. - - Tool-call executions now retain correct provider/model context via `FromToolCallInteraction(..., provider, model)` to improve traceability and metrics accuracy. - - Corrected script component GUIDs to match Grasshopper runtime values, ensuring tools and GhJSON generation can reliably create and reference script components. - -- WebChat and streaming UI: - - Prevent assistant replies from overwriting previous assistant messages: final assistant bubble now re-keys from the streaming key to the interaction's dedup key, so each turn is preserved in order. - - WebChatDialog streaming: first assistant chunk now creates a new assistant message in the UI, subsequent chunks update the same bubble with the full accumulated text instead of replacing with only the last chunk; final content is persisted to history once on completion. - - WebChatDialog streaming: partial assistant updates now also update internal `_lastReturn` and emit `ChatUpdated` events on every chunk, ensuring state consistency between UI and observers throughout streaming. - - WebChatDialog non-streaming: fixed loss of AI metrics by merging `AIReturn.Metrics` into the final assistant interaction in `WebChatObserver.OnFinal` so per-message metrics are preserved in chat history and UI. - - WebChat: reasoning-only assistant messages now render. `ChatResourceManager` renders the `Reasoning` as a collapsible panel and auto-expands it when there is no answer content, fixing empty message bubbles during streaming. - - AIChat/WebChatDialog: Ensure the initial system prompt is added as the first system message in chat history and rendered in the UI on dialog initialization. - - WebChatDialog: fixed compile-time errors by implementing missing methods (`InitializeWebViewAsync`, `ExecuteScript`, `RenderAndUpdateDom`) and UI handlers (`ClearButton_Click`, `SendButton_Click`, `UserInputTextArea_KeyDown`); added internal `DomUpdateKind` enum; ensured all UI/WebView operations marshal to Rhino's main UI thread. - - HtmlChatRenderer: restored compatibility by adding `RenderInteraction(...)` wrapper used by `WebChatDialog`. - - Introduced an internal DOM update queue to avoid running multiple WebView scripts concurrently, preventing race conditions and render glitches. - - Fixed a loop in tool-call execution that could cause repeated or stuck tool-handling cycles. - -- Providers: - - DeepSeek: Do not force `response_format: json_object` for array schemas; use text output and a guiding system prompt instead. Decoder made robust to unwrap arrays from `content` parts and from wrapper objects (`items`, `list`, or malformed `enum`) to ensure a plain JSON array is returned. - - MistralAI: - - Streaming adapter fixes replacing invalid `AICallStatus.Error`/`NoContent` with `Finished`, using `AIReturn.CreateError(...)` for errors, and aligning streaming statuses (Processing → Streaming → Finished) with the OpenAI adapter pattern. - - Fixed retrieval of available models when the API did not return the expected model list. - - Anthropic: Fixed mapping/placement of system messages to ensure correct role semantics and prompt conditioning. - -- Components – AIChat: - - AIChatComponent: Prevent NullReferenceException when closing chat without responses. `SetOutput()` now null-checks the last interaction and outputs an empty string (with a debug notice) when none exists. - - AIChatComponent: Eliminated duplicated/nested branches in "Chat History" output by centralizing output setting in `SolveInstance()` and removing the worker's `SetPersistentOutput` call. Ensures last interaction appears and output updates consistently from a single snapshot source. - - AIChatComponent: Synchronized outputs. Metrics are now emitted from `SolveInstance()` together with "Chat History" (reading from base snapshot). Base post-solve metrics emission disabled via `ShouldEmitMetricsInPostSolve()` override to avoid duplicates. Fixes intermittent metrics not updating alongside chat during streaming/incremental updates. - -- Components – Persistence and stability: - - Prevent crash on GH file open by introducing a safe, versioned persistence (v2) for `StatefulAsyncComponentBase` that stores outputs as canonical string trees keyed by output parameter GUIDs. Legacy output restore is skipped by default and can be enabled via a feature flag. - - WebChatDialog stability issues in certain scenarios. - - Build stability after refactor (compilation issues resolved). - - Infrastructure stability fixes. - -- Image generation pipeline: - - Fixed AI image output not reaching `ImageViewer` due to strict success check in `AIImgGenerateComponent`. Now treats missing `success` as true and only fails when an `error` is present, allowing the image URL/bitmap to flow to outputs. - -- Streaming stability: - - Streaming metrics propagation: after streaming completes, usage metrics (provider, model, input/output tokens, finish_reason) are now displayed in the chat UI. Implemented by: - - Requesting OpenAI to include usage in the final stream chunk via `stream_options.include_usage = true` and parsing `prompt_tokens`/`completion_tokens` in `OpenAIProvider` streaming adapter. - - Suppressing metrics during partial updates and merging final `AIReturn.Metrics` into the last assistant message in `WebChatObserver.OnFinal` when the final result has no interactions. - - Streaming stability: Fixed indefinite streaming hangs across providers by using the enhanced SSE reader with idle timeouts and terminal event detection. OpenAI, Anthropic, and Mistral adapters now properly detect completion signals (e.g., `finish_reason`, `message_stop`) and exit the stream even if `[DONE]` is omitted by the provider. +- Streaming reasoning content display and metrics across all providers +- WebChat message ordering, duplicate greetings, and metrics propagation +- Model selection wildcard resolution and validation badges +- Tool execution context and script component GUIDs +- Component persistence stability and image generation pipeline +- Streaming stability with idle timeouts and terminal detection ## [0.5.3-alpha] - 2025-08-20 ### Fixed -- Fix incorrect json schema required fields in `script_new` tool ([#304](https://github.com/architects-toolkit/SmartHopper/issues/304)). +- Fixed JSON schema validation in script tool ([#304](https://github.com/architects-toolkit/SmartHopper/issues/304)). ## [0.5.2-alpha] - 2025-08-12 ### Fixed -- StackOverflowException on first run due to recursive lazy defaults in provider settings (`SmartHopperSettings.GetSetting`, `AIProvider.GetSetting`), guarded with thread-static recursion checks. -- Readiness guard in `SmartHopperSettings.RefreshProvidersLocalStorage` to avoid partial refresh before all providers register settings UI. -- OpenAI and MistralAI providers now fall back to static model lists/capabilities on API errors or empty API responses, preventing empty model selections. +- Fixed provider initialization issues and fallback behavior for API errors ## [0.5.1-alpha] - 2025-07-30 ### Added -- Settings parameter to enable/disable AI generated greeting in chat. +- Setting to enable/disable AI-generated greeting in chat ### Fixed -- Greeting generation was using stored settings models instead of the provider's default model. To solve it, now if `AIUtils.GetResponse` doesn't get a model, it will use the provider's default model. -- Components triggered with a Boolean Toggle (permanent true value) weren't calculating when the toggle was turned to true. -- Lazy default values in `AI Provider Settings` to prevent race conditions at initialization. -- Fixed "List length in list_generate was not met for long requests" ([#277](https://github.com/architects-toolkit/SmartHopper/issues/277)). +- Fixed model selection and component trigger issues +- Fixed list generation for long requests ([#277](https://github.com/architects-toolkit/SmartHopper/issues/277)) ## [0.5.0-alpha] - 2025-07-29 ### Added -- **Model Capability Management System** - - Introduced `AIModelCapabilities` and `AIModelCapabilityRegistry` for centralized, persistent model capability tracking. - - Added capability checking and filtering methods for models (e.g., `GetCapabilities`, `SetCapabilities`, `FindModelsWithCapabilities`). - - Tool-specific capability validation now prevents execution with incompatible models. - - Default model is now managed by the `AIModelCapabilityRegistry`. Multiple models can be defined as Default for a set of capabilities. - - `AIStatefulAsyncComponentBase` will now try to use the default model if the specified model is not compatible with the tool. -- **Provider-Specific Capability Management** - - MistralAI: - - Added `MistralModelManager` for dynamic API-based capability detection and registration. - - Models now update their capabilities by querying the `/v1/models/{model_id}` endpoint. - - Automatic mapping of Mistral model features (chat, function calling, vision) to internal capability flags. - - OpenAI & DeepSeek: - - Static mapping for capabilities, with support for function calling, structured output, and image generation. -- **Image Generation Support**: Comprehensive AI image generation capabilities using OpenAI DALL-E models. - - New `DefaultImgModel` property in `IAIProvider` interface for provider capability detection. - - New `img_generate` AI tool with support for prompt, size, quality, and style parameters. - - Enhanced `AIUtils.GenerateImage()` method with provider-agnostic image generation. - - New `AIImgGenerateComponent` UI component in SmartHopper > Img category. -- Improvements in `AITools`: - - New `includeSubcategories` parameter to `gh_list_categories` tool. - - New `nameFilter`, `includeDetails` and `maxResults` parameters to `gh_list_components` tool. - - New `ImageViewer` component to visualize output images on canvas and save them to disk. -- Added component existence and connection type validation to `GHJsonLocal`. -- **Settings management in AI Providers**: - - New `SetSetting` method in `AIProvider` that let's providers set custom settings within the provider key. - - New `RefreshCachedSettings` method in `AIProvider` to refresh their cached settings. +- **Model Capability Management**: Centralized capability tracking with provider-specific detection and tool validation +- **Image Generation**: AI image generation support with DALL-E models and image viewer component +- **AI Tools**: Enhanced filtering and component validation +- **Settings**: Provider settings management improvements ### Changed -- Renamed `AIProvider.InitializeSettgins` to `AIProvider.ResetCachedSettings`. Set visibility to `private`. +- Improved provider settings initialization ### Fixed -- `gh_put` now automatically fixes GhJSON. -- OpenAI tool filter not being applied properly. -- Fixed "Parsing error when output contains { }" ([#276](https://github.com/architects-toolkit/SmartHopper/issues/276)). +- Fixed GhJSON and parsing issues ([#276](https://github.com/architects-toolkit/SmartHopper/issues/276)) ## [0.4.1-alpha] - 2025-07-23 ### Added -- New `ProgressInfo` class to `StatefulAsyncComponentBase` to provide progress information to the UI. It allows to display a dynamic progress reporting which branch is being processed. +- Progress reporting for async components ### Fixed -- Multiple fixes to `StatefulAsyncComponentBase`: - - Fixed issue: Components now transition to "Done" state when opening files with existing results instead of "Run me!" ([#113](https://github.com/architects-toolkit/SmartHopper/issues/113)) - - Calculate changed inputs based on actual values, not on object instances, to prevent false positives when connecting new sources with same values. - - Fixed issue: Stuck components when using Boolean toggle ([#260](https://github.com/architects-toolkit/SmartHopper/issues/260)). - - Fixed issue: Output metrics not being set when using Boolean toggle. -- Fixed issue ([#208](https://github.com/architects-toolkit/SmartHopper/issues/208)): enabled compatibility with params in `gh_toggle_preview` tool. -- Fixed WebChatDialog not automatically closing when Rhino is closed. +- Fixed component state transitions and boolean toggle handling ([#113](https://github.com/architects-toolkit/SmartHopper/issues/113), [#260](https://github.com/architects-toolkit/SmartHopper/issues/260)) +- Fixed preview toggle compatibility with parameters ([#208](https://github.com/architects-toolkit/SmartHopper/issues/208)) +- Fixed dialog closing behavior ## [0.4.0-alpha] - 2025-07-22 ### Added -- New `RemoveLastMessage` method to `WebChatDialog` to remove messages from the chat history. -- Added GitHub Actions workflow for automatic milestone management, moves open issues/PRs to next appropriate milestone when a milestone is closed -- JSON wrapper in `OpenAI provider` to prevent passing incorrect JSON schemas to the API. -- JSON cleaner in `DeepSeek provider` to extract data from malformed responses with `enum` property. +- Chat message removal and milestone management automation +- Provider-specific JSON schema handling ### Changed -- Enhanced chat greeting with loading animation and improved model handling ([#255](https://github.com/architects-toolkit/SmartHopper/issues/255)), including: - - New loading message while generating the greeting in `InitializeNewConversation`, with spinning animation. - - Update `chat-script.js` with new function to remove messages. - - Modified `AddMessageToWebView` to automatically add the loading class when finish reason from responses is "loading". - - Modified `AIUtils.GetResponse` to use the default model if none is specified. - - Modified `InitializeNewConversation` to use the default model for greeting generation (a fast and cheap model). -- Modified `WebChatDialog` constructor to pass the provider name to the base class. -- Modified the construction of `WebChatDialog` in `WebChatUtils.ShowWebChatDialog` to pass the provider name. -- Modified `GetModel` in `AIStatefulAsyncComponentBase` to use the provider's global model defined in settings if none is specified. -- Updated release workflow to automatically assign PRs to milestones -- Enhanced new-branch workflow with versioning guidance -- Using the `StripThinkTags` in all `DataProcessing` tools to avoid including reasoning text in the processed data. +- Enhanced chat greeting with loading animation and improved model handling ([#255](https://github.com/architects-toolkit/SmartHopper/issues/255)) +- Improved component model selection and reasoning text handling +- Enhanced CI/CD workflows for milestone and PR management ### Fixed -- Fix incorrect model handling in `AIStatefulAsyncComponentBase`. -- Fixed certificate creation tests to handle CI environment constraints -- Updated `GhRetrieveComponents` to use the correct ai tool `gh_list_components` instead of `gh_get_available_components` -- Fixes "Missing required parameter: ‘response_format.json_schema' in text-list-generate with OpenAI provider" ([#259](https://github.com/architects-toolkit/SmartHopper/issues/259)). -- Fixes "Check structured output compatibility with models" ([#273](https://github.com/architects-toolkit/SmartHopper/issues/273)). +- Fixed model handling and structured output compatibility ([#259](https://github.com/architects-toolkit/SmartHopper/issues/259), [#273](https://github.com/architects-toolkit/SmartHopper/issues/273)) ## [0.3.6-alpha] - 2025-07-20 ### Added -- Added icon to `AIModelsComponent` +- Added icon to AIModels component ## [0.3.5-alpha] - 2025-07-19 ### Added -- New methods in AIProvider base class: - - Add DefaultServerUrl property - - Added CallApi method to AIProvider base class supporting GET/POST/DELETE/PATCH - - Added RetrieveAvailableModels method to AIProvider base class with default to empty list -- Implemented RetrieveAvailableModels, CallApi and DefaultServerUrl to existing providers (MistralAIProvider, OpenAIProvider, and DeepSeekProvider). -- New AIModelsComponent component under SmartHopper > AI categories that uses provider's RetrieveAvailableModels() to fetch model list. +- Provider API methods and model retrieval +- AIModels component for listing available models ### Changed -- Update providersResources access modifiers from public to internal -- Clean up AboutDialog by removing MathJax attribution -- Moved provider selection logic from AIProviderComponentBase to AIProviderComponentBase -- Moved InputsChanged method with override for including HasProviderChanged from AIStatefulAsyncComponentBase to AIProviderComponentBase +- Improved provider architecture and code organization ### Removed -- Removed MathJax support from chat UI since it was not properly implemented and was generating security warnings on GitHub. +- Removed MathJax support from chat UI ## [0.3.4-alpha] - 2025-07-11 ### Added -- Added `Instructions` input to `AIChatComponent` ([#87](https://github.com/architects-toolkit/SmartHopper/issues/87)) -- Added `systemPrompt` parameter to `WebChatUtils.ShowWebChatDialog` -- Context manager improvements: - - Added support for "-*" to exclude all providers/context in one go - - Added support for space as additional delimiters in filter strings - - Explicitly handle "*" wildcard to include all providers/context by default -- Added `gh_group` AI tool for grouping components by GUID, with support to custom names and colors -- Added `list_generate` AI tool for generating a list of items from a prompt and count ([#6](https://github.com/architects-toolkit/SmartHopper/issues/6)) -- New `AITextListGenerate` component implementing `list_generate` AI tool with type 'text' ([#6](https://github.com/architects-toolkit/SmartHopper/issues/6)) -- Added `Category` property to `AITool` with default value "General" -- New `Filter` class for common include/exclude patterns processing +- Instructions input to AIChat component ([#87](https://github.com/architects-toolkit/SmartHopper/issues/87)) +- Context filtering improvements with wildcard support +- Component grouping and list generation tools ([#6](https://github.com/architects-toolkit/SmartHopper/issues/6)) +- AI tool categorization ### Changed -- Several improvements to `AIChatComponent`: - - Updated `WebChatDialog` to use provided system prompt or fall back to default - - Improved default system prompt for AI Chat to focus on a Grasshopper assistant, including tool call examples - - Added `gh_group` mention to default system prompt -- Modified manifest to reflect new instructions input feature in AI Chat Component -- Modified `AITextEvaluate`, `AITextGenerate`, `AIListEvaluate` and `AIListFilter` to exclude all context using the new "-*" filter -- Code reorganization: - - Reorganized `AIProvider`, `AIContext` and `AITool` managers - - Code cleanup in `AIChatComponent`, `WebChatDialog` and `WebChatUtils` - - Renamed `SmartHopper.Config` to `SmartHopper.Infrastructure` - - Renamed `SmartHopper.Config.Tests` to `SmartHopper.Infrastructure.Tests` -- Updated `StringConverter.StringToColor` to accept argb, rgb, html and known color names as input -- Change `GetResponse` parameter from `includeToolDefinitions` to `toolFilter` -- Updated AITool constructor to require category parameter -- Categorized existing tools with DataProcessing, Components, Knowledge and Scripting categories -- Updated unit tests to include category parameter -- Integrated the new `Filter` class in `GetFormattedTools` and `GetCurrentContext` - -### Removed - -- Removed unnecessary `GetModel` and `GetFormattedTools` methods in `OpenAIProvider`, `MistralAIProvider` and `TemplateProvider` -- Removed `GetResponse` method from `AIStatefulAsyncComponentBase` in favor of `CallAiToolAsync` +- Improved chat system prompts and context management +- Renamed SmartHopper.Config to SmartHopper.Infrastructure +- Improved tool filtering and organization ## [0.3.3-alpha] - 2025-06-23 -### WIP - -- Adding LaTeX support in chat UI with the MathJax library. - ### Added -- Added DeepSeek provider ([#222](https://github.com/architects-toolkit/SmartHopper/issues/222)). -- Added temperature parameter support for MistralAI, OpenAI, and DeepSeek providers. -- Added slider UI control in settings dialog for numeric parameters. -- Added reasoning support: - - Render reasoning panels for `` tags in chat UI as collapsible `
` blocks. - - Exclude reasoning from copy-paste (`mdContent`) and include in HTML display (`htmlContent`). - - Added configurable `reasoning_effort` setting (low, medium, high) for OpenAI o-series models. - - New `StripThinkTags` method in `Config.Utils.AI`. - - Set up OpenAI and DeepSeek to return reasoning in the response. +- **New Provider**: DeepSeek provider ([#222](https://github.com/architects-toolkit/SmartHopper/issues/222)) +- **Reasoning Support**: Collapsible reasoning panels in chat UI with configurable effort for OpenAI o-series models + - Reorganized providers settings and moved them from `AIProvider` to `AIProviderSettings`. ### Changed -- Updated default OpenAI model to gpt-4.1-mini. -- Mention `DeepSeek` as available provider in the About dialog. -- Settings dialog improvements: - - Added dropdown support for provider settings when a list of allowed values is provided. - - Increased max tokens for OpenAI, MistralAI and DeepSeek providers to 100000. - - Improved descriptions. - - Setting values that are empty or whitespace will be removed from the settings file on `UpdateProviderSettings`. -- AI providers updates: - - Updated deprecated OpenAI max_tokens parameter to max_completion_tokens. - - Refactored OpenAI, MistralAI, DeepSeek and TemplateProvider settings validation to use centralized validation methods. - - Renamed `OpenAI` to `OpenAIProvider`. - - Renamed `OpenAISettings` to `OpenAIProviderSettings`. - - Renamed `MistralAI` to `MistralAIProvider`. - - Renamed `MistralAISettings` to `MistralAIProviderSettings`. - - OpenAI, MisralAI and DeepSeek now remove `` tags from messages before sending them to the API, using the `StripThinkTags` method from `Config.Utils.AI`. - - Reorganized providers settings and moved them from `AIProvider` to `AIProviderSettings`. +- Removed `` tags from messages before sending them to the API, using the `StripThinkTags` method from `Config.Utils.AI`. ### Removed @@ -1162,332 +459,135 @@ Many thanks to the following contributors to this release: ### Added -- Added undo support to `MoveInstance`, `SetComponentPreview`, and `SetComponentLock`. -- New `ScriptTools` class in `SmartHopper.Core.Grasshopper.Tools` for Grasshopper script components, including: - - New `script_review` AI tool for reviewing Grasshopper scripts. - - New `script_new` AI tool for generating Grasshopper scripts. -- Added support for script components in `GhPutTools`, enabling placement of script components with code from GhJSON. -- Enhanced `GetObjectsDetails` in `GHDocumentUtils` to serialize variable input and output parameters from script components to GhJSON. -- Extended `GhPutTools` to handle variable input and output parameters when placing script components from GhJSON. -- Added support for parameter modifiers (simplify, flatten, graft, reverse) in both input and output parameters for script components in `GhPutTools` and `GHDocumentUtils`. -- New `CallAiTool` method in `AIStatefulAsyncComponentBase` to handle provider and model selection, and metrics output. -- `AiTools` now define their own endpoint. -- New icons for all components. +- **Script Tools**: New AI tools for reviewing and generating Grasshopper scripts with full parameter modifier support +- **Undo Support**: Added undo capability to canvas operations (move, preview toggle, lock toggle) +- **Icons**: Updated icons for all components ### Changed -- Minimum Rhino version required increased to 8.19 -- Updated SmartHopper logo -- Renamed `gh_retrieve_components` by `gh_get_available_components` -- Prevent `GHDocumentUtils.GetObjectsDetails` from generating humanReadable field if value is already human readable (numbers and strings) -- Renamed `evaluateList` and `filterList` AI tools to `list_evaluate` and `list_filter` -- Renamed `evaluateText` and `generateText` AI tools to `text_evaluate` and `text_generate` -- Migrated `GhPutTools` to `Utils` in `Core.Grasshopper` -- Split AI Tools into smaller files: - - `TextTools` into `text_evaluate.cs` and `text_generate.cs` - - `ListTools` into `list_evaluate.cs` and `list_filter.cs` - - `GhObjTools` into `gh_tidy_up.cs`, `gh_toggle_preview.cs`, `gh_toggle_lock.cs`, `gh_move_obj.cs` - - `GhPutTools` into `gh_put.cs` - - `WebTools` into `web_generic_page_read.cs`, `web_rhino_forum_read_post.cs` and `web_rhino_forum_search.cs` - - `GhGetTools` into `gh_get.cs`, `gh_list_components.cs` and `gh_list_categories.cs` - - `ScriptTools` into `script_new.cs` and `script_review.cs` -- Now `Put` removes all default inputs and outputs from the component before adding a new script component. -- Improved OpenAI provider to support structured output. -- Improved `script_new` in several ways: - - Now it creates component inputs and outputs. - - It returns the instance GUID of the created component. -- Modified `AITextGenerate`, `AITextEvaluate`, `AIListEvaluate` and `AIListFilter` to use `AIToolManager` instead of calling the AI tool directly. -- Improved components descriptions. +- **Minimum Requirements**: Rhino 8.19 or later required +- **AI Tools**: Renamed tools for consistency (e.g., `evaluateText` → `text_evaluate`, `evaluateList` → `list_evaluate`) +- **OpenAI Provider**: Added structured output support ### Deprecated -- `GetResponse` method in `AIStatefulAsyncComponentBase` is deprecated. Use `CallAiTool` instead. - -### Removed - -- Removed `Eto.Forms` reference from `SmartHopper.Config`. -- Removed the `GetEndpoint` method from `AIStatefulAsyncComponentBase`. +- `GetResponse` method in `AIStatefulAsyncComponentBase` (use `CallAiTool` instead) ### Fixed -- Fixed MistralAI provider not working with structured output ([#112](https://github.com/architects-toolkit/SmartHopper/issues/112)). -- Fixed OpenAI error in API URI. -- Fixed CI Signature Tests in `SmartHopper.Config.Tests`. -- Fixed OpenAI logo quality. +- Fixed MistralAI provider structured output compatibility ([#112](https://github.com/architects-toolkit/SmartHopper/issues/112)) +- Fixed OpenAI API URI error ## [0.3.1-alpha] - 2025-05-06 ### Added -- Added the "Accepted feature request: Allow for copy-paste the chat in a good format when selecting the text" ([#86](https://github.com/architects-toolkit/SmartHopper/issues/86)). -- Added support for script components in `GhGet`. -- Allow `CreateComponentGrid` for fractional row positions for components, to create a more human-like layout. -- Added `gh_tidy_up` AI tool in `GhObjTools` for arranging selected components into a dependency-based grid layout. -- New `gh_tidy_up` component. -- New `SelectingComponentBase` for components that need the button to select other components. -- New `GHJsonAnalyzer` and `GHJsonFixer` classes for analyzing and fixing GHJSON formats. +- **Chat UI**: Copy codeblocks to clipboard, collapsible tool messages, and inline metrics per message +- **Component Layout**: `gh_tidy_up` tool and component for arranging components into dependency-based grid layout +- **Script Components**: Added support in `GhGet` ### Changed -- Improved chat UI with timestamps for messages, collapsible tool messages, inline metrics per message, button to copy codeblocks to clipboard, and better formatting. -- Reorganization of JSON models for clearer structure. -- Migrated the `GhPut` tool from the `GhPutComponent` to the `GhPutTools` class, using the `AIToolManager`. -- `DeserializeJSON` now fixes invalid InstanceGuids in Grasshopper JSON documents when deserializing. -- Moved `DependencyGraphUtils` and `ConnectionGraphUtils` from `SmartHopper.Core.Graph` to `SmartHopper.Core.Grasshopper.Graph`. -- Improved `CreateComponentGrid` in `DependencyGraphUtils`: - - Now returns original pivots relative to the most top-left component to ensure relative positioning - - Uses a more human-like layout with column widths based on actual component widths - - Uses horizontal margin of 50 and vertical spacing of 80 - - Centers Params from their actual center instead of the top-left position - - Improved by detecting islands of components, ensure connected components stay together, and use barycenter heuristic algorithm for initial layer ordering - - Minimizes connection length - - Aligns parents with children -- Improved `MoveInstance`: - - Added a nice animation so that components move smoothly to their new position. - - Skip movement if initial and target positions are the same. -- Modified `GhGetComponents` to use the new `SelectingComponentBase`. -- Implemented the new `GHJsonAnalyzer` and `GHJsonFixer` in `GhPutTools` and `GhPutComponents`. +- **Component Movement**: Improved layout algorithm with smooth animations and better positioning +- **GhJSON**: Enhanced validation and automatic fixing of invalid InstanceGuids ### Fixed -- Fixed issue with tool calls in chat messages. Now the code provides exactly the json structure expected by MistralAI and OpenAI. -- Fixed tooltip visibility at the bottom of the chat. -- Fixed component placement in `GhPut` tool was too separated. -- Fixed source components in `TopologicalSort` were not sorted in reverse order. -- Limited `ghget` connections to components within the result objects set. -- Fixed `gh_tidy_up` moving components on every execution. -- Fixed `CreateComponentGrid` joining last and last-1 column together. -- (automatically added) Fixes "Panels and params position is calculated from top-left, not from center" ([#184](https://github.com/architects-toolkit/SmartHopper/issues/184)). +- Fixed tool call JSON structure for MistralAI and OpenAI providers +- Fixed component placement spacing in `gh_put` tool +- Fixed `gh_tidy_up` moving components on every execution +- Fixes "Panels and params position is calculated from top-left, not from center" ([#184](https://github.com/architects-toolkit/SmartHopper/issues/184)) ## [0.3.0-alpha] - 2025-04-27 ### Added -- Enabled the AIChat component to execute tools in Grasshopper. -- Added optional 'Filter' input to `GhGetComponents` component for filtering by errors, warnings, remarks, selected, unselected, enabled, disabled, previewon, previewoff, previewcapable, notpreviewcapable. Supports include/exclude syntax (+/-) provided as a list of tags, each tag in a separate line, comma-separated or space-separated. -- Added optional 'Type filter' input to `GhGetComponents` component to filter by component type (params, components, inputComponents, outputComponents and processingComponents). -- Added `ConnectionGraphUtils` class in `SmartHopper.Core.Graph` namespace with method `ExpandByDepth` to expand a set of component IDs by following connections up to the given depth. -- Added `GhRetrieveComponents` component and `ghretrievecomponents` AI tool for listing Grasshopper component types with descriptions, keywords, category filters, and list of inputs and outputs. -- Added `ghcategories` AI tool in `GhTools` to list Grasshopper component categories and subcategories with optional soft string filter. -- Added new `gh_toggle_preview` AI tool in `GhObjTools` for toggling Grasshopper component preview by GUID. -- Added new `gh_toggle_lock` AI tool in `GhObjTools` for toggling Grasshopper component lock state by GUID. -- Added new `gh_move_obj` AI tool in `GhObjTools` for moving Grasshopper component pivot by GUID with absolute or relative position. -- Added `MoveInstance` method in `GHCanvasUtils` to move existing instances by GUID with absolute or relative pivot positions. -- Improved security in Providers by accepting only signed assemblies. -- Added multiple CI Tests, for example, to ensure unsigned provider assemblies are rejected by `ProviderManager.VerifySignature`, to ensure only signed assemblies are loaded by `ProviderManager.LoadProviderAssembly`, and to ensure only enabled providers are registered by `ProviderManager.RegisterProviders`. -- Added `AIToolCall.cs`, a new model for AI tool call requests. -- Added `SmartHopperInitializer.cs`, a static class for safe startup and provider initialization. -- Added `StyledMessageDialog` class in `SmartHopper.Config.Dialogs` for consistent message dialog styling with the SmartHopper logo. -- Added `WebTools` to retrieve webpages from the Internet and provide them to the AI provider. -- Added `Search Rhino Forum` webtool to query posts in Rhino Forum. -- Added `Get Rhino Forum Post` webtool to retrieve full JSON of a Rhino Discourse forum post by ID. +- **AI Chat Tool Execution**: Enabled AIChat component to execute Grasshopper tools +- **Component Tools**: New tools for toggling preview/lock, moving components by GUID, and retrieving component types +- **Web Tools**: Added tools to retrieve webpages and search/query Rhino Forum posts +- **Component Filtering**: Enhanced `GhGetComponents` with filter and type filter inputs +- **Security**: Provider assemblies must be signed for acceptance ### Changed -- Renamed the 'Branches Input' and 'Processed Branches' parameters to 'Data Count' and 'Iterations Count' in `DeconstructMetricsComponents`. Improved descriptions for both parameters. -- Modified `FilterListAsync` in `ListTools` to return indices instead of filtered list items, with `AIListFilter` component now handling the final list construction. -- Renamed `GhGetSelectedComponents` (GhGetSel) to `GhGetComponents`. -- Moved `GhGet` execution logic to external tools managed by `ToolManager`. -- Improved `ghget` tool's `typeFilter` input: supports include/exclude syntax (+/-) with multiple tokens (params, components, input, output, processing) and updated schema description with definitions and examples. -- Reorganized `SmartHopper.Core.Grasshopper` files in subfolders that match the namespace. -- Isolated settings so providers access them only via `ProviderManager`, not directly via `SmartHopperSettings`. -- SmartHopper icon is now used for all dialogs within SmartHopper (about, settings, messages and ai chat) - -### Removed - -- `GhGetComponent` was replaced by `GhGetSelectedComponents` (GhGetSel) and renamed back to `GhGetComponents`. -- Removed support for net48. From now on, Rhino 8 or later is required. -- Removed `ToolFunction` and `ToolArgument` in `AIResponse`, in favor of the more flexible `AIToolCall`. +- **Minimum Requirements**: Rhino 8 or later required (removed .NET Framework 4.8 support) +- **Component Organization**: Reorganized `SmartHopper.Core.Grasshopper` files into namespace-matching subfolders +- **Settings**: Isolated provider settings access through `ProviderManager` +- **UI**: SmartHopper icon now used consistently across all dialogs ### Fixed -- Fixed double‐encryption of sensitive settings in `SettingsDialog.SaveSettings()` causing unreadable API keys -- Fixed mismatch between in-memory and on-disk `TrustedProviders` when prompting in `ProviderManager.LoadProviderAssembly()` -- Fixed a bug in `DataProcessor` where results were being duplicated when multiple branches were grouped together to unsuccessfully prevent unnecessary API calls [#32](https://github.com/architects-toolkit/SmartHopper/issues/32) -- Fixed inconsistent list format handling between `AIListEvaluate` and `AIListFilter` components. -- Fixed `MistralAI` provider not loading `AI Tools`. -- Fixed `GhGetComponent` select functionality that was accidentally omitted in the new `GhTools`. +- Fixed double-encryption of sensitive settings causing unreadable API keys +- Fixed mismatch between in-memory and on-disk `TrustedProviders` +- Fixed `DataProcessor` result duplication with grouped branches ([#32](https://github.com/architects-toolkit/SmartHopper/issues/32)) +- Fixed MistralAI provider not loading AI tools ## [0.2.0-alpha] - 2025-04-06 ### Added -- Added modular provider architecture: - - Created new provider project structure (SmartHopper.Providers.MistralAI) with dedicated resources. - - Created new provider project structure (SmartHopper.Providers.OpenAI) with dedicated resources. - - Added IAIProviderFactory interface for dynamic provider discovery. - - Implemented ProviderManager for runtime loading and management of providers. - - Added IsEnabled property to IAIProvider interface to allow disabling template or experimental providers. - - Created SmartHopper.Providers.Template project as a guide for implementing new providers. -- Added the new AIChat component with interactive chat interface and proper icon. -- Added WebView-based chat interface with AIChatComponent, WebChatDialog class, HtmlChatRenderer utility class, and ChatResourceManager. -- Added RunOnlyOnInputChanges property to StatefulAsyncComponentBase to control component execution behavior. -- Added AI provider selection improvements: - - "Default" option in the AI provider selection menu to use the provider specified in SmartHopper settings. - - Default provider selection in the settings dialog to set the global default AI provider. -- Added custom icon for the SmartHopper tab in Grasshopper. -- Added comprehensive Markdown formatting support: - - Headings, code blocks, blockquotes, and inline formatting. - - HTML tags like underline in Markdown text. - - Dedicated Markdown class in the Converters namespace for centralized markdown processing. -- Added a "Supported Data Types" section to README.md documenting currently supported and planned Grasshopper-native types. -- New update-changelog-issues action and github-pr-update-changelog-issues to automatically mention missing closed issues in the changelog. +- **AIChat Component**: Interactive chat interface with WebView-based UI and proper icon +- **Modular Provider Architecture**: Dynamic provider discovery and runtime loading with separate provider projects +- **Provider Selection**: "Default" option to use global default provider from settings +- **Markdown Support**: Comprehensive formatting with headings, code blocks, blockquotes, and inline formatting +- **Context Management**: Multiple simultaneous context providers with filtering capabilities +- **Component Execution**: RunOnlyOnInputChanges property to control component behavior ### Changed -- Refactored AI provider architecture: - - Migrated MistralAI provider to a separate project (SmartHopper.Providers.MistralAI). - - Migrated OpenAI provider to a separate project (SmartHopper.Providers.OpenAI). - - Updated SmartHopperSettings to use ProviderManager for provider discovery. - - Modified AIStatefulAsyncComponentBase to use the new provider handling approach. - - Changed provider discovery to load assemblies from the main application directory instead of a separate "Providers" subdirectory. - - Enhanced ProviderManager to only register providers that have IsEnabled set to true. - - Added warning log when duplicate AI providers are encountered during registration instead of silently ignoring them. -- Modified AIChatComponent to always run when the Run parameter is true, regardless of input changes. -- Improved version badge workflow to also update badges when color doesn't match the requirements based on version type. -- Improved ChatDialog UI with numerous enhancements: - - Modern chat-like interface featuring message bubbles and visual styling. - - Better layout with proper text wrapping to prevent horizontal scrolling. - - Responsive message sizing that adapts to the dialog width (80% max width with 350px minimum). - - Message selection and copying capabilities with a context menu. - - Automatic message height adjustment based on content and removal of visible scrollbars. - - Improved scrolling behavior. - - Allow only one chat dialog to be open per AI Chat Component. When running the component again, if there is a linked chat dialog, it will be focused instead of opening a new one. -- Enhanced About dialog: - - Decreased font size. - - Defined a minimum size. - - Better layout and styling. -- Improved code organization: - - All chat messages are now treated as markdown by default for consistent formatting. - - Changed AI components to use the default provider from SmartHopper settings when "Default" is selected. - - Updated component icon display to show the actual provider icon when "Default" is selected. -- Improved Web-based AIChat implementation: - - Refactored WebChat resource management to use embedded resources instead of file system for improved security. - - Enhanced WebView initialization for better cross-platform compatibility in Eto.Forms. - - Improved error handling and debugging in ChatResourceManager and WebChatDialog. - - Refactored WebChat HTML, CSS, and JavaScript into separate files for improved maintainability. -- Enhanced release-build.yml workflow: - - Automatically build and attach artifacts to published releases. - - Create platform-specific zip files (Rhino8-Windows, Rhino8-Mac) instead of a single zip with subfolders. -- Improved error handling in the AIStatefulAsyncComponentBase. -- Updated settings menu to use Eto.Forms and Eto.Drawing. -- Renamed the AI Context component to AI File Context. -- Enhanced context management system: - - Support for multiple simultaneous context providers - - Automatic time and environment context in AIChatComponent - - Filtering capabilities for context by provider ID and specific context keys - - Context filtering with comma-separated lists for multiple criteria - - Exclusion filtering with minus prefix (e.g., "-time" excludes the time provider while including all others) -- Modified AboutDialog to inform users about the nature and limitations of AI-generated content - -### Removed - -- Removed MistralAI provider from SmartHopper.Config project as part of the modular architecture implementation. -- Removed OpenAI provider from SmartHopper.Config project as part of the modular architecture implementation. -- Removed dependency on HtmlAgilityPack +- **Chat UI**: Modern interface with message bubbles, responsive sizing, and improved scrolling +- **Provider Architecture**: Migrated MistralAI and OpenAI to separate projects +- **Settings**: Updated to use Eto.Forms for cross-platform compatibility +- **Context**: Renamed AI Context to AI File Context ### Fixed -- Fixed AI provider handling: - - Enable the AI Provider to be stored and restored from AI-powered components on writing and reading the file ([#41](https://github.com/architects-toolkit/SmartHopper/issues/41)). - - Fixed AIChatComponent to properly use the default provider from settings when "Default" is selected in the context menu. -- Fixed build error for non-string resources in .NET Framework 4.8 target by adding GenerateResourceUsePreserializedResources property. -- Fixes "Bug: Settings menu hides sometimes" ([#94](https://github.com/architects-toolkit/SmartHopper/issues/94)). -- Fixes "Bug: AI Chat component freezes all Rhino!" ([#85](https://github.com/architects-toolkit/SmartHopper/issues/85)). -- Fixes "Bug: Settings Menu is incompatible with Mac" ([#12](https://github.com/architects-toolkit/SmartHopper/issues/12)). -- Fixes "AI disclaimer in chat and about" ([#114](https://github.com/architects-toolkit/SmartHopper/issues/114)). -- Fixed a bug opening the chat dialog that eventually froze the application. -- Fixed a bug where the chat dialog was not on top when clicking on it from the windows taskbar. +- Fixed AI provider storage and restoration in files ([#41](https://github.com/architects-toolkit/SmartHopper/issues/41)) +- Fixes "Bug: Settings menu hides sometimes" ([#94](https://github.com/architects-toolkit/SmartHopper/issues/94)) +- Fixes "Bug: AI Chat component freezes all Rhino!" ([#85](https://github.com/architects-toolkit/SmartHopper/issues/85)) +- Fixes "Bug: Settings Menu is incompatible with Mac" ([#12](https://github.com/architects-toolkit/SmartHopper/issues/12)) ## [0.1.2-alpha] - 2025-03-17 ### Changed -- Updated pull-request-validation.yml workflow to use version-tools for version validation -- Improved PR title validation with more detailed error messages and support for additional conventional commit types -- Added "security" as a valid commit type in PR title validation -- Modified update-dev-version-date.yml workflow to create a PR instead of committing changes directly to the branch - -### Removed - -- Removed Test GitHub Actions workflow +- **CI/CD**: Enhanced PR title validation and workflow automation ### Fixed -- Fixed version badge update workflow to only modify the version badge and not affect other badges in README.md -- Fixed badge addition logic in version-tools action to properly handle cases when badges don't exist -- Fixed security-patch-release.yml workflow to create a PR instead of pushing directly to main, resolving repository rule violations -- Fixed version-calculator to always perform the requested increment type without conditional logic, ensuring consistent behavior -- Fixed security-patch-release.yml workflow to create a release draft only when no PR is created -- Added new security-release-after-merge.yml workflow to create a release draft when a security patch PR is merged -- Fixed GitHub release creation by removing invalid target_commitish parameter +- Fixed version badge and GitHub Actions workflows ### Security -- (automatically added) Security release to update all workflow actions to the latest version. -- Updated several github workflows to use the latest version of actions: - - Updated tj-actions/changed-files from v45.0 to v46.0.1 - - Updated actions/checkout to v4 across all workflows - - Updated actions/setup-dotnet to v4 - - Updated actions/upload-artifact to v4 - - Updated actions/github-script to v7 -- Enhanced pull-request-validation.yml workflow with improved error logging for version and PR title checks -- Added new security-patch-release.yml workflow for creating security patch releases outside the milestone process -- Implemented GitHub Actions security best practices by pinning actions to full commit SHAs instead of version tags -- Updated security-patch-release.yml workflow to create a PR instead of pushing directly to main, resolving repository rule violations +- Updated GitHub Actions to latest versions and implemented security best practices ## [0.1.1-alpha] - 2025-03-03 ### Added -- Added the new GhGetSelectedComponents component. -- Added the new AiContext component ([#40](https://github.com/architects-toolkit/SmartHopper/issues/40)). -- Added the new ListTools class with methods: - - `FilterListAsync` (migrated from `AIListFilter` component) - - `EvaluateListAsync` (migrated from `AIListEvaluate` component) +- **GhGetSelectedComponents**: New component for selecting Grasshopper components +- **AI Context**: New component for providing context to AI tools ([#40](https://github.com/architects-toolkit/SmartHopper/issues/40)) ### Changed -- Updated README.md to better emphasize the plugin's ability to enable AI to directly read and interact with Grasshopper files. -- New About menu item using Eto.Forms instead of WinForms. -- Refactored AI text evaluation tools to improve code organization and reusability: - - Added generic `AIEvaluationResult` for standardized tool-component communication - - Created `ParsingTools` class for reusable AI response parsing - - Created `TextTools` with method `EvaluateTextAsync` (replacement of `AiTextEvaluate` main function) - - Added `GenerateTextAsync` methods to `TextTools` (migrated from `AITextGenerate` component) - - Updated `AITextGenerate` component to use the new generic tools - - Added regions in `TextTools` to improve code organization -- Refactored AI list processing tools to improve code organization and reusability: - - Added `ParseIndicesFromResponse` method to `ParsingTools` for reusable response parsing - - Added `ConcatenateItemsToJson` method to `ParsingTools` for formatting list data - - Added `ConcatenateItemsToJsonList` method to `ParsingTools` for list-to-JSON conversion - - Added regions in `ListTools` and `ParsingTools` to improve code organization - - Updated `AIListFilter` component to use the new generic tools - - Updated `AIListEvaluate` component to use the new generic tools - - Fixed error handling in list processing components to use standardized error reporting - - Improved list processing to ensure entire lists are processed as a unit +- **About Dialog**: Updated to use Eto.Forms for cross-platform compatibility +- **Code Organization**: Refactored AI text and list processing tools for improved reusability ### Fixed -- Restored functionality to set Persistent Data with the GhPutComponents component. -- Restored functionality to generate pivot grid if missing in JSON input in GhPutComponents. -- AI messages will only include context if it is not null or empty. +- Fixed Persistent Data functionality in GhPutComponents +- Fixed pivot grid generation when missing in JSON input ## [0.1.0-alpha] - 2025-01-27 ### Added -- Added the new AITextEvaluate component. +- **AITextEvaluate**: New component for AI text evaluation ### Changed -- Renamed the AI List Check components to AI List Evaluate. -- Improved AI provider's icon visualization. - -### Removed - -- Removed components based on the old Component Base. -- Removed the code for the old Component Base. +- **Component Naming**: Renamed AI List Check to AI List Evaluate +- **Component Base**: Full rewrite of component framework ### Fixed @@ -1498,54 +598,36 @@ Many thanks to the following contributors to this release: ### Added -- Added a new Component Base for AI-Powered components, including these features: - - Debouncing timer to prevent fast recalculations - - Enhanced state management system with granular state tracking - - Better stability in the state management, that prevents unwanted recalculations - - Store outputs and prevent from recalculating on file open - - Store outputs and prevent from recalculating on modifying Graft/Flatten/Simplify - - Persistent error tracking through states - - Compatibility with button and boolean toggle in the Run input - - Compatibility with Data Tree processing (Input and Output) - - Manual cancellation while processing -- Added a new library with testing components. +- **Component Base**: New framework for AI-powered components with debouncing, state management, and output persistence +- **Testing Components**: New library for testing components ### Changed -- General clean up and refactoring, including the suppression of unnecessary comments, and the removal of deprecated features. -- Migrate AI Text Generate component to use the new Component Base. -- Refactor DataTree libraries in Core to unify and simplify functionality. +- **Component Migration**: Migrated AI Text Generate to new Component Base +- **DataTree**: Refactored libraries for unified functionality ### Fixed -- Fixed lack of comprehensive error when API key is not correct ([#13](https://github.com/architects-toolkit/SmartHopper/issues/13)) -- Fixed Changing Graft/Flatten from an output requires recomputing the component ([#7](https://github.com/architects-toolkit/SmartHopper/issues/7)) -- Fixed Feature request: Store outputs and prevent from recalculating on file open ([#8](https://github.com/architects-toolkit/SmartHopper/issues/8)) -- Fixed Bug: Multiple calls to SolveInstance cause multipe API calls (in dev branch) ([#24](https://github.com/architects-toolkit/SmartHopper/issues/24)) +- Fixed API key error handling ([#13](https://github.com/architects-toolkit/SmartHopper/issues/13)) +- Fixed Graft/Flatten recompute requirement ([#7](https://github.com/architects-toolkit/SmartHopper/issues/7)) +- Fixed output persistence on file open ([#8](https://github.com/architects-toolkit/SmartHopper/issues/8)) +- Fixed multiple API calls on SolveInstance ([#24](https://github.com/architects-toolkit/SmartHopper/issues/24)) ## [0.0.0-dev.250104] - 2025-01-04 ### Added -- Added metrics for AI Provider and AI Model in AI-Powered components ([#11](https://github.com/architects-toolkit/SmartHopper/issues/11)) +- **Metrics**: Added AI Provider and AI Model metrics to AI-powered components ([#11](https://github.com/architects-toolkit/SmartHopper/issues/11)) ### Fixed -- Fixed bug with the Model input in AI-Powered components ([#3](https://github.com/architects-toolkit/SmartHopper/issues/3)) -- Fixed model parameter handling in IAIProvider interface to ensure proper model selection across providers ([#3](https://github.com/architects-toolkit/SmartHopper/issues/3)) -- Fixed issue with AI response metrics not returning the tokens used in all branches, but only the last one ([#2](https://github.com/architects-toolkit/SmartHopper/issues/2)) +- Fixed model input handling in AI-powered components ([#3](https://github.com/architects-toolkit/SmartHopper/issues/3)) +- Fixed AI response metrics to include all branches ([#2](https://github.com/architects-toolkit/SmartHopper/issues/2)) ## [0.0.0-dev.250101] - 2025-01-01 ### Added -- Initial release of SmartHopper -- Core plugin architecture for Grasshopper integration -- Base component framework for custom nodes -- GitHub Actions workflow for automated validation - - Version format checking - - Changelog updates verification - - Conventional commit enforcement -- Comprehensive documentation and examples - - README with setup instructions - - CONTRIBUTING guidelines +- **Initial Release**: Core plugin architecture for Grasshopper integration with base component framework +- **CI/CD**: GitHub Actions workflow for automated validation (version format, changelog, conventional commits) +- **Documentation**: README with setup instructions and CONTRIBUTING guidelines diff --git a/README.md b/README.md index 45683800..e64b59aa 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # SmartHopper - AI-Powered Tools and Assistant for Grasshopper3D -[![Version](https://img.shields.io/badge/version-1.4.2--beta-yellow?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) -[![Status](https://img.shields.io/badge/status-Beta-yellow?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) +[![Version](https://img.shields.io/badge/version-1.4.2--rc-purple?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) +[![Status](https://img.shields.io/badge/status-Release%20Candidate-purple?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/releases) [![.NET CI](https://img.shields.io/github/actions/workflow/status/architects-toolkit/SmartHopper/.github/workflows/ci-dotnet-tests.yml?label=tests&logo=dotnet&style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/actions/workflows/ci-dotnet-tests.yml) [![Ready to use](https://img.shields.io/badge/ready_to_use-YES-brightgreen?style=for-the-badge)](https://smarthopper.xyz/#installation) [![License](https://img.shields.io/badge/license-LGPL%20v3-white?style=for-the-badge)](https://github.com/architects-toolkit/SmartHopper/blob/main/LICENSE) diff --git a/Solution.props b/Solution.props index 3ee46eaa..289efab8 100644 --- a/Solution.props +++ b/Solution.props @@ -1,5 +1,5 @@ - 1.4.2-beta + 1.4.2-rc \ No newline at end of file diff --git a/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj b/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj index 83358959..2740e7aa 100644 --- a/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj +++ b/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj @@ -1,4 +1,4 @@ -