diff --git a/.github/ISSUE_TEMPLATE/model-verification.yml b/.github/ISSUE_TEMPLATE/model-verification.yml new file mode 100644 index 000000000..c658bf1fb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/model-verification.yml @@ -0,0 +1,625 @@ +name: ✅ Model Verification Report +description: Certify that an AI model works end-to-end in SmartHopper so it can be promoted to Verified=true. +title: "[model verification]" +labels: ["model-verification"] +assignees: [] +body: + - type: markdown + attributes: + value: | + ## How model verification works + + Models declared in each `*ProviderModels.cs` file are flagged with `Verified = true/false`. + A model is promoted to `Verified = true` once **two distinct users** have submitted a + successful verification report for the same `provider/model` pair. + + - The author of this issue counts as the **first verifier** if the required tests are checked below. + - Other users certify the same model by posting a comment that **starts with `/verify-confirm`** + and copies the codeblock shown below (one comment per user). + - Organization members and collaborators may post a comment that **starts with `/verify-force`** + to immediately promote the model regardless of the user count. + + Please only check tests that are **declared in the model's `Capabilities`** in the corresponding + `*ProviderModels.cs` file. Tests for capabilities the model does not advertise should be left + unchecked. + + > **Note:** The issue title will be automatically updated by the verification workflow + > based on the selected model and SmartHopper version. + + ### Comment template for additional verifiers + + Copy the codeblock below into a new comment, tick the boxes for the tests you successfully ran, + and submit. **Do not remove the `/verify-confirm` line or the HTML marker** — the workflow + will ignore comments that don't match this exact preamble. + + ````markdown + /verify-confirm + + + - [ ] **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-model + attributes: + label: Provider / Model + description: Select the provider/model pair. Use type-to-filter to search. Only non-deprecated models are listed. This list is auto-updated from `*ProviderModels.cs`. + options: + # AUTO-GENERATED-MODEL-OPTIONS-START — updated by tools/Update-ModelVerificationTemplate.ps1 + - Anthropic / claude-sonnet-4-6 + - Anthropic / claude-opus-4-6 + - Anthropic / claude-opus-4-7 + - Anthropic / claude-opus-4-5-20251101 + - Anthropic / claude-haiku-4-5-20251001 + - Anthropic / claude-sonnet-4-5-20250929 + - DeepSeek / deepseek-v4-flash + - DeepSeek / deepseek-v4-pro + - MistralAI / mistral-small-2603 + - MistralAI / mistral-medium-3-5 + - MistralAI / ministral-3b-2512 + - MistralAI / ministral-8b-2512 + - MistralAI / ministral-14b-2512 + - MistralAI / mistral-large-2512 + - MistralAI / devstral-2512 + - MistralAI / codestral-2508 + - MistralAI / mistral-medium-2508 + - MistralAI / codestral-embed + - MistralAI / labs-leanstral-2603 + - MistralAI / magistral-medium-2509 + - MistralAI / mistral-embed-2312 + - MistralAI / mistral-medium-2505 + - MistralAI / mistral-ocr-2512 + - MistralAI / open-mistral-nemo + - MistralAI / voxtral-mini-2602 + - MistralAI / voxtral-mini-tts-2603 + - MistralAI / voxtral-small-2507 + - OpenAI / gpt-5.4-nano-2026-03-17 + - OpenAI / gpt-5.4-mini-2026-03-17 + - OpenAI / gpt-5.3-chat-latest + - OpenAI / gpt-5.3-codex + - OpenAI / gpt-5.4-2026-03-05 + - OpenAI / gpt-5.5-2026-04-23 + - OpenAI / gpt-5.4-pro-2026-03-05 + - OpenAI / gpt-5.5-pro-2026-04-23 + - OpenAI / gpt-5.1-codex-mini + - OpenAI / gpt-audio-mini-2025-12-15 + - OpenAI / gpt-5.1-2025-11-13 + - OpenAI / gpt-5.1-chat-latest + - OpenAI / gpt-5.1-codex + - OpenAI / gpt-5.1-codex-max + - OpenAI / gpt-5.2-2025-12-11 + - OpenAI / gpt-5.2-chat-latest + - OpenAI / gpt-5.2-codex + - OpenAI / gpt-audio-2025-08-28 + - OpenAI / gpt-5.2-pro-2025-12-11 + - OpenAI / o4-mini-deep-research-2025-06-26 + - OpenAI / gpt-5-codex + - OpenAI / o3-deep-research-2025-06-26 + - OpenAI / gpt-4o-audio-preview-2025-06-03 + - OpenAI / gpt-5-pro-2025-10-06 + - OpenAI / gpt-5-mini-2025-08-07 + - OpenAI / gpt-5-nano-2025-08-07 + - OpenAI / gpt-5-2025-08-07 + - OpenAI / gpt-5-chat-latest + - OpenAI / o3-pro-2025-06-10 + - OpenAI / gpt-4.1-nano-2025-04-14 + - OpenAI / gpt-4o-mini-search-preview-2025-03-11 + - OpenAI / gpt-4.1-mini-2025-04-14 + - OpenAI / o4-mini-2025-04-16 + - OpenAI / gpt-4.1-2025-04-14 + - OpenAI / o3-2025-04-16 + - OpenAI / gpt-4o-search-preview-2025-03-11 + - OpenAI / o1-pro-2025-03-19 + - OpenAI / o3-mini-2025-01-31 + - OpenAI / o1-2024-12-17 + - OpenAI / gpt-4o-mini-2024-07-18 + - OpenAI / gpt-4o-2024-08-06 + - OpenAI / gpt-4o-2024-05-13 + - OpenAI / dall-e-3 + - OpenAI / gpt-3.5-turbo-instruct + - OpenAI / gpt-3.5-turbo-16k + - OpenAI / chat-latest + - OpenAI / chatgpt-image-latest + - OpenAI / gpt-4o-audio-preview-2024-12-17 + - OpenAI / gpt-4o-mini-audio-preview-2024-12-17 + - OpenAI / gpt-4o-mini-transcribe-2025-03-20 + - OpenAI / gpt-4o-mini-transcribe-2025-12-15 + - OpenAI / gpt-4o-mini-tts-2025-03-20 + - OpenAI / gpt-4o-mini-tts-2025-12-15 + - OpenAI / gpt-4o-transcribe + - OpenAI / gpt-4o-transcribe-diarize + - OpenAI / gpt-5-search-api-2025-10-14 + - OpenAI / gpt-audio-1.5 + - OpenAI / gpt-audio-mini-2025-10-06 + - OpenAI / gpt-image-1 + - OpenAI / gpt-image-1-mini + - OpenAI / gpt-image-1.5 + - OpenAI / gpt-image-2-2026-04-21 + - OpenAI / omni-moderation-2024-09-26 + - OpenAI / sora-2 + - OpenAI / sora-2-pro + - OpenAI / text-embedding-3-large + - OpenAI / text-embedding-3-small + - OpenAI / tts-1 + - OpenAI / tts-1-1106 + - OpenAI / tts-1-hd + - OpenAI / tts-1-hd-1106 + - OpenAI / whisper-1 + - OpenRouter / openrouter/pareto-code + - OpenRouter / baidu/cobuddy:free + - OpenRouter / baidu/qianfan-ocr-fast:free + - OpenRouter / google/gemma-4-26b-a4b-it:free + - OpenRouter / google/gemma-4-31b-it:free + - OpenRouter / google/lyria-3-clip-preview + - OpenRouter / google/lyria-3-pro-preview + - OpenRouter / inclusionai/ring-2.6-1t:free + - OpenRouter / minimax/minimax-m2.5:free + - OpenRouter / nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free + - OpenRouter / nvidia/nemotron-3-super-120b-a12b:free + - OpenRouter / openrouter/owl-alpha + - OpenRouter / poolside/laguna-m.1:free + - OpenRouter / poolside/laguna-xs.2:free + - OpenRouter / ibm-granite/granite-4.1-8b + - OpenRouter / rekaai/reka-edge + - OpenRouter / liquid/lfm-2-24b-a2b + - OpenRouter / qwen/qwen3.5-9b + - OpenRouter / inclusionai/ling-2.6-flash + - OpenRouter / qwen/qwen3.5-flash-02-23 + - OpenRouter / tencent/hy3-preview + - OpenRouter / deepseek/deepseek-v4-flash + - OpenRouter / google/gemma-4-26b-a4b-it + - OpenRouter / google/gemma-4-31b-it + - OpenRouter / bytedance-seed/seed-2.0-mini + - OpenRouter / nvidia/nemotron-3-super-120b-a12b + - OpenRouter / mistralai/mistral-small-2603 + - OpenRouter / inception/mercury-2 + - OpenRouter / arcee-ai/trinity-large-thinking + - OpenRouter / deepseek/deepseek-v4-pro + - OpenRouter / qwen/qwen3.5-35b-a3b + - OpenRouter / qwen/qwen3.6-35b-a3b + - OpenRouter / minimax/minimax-m2.5 + - OpenRouter / kwaipilot/kat-coder-pro-v2 + - OpenRouter / minimax/minimax-m2.7 + - OpenRouter / openai/gpt-5.4-nano + - OpenRouter / qwen/qwen3.6-flash + - OpenRouter / qwen/qwen3.5-27b + - OpenRouter / qwen/qwen3.5-plus-02-15 + - OpenRouter / aion-labs/aion-2.0 + - OpenRouter / z-ai/glm-5 + - OpenRouter / qwen/qwen3.6-plus + - OpenRouter / bytedance-seed/seed-2.0-lite + - OpenRouter / xiaomi/mimo-v2-omni + - OpenRouter / xiaomi/mimo-v2.5 + - OpenRouter / qwen/qwen3.5-122b-a10b + - OpenRouter / google/gemini-3.1-flash-lite + - OpenRouter / google/gemini-3.1-flash-lite-preview + - OpenRouter / qwen/qwen3.5-397b-a17b + - OpenRouter / qwen/qwen3.5-plus-20260420 + - OpenRouter / inclusionai/ling-2.6-1t + - OpenRouter / x-ai/grok-4.20 + - OpenRouter / x-ai/grok-4.3 + - OpenRouter / google/gemini-3.1-flash-image-preview + - OpenRouter / xiaomi/mimo-v2-pro + - OpenRouter / xiaomi/mimo-v2.5-pro + - OpenRouter / qwen/qwen3.6-27b + - OpenRouter / ~moonshotai/kimi-latest + - OpenRouter / moonshotai/kimi-k2.6 + - OpenRouter / z-ai/glm-5.1 + - OpenRouter / z-ai/glm-5-turbo + - OpenRouter / z-ai/glm-5v-turbo + - OpenRouter / ~google/gemini-flash-latest + - OpenRouter / ~openai/gpt-mini-latest + - OpenRouter / openai/gpt-5.4-mini + - OpenRouter / ~anthropic/claude-haiku-latest + - OpenRouter / x-ai/grok-4.20-multi-agent + - OpenRouter / qwen/qwen3.6-max-preview + - OpenRouter / mistralai/mistral-medium-3-5 + - OpenRouter / openai/gpt-5.3-chat + - OpenRouter / openai/gpt-5.3-codex + - OpenRouter / ~anthropic/claude-sonnet-latest + - OpenRouter / anthropic/claude-sonnet-4.6 + - OpenRouter / openai/gpt-5.4 + - OpenRouter / openai/gpt-5.4-image-2 + - OpenRouter / ~google/gemini-pro-latest + - OpenRouter / google/gemini-3.1-pro-preview + - OpenRouter / google/gemini-3.1-pro-preview-customtools + - OpenRouter / ~anthropic/claude-opus-latest + - OpenRouter / anthropic/claude-opus-4.7 + - OpenRouter / ~openai/gpt-latest + - OpenRouter / openai/gpt-5.5 + - OpenRouter / openai/gpt-chat-latest + - OpenRouter / anthropic/claude-opus-4.6-fast + - OpenRouter / openai/gpt-5.4-pro + - OpenRouter / openai/gpt-5.5-pro + - OpenRouter / openrouter/bodybuilder + - OpenRouter / liquid/lfm-2.5-1.2b-instruct:free + - OpenRouter / liquid/lfm-2.5-1.2b-thinking:free + - OpenRouter / nvidia/nemotron-3-nano-30b-a3b:free + - OpenRouter / openrouter/free + - OpenRouter / mistralai/ministral-3b-2512 + - OpenRouter / arcee-ai/trinity-mini + - OpenRouter / essentialai/rnj-1-instruct + - OpenRouter / mistralai/ministral-8b-2512 + - OpenRouter / mistralai/ministral-14b-2512 + - OpenRouter / nvidia/nemotron-3-nano-30b-a3b + - OpenRouter / bytedance-seed/seed-1.6-flash + - OpenRouter / stepfun/step-3.5-flash + - OpenRouter / xiaomi/mimo-v2-flash + - OpenRouter / deepseek/deepseek-v3.2 + - OpenRouter / z-ai/glm-4.7-flash + - OpenRouter / deepseek/deepseek-v3.2-speciale + - OpenRouter / arcee-ai/trinity-large-preview + - OpenRouter / allenai/olmo-3-32b-think + - OpenRouter / nex-agi/deepseek-v3.1-nex-n1 + - OpenRouter / upstage/solar-pro-3 + - OpenRouter / qwen/qwen3-coder-next + - OpenRouter / z-ai/glm-4.6v + - OpenRouter / minimax/minimax-m2.1 + - OpenRouter / prime-intellect/intellect-3 + - OpenRouter / minimax/minimax-m2-her + - OpenRouter / deepcogito/cogito-v2.1-671b + - OpenRouter / mistralai/mistral-large-2512 + - OpenRouter / z-ai/glm-4.7 + - OpenRouter / bytedance-seed/seed-1.6 + - OpenRouter / mistralai/devstral-2512 + - OpenRouter / moonshotai/kimi-k2.5 + - OpenRouter / openai/gpt-5.1-codex-mini + - OpenRouter / amazon/nova-2-lite-v1 + - OpenRouter / openai/gpt-audio-mini + - OpenRouter / relace/relace-search + - OpenRouter / qwen/qwen3-max-thinking + - OpenRouter / google/gemini-3-flash-preview + - OpenRouter / writer/palmyra-x5 + - OpenRouter / openai/gpt-5.1 + - OpenRouter / openai/gpt-5.1-chat + - OpenRouter / openai/gpt-5.1-codex + - OpenRouter / openai/gpt-5.1-codex-max + - OpenRouter / openai/gpt-5.2 + - OpenRouter / openai/gpt-5.2-chat + - OpenRouter / openai/gpt-5.2-codex + - OpenRouter / google/gemini-3-pro-image-preview + - OpenRouter / anthropic/claude-opus-4.5 + - OpenRouter / anthropic/claude-opus-4.6 + - OpenRouter / openai/gpt-audio + - OpenRouter / openai/gpt-5.2-pro + - OpenRouter / nvidia/nemotron-nano-12b-v2-vl:free + - OpenRouter / nvidia/nemotron-nano-9b-v2:free + - OpenRouter / qwen/qwen3-next-80b-a3b-instruct:free + - OpenRouter / ibm-granite/granite-4.0-h-micro + - OpenRouter / nvidia/nemotron-nano-9b-v2 + - OpenRouter / baidu/ernie-4.5-21b-a3b + - OpenRouter / baidu/ernie-4.5-21b-a3b-thinking + - OpenRouter / openai/gpt-oss-safeguard-20b + - OpenRouter / microsoft/phi-4-mini-instruct + - OpenRouter / nousresearch/hermes-4-70b + - OpenRouter / nvidia/llama-3.3-nemotron-super-49b-v1.5 + - OpenRouter / qwen/qwen3-30b-a3b-thinking-2507 + - OpenRouter / deepseek/deepseek-v3.2-exp + - OpenRouter / qwen/qwen3-vl-32b-instruct + - OpenRouter / alibaba/tongyi-deepresearch-30b-a3b + - OpenRouter / qwen/qwen3-vl-8b-instruct + - OpenRouter / thedrummer/cydonia-24b-v4.1 + - OpenRouter / qwen/qwen3-vl-30b-a3b-instruct + - OpenRouter / baidu/ernie-4.5-vl-28b-a3b + - OpenRouter / deepseek/deepseek-chat-v3.1 + - OpenRouter / qwen/qwen-plus-2025-07-28 + - OpenRouter / qwen/qwen-plus-2025-07-28:thinking + - OpenRouter / qwen/qwen3-next-80b-a3b-thinking + - OpenRouter / google/gemini-2.5-flash-lite-preview-09-2025 + - OpenRouter / qwen/qwen3-vl-235b-a22b-instruct + - OpenRouter / deepseek/deepseek-v3.1-terminus + - OpenRouter / qwen/qwen3-coder-flash + - OpenRouter / minimax/minimax-m2 + - OpenRouter / qwen/qwen3-next-80b-a3b-instruct + - OpenRouter / relace/relace-apply-3 + - OpenRouter / qwen/qwen3-vl-8b-thinking + - OpenRouter / qwen/qwen3-vl-30b-a3b-thinking + - OpenRouter / z-ai/glm-4.5v + - OpenRouter / mistralai/mistral-medium-3.1 + - OpenRouter / openai/gpt-5-image-mini + - OpenRouter / moonshotai/kimi-k2-thinking + - OpenRouter / qwen/qwen3-vl-235b-a22b-thinking + - OpenRouter / nousresearch/hermes-4-405b + - OpenRouter / qwen/qwen3-coder-plus + - OpenRouter / google/gemini-2.5-flash-image + - OpenRouter / qwen/qwen3-max + - OpenRouter / anthropic/claude-haiku-4.5 + - OpenRouter / openai/o4-mini-deep-research + - OpenRouter / openai/gpt-5-codex + - OpenRouter / openai/gpt-5-image + - OpenRouter / amazon/nova-premier-v1 + - OpenRouter / anthropic/claude-sonnet-4.5 + - OpenRouter / perplexity/sonar-pro-search + - OpenRouter / openai/o3-deep-research + - OpenRouter / openai/gpt-4o-audio-preview + - OpenRouter / mistralai/voxtral-small-24b-2507 + - OpenRouter / openai/gpt-5-pro + - OpenRouter / cognitivecomputations/dolphin-mistral-24b-venice-edition:free + - OpenRouter / openai/gpt-oss-120b:free + - OpenRouter / openai/gpt-oss-20b:free + - OpenRouter / qwen/qwen3-coder:free + - OpenRouter / z-ai/glm-4.5-air:free + - OpenRouter / qwen/qwen3-235b-a22b-2507 + - OpenRouter / z-ai/glm-4-32b + - OpenRouter / google/gemma-3n-e4b-it + - OpenRouter / openai/gpt-oss-20b + - OpenRouter / openai/gpt-oss-120b + - OpenRouter / bytedance/ui-tars-1.5-7b + - OpenRouter / mistralai/mistral-small-3.2-24b-instruct + - OpenRouter / qwen/qwen3-coder-30b-a3b-instruct + - OpenRouter / mistralai/devstral-small + - OpenRouter / qwen/qwen3-30b-a3b-instruct-2507 + - OpenRouter / openai/gpt-5-nano + - OpenRouter / tencent/hunyuan-a13b-instruct + - OpenRouter / google/gemini-2.5-flash-lite + - OpenRouter / z-ai/glm-4.5-air + - OpenRouter / mistralai/codestral-2508 + - OpenRouter / baidu/ernie-4.5-300b-a47b + - OpenRouter / morph/morph-v3-fast + - OpenRouter / baidu/ernie-4.5-vl-424b-a47b + - OpenRouter / qwen/qwen3-235b-a22b-thinking-2507 + - OpenRouter / qwen/qwen3-coder + - OpenRouter / morph/morph-v3-large + - OpenRouter / mistralai/devstral-medium + - OpenRouter / openai/gpt-5-mini + - OpenRouter / deepseek/deepseek-r1-0528 + - OpenRouter / minimax/minimax-m1 + - OpenRouter / z-ai/glm-4.5 + - OpenRouter / moonshotai/kimi-k2 + - OpenRouter / switchpoint/router + - OpenRouter / google/gemini-2.5-flash + - OpenRouter / ai21/jamba-large-1.7 + - OpenRouter / openai/gpt-5 + - OpenRouter / openai/gpt-5-chat + - OpenRouter / google/gemini-2.5-pro + - OpenRouter / google/gemini-2.5-pro-preview + - OpenRouter / anthropic/claude-sonnet-4 + - OpenRouter / anthropic/claude-opus-4 + - OpenRouter / anthropic/claude-opus-4.1 + - OpenRouter / openai/o3-pro + - OpenRouter / meta-llama/llama-guard-3-8b + - OpenRouter / google/gemma-3-4b-it + - OpenRouter / google/gemma-3-12b-it + - OpenRouter / google/gemma-3-27b-it + - OpenRouter / arcee-ai/spotlight + - OpenRouter / meta-llama/llama-guard-4-12b + - OpenRouter / rekaai/reka-flash-3 + - OpenRouter / qwen/qwen3-14b + - OpenRouter / qwen/qwen3-32b + - OpenRouter / meta-llama/llama-4-scout + - OpenRouter / openai/gpt-4.1-nano + - OpenRouter / qwen/qwen3-8b + - OpenRouter / qwen/qwen3-30b-a3b + - OpenRouter / mistralai/mistral-small-3.1-24b-instruct + - OpenRouter / meta-llama/llama-4-maverick + - OpenRouter / mistralai/mistral-saba + - OpenRouter / openai/gpt-4o-mini-search-preview + - OpenRouter / deepseek/deepseek-chat-v3-0324 + - OpenRouter / arcee-ai/coder-large + - OpenRouter / thedrummer/skyfall-36b-v2 + - OpenRouter / alfredpros/codellama-7b-instruct-solidity + - OpenRouter / arcee-ai/virtuoso-large + - OpenRouter / openai/gpt-4.1-mini + - OpenRouter / qwen/qwen3-235b-a22b + - OpenRouter / mistralai/mistral-medium-3 + - OpenRouter / arcee-ai/maestro-reasoning + - OpenRouter / openai/o3-mini-high + - OpenRouter / openai/o4-mini + - OpenRouter / openai/o4-mini-high + - OpenRouter / openai/gpt-4.1 + - OpenRouter / openai/o3 + - OpenRouter / perplexity/sonar-deep-research + - OpenRouter / perplexity/sonar-reasoning-pro + - OpenRouter / cohere/command-a + - OpenRouter / openai/gpt-4o-search-preview + - OpenRouter / google/gemini-2.5-pro-preview-05-06 + - OpenRouter / perplexity/sonar-pro + - OpenRouter / openai/o1-pro + - OpenRouter / meta-llama/llama-3.3-70b-instruct:free + - OpenRouter / mistralai/mistral-small-24b-instruct-2501 + - OpenRouter / qwen/qwen-turbo + - OpenRouter / amazon/nova-micro-v1 + - OpenRouter / microsoft/phi-4 + - OpenRouter / cohere/command-r7b-12-2024 + - OpenRouter / amazon/nova-lite-v1 + - OpenRouter / deepseek/deepseek-r1-distill-qwen-32b + - OpenRouter / meta-llama/llama-3.3-70b-instruct + - OpenRouter / qwen/qwen-vl-plus + - OpenRouter / qwen/qwen2.5-vl-72b-instruct + - OpenRouter / sao10k/l3.3-euryale-70b + - OpenRouter / qwen/qwen-plus + - OpenRouter / deepseek/deepseek-r1-distill-llama-70b + - OpenRouter / deepseek/deepseek-chat + - OpenRouter / perplexity/sonar + - OpenRouter / qwen/qwen-2.5-coder-32b-instruct + - OpenRouter / minimax/minimax-01 + - OpenRouter / aion-labs/aion-1.0-mini + - OpenRouter / aion-labs/aion-rp-llama-3.1-8b + - OpenRouter / qwen/qwen-vl-max + - OpenRouter / deepseek/deepseek-r1 + - OpenRouter / sao10k/l3.1-70b-hanami-x1 + - OpenRouter / amazon/nova-pro-v1 + - OpenRouter / qwen/qwen-max + - OpenRouter / openai/o3-mini + - OpenRouter / mistralai/mistral-large-2407 + - OpenRouter / mistralai/mistral-large-2411 + - OpenRouter / mistralai/pixtral-large-2411 + - OpenRouter / aion-labs/aion-1.0 + - OpenRouter / openai/gpt-4o-2024-11-20 + - OpenRouter / openai/o1 + - OpenRouter / meta-llama/llama-3.2-3b-instruct:free + - OpenRouter / nousresearch/hermes-3-llama-3.1-405b:free + - OpenRouter / sao10k/l3-lunaris-8b + - OpenRouter / qwen/qwen-2.5-7b-instruct + - OpenRouter / meta-llama/llama-3.2-1b-instruct + - OpenRouter / meta-llama/llama-3.2-11b-vision-instruct + - OpenRouter / nousresearch/hermes-3-llama-3.1-70b + - OpenRouter / meta-llama/llama-3.2-3b-instruct + - OpenRouter / qwen/qwen-2.5-72b-instruct + - OpenRouter / thedrummer/unslopnemo-12b + - OpenRouter / thedrummer/rocinante-12b + - OpenRouter / cohere/command-r-08-2024 + - OpenRouter / sao10k/l3.1-euryale-70b + - OpenRouter / nousresearch/hermes-3-llama-3.1-405b + - OpenRouter / anthropic/claude-3.5-haiku + - OpenRouter / anthracite-org/magnum-v4-72b + - OpenRouter / cohere/command-r-plus-08-2024 + - OpenRouter / inflection/inflection-3-pi + - OpenRouter / inflection/inflection-3-productivity + - OpenRouter / mistralai/mistral-nemo + - OpenRouter / meta-llama/llama-3.1-8b-instruct + - OpenRouter / nousresearch/hermes-2-pro-llama-3-8b + - OpenRouter / meta-llama/llama-3.1-70b-instruct + - OpenRouter / openai/gpt-4o-mini + - OpenRouter / openai/gpt-4o-mini-2024-07-18 + - OpenRouter / google/gemma-2-27b-it + - OpenRouter / sao10k/l3-euryale-70b + - OpenRouter / openai/gpt-4o + - OpenRouter / openai/gpt-4o-2024-08-06 + - OpenRouter / openai/gpt-4o-2024-05-13 + - OpenRouter / openrouter/auto + - OpenRouter / gryphe/mythomax-l2-13b + - OpenRouter / microsoft/wizardlm-2-8x22b + - OpenRouter / undi95/remm-slerp-l2-13b + - OpenRouter / meta-llama/llama-3-70b-instruct + - OpenRouter / mancer/weaver + - OpenRouter / anthropic/claude-3-haiku + - OpenRouter / openai/gpt-3.5-turbo + - OpenRouter / openai/gpt-3.5-turbo-0613 + - OpenRouter / openai/gpt-3.5-turbo-instruct + - OpenRouter / openai/gpt-3.5-turbo-16k + - OpenRouter / mistralai/mistral-large + - OpenRouter / mistralai/mixtral-8x22b-instruct + - OpenRouter / alpindale/goliath-120b + - OpenRouter / openai/gpt-4-1106-preview + - OpenRouter / openai/gpt-4-turbo + - OpenRouter / openai/gpt-4-turbo-preview + - OpenRouter / openai/gpt-4 + - OpenRouter / openai/gpt-4-0314 + # AUTO-GENERATED-MODEL-OPTIONS-END + validations: + required: true + + - type: dropdown + id: smarthopper-version + attributes: + label: SmartHopper Version + description: Select the version of SmartHopper you tested this on. This list is auto-updated with all released versions. + options: + # AUTO-GENERATED-VERSION-OPTIONS-START — updated by tools/Update-VersionVerificationTemplate.ps1 + - 1.4.2 + - 1.4.1 + - 1.4.0 + - 1.3.0 + - 1.2.4 + - 1.2.3 + - 1.2.2 + - 1.2.1 + - 1.2.0 + - 1.1.1 + - 1.1.0 + - 1.0.1 + - 1.0.0 + # AUTO-GENERATED-VERSION-OPTIONS-END + 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.md b/.github/WORKFLOWS.md new file mode 100644 index 000000000..05906d7b9 --- /dev/null +++ b/.github/WORKFLOWS.md @@ -0,0 +1,111 @@ +# Workflow Conventions + +> Canonical reference for all GitHub Actions conventions in SmartHopper. +> Every contributor and every new workflow **must** follow these rules. + +## Branch Protection Model + +| Pattern | Protection | +|---------|-----------| +| `main`, `main-*` | PR + codeowner approval required | +| `dev`, `dev-*` | PR + codeowner approval required | +| `hotfix/*` | PR + codeowner approval required | +| `release/**` | PR + codeowner approval required | + +**No workflow may push directly to a protected branch.** All automated +changes must go through a PR created by `peter-evans/create-pull-request@v6` +(preferred) or `gh pr create`. + +## Action Version Pinning + +| Action | Pin style | +|--------|-----------| +| First-party (`actions/*`) | **Tag** — e.g. `actions/checkout@v4` | +| Third-party (community) | **Tag** — e.g. `peter-evans/create-pull-request@v6` | +| Local composite actions | **Path** — e.g. `./.github/actions/…` | + +Do **not** use SHA pinning (`@abc123…`). Tag pinning is easier to audit +and auto-update with Dependabot. + +## PR Creation Convention + +Use `peter-evans/create-pull-request@v6` for chore workflows that modify +files in the working tree. Use `gh pr create` only when constructing PRs +from existing branches (e.g. release pipeline). + +Every automated PR must: +1. Carry the `automated` label. +2. Call `./.github/actions/milestone/assign-pr` to assign the current milestone. +3. Call `./.github/actions/dispatch-required-pr-checks` to trigger status checks. + +## Concurrency & `cancel-in-progress` + +| Workflow type | `cancel-in-progress` | Rationale | +|---------------|---------------------|-----------| +| PR validation / CI checks | `true` | Superseded by newer pushes | +| Chore (version-date, badge, manifest, …) | `false` | Must complete to avoid stale state | +| Release pipeline (`release-1` … `release-6`) | `false` | Irreversible side-effects | +| Issue/label management | `false` | Quick, idempotent | + +## Timeout Policy + +Every job **must** declare `timeout-minutes`. Defaults: + +| Job type | Timeout | +|----------|---------| +| Lightweight (label, comment, branch delete) | 5 min | +| Standard (checkout + script) | 10 min | +| Build / test (.NET CI) | 30 min | +| Heavy (model fetch, AI generation) | 45 min | + +## PowerShell Conventions + +When a workflow step uses `shell: pwsh`, call scripts with the `&` +(call operator), **not** by spawning a nested `pwsh` process: + +```yaml +# Good +shell: pwsh +run: | + & .\tools\My-Script.ps1 -Param value + +# Bad — spawns a child pwsh inside the pwsh shell +shell: pwsh +run: | + pwsh -ExecutionPolicy Bypass -File .\tools\My-Script.ps1 -Param value +``` + +## Validation Tiers + +### Provider Model Validation (`Update-ProviderModels.ps1`) + +| Tier | Severity | Blocks merge? | Examples | +|------|----------|---------------|----------| +| Error | `::error::` | Yes | Invalid capability expression, realtime model in list, pending capability | +| Warning | `::warning::` | No | Missing default for a composite capability category | + +### PR Validation (`pr-validation.yml`) + +| Check | Blocks merge? | Notes | +|-------|---------------|-------| +| Version format | Yes | Must be valid semver | +| Code style | Yes | Trailing whitespace, namespace, using order | +| Changelog | **Warning only** | Skipped for `chore/ci/style/build/revert/docs` PRs | +| PR title | Yes | Must follow conventional commits | + +## Node.js Runtime + +Set `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true` as a repository-level +environment variable (or in individual workflows) to opt into Node.js 24 +before the June 2026 deadline. + +## Cross-Reference Comments + +When two workflows overlap in trigger or purpose, each must carry a +header comment referencing the other. Example: + +```yaml +# Related workflows: +# - chore-version-date.yml (updates version date on dev pushes) +# - chore-version-badge.yml (updates README badge on version change) +``` diff --git a/.github/actions/ai/fetch-models/action.yml b/.github/actions/ai/fetch-models/action.yml new file mode 100644 index 000000000..b077de875 --- /dev/null +++ b/.github/actions/ai/fetch-models/action.yml @@ -0,0 +1,142 @@ +# 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 + 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 + default: 'false' + fail-on-validation-errors: + description: 'When true, fail if updated provider models are missing required defaults, contain pending capabilities, or contain realtime models' + 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 }} + PROVIDER_API_KEY: ${{ inputs.provider-api-key }} + run: | + $ErrorActionPreference = 'Stop' + + $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 + } + if ('${{ inputs.fail-on-validation-errors }}' -eq 'true') { + $params['FailOnValidationErrors'] = $true + } + + try { + $reportJson = & .\tools\Update-ProviderModels.ps1 @params + $scriptExit = $LASTEXITCODE + + # The script emits its JSON report on stdout regardless of validation + # outcome. Try to parse it even on non-zero exit so we can surface the + # validation details (the script also writes ::error:: annotations, + # but exposing the structured report lets the caller decide what to do). + $report = $null + if ($reportJson) { + try { $report = $reportJson | ConvertFrom-Json } catch { $report = $null } + } + + if ($scriptExit -ne 0) { + Write-Host "::error::Update-ProviderModels.ps1 exited with code $scriptExit" + "success=false" >> $env:GITHUB_OUTPUT + "error=Update-ProviderModels.ps1 exited with code $scriptExit" >> $env:GITHUB_OUTPUT + "changed=false" >> $env:GITHUB_OUTPUT + "count=0" >> $env:GITHUB_OUTPUT + "models=[]" >> $env:GITHUB_OUTPUT + if ($report) { + "report<> $env:GITHUB_OUTPUT + "$reportJson" >> $env:GITHUB_OUTPUT + "EOF_REPORT" >> $env:GITHUB_OUTPUT + } else { + "report={}" >> $env:GITHUB_OUTPUT + } + exit $scriptExit + } + + $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 { + # Print the failure visibly. The previous implementation wrote the + # message to $env:GITHUB_OUTPUT only, which hid the actual cause from + # the run log and made these jobs near-impossible to diagnose. + Write-Host "::error::Update-ProviderModels.ps1 failed: $($_.Exception.Message)" + if ($_.ScriptStackTrace) { Write-Host $_.ScriptStackTrace } + + "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 + exit 1 + } diff --git a/.github/actions/ai/mistral-chat/action.yml b/.github/actions/ai/mistral-chat/action.yml new file mode 100644 index 000000000..beb1c3d58 --- /dev/null +++ b/.github/actions/ai/mistral-chat/action.yml @@ -0,0 +1,129 @@ +# Generic, reusable Mistral AI Chat Completions API wrapper. +# This action is a raw API client with no domain-specific logic. +# It can be used by any workflow that needs AI text generation capabilities. + +name: 'Mistral AI Chat' +description: 'Generic wrapper around the Mistral AI Chat Completions API' + +inputs: + api-key: + description: 'Mistral AI API key' + required: true + model: + description: 'Mistral model name' + required: false + default: 'mistral-medium-latest' + system-prompt: + description: 'System message content' + required: false + default: '' + user-prompt: + description: 'User message content' + required: true + temperature: + description: 'Sampling temperature' + required: false + default: '0.7' + max-tokens: + description: 'Maximum tokens in response' + required: false + default: '4096' + +outputs: + response: + description: 'Raw text content from the API response' + value: ${{ steps.call_api.outputs.response }} + usage-prompt-tokens: + description: 'Prompt tokens used' + value: ${{ steps.call_api.outputs.usage-prompt-tokens }} + usage-completion-tokens: + description: 'Completion tokens used' + value: ${{ steps.call_api.outputs.usage-completion-tokens }} + success: + description: 'Whether the API call succeeded' + value: ${{ steps.call_api.outputs.success }} + error: + description: 'Error message if the call failed' + value: ${{ steps.call_api.outputs.error }} + +runs: + using: 'composite' + steps: + - name: Call Mistral AI API + id: call_api + shell: bash + env: + API_KEY: ${{ inputs.api-key }} + MODEL: ${{ inputs.model }} + SYSTEM_PROMPT: ${{ inputs.system-prompt }} + USER_PROMPT: ${{ inputs.user-prompt }} + TEMPERATURE: ${{ inputs.temperature }} + MAX_TOKENS: ${{ inputs.max-tokens }} + run: | + # Build JSON payload using jq to safely handle special characters + PAYLOAD=$(jq -n \ + --arg model "$MODEL" \ + --arg system "$SYSTEM_PROMPT" \ + --arg user "$USER_PROMPT" \ + --argjson temperature "$TEMPERATURE" \ + --argjson max_tokens "$MAX_TOKENS" \ + '{ + model: $model, + messages: ( + if ($system | length) > 0 + then [{role: "system", content: $system}, {role: "user", content: $user}] + else [{role: "user", content: $user}] + end + ), + temperature: $temperature, + max_tokens: $max_tokens + }') + + # Call the API + HTTP_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "https://api.mistral.ai/v1/chat/completions" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $API_KEY" \ + -d "$PAYLOAD") + + # Split body and status code + HTTP_BODY=$(echo "$HTTP_RESPONSE" | sed '$d') + HTTP_STATUS=$(echo "$HTTP_RESPONSE" | tail -n 1) + + if [ "$HTTP_STATUS" -eq 200 ]; then + # Extract response content + CONTENT=$(echo "$HTTP_BODY" | jq -r '.choices[0].message.content // empty') + PROMPT_TOKENS=$(echo "$HTTP_BODY" | jq -r '.usage.prompt_tokens // 0') + COMPLETION_TOKENS=$(echo "$HTTP_BODY" | jq -r '.usage.completion_tokens // 0') + + if [ -z "$CONTENT" ]; then + echo "::warning::Mistral API call succeeded but returned empty content" + echo "success=false" >> "$GITHUB_OUTPUT" + echo "error=API returned empty content" >> "$GITHUB_OUTPUT" + echo "response=" >> "$GITHUB_OUTPUT" + echo "usage-prompt-tokens=0" >> "$GITHUB_OUTPUT" + echo "usage-completion-tokens=0" >> "$GITHUB_OUTPUT" + else + echo "success=true" >> "$GITHUB_OUTPUT" + echo "error=" >> "$GITHUB_OUTPUT" + { + echo "response<> "$GITHUB_OUTPUT" + echo "usage-prompt-tokens=$PROMPT_TOKENS" >> "$GITHUB_OUTPUT" + echo "usage-completion-tokens=$COMPLETION_TOKENS" >> "$GITHUB_OUTPUT" + fi + else + # Error handling + ERROR_MSG=$(echo "$HTTP_BODY" | head -c 500) + echo "::warning::Mistral API call failed: HTTP $HTTP_STATUS - $ERROR_MSG" + echo "success=false" >> "$GITHUB_OUTPUT" + { + echo "error<> "$GITHUB_OUTPUT" + echo "response=" >> "$GITHUB_OUTPUT" + echo "usage-prompt-tokens=0" >> "$GITHUB_OUTPUT" + echo "usage-completion-tokens=0" >> "$GITHUB_OUTPUT" + fi 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 000000000..f20f02d55 --- /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 000000000..cf42e544e --- /dev/null +++ b/.github/actions/cherry-pick-to-branch/action.yml @@ -0,0 +1,243 @@ +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 }} + pr-title: + description: 'Title of the PR created (empty if skipped).' + value: ${{ steps.open-pr.outputs.pr-title }} + 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" + echo "pr-title=$title" >> "$GITHUB_OUTPUT" + + 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/actions/dispatch-required-pr-checks/action.yml b/.github/actions/dispatch-required-pr-checks/action.yml new file mode 100644 index 000000000..f8ed95b42 --- /dev/null +++ b/.github/actions/dispatch-required-pr-checks/action.yml @@ -0,0 +1,68 @@ +name: Dispatch required PR checks +description: Dispatches the protected-branch PR check workflows for the current PR branch. + +inputs: + token: + description: GitHub token with actions:write permission. + required: true + ref: + description: Branch or ref to run the checks against. + required: true + pr-number: + description: Pull request number. + required: false + default: '' + base-ref: + description: Pull request base branch. + required: false + default: '' + pr-title: + description: Pull request title. + required: false + default: '' + +runs: + using: composite + steps: + - name: Dispatch workflows + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + REF: ${{ inputs.ref }} + PR_NUMBER: ${{ inputs.pr-number }} + BASE_REF: ${{ inputs.base-ref }} + PR_TITLE: ${{ inputs.pr-title }} + run: | + set -euo pipefail + + workflows=( + "check-provider-models.yml" + "pr-license-headers.yml" + "pr-validation.yml" + "pr-build-hash-validation.yml" + "pr-version-validation.yml" + "ci-dotnet-tests.yml" + ) + + for workflow in "${workflows[@]}"; do + if [[ ! -f ".github/workflows/$workflow" ]]; then + echo "Skipping $workflow because it is not present on $REF" + continue + fi + + args=(workflow run "$workflow" --ref "$REF") + if [[ -n "$PR_NUMBER" ]]; then + args+=(-f "pr_number=$PR_NUMBER") + fi + if [[ -n "$BASE_REF" ]]; then + args+=(-f "base_ref=$BASE_REF") + fi + if [[ -n "$PR_TITLE" ]]; then + args+=(-f "pr_title=$PR_TITLE") + fi + + echo "Dispatching $workflow for $REF" + if ! gh "${args[@]}" 2>&1; then + echo "Warning: Could not dispatch $workflow (may not have workflow_dispatch trigger or needs refresh)" + fi + done diff --git a/.github/actions/documentation/generate-release-notes/action.yml b/.github/actions/documentation/generate-release-notes/action.yml new file mode 100644 index 000000000..fc6fa9faa --- /dev/null +++ b/.github/actions/documentation/generate-release-notes/action.yml @@ -0,0 +1,212 @@ +# Release notes generation action for SmartHopper. +# Contains the domain logic for generating user-friendly release notes +# from the CHANGELOG.md using the Mistral AI Chat Completions API. +# +# The prompts in this action are derived from .windsurf/workflows/release-notes.md +# and should be kept in sync with that file when the release notes format changes. + +name: 'Generate Release Notes' +description: 'Generate AI-powered release notes from CHANGELOG.md using Mistral AI' + +inputs: + version: + description: 'Full version string (e.g., 1.4.2-alpha)' + required: true + major: + description: 'Major version component' + required: true + minor: + description: 'Minor version component' + required: true + patch: + description: 'Patch version component' + required: true + suffix: + description: 'Version suffix (e.g., alpha)' + required: false + default: '' + mistral-api-key: + description: 'Mistral AI API key (passed through to mistral-chat)' + required: true + model: + description: 'Mistral model name' + required: false + default: 'mistral-medium-latest' + changelog-path: + description: 'Path to the CHANGELOG.md file' + required: false + default: 'CHANGELOG.md' + +outputs: + title: + description: 'Generated release title' + value: ${{ steps.parse_response.outputs.title }} + body: + description: 'Generated release notes body' + value: ${{ steps.parse_response.outputs.body }} + +runs: + using: 'composite' + steps: + - name: Extract changelog section + id: extract_changelog + shell: bash + env: + VERSION: ${{ inputs.version }} + CHANGELOG_PATH: ${{ inputs.changelog-path }} + run: | + # Extract content between ## [$VERSION] and the next ## [ heading + SECTION=$(sed -n "/^## \[$VERSION\]/,/^## \[/p" "$CHANGELOG_PATH" | sed '$d') + + if [ -z "$SECTION" ]; then + echo "::warning::Could not find changelog section for version $VERSION" + echo "fallback=true" >> "$GITHUB_OUTPUT" + SECTION="No changelog section found for version $VERSION." + else + echo "fallback=false" >> "$GITHUB_OUTPUT" + fi + + { + echo "changelog_section<> "$GITHUB_OUTPUT" + + - name: Determine release type + id: release_type + shell: bash + env: + PATCH: ${{ inputs.patch }} + run: | + if [ "$PATCH" != "0" ]; then + echo "type=patch" >> "$GITHUB_OUTPUT" + else + echo "type=major-minor" >> "$GITHUB_OUTPUT" + fi + + - name: Build system prompt + id: build_prompt + shell: bash + env: + VERSION: ${{ inputs.version }} + SUFFIX: ${{ inputs.suffix }} + RELEASE_TYPE: ${{ steps.release_type.outputs.type }} + run: | + if [ "$RELEASE_TYPE" = "major-minor" ]; then + # Determine stage language for Important Notes section + if [ -n "$SUFFIX" ]; then + STAGE_LINE="- This is an ${SUFFIX} release with some features still unstable or subject to change" + else + STAGE_LINE="- This is a stable release" + fi + + SYSTEM_PROMPT="You are a release notes writer for SmartHopper, a Grasshopper plugin for AI-assisted design in Rhino. + + Given a changelog section, generate: + 1. A release title on the FIRST LINE in this exact format: SmartHopper ${VERSION}: + Examples: \"SmartHopper 0.3.0-alpha: Powerful AI tools and enhanced security\" + 2. Then a blank line, followed by release notes in markdown: + + + + ## Feature/Change 1 + + + ## Feature/Change 2 + + + ## Technical Requirements + - Rhino 8.24 or above is required + - Windows 10/11 or macOS + - Valid API keys for MistralAI, OpenAI, DeepSeek, Anthropic or OpenRouter + + ## Important Notes + ${STAGE_LINE} + - API keys are required, and usage costs apply based on your provider + - Documentation is currently under development + + ## We Value Your Feedback! + Help shape SmartHopper's future by: + - Sharing your experiences with the new features + - Suggesting improvements via our discussion (https://github.com/architects-toolkit/SmartHopper/discussions) + - Telling us what AI capabilities would help your workflow most + + We hope you enjoy these new features and improvements! + Happy designing! + + RULES: + - Focus on user-facing changes only. Omit internal refactors, CI changes, and developer tooling. + - Summarize and group related changes into coherent themes. Do not copy-paste the changelog. + - Use emojis for section headers. + - The FIRST LINE must be the title only. Then blank line. Then the body." + else + # Patch release prompt + SYSTEM_PROMPT="You are a release notes writer for SmartHopper, a Grasshopper plugin for AI-assisted design in Rhino. + + Given a changelog section for a patch release, generate: + 1. A release title on the FIRST LINE: SmartHopper ${VERSION}: [Patch] + 2. Then a blank line, followed by release notes: + + + + ## Detailed list of changes + - + - + - Fixed issue [#N](link) (preserve issue links from the changelog) + + RULES: + - Summarize the changelog, do not copy-paste it literally. + - Preserve issue links from the original changelog. + - The FIRST LINE must be the title only. Then blank line. Then the body." + fi + + { + echo "system_prompt<> "$GITHUB_OUTPUT" + + - name: Call Mistral AI + id: ai_call + uses: ./.github/actions/ai/mistral-chat + with: + api-key: ${{ inputs.mistral-api-key }} + model: ${{ inputs.model }} + system-prompt: ${{ steps.build_prompt.outputs.system_prompt }} + user-prompt: | + Generate release notes for version ${{ inputs.version }} from this changelog: + + ${{ steps.extract_changelog.outputs.changelog_section }} + + - name: Parse title and body from AI response + id: parse_response + shell: bash + env: + AI_RESPONSE: ${{ steps.ai_call.outputs.response }} + AI_SUCCESS: ${{ steps.ai_call.outputs.success }} + VERSION: ${{ inputs.version }} + CHANGELOG_SECTION: ${{ steps.extract_changelog.outputs.changelog_section }} + run: | + if [ "$AI_SUCCESS" = "true" ] && [ -n "$AI_RESPONSE" ]; then + # First line is the title, rest is the body + TITLE=$(echo "$AI_RESPONSE" | head -n 1) + BODY=$(echo "$AI_RESPONSE" | tail -n +3) + + echo "::notice::AI release notes generated successfully" + else + echo "::warning::AI generation failed or returned empty, using fallback" + TITLE="SmartHopper $VERSION: " + BODY="$CHANGELOG_SECTION" + fi + + { + echo "title<> "$GITHUB_OUTPUT" + + { + echo "body<> "$GITHUB_OUTPUT" diff --git a/.github/actions/dotnet-build/action.yml b/.github/actions/dotnet-build/action.yml index 5516b5b1d..2cec09d4e 100644 --- a/.github/actions/dotnet-build/action.yml +++ b/.github/actions/dotnet-build/action.yml @@ -57,10 +57,10 @@ runs: if (-not (Test-Path signing.snk)) { if ("${{ inputs.signing_snk_base64 }}" -ne "") { Write-Host "Decoding signing.snk from Base64 secret" - pwsh tools/Sign-StrongNames.ps1 -Base64 "${{ inputs.signing_snk_base64 }}" + & tools/Sign-StrongNames.ps1 -Base64 "${{ inputs.signing_snk_base64 }}" } else { Write-Host "Generating signing.snk" - pwsh tools/Sign-StrongNames.ps1 -Generate + & tools/Sign-StrongNames.ps1 -Generate } } else { Write-Host "signing.snk already exists" @@ -71,7 +71,7 @@ runs: shell: pwsh run: | Write-Host "Updating InternalsVisibleTo entries with public key from signing.snk" - pwsh tools/Update-InternalsVisibleTo.ps1 + & tools/Update-InternalsVisibleTo.ps1 - name: 'Checkout unlicensed lib' uses: actions/checkout@v4 @@ -183,4 +183,4 @@ runs: Write-Host "Auth-signing SmartHopper provider DLLs in $buildPath" $trimmedPassword = $env:PFX_PASSWORD.Trim() ./tools/Sign-Authenticode.ps1 -Sign $buildPath -Password $trimmedPassword - if ($LASTEXITCODE -ne 0) { Write-Error "Authenticode signing failed for provider assemblies"; exit $LASTEXITCODE } \ No newline at end of file + if ($LASTEXITCODE -ne 0) { Write-Error "Authenticode signing failed for provider assemblies"; exit $LASTEXITCODE } diff --git a/.github/actions/milestone/assign-pr/action.yml b/.github/actions/milestone/assign-pr/action.yml index cca711f2f..697db7ab4 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 @@ -17,19 +21,10 @@ 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 from Solution.props - id: read-version + - name: Read version and assign to milestone uses: actions/github-script@v7 with: github-token: ${{ inputs.token }} @@ -37,34 +32,42 @@ 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. let processedVersion = fullVersion; const lastDotIndex = processedVersion.lastIndexOf('.'); - if (lastDotIndex > processedVersion.indexOf('-')) { + const dashIndex = processedVersion.indexOf('-'); + if (dashIndex !== -1 && lastDotIndex > dashIndex) { // Only remove if the dot is after the dash (part of build number) processedVersion = processedVersion.substring(0, lastDotIndex); } @@ -73,37 +76,78 @@ runs: processedVersion = processedVersion.replace('-dev', '-alpha'); console.log('Processed version for milestone:', processedVersion); - 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: | + + // Find milestone with matching title — paginate to walk through ALL + // milestones (default page size is 30, and the repo has more than that). + const milestones = await github.paginate(github.rest.issues.listMilestones, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', // Include both open and closed milestones + per_page: 100 + }); + + 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) { + // GitHub returns 422 with error code "already_exists" when a milestone + // with the same title already exists. This can happen if pagination + // missed it or another job created it concurrently — recover by + // re-listing and picking up the existing one instead of failing. + const msg = error.message || ''; + if (error.status === 422 && /already[_ ]exists/.test(msg)) { + console.log(`ℹ️ Milestone ${processedVersion} already exists — re-listing to find it`); + const refreshed = await github.paginate(github.rest.issues.listMilestones, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + per_page: 100 + }); + targetMilestone = refreshed.find(m => m.title === processedVersion); + if (!targetMilestone) { + core.setFailed(`Milestone ${processedVersion} reported as already_exists but could not be found after re-listing.`); + return; + } + console.log(`Found existing milestone after re-list: ${targetMilestone.title} (${targetMilestone.state})`); + } else { + 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 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: milestoneNumber + milestone: targetMilestone.number }); - console.log(`Successfully assigned PR #${prNumber} to milestone "${milestoneTitle}"`); + console.log(`Successfully assigned PR #${prNumber} to milestone "${targetMilestone.title}"`); // Add a comment to the PR if requested if ('${{ inputs.comment-on-pr }}' === 'true') { @@ -111,7 +155,7 @@ runs: owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, - body: `🏷️ This PR has been automatically assigned to milestone **${milestoneTitle}** based on the version in \`Solution.props\`.` + body: `🏷️ This PR has been automatically assigned to milestone **${targetMilestone.title}** based on the version in \`Solution.props\`.` }); } diff --git a/.github/actions/utils/safe-commit/action.yml b/.github/actions/utils/safe-commit/action.yml new file mode 100644 index 000000000..6e9dde3c0 --- /dev/null +++ b/.github/actions/utils/safe-commit/action.yml @@ -0,0 +1,127 @@ +name: Safe Commit +description: | + Commits staged changes and pushes. If the push fails (e.g. protected branch), + automatically creates a PR instead. This removes the need to hardcode protected + branch patterns in workflows. + +inputs: + commit-message: + description: 'Commit message for the changes' + required: true + pr-title: + description: 'PR title if a PR needs to be created (defaults to commit message)' + required: false + default: '' + pr-body: + description: 'PR body if a PR needs to be created' + required: false + default: 'Automated changes that could not be pushed directly to the branch.' + pr-labels: + description: 'Comma-separated labels to add to the PR' + required: false + default: '' + token: + description: 'GitHub token for creating PRs' + required: true + +outputs: + method: + description: 'How the changes were applied: "direct" (pushed) or "pr" (pull request created)' + value: ${{ steps.result.outputs.method }} + pr-number: + description: 'PR number if a PR was created (empty for direct push)' + value: ${{ steps.result.outputs.pr_number }} + pr-url: + description: 'PR URL if a PR was created (empty for direct push)' + value: ${{ steps.result.outputs.pr_url }} + changed: + description: 'Whether any changes were committed: "true" or "false"' + value: ${{ steps.commit.outputs.changed }} + +runs: + using: composite + steps: + - name: Check for changes and commit + id: commit + shell: bash + run: | + if git diff --cached --quiet; then + echo "No staged changes to commit" + echo "changed=false" >> "$GITHUB_OUTPUT" + else + git commit -m "${{ inputs.commit-message }}" + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Try direct push + id: push + if: steps.commit.outputs.changed == 'true' + shell: bash + run: | + if git push 2>&1; then + echo "push_ok=true" >> "$GITHUB_OUTPUT" + else + echo "Direct push failed (branch may be protected), will create PR" + echo "push_ok=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create PR if push failed + id: create-pr + if: steps.commit.outputs.changed == 'true' && steps.push.outputs.push_ok == 'false' + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + run: | + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + TEMP_BRANCH="chore/auto-$(date +%s)-$(echo "${{ inputs.commit-message }}" | tr ' ' '-' | tr -cd '[:alnum:]-' | head -c 40)" + + # Create temp branch and push + git checkout -b "$TEMP_BRANCH" + git push origin "$TEMP_BRANCH" + + # Create PR + PR_TITLE="${{ inputs.pr-title }}" + if [ -z "$PR_TITLE" ]; then + PR_TITLE="${{ inputs.commit-message }}" + fi + + PR_URL=$(gh pr create \ + --base "$CURRENT_BRANCH" \ + --head "$TEMP_BRANCH" \ + --title "$PR_TITLE" \ + --body "${{ inputs.pr-body }}") + + PR_NUMBER=$(echo "$PR_URL" | grep -oP '\d+$') + + # Add labels if specified + if [ -n "${{ inputs.pr-labels }}" ]; then + IFS=',' read -ra LABELS <<< "${{ inputs.pr-labels }}" + for label in "${LABELS[@]}"; do + label=$(echo "$label" | xargs) + gh pr edit "$PR_NUMBER" --add-label "$label" 2>/dev/null || true + done + fi + + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" + + # Switch back to original branch + git checkout "$CURRENT_BRANCH" + + - name: Set result + id: result + shell: bash + run: | + if [ "${{ steps.commit.outputs.changed }}" != "true" ]; then + echo "method=none" >> "$GITHUB_OUTPUT" + echo "pr_number=" >> "$GITHUB_OUTPUT" + echo "pr_url=" >> "$GITHUB_OUTPUT" + elif [ "${{ steps.push.outputs.push_ok }}" == "true" ]; then + echo "method=direct" >> "$GITHUB_OUTPUT" + echo "pr_number=" >> "$GITHUB_OUTPUT" + echo "pr_url=" >> "$GITHUB_OUTPUT" + else + echo "method=pr" >> "$GITHUB_OUTPUT" + echo "pr_number=${{ steps.create-pr.outputs.pr_number }}" >> "$GITHUB_OUTPUT" + echo "pr_url=${{ steps.create-pr.outputs.pr_url }}" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/actions/versioning/check-issues-for-version/action.yml b/.github/actions/versioning/check-issues-for-version/action.yml index e2aa9c7fd..f91c4e8d6 100644 --- a/.github/actions/versioning/check-issues-for-version/action.yml +++ b/.github/actions/versioning/check-issues-for-version/action.yml @@ -114,6 +114,23 @@ runs: } } while ($response.items.Count -eq $perPage) + # Post-filter: ensure labels exactly match "version: X.Y.Z" or "version: X.Y.Z-" + # GitHub search API does prefix matching, so "version: 1.4.2" also matches "version: 1.4.20" + $filteredIssues = @() + foreach ($issue in $openIssues) { + $hasExactMatch = $false + foreach ($label in $issue.labels) { + if ($label.name -eq "version: $baseVersion" -or $label.name -match "^version: $([regex]::Escape($baseVersion))-(alpha|beta|rc)$") { + $hasExactMatch = $true + break + } + } + if ($hasExactMatch) { + $filteredIssues += $issue + } + } + $openIssues = $filteredIssues + $openIssueCount = $openIssues.Count $openIssueNumbers = ($openIssues | ForEach-Object { $_.number }) -join "," @@ -182,11 +199,28 @@ runs: 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" + $closedIssuesUri = "https://api.github.com/search/issues?q=$([Uri]::EscapeDataString($query))&sort=updated&order=desc&per_page=10" $closedResponse = Invoke-RestMethod -Uri $closedIssuesUri -Headers $headers -Method GET + $lastClosedIssue = $null if ($closedResponse.total_count -gt 0 -and $closedResponse.items.Count -gt 0) { - $lastClosedIssue = $closedResponse.items[0] + # Post-filter: verify the closed issue's labels match exactly + # GitHub search API does prefix matching, so "version: 1.4.2" also matches "version: 1.4.20" + foreach ($candidate in $closedResponse.items) { + $hasExactLabel = $false + foreach ($label in $candidate.labels) { + if ($label.name -eq "version: $version" -or $label.name -eq "version: $baseVersion" -or $label.name -match "^version: $([regex]::Escape($baseVersion))-(alpha|beta|rc)$") { + $hasExactLabel = $true + break + } + } + if ($hasExactLabel) { + $lastClosedIssue = $candidate + break + } + } + } + if ($lastClosedIssue) { $closedAt = [DateTime]::Parse($lastClosedIssue.closed_at) $now = Get-Date $closedAgeDays = ($now - $closedAt).Days diff --git a/.github/actions/versioning/manage-milestones/action.yml b/.github/actions/versioning/manage-milestones/action.yml index 0ab59103d..b6dccd9ab 100644 --- a/.github/actions/versioning/manage-milestones/action.yml +++ b/.github/actions/versioning/manage-milestones/action.yml @@ -60,7 +60,11 @@ runs: console.log(`✅ Created milestone: ${title}`); return { title, created: true }; } catch (error) { - if (error.message.includes('already exists')) { + // GitHub returns 422 with error code "already_exists" when a milestone + // with the same title already exists. Older Octokit versions surfaced + // this as "already exists" in the message — handle both forms. + const msg = error.message || ''; + if (error.status === 422 && /already[_ ]exists/.test(msg)) { console.log(`ℹ️ Milestone ${title} already exists`); return { title, created: false }; } 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 000000000..e96f3f4cf --- /dev/null +++ b/.github/actions/versioning/update-manifest-text/action.yml @@ -0,0 +1,114 @@ +name: 'Update Manifest Text' +description: 'Determines and optionally updates manifest.yml note text based on version type, reading text from yak-package/.manifest.json' + +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' + notes-path: + description: 'Path to .manifest.json file containing note texts (relative to workspace)' + required: false + default: 'yak-package/.manifest.json' + 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 (dev, 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 }}" + NOTES_PATH="${{ inputs.notes-path }}" + + # Determine release type from version suffix + if [[ "$VERSION" =~ -dev ]]; then + TYPE="dev" + elif [[ "$VERSION" =~ -alpha ]]; then + TYPE="alpha" + elif [[ "$VERSION" =~ -beta ]]; then + TYPE="beta" + elif [[ "$VERSION" =~ -rc ]]; then + TYPE="rc" + else + TYPE="stable" + fi + + # Read note text from .manifest.json + if [ ! -f "$NOTES_PATH" ]; then + echo "::error::Notes file not found at $NOTES_PATH" + exit 1 + fi + + TEXT=$(jq -r --arg type "$TYPE" '.notes[$type] // empty' "$NOTES_PATH") + if [ -z "$TEXT" ]; then + echo "::error::No note text found for release type '$TYPE' in $NOTES_PATH" + exit 1 + 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 + + # Create a backup before modification to detect no-ops + cp "$MANIFEST_PATH" "$MANIFEST_PATH.bak" + + # Replace {{NOTE_TEXT}} placeholder with the resolved note text + if grep -q '{{NOTE_TEXT}}' "$MANIFEST_PATH"; then + echo "Replacing {{NOTE_TEXT}} placeholder with $TYPE text" + ESCAPED_TEXT=$(echo "$TEXT" | sed 's/[&/\]/\\&/g') + sed -i "s|{{NOTE_TEXT}}|$ESCAPED_TEXT|" "$MANIFEST_PATH" + else + echo "::warning::Could not find {{NOTE_TEXT}} placeholder in $MANIFEST_PATH" + echo "Current content around description:" + grep -A 10 "description:" "$MANIFEST_PATH" || true + echo "updated=false" >> $GITHUB_OUTPUT + rm -f "$MANIFEST_PATH.bak" + exit 0 + fi + + # Only report updated if the file content actually changed + if ! cmp -s "$MANIFEST_PATH.bak" "$MANIFEST_PATH"; then + echo "updated=true" >> $GITHUB_OUTPUT + echo "Manifest successfully updated" + else + echo "No actual changes made (text already matches)" + echo "updated=false" >> $GITHUB_OUTPUT + fi + + rm -f "$MANIFEST_PATH.bak" diff --git a/.github/issue-labeler.yml b/.github/issue-labeler.yml new file mode 100644 index 000000000..398b3fe5b --- /dev/null +++ b/.github/issue-labeler.yml @@ -0,0 +1,18 @@ +# Issue auto-labeling configuration for github/issue-labeler@v3 +# Maps issue title/body regex patterns to labels. + +"provider: OpenAI": + - '(?i)\bOpenAI\b' +"provider: MistralAI": + - '(?i)\bMistral\b' +"provider: Anthropic": + - '(?i)\bAnthropic\b|\bClaude\b' +"provider: DeepSeek": + - '(?i)\bDeepSeek\b' +"provider: OpenRouter": + - '(?i)\bOpenRouter\b' + +"os: Windows": + - '(?i)\bWindows\b' +"os: MacOS": + - '(?i)\bmacOS\b|\bMac OS\b' diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..cfafab993 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,73 @@ +# PR auto-labeling configuration for actions/labeler@v5 +# Maps file path patterns to labels applied automatically on PR open/sync. + +# --- Provider labels --- +"provider: OpenAI": + - changed-files: + - any-glob-to-any-file: 'src/SmartHopper.Providers.OpenAI/**' + +"provider: MistralAI": + - changed-files: + - any-glob-to-any-file: 'src/SmartHopper.Providers.MistralAI/**' + +"provider: Anthropic": + - changed-files: + - any-glob-to-any-file: 'src/SmartHopper.Providers.Anthropic/**' + +"provider: DeepSeek": + - changed-files: + - any-glob-to-any-file: 'src/SmartHopper.Providers.DeepSeek/**' + +"provider: OpenRouter": + - changed-files: + - any-glob-to-any-file: 'src/SmartHopper.Providers.OpenRouter/**' + +# --- Scope labels --- +"scope: UI": + - changed-files: + - any-glob-to-any-file: + - 'src/SmartHopper.Components/**' + - 'src/SmartHopper.Menu/**' + +"scope: AI Calls": + - changed-files: + - any-glob-to-any-file: 'src/SmartHopper.Core/AI/**' + +"scope: Component Base": + - changed-files: + - any-glob-to-any-file: 'src/SmartHopper.Core.Grasshopper/**' + +"scope: Security": + - changed-files: + - any-glob-to-any-file: + - '**/*Sign*' + - '**/*Hash*' + - '**/*Authenticode*' + - '.hashes/**' + +# --- OS labels --- +"os: Windows": + - changed-files: + - any-glob-to-any-file: + - '**/win-*/**' + - '**/*.Windows.*' + +"os: MacOS": + - changed-files: + - any-glob-to-any-file: + - '**/osx-*/**' + - '**/*.MacOS.*' + +# --- Infrastructure labels --- +ci: + - changed-files: + - any-glob-to-any-file: + - '.github/workflows/**' + - '.github/actions/**' + +documentation: + - changed-files: + - any-glob-to-any-file: + - '**/*.md' + - 'docs/**' + - '.github/ISSUE_TEMPLATE/**' diff --git a/.github/labels.yml b/.github/labels.yml index 89a98e453..50a30dd5d 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -1,3 +1,17 @@ +# Automation Labels +- name: "automated" + color: "C0C0C0" + description: "Automatically generated by CI/CD workflows" +- name: "needs-attention" + color: "FF4500" + description: "Requires manual review or intervention" +- name: "promotion: blocked" + color: "FF4500" + description: "Promotion is blocked and requires manual intervention" +- name: "model-verification" + color: "00FF00" + description: "Model verification workflow" + # Type: Priority - name: "priority: critical" color: "FF0000" @@ -181,6 +195,11 @@ color: "500" description: "Issues related to Authenticode, SHA256, etc." +# Stale management +- name: "stale" + color: "CFD3D7" + description: "Issue or PR has been inactive and may be closed soon" + # Labels for OS discrimination - name: "os: Windows" color: "0078D4" diff --git a/.github/workflows/RELEASE_WORKFLOW.md b/.github/workflows/RELEASE_WORKFLOW.md index 38bd10299..060276312 100644 --- a/.github/workflows/RELEASE_WORKFLOW.md +++ b/.github/workflows/RELEASE_WORKFLOW.md @@ -213,9 +213,13 @@ Version is determined by the milestone title (e.g., milestone `1.2.0` → releas - **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-promotion.yml** — Scans open no-suffix milestones daily; promotes eligible staged releases; supports `promotion: freeze` label - **release-6-upload-yak.yml** — Uploads to Yak package manager (manual or dispatched by build) +### Patch Propagation + +- **patch-propagate.yml** — Fan-out cherry-picks to multiple target branches; supports `auto-discover` to find all `dev`/`dev-*` branches, `include-main-branches` for critical fixes, and `exclude-branches` to skip specific targets + ### 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 @@ -239,7 +243,7 @@ All PRs (release → dev, dev → main) run: - **main-X.Y.Z**: Protected stabilization branch (created by automation); `github-actions[bot]` has bypass for create/delete - **release/\***: Temporary branches, deleted after merge -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`. +All CI checks (`ci-dotnet-tests`, `pr-validation`, `pr-version-validation`, `pr-build-hash-validation`) run on PRs to `dev-*` and `main-*` branches identical to `dev` and `main`. Manifest text validation was removed — `manifest.yml` now uses the `{{NOTE_TEXT}}` placeholder, resolved at build time by `release-6-upload-yak.yml`. ### Stabilization Path Example @@ -279,6 +283,25 @@ All CI checks (`ci-dotnet-tests`, `pr-validation`, `pr-version-validation`, `pr- - ❌ 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) +- ❌ A `promotion: freeze` label is active for the version (see below) + +### Controlling Promotion + +**Freezing promotion:** +- Add the `promotion: freeze` label to any open issue that also has a `version: X.Y.Z` label +- Promotion will be skipped for that version until the label is removed or the issue is closed +- `force-promote` in workflow_dispatch overrides the freeze + +**Blocked promotion notifications:** +- When a release is older than 30 days but cannot be promoted, an issue titled `\u26d4 Promotion blocked: X.Y.Z-stage` is auto-created +- The issue is updated daily with the latest blocking reason +- Close the issue manually once the blocking condition is resolved + +**Patch propagation:** +- Use `patch-propagate.yml` with `auto-discover: true` to fan out a fix to all `dev` and `dev-*` branches +- Set `include-main-branches: true` for critical fixes that also need to reach `main-*` branches +- Use `exclude-branches` to skip specific branches from auto-discovery +- If any cherry-pick has conflicts, an issue is auto-created with the `needs-attention` label ### Regular Release Example diff --git a/.github/workflows/check-provider-models.yml b/.github/workflows/check-provider-models.yml new file mode 100644 index 000000000..b3a6cee9a --- /dev/null +++ b/.github/workflows/check-provider-models.yml @@ -0,0 +1,206 @@ +name: ✅ Check Provider Models + +# Related workflows (provider model management): +# - check-provider-models.yml (validates model definitions in PRs) +# - chore-update-provider-models.yml (fetches and updates model lists) +# - chore-update-model-verification-template.yml (updates issue template) +# - model-verification.yml (community model verification process) + +on: + pull_request: + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to validate. + required: false + type: string + base_ref: + description: Pull request base branch. + required: false + type: string + pr_title: + description: Pull request title. + required: false + type: string + +permissions: + contents: read + +jobs: + paths-filter: + name: Paths Filter + runs-on: ubuntu-latest + timeout-minutes: 45 + outputs: + should_run: ${{ steps.filter.outputs.should_run }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - id: filter + shell: bash + env: + BASE_REF: ${{ github.event.inputs.base_ref || github.event.pull_request.base.ref || 'main' }} + run: | + set -euo pipefail + + git fetch origin "$BASE_REF" --depth=1 + + should_run=false + mapfile -t changed_files < <(git diff --name-only "origin/$BASE_REF"...HEAD) + + for file in "${changed_files[@]}"; do + case "$file" in + tools/Update-ProviderModels.ps1) + should_run=true + break + ;; + src/SmartHopper.Infrastructure/AIModels/AICapability.cs|src/SmartHopper.Providers.*/*ProviderModels.cs) + if python3 - "$BASE_REF" "$file" <<'PY' + import subprocess + import sys + from pathlib import Path + + base_ref, path = sys.argv[1], sys.argv[2] + bom = b"\xef\xbb\xbf" + + try: + base = subprocess.check_output(["git", "show", f"origin/{base_ref}:{path}"]) + except subprocess.CalledProcessError: + sys.exit(1) + + head = Path(path).read_bytes() + if base.startswith(bom): + base = base[len(bom):] + if head.startswith(bom): + head = head[len(bom):] + + sys.exit(0 if base == head else 1) + PY + then + echo "Ignoring BOM-only provider model change: $file" + else + should_run=true + break + fi + ;; + esac + done + + echo "should_run=$should_run" >> "$GITHUB_OUTPUT" + + validate-provider-models: + name: Validate provider model defaults + needs: paths-filter + if: needs.paths-filter.outputs.should_run == 'true' + runs-on: windows-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + provider: + - OpenAI + - MistralAI + - Anthropic + - OpenRouter + - DeepSeek + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate ${{ matrix.provider }} model declarations + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $provider = '${{ matrix.provider }}' + + $rawOutput = .\tools\Update-ProviderModels.ps1 ` + -Provider $provider ` + -ValidateOnly + $output = @($rawOutput -split "`r?`n") + + $output | ForEach-Object { Write-Host $_ } + + $jsonStartIndex = -1 + for ($i = 0; $i -lt $output.Count; $i++) { + if ($output[$i].TrimStart().StartsWith('{')) { + $jsonStartIndex = $i + break + } + } + + if ($jsonStartIndex -lt 0) { + Write-Output "::error title=$provider provider model validation::Validation script did not emit a JSON report." + exit 1 + } + + $reportJson = ($output[$jsonStartIndex..($output.Count - 1)] -join "`n") + $report = $reportJson | ConvertFrom-Json + + Write-Output "## $provider provider model validation" >> $env:GITHUB_STEP_SUMMARY + Write-Output "" >> $env:GITHUB_STEP_SUMMARY + + if ($report.validation.success) { + Write-Output "✅ Validation passed." >> $env:GITHUB_STEP_SUMMARY + exit 0 + } + + Write-Output "❌ Validation failed with $($report.validation.errors.Count) error(s)." >> $env:GITHUB_STEP_SUMMARY + Write-Output "" >> $env:GITHUB_STEP_SUMMARY + + if ($report.validation.missingDefaultCapabilities.Count -gt 0) { + Write-Output "### Missing default capabilities" >> $env:GITHUB_STEP_SUMMARY + foreach ($capability in $report.validation.missingDefaultCapabilities) { + Write-Output "- ``AICapability.$capability``" >> $env:GITHUB_STEP_SUMMARY + } + Write-Output "" >> $env:GITHUB_STEP_SUMMARY + } + + if ($report.validation.pendingCapabilityModels.Count -gt 0) { + Write-Output "### Pending capability definitions" >> $env:GITHUB_STEP_SUMMARY + foreach ($model in $report.validation.pendingCapabilityModels) { + Write-Output "- ``$model``" >> $env:GITHUB_STEP_SUMMARY + } + Write-Output "" >> $env:GITHUB_STEP_SUMMARY + } + + if ($report.validation.realtimeModels.Count -gt 0) { + Write-Output "### Realtime models" >> $env:GITHUB_STEP_SUMMARY + foreach ($model in $report.validation.realtimeModels) { + Write-Output "- ``$model``" >> $env:GITHUB_STEP_SUMMARY + } + Write-Output "" >> $env:GITHUB_STEP_SUMMARY + } + + Write-Output "### Validation errors" >> $env:GITHUB_STEP_SUMMARY + foreach ($errorMessage in $report.validation.errors) { + $escaped = $errorMessage.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A').Replace(':', '%3A').Replace(',', '%2C') + Write-Output "::error title=$provider provider model validation::$escaped" + Write-Output "- $errorMessage" >> $env:GITHUB_STEP_SUMMARY + } + + exit 1 + + required-check: + name: ✅ Check Provider Models + needs: [paths-filter, validate-provider-models] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Verify required workflow jobs + shell: bash + run: | + paths_filter="${{ needs.paths-filter.result }}" + validate_provider_models="${{ needs.validate-provider-models.result }}" + + if [[ "$paths_filter" != "success" ]]; then + echo "::error::Paths filter finished with $paths_filter" + exit 1 + fi + + if [[ "$validate_provider_models" != "success" && "$validate_provider_models" != "skipped" ]]; then + echo "::error::Provider model validation finished with $validate_provider_models" + exit 1 + fi diff --git a/.github/workflows/chore-changelog-review.yml b/.github/workflows/chore-changelog-review.yml new file mode 100644 index 000000000..a598f10c5 --- /dev/null +++ b/.github/workflows/chore-changelog-review.yml @@ -0,0 +1,374 @@ +name: 📝 AI Changelog Review & Simplification + +# Description: Uses Mistral AI to simplify changelog entries for end users. +# On release/* PRs targeting dev/dev-*, the AI rewrites the [Unreleased] section +# to be user-focused and commits the simplified changelog directly to the source +# branch. A summary of changes is posted as a PR comment. +# +# Follows .windsurf/workflows/changelog-review.md guidelines: +# - Keep entries to 1-2 lines maximum +# - Focus on end-user value, not implementation details +# - Preserve all issue references [#123](link) +# - Briefly mention CI/CD improvements (one-liner) +# - Exclude file paths, workflow names, internal tooling details +# +# Triggers: +# - pull_request opened/synchronize targeting dev/dev-* from release/* branches +# - workflow_dispatch with optional PR number +# +# Permissions: +# - contents: write - Required to read CHANGELOG.md, commit, and push +# - pull-requests: write - Required to post review comments + +on: + pull_request: + types: + - opened + - synchronize + branches: + - 'dev' + - 'dev-*' + paths: + - 'CHANGELOG.md' + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to review (optional, uses latest release PR if empty)' + required: false + default: '' + +permissions: + contents: write + pull-requests: write + +jobs: + review-changelog: + name: Review & simplify changelog + runs-on: ubuntu-latest + timeout-minutes: 10 + # Only run for release branches or manual dispatch + if: | + github.event_name == 'workflow_dispatch' || + startsWith(github.event.pull_request.head.ref, 'release/') + steps: + - name: Determine PR context + id: context + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ github.event.inputs.pr_number }}" ]; then + PR_NUMBER="${{ github.event.inputs.pr_number }}" + elif [ "${{ github.event_name }}" = "pull_request" ]; then + PR_NUMBER="${{ github.event.pull_request.number }}" + else + # Find latest open release PR targeting dev or dev-* + PR_NUMBER=$(gh pr list \ + --repo "${{ github.repository }}" \ + --state open \ + --json number,headRefName,baseRefName \ + --jq '[.[] | select(.headRefName | startswith("release/")) | select(.baseRefName | test("^dev(-.*)?$"))][0].number // empty') + fi + + if [ -z "$PR_NUMBER" ]; then + echo "::notice::No release PR found to review" + echo "skip=true" >> "$GITHUB_OUTPUT" + else + # Get the head branch name for checkout + HEAD_BRANCH=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json headRefName --jq '.headRefName') + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "head_branch=$HEAD_BRANCH" >> "$GITHUB_OUTPUT" + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout PR source branch + if: steps.context.outputs.skip != 'true' + uses: actions/checkout@v4 + with: + ref: ${{ steps.context.outputs.head_branch }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + if: steps.context.outputs.skip != 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Extract changelog [Unreleased] section + if: steps.context.outputs.skip != 'true' + id: changelog + run: | + # Extract the [Unreleased] section from CHANGELOG.md + UNRELEASED=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{if(found) exit} found{print}' CHANGELOG.md) + + if [ -z "$UNRELEASED" ]; then + echo "::notice::No [Unreleased] section found or it is empty" + echo "empty=true" >> "$GITHUB_OUTPUT" + else + echo "empty=false" >> "$GITHUB_OUTPUT" + { + echo "content<> "$GITHUB_OUTPUT" + fi + + - name: Get recent commit messages + if: steps.context.outputs.skip != 'true' + id: commits + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Get commits from the PR + COMMITS=$(gh pr view "${{ steps.context.outputs.pr_number }}" \ + --repo "${{ github.repository }}" \ + --json commits \ + --jq '.commits[].messageHeadline' 2>/dev/null || echo "") + + if [ -z "$COMMITS" ]; then + # Fallback: query commits via REST API to avoid local ref resolution issues + COMMITS=$(gh api repos/${{ github.repository }}/pulls/${{ steps.context.outputs.pr_number }}/commits \ + --jq '.[].commit.message' 2>/dev/null || echo "") + fi + + { + echo "messages<> "$GITHUB_OUTPUT" + + - name: AI review & simplification + if: steps.context.outputs.skip != 'true' && steps.changelog.outputs.empty != 'true' + id: ai-review + uses: ./.github/actions/ai/mistral-chat + with: + api-key: ${{ secrets.MISTRAL_API_KEY }} + model: mistral-medium-latest + temperature: '0.3' + max-tokens: '4096' + system-prompt: | + You are a changelog editor for SmartHopper, a Grasshopper plugin for Rhino 3D. + Your job is to rewrite the [Unreleased] section of the changelog for end users. + + RULES: + 1. Each entry must be 1-2 lines maximum + 2. Focus on end-user value, not implementation details + 3. ALWAYS preserve issue references like [#123](https://github.com/architects-toolkit/SmartHopper/issues/123) + 4. ALWAYS preserve the contributors section unchanged (### New Contributors and everything below it) + 5. Preserve the markdown structure: ### Added, ### Changed, ### Fixed, ### Deprecated, ### Removed sections + 6. Remove empty sections (sections with no entries after simplification) + 7. Do NOT add any sections that don't exist in the original + + INCLUDE in changelog: + - New AI models and capabilities + - New components or features + - UI/UX changes + - Breaking changes that affect saved files + - Performance improvements users will notice + - Deprecated features or models + - GitHub issue references + + BRIEFLY MENTION (one-liner): + - CI/CD improvements + - Infrastructure stability improvements + - Code quality improvements (only if significant) + + EXCLUDE from changelog: + - Detailed CI workflow specifications + - Concurrency settings and race condition prevention + - Per-provider technical minutiae + - Internal tooling details + - File paths and implementation details + + SIMPLIFICATION EXAMPLES: + - Technical CI details -> "- **CI/CD**: Enhanced workflow automation for model verification and stabilization branch management" + - Per-model ranking changes -> "- **AI model rankings**: Adjusted default models and rankings across providers based on official documentation" + + OUTPUT FORMAT: + Respond with a JSON object containing TWO fields: + { + "simplified_section": "The complete rewritten [Unreleased] section content (everything between '## [Unreleased]' and the next '## [x.y.z]' header). Use actual newlines in the string.", + "changes_made": [ + "Short description of each simplification you made" + ] + } + + If no changes are needed, return: + {"simplified_section": "", "changes_made": []} + with an empty simplified_section to signal no rewrite is needed. + user-prompt: | + Rewrite this changelog [Unreleased] section. Simplify overly technical entries + into user-focused descriptions. + + ## Current [Unreleased] changelog entries: + ${{ steps.changelog.outputs.content }} + + ## Recent commits (for context only): + ${{ steps.commits.outputs.messages }} + + - name: Apply simplified changelog + if: steps.context.outputs.skip != 'true' && steps.ai-review.outputs.success == 'true' + id: apply + uses: actions/github-script@v7 + env: + AI_RESPONSE: ${{ steps.ai-review.outputs.response }} + with: + script: | + const fs = require('fs'); + const response = process.env.AI_RESPONSE; + + // Parse AI response + let review; + try { + const jsonMatch = response.match(/```(?:json)?\s*([\s\S]*?)```/) || [null, response]; + review = JSON.parse(jsonMatch[1].trim()); + } catch (e) { + core.warning(`Failed to parse AI response: ${e.message}`); + core.setOutput('applied', 'false'); + core.setOutput('changes_made', '[]'); + return; + } + + // Check if changes are needed + if (!review.simplified_section || review.simplified_section.trim() === '') { + core.info('No simplifications needed'); + core.setOutput('applied', 'false'); + core.setOutput('changes_made', JSON.stringify(review.changes_made || [])); + return; + } + + // Read the current CHANGELOG.md + const changelog = fs.readFileSync('CHANGELOG.md', 'utf8'); + + // Find the [Unreleased] section and replace its content + const unreleasedHeader = /^## \[Unreleased\]/m; + const nextVersionHeader = /\n## \[\d+\.\d+/m; + + const headerMatch = changelog.match(unreleasedHeader); + if (!headerMatch) { + core.warning('Could not find ## [Unreleased] header'); + core.setOutput('applied', 'false'); + core.setOutput('changes_made', '[]'); + return; + } + + const headerEnd = headerMatch.index + headerMatch[0].length; + const restAfterHeader = changelog.substring(headerEnd); + const nextMatch = restAfterHeader.match(nextVersionHeader); + + let newChangelog; + if (nextMatch) { + const contentEnd = headerEnd + nextMatch.index; + newChangelog = changelog.substring(0, headerEnd) + '\n\n' + + review.simplified_section.trim() + '\n\n' + + changelog.substring(contentEnd + 1); + } else { + newChangelog = changelog.substring(0, headerEnd) + '\n\n' + + review.simplified_section.trim() + '\n'; + } + + // Write back + fs.writeFileSync('CHANGELOG.md', newChangelog, 'utf8'); + core.setOutput('applied', 'true'); + core.setOutput('changes_made', JSON.stringify(review.changes_made || [])); + + - name: Stage changelog changes + if: steps.apply.outputs.applied == 'true' + run: git add CHANGELOG.md + + - name: Commit simplified changelog + if: steps.apply.outputs.applied == 'true' + id: safe-commit + uses: ./.github/actions/utils/safe-commit + with: + commit-message: 'docs: AI-simplified changelog entries' + pr-title: 'docs: AI-simplified changelog entries' + pr-body: 'Automated changelog simplification applied by AI review workflow.' + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Post review summary comment + if: steps.context.outputs.skip != 'true' && steps.ai-review.outputs.success == 'true' && steps.safe-commit.outputs.method != '' + uses: actions/github-script@v7 + env: + CHANGES_MADE: ${{ steps.apply.outputs.changes_made }} + with: + script: | + const prNumber = parseInt('${{ steps.context.outputs.pr_number }}', 10); + const applied = '${{ steps.apply.outputs.applied }}' === 'true'; + const commitMethod = '${{ steps.safe-commit.outputs.method }}'; + const promptTokens = '${{ steps.ai-review.outputs.usage-prompt-tokens || 0 }}'; + const completionTokens = '${{ steps.ai-review.outputs.usage-completion-tokens || 0 }}'; + + let changesMade = []; + try { + changesMade = JSON.parse(process.env.CHANGES_MADE || '[]'); + } catch (e) { /* ignore parse errors */ } + + let body = '## AI Changelog Simplification\n\n'; + + if (applied && changesMade.length > 0) { + body += '### Changes applied\n\n'; + if (commitMethod === 'direct') { + body += 'The following simplifications were committed directly to the branch:\n\n'; + } else if (commitMethod === 'pr') { + body += 'The following simplifications were submitted via PR (branch is protected):\n\n'; + } + for (const change of changesMade) { + body += `- ${change}\n`; + } + body += '\n'; + body += '> Review the commit to verify the changes are correct. Revert or amend if needed.\n\n'; + } else { + body += 'Changelog looks good - no simplifications needed.\n\n'; + } + + body += `---\n*Tokens used: ${promptTokens} prompt + ${completionTokens} completion*\n`; + body += '*Generated by `.github/workflows/chore-changelog-review.yml` using Mistral AI*'; + + // Find and update existing bot comment, or create new one + const comments = await github.paginate( + github.rest.issues.listComments, + { owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, per_page: 100 } + ); + + const marker = '## AI Changelog Simplification'; + const existing = comments.find(c => + c.user.type === 'Bot' && c.body && c.body.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body + }); + core.info(`Updated existing comment #${existing.id}`); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body + }); + core.info('Created new review comment'); + } + + - name: Post notice if AI call failed + if: steps.context.outputs.skip != 'true' && steps.ai-review.outputs.success == 'false' + run: | + echo "::warning::AI changelog review failed: ${{ steps.ai-review.outputs.error }}" + echo "The Mistral AI API call failed. This is non-blocking." >> "$GITHUB_STEP_SUMMARY" + + - name: Summary + if: always() && steps.context.outputs.skip != 'true' + run: | + echo "## Changelog Review" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- PR: #${{ steps.context.outputs.pr_number }}" >> "$GITHUB_STEP_SUMMARY" + echo "- AI Success: ${{ steps.ai-review.outputs.success || 'skipped' }}" >> "$GITHUB_STEP_SUMMARY" + echo "- Changes applied: ${{ steps.apply.outputs.applied || 'n/a' }}" >> "$GITHUB_STEP_SUMMARY" + echo "- Commit method: ${{ steps.safe-commit.outputs.method || 'n/a' }}" >> "$GITHUB_STEP_SUMMARY" + if [ "${{ steps.ai-review.outputs.success }}" = "true" ]; then + echo "- Token usage: ${{ steps.ai-review.outputs.usage-prompt-tokens || '0' }} prompt + ${{ steps.ai-review.outputs.usage-completion-tokens || '0' }} completion" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/chore-update-contributors.yml b/.github/workflows/chore-update-contributors.yml index 02ddb0bf1..efa071984 100644 --- a/.github/workflows/chore-update-contributors.yml +++ b/.github/workflows/chore-update-contributors.yml @@ -24,12 +24,18 @@ on: - 'hotfix/**' permissions: + actions: write contents: write pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: update-contributors: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -313,16 +319,18 @@ jobs: id: create-pr run: | PR_TITLE="docs: update contributors section for ${{ env.TARGET_BRANCH }}" - PR_BODY=$(cat << 'EOF' - This PR updates the contributors section in CHANGELOG.md under [Unreleased] to acknowledge new contributors since the last release. - - This is an automated PR created by the Update Contributors workflow. - EOF) + PR_BODY=$(printf '%s\n\n%s' \ + "This PR updates the contributors section in CHANGELOG.md under [Unreleased] to acknowledge new contributors since the last release." \ + "This is an automated PR created by the Update Contributors workflow.") - gh pr create --base ${{ env.TARGET_BRANCH }} --head ${{ env.CONTRIB_BRANCH }} --title "$PR_TITLE" --body "$PR_BODY" + gh pr create --base ${{ env.TARGET_BRANCH }} --head ${{ env.CONTRIB_BRANCH }} --title "$PR_TITLE" --body "$PR_BODY" 2>&1 || true - # Capture PR number + # Capture PR number (works whether pr was just created or already existed) PR_NUMBER=$(gh pr list --base ${{ env.TARGET_BRANCH }} --head ${{ env.CONTRIB_BRANCH }} --json number --jq '.[0].number') + if [ -z "$PR_NUMBER" ]; then + echo "::error::Failed to create or find PR" + exit 1 + fi echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -333,3 +341,13 @@ jobs: with: pr-number: ${{ steps.create-pr.outputs.pr_number }} token: ${{ secrets.GITHUB_TOKEN }} + + - name: Dispatch required PR checks + if: steps.identify-contributors.outputs.has_contributors == 'true' && steps.update-changelog.outputs.changelog_updated == 'true' && env.CHANGES_PUSHED == 'true' && steps.create-pr.outputs.pr_number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ env.CONTRIB_BRANCH }} + pr-number: ${{ steps.create-pr.outputs.pr_number }} + base-ref: ${{ env.TARGET_BRANCH }} + pr-title: "docs: update contributors section for ${{ env.TARGET_BRANCH }}" diff --git a/.github/workflows/chore-update-copyright-year.yml b/.github/workflows/chore-update-copyright-year.yml index 2e9d910ab..571986914 100644 --- a/.github/workflows/chore-update-copyright-year.yml +++ b/.github/workflows/chore-update-copyright-year.yml @@ -13,6 +13,7 @@ on: workflow_dispatch: permissions: + actions: write contents: write pull-requests: write @@ -20,6 +21,7 @@ jobs: update-copyright-year: name: 🗓️ Update Copyright Year runs-on: windows-latest + timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 @@ -36,12 +38,12 @@ jobs: - name: Update license headers in C# files shell: pwsh run: | - pwsh -ExecutionPolicy Bypass -File .\tools\Update-LicenseHeaders.ps1 + & .\tools\Update-LicenseHeaders.ps1 - name: Update copyright year in Directory.Build.props shell: pwsh run: | - pwsh -ExecutionPolicy Bypass -File .\tools\Update-CopyrightYear.ps1 + & .\tools\Update-CopyrightYear.ps1 - name: Check for changes id: check-changes @@ -74,7 +76,6 @@ jobs: base: dev delete-branch: true labels: | - chore automated - name: Assign PR to Milestone @@ -84,6 +85,28 @@ jobs: pr-number: ${{ steps.create-pr.outputs.pull-request-number }} token: ${{ secrets.GITHUB_TOKEN }} + - name: Dispatch required PR checks + if: steps.check-changes.outputs.has-changes == 'true' && steps.create-pr.outputs.pull-request-number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: chore/update-copyright-year-${{ steps.year.outputs.year }} + pr-number: ${{ steps.create-pr.outputs.pull-request-number }} + base-ref: dev + pr-title: "chore: update copyright year to ${{ steps.year.outputs.year }}" + + - name: Label PR on failure + if: failure() && steps.create-pr.outputs.pull-request-number != '' + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ steps.create-pr.outputs.pull-request-number }}, + labels: ['needs-attention'] + }); + - name: No changes needed if: steps.check-changes.outputs.has-changes == 'false' shell: pwsh diff --git a/.github/workflows/chore-update-model-verification-template.yml b/.github/workflows/chore-update-model-verification-template.yml new file mode 100644 index 000000000..524260eaa --- /dev/null +++ b/.github/workflows/chore-update-model-verification-template.yml @@ -0,0 +1,119 @@ +name: 🔄 Update Model Verification Template + +# Related workflows (provider model management): +# - check-provider-models.yml (validates model definitions in PRs) +# - chore-update-provider-models.yml (fetches and updates model lists) +# - chore-update-model-verification-template.yml (updates issue template) +# - model-verification.yml (community model verification process) + +# Description: Keeps the model dropdown in the model-verification issue template +# in sync with the *ProviderModels.cs source files. Runs the +# Update-ModelVerificationTemplate.ps1 script whenever provider model +# definitions change on the default branch, and opens a PR with the result. +# +# Triggers: +# - push to main touching any *ProviderModels.cs file +# - workflow_dispatch for manual runs + +on: + push: + branches: [main] + paths: + - 'src/SmartHopper.Providers.*/[A-Z]*ProviderModels.cs' + workflow_dispatch: + +permissions: + actions: write + contents: write + pull-requests: write + +jobs: + update-template: + name: Update issue template dropdown + runs-on: windows-latest + timeout-minutes: 45 + concurrency: + group: update-model-verification-template + cancel-in-progress: true + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 1 + + - name: Run Update-ModelVerificationTemplate.ps1 + id: update + shell: pwsh + run: | + & .\tools\Update-ModelVerificationTemplate.ps1 + $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-ModelVerificationTemplate.ps1 failed with exit code $code" + } + + - name: Emit step summary + if: always() + shell: pwsh + run: | + $changed = '${{ steps.update.outputs.changed }}' + if ($changed -eq 'true') { + Write-Output "## Model verification template updated" >> $env:GITHUB_STEP_SUMMARY + Write-Output "The dropdown options in ``model-verification.yml`` were refreshed from ``*ProviderModels.cs``." >> $env:GITHUB_STEP_SUMMARY + } else { + Write-Output "## No changes needed" >> $env:GITHUB_STEP_SUMMARY + Write-Output "The dropdown options in ``model-verification.yml`` are already up to date." >> $env:GITHUB_STEP_SUMMARY + } + + - name: Create Pull Request + if: steps.update.outputs.changed == 'true' + id: create-pr + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "chore(templates): update model-verification dropdown options" + title: "chore(templates): sync model-verification template with ProviderModels" + body: | + Auto-generated PR from `.github/workflows/chore-update-model-verification-template.yml`. + + The model dropdown in `.github/ISSUE_TEMPLATE/model-verification.yml` has been + refreshed to reflect the current non-deprecated models declared in each + `*ProviderModels.cs` file. + branch: "chore/update-model-verification-template" + base: main + delete-branch: true + labels: | + automated + + - name: Assign PR to Milestone + if: steps.update.outputs.changed == 'true' && steps.create-pr.outputs.pull-request-number != '' + uses: ./.github/actions/milestone/assign-pr + with: + pr-number: ${{ steps.create-pr.outputs.pull-request-number }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Dispatch required PR checks + if: steps.update.outputs.changed == 'true' && steps.create-pr.outputs.pull-request-number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: chore/update-model-verification-template + pr-number: ${{ steps.create-pr.outputs.pull-request-number }} + base-ref: main + pr-title: "chore(templates): sync model-verification template with ProviderModels" + + - name: Label PR on failure + if: failure() && steps.create-pr.outputs.pull-request-number != '' + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ steps.create-pr.outputs.pull-request-number }}, + labels: ['needs-attention'] + }); diff --git a/.github/workflows/chore-update-provider-models.yml b/.github/workflows/chore-update-provider-models.yml new file mode 100644 index 000000000..365272fcf --- /dev/null +++ b/.github/workflows/chore-update-provider-models.yml @@ -0,0 +1,243 @@ +name: 🔄 Update Provider Models + +# Related workflows (provider model management): +# - check-provider-models.yml (validates model definitions in PRs) +# - chore-update-provider-models.yml (fetches and updates model lists) +# - chore-update-model-verification-template.yml (updates issue template) +# - model-verification.yml (community model verification process) + +# 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: + actions: write + contents: write + pull-requests: write + +jobs: + fetch-and-update: + name: ${{ matrix.provider }} + runs-on: windows-latest + timeout-minutes: 45 + concurrency: + group: provider-models-${{ matrix.provider }} + cancel-in-progress: false + strategy: + fail-fast: false + matrix: + include: + - provider: OpenAI + - provider: MistralAI + - provider: Anthropic + - provider: OpenRouter + - provider: DeepSeek + steps: + - name: Skip if secret is absent or filtered out + id: check + shell: bash + env: + API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + 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" ]; then + normalized="${FILTER// /}" + if [[ ",${normalized}," != *",${{ matrix.provider }},"* ]]; then + echo "skipped=true" >> "$GITHUB_OUTPUT" + echo "Provider ${{ matrix.provider }} not in filter '$FILTER'. Skipping." + else + echo "skipped=false" >> "$GITHUB_OUTPUT" + fi + else + echo "skipped=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout main + if: steps.check.outputs.skipped == 'false' + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 1 + + - 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.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 + # Don't fail the scheduled job on validation errors. New models picked + # up from OpenRouter often arrive with incomplete metadata (no input + # modalities, no supported_parameters), which maps to + # `AICapability.None` and trips the validator. Failing the workflow in + # that case hides the new models entirely — no PR is opened, the file + # changes are discarded, and the maintainer never sees what needs to + # be reviewed. Instead, let validation issues flow into the PR body so + # the maintainer can complete the metadata before merging. The PR-level + # `check-provider-models.yml` job still enforces validation on the PR. + fail-on-validation-errors: false + + - name: Compute validation summary + if: steps.check.outputs.skipped == 'false' && steps.models.outputs.success == 'true' + id: validation + shell: pwsh + env: + REPORT_JSON: ${{ steps.models.outputs.report }} + run: | + $ErrorActionPreference = 'Stop' + $report = $env:REPORT_JSON | ConvertFrom-Json + + $hasIssues = $false + $summaryLines = [System.Collections.Generic.List[string]]::new() + + if ($report.validation -and -not $report.validation.success) { + $hasIssues = $true + $summaryLines.Add('### Validation issues — manual review required') + $summaryLines.Add('') + + if ($report.validation.missingDefaultCapabilities.Count -gt 0) { + $summaryLines.Add('**Missing default capabilities:**') + foreach ($capability in $report.validation.missingDefaultCapabilities) { + $summaryLines.Add("- ``AICapability.$capability``") + } + $summaryLines.Add('') + } + + if ($report.validation.pendingCapabilityModels.Count -gt 0) { + $summaryLines.Add('**Models with pending capability definitions:**') + foreach ($model in $report.validation.pendingCapabilityModels) { + $summaryLines.Add("- ``$model``") + } + $summaryLines.Add('') + } + + if ($report.validation.realtimeModels.Count -gt 0) { + $summaryLines.Add('**Realtime models (should be excluded):**') + foreach ($model in $report.validation.realtimeModels) { + $summaryLines.Add("- ``$model``") + } + $summaryLines.Add('') + } + } + + $body = ($summaryLines -join "`n") + + "has-issues=$($hasIssues.ToString().ToLower())" >> $env:GITHUB_OUTPUT + "body<> $env:GITHUB_OUTPUT + "$body" >> $env:GITHUB_OUTPUT + "EOF_BODY" >> $env:GITHUB_OUTPUT + + - name: Emit step summary + if: steps.check.outputs.skipped == 'false' && steps.models.outputs.success == 'true' + shell: pwsh + env: + REPORT_JSON: ${{ steps.models.outputs.report }} + VALIDATION_BODY: ${{ steps.validation.outputs.body }} + run: | + $report = $env:REPORT_JSON | 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 + } + if (-not [string]::IsNullOrWhiteSpace($env:VALIDATION_BODY)) { + Write-Output "" >> $env:GITHUB_STEP_SUMMARY + Write-Output $env:VALIDATION_BODY >> $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' + id: create-pr + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "chore(models): update ${{ matrix.provider }} model list" + title: "chore(models): auto-add new and deprecate stale ${{ matrix.provider }} models" + body: | + Auto-generated PR from `.github/workflows/chore-update-provider-models.yml`. + + ${{ steps.validation.outputs.body }} + + ### 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 `Verified` flags for newly added models. + - Resolve any validation issues listed above before merging (the PR-level `check-provider-models` job will block the merge otherwise). + branch: "chore/update-models-${{ matrix.provider }}" + base: main + delete-branch: true + labels: | + automated + + - name: Assign PR to Milestone + if: steps.check.outputs.skipped == 'false' && steps.models.outputs.changed == 'true' && steps.create-pr.outputs.pull-request-number != '' + uses: ./.github/actions/milestone/assign-pr + with: + pr-number: ${{ steps.create-pr.outputs.pull-request-number }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Dispatch required PR checks + if: steps.check.outputs.skipped == 'false' && steps.models.outputs.changed == 'true' && steps.create-pr.outputs.pull-request-number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: chore/update-models-${{ matrix.provider }} + pr-number: ${{ steps.create-pr.outputs.pull-request-number }} + base-ref: main + pr-title: "chore(models): auto-add new and deprecate stale ${{ matrix.provider }} models" + + - name: Label PR on failure + if: failure() && steps.create-pr.outputs.pull-request-number != '' + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ steps.create-pr.outputs.pull-request-number }}, + labels: ['needs-attention'] + }); diff --git a/.github/workflows/chore-version-badge.yml b/.github/workflows/chore-version-badge.yml index 95f99a4ca..85f902974 100644 --- a/.github/workflows/chore-version-badge.yml +++ b/.github/workflows/chore-version-badge.yml @@ -1,5 +1,11 @@ name: 🔄 Update Version Badge +# Related workflows (triggered on Solution.props / version changes): +# - chore-version-date.yml (updates version date suffix on dev/release pushes) +# - chore-version-badge.yml (updates README badge on version change) +# - chore-version-main-release.yml (strips date suffix for main release PRs) +# (dev-update-manifest.yml was removed — manifest text is now resolved at build time via .manifest.json) + # Description: This workflow automatically updates the version badge in the README.md # when the version in Solution.props changes. # @@ -19,14 +25,24 @@ 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: + actions: write contents: write pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: paths-check: runs-on: ubuntu-latest + timeout-minutes: 10 outputs: version_changed: ${{ steps.filter.outputs.version }} steps: @@ -41,11 +57,12 @@ jobs: update-badge: needs: paths-check runs-on: ubuntu-latest + timeout-minutes: 10 if: > startsWith(github.ref, 'refs/heads/release/') || needs.paths-check.outputs.version_changed == 'true' steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.ref_name }} @@ -103,3 +120,25 @@ jobs: with: pr-number: ${{ steps.create-pr.outputs.pull-request-number }} token: ${{ secrets.GITHUB_TOKEN }} + + - name: Dispatch required PR checks + if: steps.update-badge.outputs.badges-changed == 'true' && steps.check-changes.outputs.has_changes == 'true' && steps.create-pr.outputs.pull-request-number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: docs/update-version-badge-${{ github.ref_name }} + pr-number: ${{ steps.create-pr.outputs.pull-request-number }} + base-ref: ${{ github.ref_name }} + pr-title: "docs: update version badge for ${{ github.ref_name }} to ${{ steps.update-badge.outputs.version }}" + + - name: Label PR on failure + if: failure() && steps.create-pr.outputs.pull-request-number != '' + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ steps.create-pr.outputs.pull-request-number }}, + labels: ['needs-attention'] + }); diff --git a/.github/workflows/chore-version-date.yml b/.github/workflows/chore-version-date.yml index a6f6f54ff..260b1c549 100644 --- a/.github/workflows/chore-version-date.yml +++ b/.github/workflows/chore-version-date.yml @@ -1,5 +1,11 @@ name: 🔄 Update Version Date +# Related workflows (triggered on Solution.props / version changes): +# - chore-version-date.yml (updates version date suffix on dev/release pushes) +# - chore-version-badge.yml (updates README badge on version change) +# - chore-version-main-release.yml (strips date suffix for main release PRs) +# (dev-update-manifest.yml was removed — manifest text is now resolved at build time via .manifest.json) + # Description: This workflow automatically updates the date component # when the version includes it. It creates a PR with the updated date # to ensure the version reflects the latest changes. @@ -25,15 +31,21 @@ on: workflow_dispatch: # Allow manual triggering permissions: + actions: write contents: write pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: update-date: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.ref_name }} @@ -96,7 +108,6 @@ jobs: base: ${{ github.ref_name }} delete-branch: true labels: | - chore automated - name: Assign PR to Milestone @@ -105,3 +116,25 @@ jobs: with: pr-number: ${{ steps.create-pr.outputs.pull-request-number }} token: ${{ secrets.GITHUB_TOKEN }} + + - name: Dispatch required PR checks + if: steps.calculate-version.outputs.was-date-updated == 'true' && steps.check-changes.outputs.has_changes == 'true' && steps.create-pr.outputs.pull-request-number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: chore/update-version-date-${{ github.ref_name }} + pr-number: ${{ steps.create-pr.outputs.pull-request-number }} + base-ref: ${{ github.ref_name }} + pr-title: "chore: update development version date to ${{ steps.calculate-version.outputs.new-version }}" + + - name: Label PR on failure + if: failure() && steps.create-pr.outputs.pull-request-number != '' + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ steps.create-pr.outputs.pull-request-number }}, + labels: ['needs-attention'] + }); diff --git a/.github/workflows/chore-version-main-release.yml b/.github/workflows/chore-version-main-release.yml index 41dff5d7a..60288b9b7 100644 --- a/.github/workflows/chore-version-main-release.yml +++ b/.github/workflows/chore-version-main-release.yml @@ -1,5 +1,11 @@ name: 🔄 Remove Release Version Date +# Related workflows (triggered on Solution.props / version changes): +# - chore-version-date.yml (updates version date suffix on dev/release pushes) +# - chore-version-badge.yml (updates README badge on version change) +# - chore-version-main-release.yml (strips date suffix for main release PRs) +# (dev-update-manifest.yml was removed — manifest text is now resolved at build time via .manifest.json) + # Description: Automatically strips the date component from the version in Solution.props # when a pull request targets the main branch. Commits the updated version back to the PR branch. @@ -11,16 +17,22 @@ on: workflow_dispatch: permissions: + actions: write 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 runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout PR branch - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} @@ -72,7 +84,6 @@ jobs: base: ${{ github.event.pull_request.head.ref }} delete-branch: true labels: | - chore automated - name: Assign PR to Milestone @@ -81,3 +92,25 @@ jobs: with: pr-number: ${{ steps.create-pr.outputs.pull-request-number }} token: ${{ secrets.GITHUB_TOKEN }} + + - name: Dispatch required PR checks + if: steps.strip-date.outputs.new-version != steps.get-version.outputs.version && steps.create-pr.outputs.pull-request-number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: chore/strip-version-date-${{ github.event.pull_request.head.ref }} + pr-number: ${{ steps.create-pr.outputs.pull-request-number }} + base-ref: ${{ github.event.pull_request.head.ref }} + pr-title: "chore: remove date from version for main release" + + - name: Label PR on failure + if: failure() && steps.create-pr.outputs.pull-request-number != '' + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ steps.create-pr.outputs.pull-request-number }}, + labels: ['needs-attention'] + }); diff --git a/.github/workflows/ci-dotnet-tests.yml b/.github/workflows/ci-dotnet-tests.yml index afaee7a34..8cb75a0df 100644 --- a/.github/workflows/ci-dotnet-tests.yml +++ b/.github/workflows/ci-dotnet-tests.yml @@ -1,5 +1,14 @@ name: 🧪 .NET CI +# Related workflows (PR validation suite): +# - pr-validation.yml (version, code style, changelog, title checks) +# - pr-license-headers.yml (license header compliance) +# - pr-build-hash-validation.yml (build hash integrity) +# - pr-version-validation.yml (version consistency) +# - pr-dependency-validation.yml (dependency checks) +# (pr-manifest-validation.yml was removed — manifest text uses {{NOTE_TEXT}} placeholder resolved at build time) +# - ci-dotnet-tests.yml (.NET build and test) + # Description: This workflow runs .NET tests on the SmartHopper solution to ensure that changes do not break functionality. # # Flow: @@ -27,16 +36,35 @@ on: - 'dev-*' - hotfix/** - release/** + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to validate. + required: false + type: string + base_ref: + description: Pull request base branch. + required: false + type: string + pr_title: + description: Pull request title. + required: false + type: string 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 prep: runs-on: windows-latest + timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@v4 @@ -52,10 +80,10 @@ jobs: if (-not (Test-Path signing.snk)) { if ("${{ secrets.SIGNING_SNK_BASE64 }}" -ne "") { Write-Host "Decoding signing.snk from Base64 secret" - pwsh tools/Sign-StrongNames.ps1 -Base64 "${{ secrets.SIGNING_SNK_BASE64 }}" + & tools/Sign-StrongNames.ps1 -Base64 "${{ secrets.SIGNING_SNK_BASE64 }}" } else { Write-Host "Generating signing.snk" - pwsh tools/Sign-StrongNames.ps1 -Generate + & tools/Sign-StrongNames.ps1 -Generate } } else { Write-Host "signing.snk already exists" @@ -65,7 +93,7 @@ jobs: shell: pwsh run: | Write-Host "Updating InternalsVisibleTo entries with public key from signing.snk" - pwsh tools/Update-InternalsVisibleTo.ps1 + & tools/Update-InternalsVisibleTo.ps1 - name: Upload pre-processed source uses: actions/upload-artifact@v4 @@ -85,6 +113,7 @@ jobs: matrix: os: [windows-latest, macos-latest] runs-on: ${{ matrix.os }} + timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@v4 @@ -126,3 +155,23 @@ jobs: # with a true net7.0 (non-Windows) target. Core.Tests and Core.Grasshopper.Tests # depend on WindowsDesktop/WinForms APIs that are not available on macOS net7.0. dotnet test src/SmartHopper.Infrastructure.Tests/SmartHopper.Infrastructure.Tests.csproj --no-build --configuration Release --framework net7.0 --results-directory TestResults --verbosity normal + + required-check: + name: 🧪 .NET CI + needs: [prep, build] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Verify required workflow jobs + shell: bash + run: | + if [[ "${{ needs.prep.result }}" != "success" ]]; then + echo "::error::Prep finished with ${{ needs.prep.result }}" + exit 1 + fi + + if [[ "${{ needs.build.result }}" != "success" ]]; then + echo "::error::Build/test matrix finished with ${{ needs.build.result }}" + exit 1 + fi diff --git a/.github/workflows/dev-update-manifest.yml b/.github/workflows/dev-update-manifest.yml deleted file mode 100644 index d51a1ec69..000000000 --- a/.github/workflows/dev-update-manifest.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: 📋 Dev - Auto Update Manifest Text - -# Description: This workflow automatically updates the manifest.yml text based on the -# version type in Solution.props. For -dev versions, it uses alpha text. For -beta -# versions, it uses beta text. For stable versions, it uses the stable text. -# -# Triggers: -# - Automatically when Solution.props is modified in dev branch -# - Automatically when a PR to dev is merged that modifies Solution.props -# -# Permissions: -# - contents:write - Required to commit manifest changes - -on: - push: - branches: - - dev - paths: - - 'Solution.props' - pull_request: - branches: - - dev - types: - - closed - paths: - - 'Solution.props' - -permissions: - contents: write - -jobs: - update-manifest: - name: 📋 Update Manifest Text for Dev Version - runs-on: ubuntu-latest - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.merged == true) - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - ref: dev - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Get version from Solution.props - 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 - - - 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 - echo "changed=true" >> $GITHUB_OUTPUT - echo "Manifest text updated" - fi - - - name: Commit and push changes - if: steps.check_changes.outputs.changed == 'true' - run: | - 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 push diff --git a/.github/workflows/github-issue-auto-label.yml b/.github/workflows/github-issue-auto-label.yml new file mode 100644 index 000000000..fe2eed06a --- /dev/null +++ b/.github/workflows/github-issue-auto-label.yml @@ -0,0 +1,38 @@ +name: 🏷️ Issue Auto-Label + +# Related workflows (issue label management): +# - github-issue-labels-close.yml (closes issue when close: label added) +# - github-issue-labels-on-close.yml (converts status→close labels on issue close) +# - github-labels-sync.yml (syncs label definitions from labels.yml) +# - issue-auto-tag.yml (auto-labels new issues with version) +# - github-issue-auto-label.yml (auto-labels issues based on content) + +# Description: Automatically labels issues based on title and body content +# using regex patterns defined in .github/issue-labeler.yml. +# Complements issue-auto-tag.yml which handles version labels specifically. +# +# Triggers: +# - issues opened or edited +# +# Permissions: +# - issues: write - Required to apply labels + +on: + issues: + types: [opened, edited] + +permissions: + issues: write + contents: read + +jobs: + label: + name: Apply content-based labels + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: github/issue-labeler@v3.4 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/issue-labeler.yml + enable-versioned-regex: 0 diff --git a/.github/workflows/github-issue-labels-close.yml b/.github/workflows/github-issue-labels-close.yml index ac3f85572..64b78f470 100644 --- a/.github/workflows/github-issue-labels-close.yml +++ b/.github/workflows/github-issue-labels-close.yml @@ -1,5 +1,11 @@ name: 🏷️ Close Issue on Close Label +# Related workflows (issue label management): +# - github-issue-labels-close.yml (closes issue when close: label added) +# - github-issue-labels-on-close.yml (converts status→close labels on issue close) +# - github-labels-sync.yml (syncs label definitions from labels.yml) +# - issue-auto-tag.yml (auto-labels new issues based on content) + # Description: This workflow automatically closes an issue when a "close:" type label is added. # # Trigger: Automatically when a label is added to an issue @@ -14,12 +20,17 @@ 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 + timeout-minutes: 5 steps: - name: Close issue if close label is added - uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 + uses: actions/github-script@v7 with: script: | const issue = context.payload.issue; diff --git a/.github/workflows/github-issue-labels-on-close.yml b/.github/workflows/github-issue-labels-on-close.yml index 83d5ead52..a1bd789d2 100644 --- a/.github/workflows/github-issue-labels-on-close.yml +++ b/.github/workflows/github-issue-labels-on-close.yml @@ -1,5 +1,11 @@ name: 🏷️ Update Issue Labels on Close +# Related workflows (issue label management): +# - github-issue-labels-close.yml (closes issue when close: label added) +# - github-issue-labels-on-close.yml (converts status→close labels on issue close) +# - github-labels-sync.yml (syncs label definitions from labels.yml) +# - issue-auto-tag.yml (auto-labels new issues based on content) + # Description: This workflow updates some issue labels when they are closed. # 1. Converts some status to close labels: # - 'status: needs more details' to 'close: lack of details' @@ -18,12 +24,17 @@ on: permissions: issues: write +concurrency: + group: issue-labels-${{ github.event.issue.number }} + cancel-in-progress: false + jobs: update-issue-labels: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Update issue labels - uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1 + uses: actions/github-script@v7 with: script: | const issue = context.payload.issue; diff --git a/.github/workflows/github-labels-sync.yml b/.github/workflows/github-labels-sync.yml index 696df8eb5..2d830ab21 100644 --- a/.github/workflows/github-labels-sync.yml +++ b/.github/workflows/github-labels-sync.yml @@ -1,5 +1,11 @@ name: 🏷️ Synchronize Repository Labels +# Related workflows (issue label management): +# - github-issue-labels-close.yml (closes issue when close: label added) +# - github-issue-labels-on-close.yml (converts status→close labels on issue close) +# - github-labels-sync.yml (syncs label definitions from labels.yml) +# - issue-auto-tag.yml (auto-labels new issues based on content) + # Description: This workflow ensures that repository labels are synchronized with # the definitions in .github/labels.yml. It maintains consistent labeling across # the repository by adding, updating, or removing labels as needed. @@ -23,9 +29,14 @@ on: permissions: issues: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: build: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/github-pr-auto-label.yml b/.github/workflows/github-pr-auto-label.yml new file mode 100644 index 000000000..d03cd0ef4 --- /dev/null +++ b/.github/workflows/github-pr-auto-label.yml @@ -0,0 +1,31 @@ +name: 🏷️ PR Auto-Label + +# Description: Automatically labels pull requests based on the files changed. +# Uses the actions/labeler action with config in .github/labeler.yml. +# +# Triggers: +# - pull_request opened or synchronized (new commits pushed) +# +# Permissions: +# - contents: read - Required to read file changes +# - pull-requests: write - Required to apply labels + +on: + pull_request: + types: [opened, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + label: + name: Apply path-based labels + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/labeler@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/labeler.yml + sync-labels: false diff --git a/.github/workflows/github-stale-management.yml b/.github/workflows/github-stale-management.yml new file mode 100644 index 000000000..482e0ff4b --- /dev/null +++ b/.github/workflows/github-stale-management.yml @@ -0,0 +1,74 @@ +name: 🕰️ Stale Issue & PR Management + +# Description: Marks issues and PRs as stale after periods of inactivity, +# then closes them if no further activity occurs. Runs weekly on Monday mornings. +# +# Policy: +# - Issues: stale after 60 days of inactivity, closed after 14 more days +# - PRs: stale after 30 days of inactivity, closed after 7 more days +# - Exempt: issues/PRs with priority:*, status: in progress, or automated labels +# +# Triggers: +# - schedule: every Monday at 6:00 UTC +# - workflow_dispatch for manual runs +# +# Permissions: +# - issues: write - Required to label and close stale issues +# - pull-requests: write - Required to label and close stale PRs + +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + name: Manage stale issues and PRs + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/stale@v9 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # Issue settings + days-before-issue-stale: 60 + days-before-issue-close: 14 + stale-issue-label: stale + stale-issue-message: > + This issue has been automatically marked as **stale** because it has + not had activity in the last 60 days. It will be closed in 14 days + if no further activity occurs. If this issue is still relevant, + please leave a comment or remove the `stale` label. + close-issue-message: > + This issue was closed automatically after being stale for 14 days + with no further activity. Feel free to reopen if it is still relevant. + + # PR settings + days-before-pr-stale: 30 + days-before-pr-close: 7 + stale-pr-label: stale + stale-pr-message: > + This pull request has been automatically marked as **stale** because + it has not had activity in the last 30 days. It will be closed in 7 + days if no further activity occurs. + close-pr-message: > + This PR was closed automatically after being stale for 7 days + with no further activity. Feel free to reopen if it is still needed. + + # Exemptions — never mark these as stale + exempt-issue-labels: > + priority: critical,priority: high,priority: medium, + status: in progress,status: blocked, + automated,model-verification + exempt-pr-labels: > + priority: critical,priority: high, + automated,needs-attention + exempt-all-milestones: true + + # Processing limits + operations-per-run: 60 diff --git a/.github/workflows/hotfix-0-new-branch.yml b/.github/workflows/hotfix-0-new-branch.yml index ec53d64b0..bf0a63a6a 100644 --- a/.github/workflows/hotfix-0-new-branch.yml +++ b/.github/workflows/hotfix-0-new-branch.yml @@ -1,5 +1,13 @@ name: 🔥 0 Create Hotfix Branch +# Related workflows (stabilization & hotfix paths): +# - stabilization-0-init.yml (creates dev-X.Y.Z and main-X.Y.Z branches) +# - stabilization-1-cancel.yml (cancels stabilization path) +# - stabilization-2-complete.yml (completes stabilization) +# - hotfix-0-new-branch.yml (creates hotfix branch from main) +# - hotfix-1-release-hotfix.yml (builds and releases hotfix) +# - main-sync-to-dev.yml (syncs main changes to dev and dev-* branches) + # Description: This workflow creates a new hotfix branch from main for emergency patches. # It calculates the next patch version and creates a hotfix/X.X.X-description branch. # @@ -23,6 +31,7 @@ permissions: jobs: create-hotfix-branch: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout main branch uses: actions/checkout@v4 diff --git a/.github/workflows/hotfix-1-release-hotfix.yml b/.github/workflows/hotfix-1-release-hotfix.yml index 4dd605709..42d0221b9 100644 --- a/.github/workflows/hotfix-1-release-hotfix.yml +++ b/.github/workflows/hotfix-1-release-hotfix.yml @@ -1,5 +1,13 @@ name: 🔥 1 Prepare Hotfix Release +# Related workflows (stabilization & hotfix paths): +# - stabilization-0-init.yml (creates dev-X.Y.Z and main-X.Y.Z branches) +# - stabilization-1-cancel.yml (cancels stabilization path) +# - stabilization-2-complete.yml (completes stabilization) +# - hotfix-0-new-branch.yml (creates hotfix branch from main) +# - hotfix-1-release-hotfix.yml (builds and releases hotfix) +# - main-sync-to-dev.yml (syncs main changes to dev and dev-* branches) + # Description: This workflow prepares a hotfix release by updating version, changelog, and badges. # It creates a release/X.X.X-hotfix-description branch with a PR to main. # @@ -15,6 +23,7 @@ on: workflow_dispatch: permissions: + actions: write contents: write issues: read pull-requests: write @@ -22,6 +31,7 @@ permissions: jobs: prepare-hotfix-release: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Validate branch name run: | @@ -317,9 +327,9 @@ jobs: This is an automated PR created by the hotfix workflow." - gh pr create --base dev --head $DEV_UPDATE_BRANCH --title "$PR_TITLE" --body "$PR_BODY" + gh pr create --base dev --head $DEV_UPDATE_BRANCH --title "$PR_TITLE" --body "$PR_BODY" 2>&1 || true - # Capture PR number + # Capture PR number (works whether PR was just created or already existed) DEV_PR_NUMBER=$(gh pr list --base dev --head $DEV_UPDATE_BRANCH --json number --jq '.[0].number') echo "dev_pr_number=$DEV_PR_NUMBER" >> $GITHUB_OUTPUT @@ -335,6 +345,16 @@ jobs: pr-number: ${{ steps.create-dev-pr.outputs.dev_pr_number }} token: ${{ secrets.GITHUB_TOKEN }} + - name: Dispatch required PR checks for dev collision PR + if: steps.check-dev.outputs.DEV_COLLISION == 'true' && steps.create-dev-pr.outputs.dev_pr_number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: chore/bump-dev-version-for-hotfix-${{ steps.extract-version.outputs.version }} + pr-number: ${{ steps.create-dev-pr.outputs.dev_pr_number }} + base-ref: dev + pr-title: "chore: bump dev version to ${{ steps.check-dev.outputs.DEV_NEW_VERSION }} (hotfix collision)" + - name: Create Pull Request to main id: create-pr run: | @@ -342,10 +362,14 @@ jobs: --base main \ --head ${{ steps.set-branch.outputs.release_branch }} \ --title "chore: hotfix release ${{ steps.extract-version.outputs.version }}" \ - --body $'## 🔥 Hotfix Release ${{ steps.extract-version.outputs.version }}\n\n**Description:** ${{ steps.extract-description.outputs.description }}\n\nThis PR contains a hotfix release that addresses critical issues in production.\n\n### Changes\n\n- Updated version to ${{ steps.extract-version.outputs.version }}\n- Updated CHANGELOG.md with release notes\n- Updated README badges\n\n### Release Process\n\nOnce this PR is merged to main:\n1. A GitHub Release will be created automatically (workflow: release-3-pr-to-main-closed.yml)\n2. The project will be built (workflow: release-4-build.yml)\n3. Artifacts will be uploaded to Yak (workflow: release-5-upload-yak.yml)\n\n⚠️ **Note:** After merging to main, you may want to cherry-pick or merge these changes back to dev to keep it in sync.' + --body $'## 🔥 Hotfix Release ${{ steps.extract-version.outputs.version }}\n\n**Description:** ${{ steps.extract-description.outputs.description }}\n\nThis PR contains a hotfix release that addresses critical issues in production.\n\n### Changes\n\n- Updated version to ${{ steps.extract-version.outputs.version }}\n- Updated CHANGELOG.md with release notes\n- Updated README badges\n\n### Release Process\n\nOnce this PR is merged to main:\n1. A GitHub Release will be created automatically (workflow: release-3-pr-to-main-closed.yml)\n2. The project will be built (workflow: release-4-build.yml)\n3. Artifacts will be uploaded to Yak (workflow: release-5-upload-yak.yml)\n\n⚠️ **Note:** After merging to main, you may want to cherry-pick or merge these changes back to dev to keep it in sync.' 2>&1 || true - # Capture PR number + # Capture PR number (works whether PR was just created or already existed) PR_NUMBER=$(gh pr list --base main --head ${{ steps.set-branch.outputs.release_branch }} --json number --jq '.[0].number') + if [ -z "$PR_NUMBER" ]; then + echo "::error::Failed to create or find hotfix PR" + exit 1 + fi echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -357,6 +381,16 @@ jobs: pr-number: ${{ steps.create-pr.outputs.pr_number }} token: ${{ secrets.GITHUB_TOKEN }} + - name: Dispatch required PR checks for hotfix PR + if: steps.create-pr.outputs.pr_number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ steps.set-branch.outputs.release_branch }} + pr-number: ${{ steps.create-pr.outputs.pr_number }} + base-ref: main + pr-title: "chore: hotfix release ${{ steps.extract-version.outputs.version }}" + - name: Update PR description with collision info if: steps.check-milestones.outputs.result != '' || steps.check-dev.outputs.DEV_COLLISION == 'true' run: | diff --git a/.github/workflows/issue-auto-tag.yml b/.github/workflows/issue-auto-tag.yml index 5bfc21c08..c90b5f8a3 100644 --- a/.github/workflows/issue-auto-tag.yml +++ b/.github/workflows/issue-auto-tag.yml @@ -1,5 +1,11 @@ name: 🏷️ Auto-Tag Issues with Version +# Related workflows (issue label management): +# - github-issue-labels-close.yml (closes issue when close: label added) +# - github-issue-labels-on-close.yml (converts status→close labels on issue close) +# - github-labels-sync.yml (syncs label definitions from labels.yml) +# - issue-auto-tag.yml (auto-labels new issues based on content) + # Description: Automatically tags newly created issues with the appropriate version label # based on the SmartHopper Version field from the bug report template. # @@ -20,6 +26,7 @@ permissions: jobs: auto-tag-issue: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/main-sync-to-dev.yml b/.github/workflows/main-sync-to-dev.yml new file mode 100644 index 000000000..29feca077 --- /dev/null +++ b/.github/workflows/main-sync-to-dev.yml @@ -0,0 +1,388 @@ +name: 🔁 Sync main to dev branches + +# Related workflows (stabilization & hotfix paths): +# - stabilization-0-init.yml (creates dev-X.Y.Z and main-X.Y.Z branches) +# - stabilization-1-cancel.yml (cancels stabilization path) +# - stabilization-2-complete.yml (completes stabilization) +# - hotfix-0-new-branch.yml (creates hotfix branch from main) +# - hotfix-1-release-hotfix.yml (builds and releases hotfix) +# - main-sync-to-dev.yml (syncs main changes to dev and dev-* branches) + +# Description: On changes to the `main` branch, automatically open (or reuse) +# 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 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/`, `hashes/`, or `tools/` (CI, rules, git hooks, +# provider hashes, automation scripts); +# * *modifications* (not add/rename/delete) to existing +# `src/SmartHopper.Providers.*/*ProviderModels.cs` files, so model +# verification flips, deprecations, and model-list updates propagate. +# +# Behavior per target: +# - 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: + branches: + - main + workflow_dispatch: + +permissions: + actions: write + contents: write + pull-requests: write + +jobs: + discover: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + targets: ${{ steps.discover.outputs.targets }} + count: ${{ steps.discover.outputs.count }} + steps: + - name: Discover dev and 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} target branch(es): ${JSON.stringify(targets)}`); + + sync: + needs: discover + if: needs.discover.outputs.count != '0' + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + target: ${{ fromJson(needs.discover.outputs.targets) }} + concurrency: + group: "main-sync-${{ matrix.target }}" + cancel-in-progress: false + steps: + - name: Compute sync plan for ${{ matrix.target }} + id: plan + uses: actions/github-script@v7 + with: + script: | + const base = '${{ matrix.target }}'; + const head = 'main'; + const { owner, repo } = context.repo; + + // 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/', 'tools/']; + // 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}`, + }); + 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.`); + core.setOutput('mode', 'skip'); + return; + } + + if (!isStabilization) { + core.setOutput('mode', 'direct-pr'); + core.setOutput('ahead-by', String(cmp.data.ahead_by)); + return; + } + + // 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(', ') + ); + } + + // 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' + id: 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, + }); + if (existing.data.length > 0) { + const pr = existing.data[0]; + core.info(`Reusing existing open PR #${pr.number}: ${pr.html_url}`); + core.setOutput('pr-number', String(pr.number)); + core.setOutput('pr-title', pr.title); + core.summary.addRaw(`- ♻️ Reused PR [#${pr.number}](${pr.html_url}) → \`${base}\`\n`); + await core.summary.write(); + return; + } + + const body = [ + `Automated sync of direct commits to \`main\` (e.g., workflow or hash updates) into \`${base}\`.`, + '', + `- Source: \`${head}\``, + `- Target: \`${base}\``, + `- 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.setOutput('pr-number', String(created.data.number)); + core.setOutput('pr-title', `chore: sync main → ${base}`); + core.summary.addRaw(`- ✅ Created PR [#${created.data.number}](${created.data.html_url}) → \`${base}\`\n`); + await core.summary.write(); + + - name: Checkout repository for composite action (direct-pr) + if: steps.plan.outputs.mode == 'direct-pr' && steps.direct-pr.outputs.pr-number != '' + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Dispatch required PR checks for direct sync PR + if: steps.plan.outputs.mode == 'direct-pr' && steps.direct-pr.outputs.pr-number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: main + pr-number: ${{ steps.direct-pr.outputs.pr-number }} + base-ref: ${{ matrix.target }} + pr-title: ${{ steps.direct-pr.outputs.pr-title }} + + # --- 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 + + # 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' + id: cherry-pick-pr + 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.setOutput('pr-number', String(pr.number)); + core.setOutput('pr-title', pr.title); + 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(); + 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/`, `tools/`, 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.setOutput('pr-number', String(created.data.number)); + core.setOutput('pr-title', `chore: sync main → ${base} (infra + provider models)`); + 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(); + + - name: Dispatch required PR checks for cherry-pick sync PR + if: steps.plan.outputs.mode == 'cherry-pick' && steps.apply.outputs.has-changes == 'true' && steps.cherry-pick-pr.outputs.pr-number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ steps.apply.outputs.sync-branch }} + pr-number: ${{ steps.cherry-pick-pr.outputs.pr-number }} + base-ref: ${{ matrix.target }} + pr-title: ${{ steps.cherry-pick-pr.outputs.pr-title }} + + notify-on-failure: + needs: [discover, sync] + if: failure() + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write + steps: + - name: Create failure issue + uses: actions/github-script@v7 + with: + script: | + const title = `🔴 main-sync-to-dev failed (run #${context.runNumber})`; + const body = [ + `The **Sync main to dev branches** workflow failed.`, + ``, + `**Run:** ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + `**Ref:** \`${context.ref}\``, + `**Triggered by:** ${context.actor}`, + ``, + `Please investigate the failure and re-run if needed.`, + ].join('\n'); + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + labels: ['automated', 'needs-attention'], + }); diff --git a/.github/workflows/milestone-management.yml b/.github/workflows/milestone-management.yml index 02326b290..20f04d76f 100644 --- a/.github/workflows/milestone-management.yml +++ b/.github/workflows/milestone-management.yml @@ -20,10 +20,15 @@ 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' runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout repository @@ -38,6 +43,7 @@ jobs: move-open-items: if: github.event_name == 'milestone' runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout repository diff --git a/.github/workflows/model-verification.yml b/.github/workflows/model-verification.yml new file mode 100644 index 000000000..a9f9f2960 --- /dev/null +++ b/.github/workflows/model-verification.yml @@ -0,0 +1,285 @@ +name: ✅ Model Verification + +# Related workflows (provider model management): +# - check-provider-models.yml (validates model definitions in PRs) +# - chore-update-provider-models.yml (fetches and updates model lists) +# - chore-update-model-verification-template.yml (updates issue template) +# - model-verification.yml (community model verification process) + +# 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: + actions: write + contents: write + pull-requests: write + issues: write + +concurrency: + group: model-verify-${{ github.event.issue.number || inputs.issue_number }} + cancel-in-progress: false + +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 + timeout-minutes: 45 + 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 / Model (dropdown rendered as plain text under "### Provider / Model") + const providerModelMatch = body.match(/###\s*Provider \/ Model\s*\r?\n\s*([A-Za-z0-9_\-]+)\s*\/\s*([^\r\n]+?)\s*$/m); + const versionMatch = body.match(/###\s*SmartHopper Version\s*\r?\n\s*([^\r\n]+?)\s*$/m); + if (!providerModelMatch) { + core.warning('Could not parse provider and model from issue body.'); + core.setOutput('should_promote', 'false'); + return; + } + const provider = providerModelMatch[1].trim(); + const model = providerModelMatch[2].trim(); + const version = versionMatch ? versionMatch[1].trim() : 'unknown'; + core.info(`Parsed provider='${provider}', model='${model}', version='${version}'`); + + // Check if title needs updating (it starts as just "[model verification]") + if (issue.title === "[model verification]") { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + title: `[model verification] ${provider} / ${model} (v${version})` + }); + core.info(`Updated issue title to include model and version`); + } + + // 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: | + & .\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: | + automated + + - name: Assign PR to Milestone + if: steps.tally.outputs.should_promote == 'true' && steps.promote.outputs.changed == 'true' && steps.create-pr.outputs.pull-request-number != '' + uses: ./.github/actions/milestone/assign-pr + with: + pr-number: ${{ steps.create-pr.outputs.pull-request-number }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Dispatch required PR checks + if: steps.tally.outputs.should_promote == 'true' && steps.promote.outputs.changed == 'true' && steps.create-pr.outputs.pull-request-number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: chore/verify-${{ steps.tally.outputs.provider }}-${{ steps.tally.outputs.model }} + pr-number: ${{ steps.create-pr.outputs.pull-request-number }} + base-ref: dev + pr-title: "chore(models): verify ${{ steps.tally.outputs.provider }}/${{ steps.tally.outputs.model }}" + + - 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/.github/workflows/patch-propagate.yml b/.github/workflows/patch-propagate.yml new file mode 100644 index 000000000..d678c1c81 --- /dev/null +++ b/.github/workflows/patch-propagate.yml @@ -0,0 +1,234 @@ +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: false + type: string + default: '' + auto-discover: + description: 'Automatically discover all dev and dev-* branches as targets (overrides target-branches).' + required: false + type: boolean + default: false + include-main-branches: + description: 'When auto-discover is true, also include main-* branches (for critical fixes only).' + required: false + type: boolean + default: false + exclude-branches: + description: 'Comma-separated branches to exclude from auto-discovery.' + required: false + type: string + default: '' + 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: + actions: write + contents: write + pull-requests: write + issues: write + +jobs: + resolve: + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + targets: ${{ inputs.auto-discover == true && steps.discover.outputs.targets || steps.split.outputs.targets }} + has-targets: ${{ inputs.auto-discover == true && (steps.discover.outputs.count != '0') || steps.split.outputs.has-targets == 'true' }} + steps: + - name: Discover branches + if: inputs.auto-discover == true + 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 + }); + let targets = branches + .map(b => b.name) + .filter(n => n === 'dev' || n.startsWith('dev-')); + if ('${{ inputs.include-main-branches }}' === 'true') { + const mainBranches = branches + .map(b => b.name) + .filter(n => n.startsWith('main-')); + targets = targets.concat(mainBranches); + } + // Exclude specified branches + const excludeRaw = '${{ inputs.exclude-branches }}'; + if (excludeRaw) { + const excludeSet = new Set(excludeRaw.split(',').map(b => b.trim()).filter(Boolean)); + targets = targets.filter(t => !excludeSet.has(t)); + } + // Exclude the source branch to avoid self-cherry-pick + const source = '${{ inputs.source-branch }}'; + targets = targets.filter(t => t !== source); + core.setOutput('targets', JSON.stringify(targets)); + core.setOutput('count', targets.length.toString()); + console.log(`Discovered ${targets.length} target branch(es): ${JSON.stringify(targets)}`); + + - name: Split target list + if: inputs.auto-discover != true + 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:]]*$//' \ + | awk 'NF' \ + | 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 + timeout-minutes: 10 + concurrency: + group: "patch-${{ matrix.target }}" + cancel-in-progress: false + 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" + + - name: Dispatch required PR checks + if: steps.cp.outputs.status == 'created' && steps.cp.outputs.pr-number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ steps.cp.outputs.branch-name }} + pr-number: ${{ steps.cp.outputs.pr-number }} + base-ref: ${{ matrix.target }} + pr-title: ${{ steps.cp.outputs.pr-title }} + + - name: Fail on unresolved conflicts + if: steps.cp.outputs.has-conflicts == 'true' + shell: bash + run: | + echo "::error::Cherry-pick to ${{ matrix.target }} had conflicts. PR created with conflict markers for manual resolution." + exit 1 + + summary: + needs: [resolve, propagate] + if: always() && needs.resolve.outputs.has-targets == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Aggregate results and notify on failure + uses: actions/github-script@v7 + with: + script: | + const propagateResult = '${{ needs.propagate.result }}'; + const shas = '${{ inputs.source-shas }}'; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + if (propagateResult === 'failure') { + // Create issue for manual attention + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `\ud83c\udf52 Patch propagate needs attention (run #${context.runNumber})`, + body: `One or more cherry-picks had conflicts or failures.\n\n` + + `**Run:** [#${context.runNumber}](${runUrl})\n` + + `**SHAs:** \`${shas}\`\n` + + `**Source branch:** \`${{ inputs.source-branch }}\`\n\n` + + `Please review the workflow run and resolve any draft PRs with conflict markers.`, + labels: ['automated', 'needs-attention'] + }); + console.log('Created needs-attention issue for failed propagation'); + } else { + console.log(`Propagation result: ${propagateResult} — no issue needed`); + } diff --git a/.github/workflows/pr-anonymize-public-key.yml b/.github/workflows/pr-anonymize-public-key.yml index 9746459e2..894d7b1c7 100644 --- a/.github/workflows/pr-anonymize-public-key.yml +++ b/.github/workflows/pr-anonymize-public-key.yml @@ -24,17 +24,23 @@ on: workflow_dispatch: permissions: + actions: write contents: write pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: anonymize-key: name: 🔐 Anonymize Public Key runs-on: windows-latest + timeout-minutes: 10 steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.ref }} @@ -125,11 +131,14 @@ jobs: git add src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj - # Check if there are changes to commit - if (git diff --staged --quiet) { - Write-Host "No changes to commit, skipping PR creation" + # Check if there are changes to commit (staged) + $stagedDiff = git diff --staged + if ([string]::IsNullOrWhiteSpace($stagedDiff)) { + Write-Host "No staged changes to commit, skipping PR creation" echo "changes_pushed=false" >> $env:GITHUB_OUTPUT } else { + Write-Host "Staged changes detected:" + Write-Host $stagedDiff git commit -m "chore: anonymize SmartHopperPublicKey" git push origin $ANON_BRANCH echo "changes_pushed=true" >> $env:GITHUB_OUTPUT @@ -140,7 +149,18 @@ jobs: id: create-pr shell: pwsh run: | - $PR_TITLE = "chore: anonymize SmartHopperPublicKey for ${{ steps.set-branches.outputs.target_branch }}" + $TARGET_BRANCH = "${{ steps.set-branches.outputs.target_branch }}" + $ANON_BRANCH = "${{ steps.set-branches.outputs.anon_branch }}" + + # Verify branches actually differ before creating PR + $diff = git diff "$TARGET_BRANCH..$ANON_BRANCH" + if ([string]::IsNullOrWhiteSpace($diff)) { + Write-Host "No differences between $TARGET_BRANCH and $ANON_BRANCH, deleting branch and skipping PR" + git push origin --delete $ANON_BRANCH + exit 0 + } + + $PR_TITLE = "chore: anonymize SmartHopperPublicKey for $TARGET_BRANCH" $PR_BODY = @' This PR anonymizes the SmartHopperPublicKey in SmartHopper.Infrastructure.csproj to replace any real public key with a harmless placeholder. @@ -149,10 +169,15 @@ jobs: This is an automated PR created by the Anonymize Public Key workflow. '@ - $prUrl = gh pr create --base ${{ steps.set-branches.outputs.target_branch }} --head ${{ steps.set-branches.outputs.anon_branch }} --title "$PR_TITLE" --body "$PR_BODY" + gh pr create --base ${{ steps.set-branches.outputs.target_branch }} --head ${{ steps.set-branches.outputs.anon_branch }} --title "$PR_TITLE" --body "$PR_BODY" 2>&1 | Out-Default + if ($LASTEXITCODE -ne 0) { Write-Host "gh pr create exited $LASTEXITCODE (PR may already exist)" } - # Capture PR number + # Capture PR number (works whether PR was just created or already existed) $PR_NUMBER = gh pr list --base ${{ steps.set-branches.outputs.target_branch }} --head ${{ steps.set-branches.outputs.anon_branch }} --json number --jq '.[0].number' + if (-not $PR_NUMBER) { + Write-Error "Failed to create or find anonymization PR" + exit 1 + } echo "pr_number=$PR_NUMBER" >> $env:GITHUB_OUTPUT env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -163,3 +188,25 @@ jobs: with: pr-number: ${{ steps.create-pr.outputs.pr_number }} token: ${{ secrets.GITHUB_TOKEN }} + + - name: Dispatch required PR checks + if: steps.anonymize.outputs.changes_made == 'true' && steps.create-branch.outputs.changes_pushed == 'true' && steps.create-pr.outputs.pr_number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ steps.set-branches.outputs.anon_branch }} + pr-number: ${{ steps.create-pr.outputs.pr_number }} + base-ref: ${{ steps.set-branches.outputs.target_branch }} + pr-title: "chore: anonymize SmartHopperPublicKey for ${{ steps.set-branches.outputs.target_branch }}" + + - name: Label PR on failure + if: failure() && steps.create-pr.outputs.pr_number != '' + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: ${{ steps.create-pr.outputs.pr_number }}, + labels: ['needs-attention'] + }); diff --git a/.github/workflows/pr-block-dev-to-main.yml b/.github/workflows/pr-block-dev-to-main.yml index 4b53a4743..b70bc8b86 100644 --- a/.github/workflows/pr-block-dev-to-main.yml +++ b/.github/workflows/pr-block-dev-to-main.yml @@ -20,10 +20,15 @@ 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 runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4 with: diff --git a/.github/workflows/pr-build-hash-validation.yml b/.github/workflows/pr-build-hash-validation.yml index 9ef156bb0..680c73e21 100644 --- a/.github/workflows/pr-build-hash-validation.yml +++ b/.github/workflows/pr-build-hash-validation.yml @@ -1,5 +1,14 @@ name: 🔍 PR Build & Hash Validation +# Related workflows (PR validation suite): +# - pr-validation.yml (version, code style, changelog, title checks) +# - pr-license-headers.yml (license header compliance) +# - pr-build-hash-validation.yml (build hash integrity) +# - pr-version-validation.yml (version consistency) +# - pr-dependency-validation.yml (dependency checks) +# (pr-manifest-validation.yml was removed — manifest text uses {{NOTE_TEXT}} placeholder resolved at build time) +# - ci-dotnet-tests.yml (.NET build and test) + # Description: Validates that PRs to main/dev/hotfix/release branches can build # successfully and generate valid hash manifests without conflicts. @@ -12,16 +21,35 @@ on: - 'dev-*' - 'hotfix/**' - 'release/**' + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to validate. + required: false + type: string + base_ref: + description: Pull request base branch. + required: false + type: string + pr_title: + description: Pull request title. + required: false + type: string 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: name: 🚫 Block Manual Hash Edits runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout PR Branch uses: actions/checkout@v4 @@ -32,44 +60,78 @@ jobs: shell: bash run: | echo "Checking for manual edits to hashes/ directory..." - - # Get the list of changed files in the PR - git fetch origin ${{ github.base_ref }} --depth=1 - changed_files=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) - + echo "Source of truth: main branch" + echo "" + + # Fetch both the PR base branch (for computing changed files) and main (source of truth) + BASE_REF="${{ github.event.inputs.base_ref || github.base_ref }}" + git fetch origin "$BASE_REF" --depth=1 + git fetch origin main --depth=1 + + # Get the list of changed files in the PR (relative to the PR base branch) + changed_files=$(git diff --name-only "origin/$BASE_REF"..HEAD) + echo "Changed files:" echo "$changed_files" echo "" - + # Check if any files in hashes/ were modified hash_files_changed=$(echo "$changed_files" | grep -E "^hashes/" || true) - - if [ -n "$hash_files_changed" ]; then + + if [ -z "$hash_files_changed" ]; then + echo "✅ No hash file edits detected." + exit 0 + fi + + # For each changed hash file, compare its PR content with the main branch content. + # Allowed case: file content exactly matches main (e.g. branch was updated from main + # and carries the hash commit verbatim). + # Blocked case: file content differs from main, or file does not exist on main. + mismatched_files=() + while IFS= read -r file; do + [ -z "$file" ] && continue + pr_hash=$(git rev-parse "HEAD:$file" 2>/dev/null || true) + main_hash=$(git rev-parse "origin/main:$file" 2>/dev/null || true) + + if [ -z "$main_hash" ]; then + echo " ✗ $file: does not exist on main (new hash file not allowed)" + mismatched_files+=("$file") + elif [ "$pr_hash" != "$main_hash" ]; then + echo " ✗ $file: differs from main" + mismatched_files+=("$file") + else + echo " ✓ $file: matches main exactly (allowed)" + fi + done <<< "$hash_files_changed" + + echo "" + if [ ${#mismatched_files[@]} -gt 0 ]; then echo "❌ ERROR: Manual edits detected to hash files!" echo "" - echo "The following hash files were modified in this PR:" - echo "$hash_files_changed" | while read -r file; do - echo " - $file" + echo "The following hash files differ from the main branch (source of truth):" + for f in "${mismatched_files[@]}"; do + echo " - $f" done echo "" echo "🚫 Hash files MUST NOT be manually edited." echo "" echo "Hash manifests are automatically generated by GitHub Actions during the release process." echo "If you need to update hashes:" - echo " 1. Revert your changes to the hashes/ directory" + echo " 1. Revert your changes to the hashes/ directory to match main" echo " 2. The CI will generate and commit hash files automatically when needed" echo "" echo "If you believe this is an error, please contact the maintainers." exit 1 - else - echo "✅ No manual hash file edits detected." fi + echo "✅ Hash file changes match main exactly; allowed." + # 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 prep: needs: validate-no-manual-hash-edits runs-on: windows-latest + timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@v4 @@ -119,6 +181,7 @@ jobs: - os: macos-latest platform: net7.0 runs-on: ${{ matrix.os }} + timeout-minutes: 10 steps: - name: Checkout PR Branch uses: actions/checkout@v4 @@ -238,4 +301,29 @@ jobs: exit 1 } else { Write-Host "##[notice] ✅ VALIDATION PASSED: Build successful and no hash conflicts" - } \ No newline at end of file + } + + required-check: + name: 🔍 PR Build & Hash Validation + needs: [validate-no-manual-hash-edits, prep, validate-build-and-hash] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Verify required workflow jobs + shell: bash + run: | + if [[ "${{ needs.validate-no-manual-hash-edits.result }}" != "success" ]]; then + echo "::error::Manual hash edit validation finished with ${{ needs.validate-no-manual-hash-edits.result }}" + exit 1 + fi + + if [[ "${{ needs.prep.result }}" != "success" ]]; then + echo "::error::Prep finished with ${{ needs.prep.result }}" + exit 1 + fi + + if [[ "${{ needs.validate-build-and-hash.result }}" != "success" ]]; then + echo "::error::Build/hash validation matrix finished with ${{ needs.validate-build-and-hash.result }}" + exit 1 + fi diff --git a/.github/workflows/pr-delete-auto-branches.yml b/.github/workflows/pr-delete-auto-branches.yml index 426c35c23..9afd42a3a 100644 --- a/.github/workflows/pr-delete-auto-branches.yml +++ b/.github/workflows/pr-delete-auto-branches.yml @@ -36,11 +36,20 @@ jobs: startsWith(github.event.pull_request.head.ref, 'bugfix/') || startsWith(github.event.pull_request.head.ref, 'bug/') || startsWith(github.event.pull_request.head.ref, 'fix/') || - startsWith(github.event.pull_request.head.ref, 'hash-update/') + startsWith(github.event.pull_request.head.ref, 'hash-update/') || + startsWith(github.event.pull_request.head.ref, 'patch/') runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Delete branch - uses: breningham/delete-branch@v1.0.0 - with: - branch_name: ${{ github.event.pull_request.head.ref }} - github_token: ${{ secrets.GITHUB_TOKEN }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + HTTP_CODE=$(gh api \ + -X DELETE \ + "repos/${{ github.repository }}/git/refs/heads/${BRANCH}" \ + --silent \ + 2>&1) && echo "Deleted branch ${BRANCH}" || { + echo "::notice::Branch ${BRANCH} could not be deleted (may already be gone). This is expected." + } diff --git a/.github/workflows/pr-dependency-validation.yml b/.github/workflows/pr-dependency-validation.yml index ad29b23f4..430a22320 100644 --- a/.github/workflows/pr-dependency-validation.yml +++ b/.github/workflows/pr-dependency-validation.yml @@ -1,5 +1,14 @@ name: 📦 PR - Validate GhJSON Dependencies +# Related workflows (PR validation suite): +# - pr-validation.yml (version, code style, changelog, title checks) +# - pr-license-headers.yml (license header compliance) +# - pr-build-hash-validation.yml (build hash integrity) +# - pr-version-validation.yml (version consistency) +# - pr-dependency-validation.yml (dependency checks) +# (pr-manifest-validation.yml was removed — manifest text uses {{NOTE_TEXT}} placeholder resolved at build time) +# - ci-dotnet-tests.yml (.NET build and test) + # Description: This workflow validates that GhJSON dependencies use PackageReference # instead of ProjectReference. This ensures the production build uses the published # NuGet packages rather than local project references. @@ -32,10 +41,15 @@ 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 runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout code diff --git a/.github/workflows/pr-license-headers.yml b/.github/workflows/pr-license-headers.yml new file mode 100644 index 000000000..b431be843 --- /dev/null +++ b/.github/workflows/pr-license-headers.yml @@ -0,0 +1,159 @@ +name: 📜 Update License Headers + +# Related workflows (PR validation suite): +# - pr-validation.yml (version, code style, changelog, title checks) +# - pr-license-headers.yml (license header compliance) +# - pr-build-hash-validation.yml (build hash integrity) +# - pr-version-validation.yml (version consistency) +# - pr-dependency-validation.yml (dependency checks) +# (pr-manifest-validation.yml was removed — manifest text uses {{NOTE_TEXT}} placeholder resolved at build time) +# - ci-dotnet-tests.yml (.NET build and test) + +# Description: Automatically applies LGPL license headers via tools/Update-LicenseHeaders.ps1 +# and commits any changes back to the pull request branch. +# +# Triggers: +# - Pull requests targeting any branch +# +# Behavior: +# - Runs on all PRs (including protected branches and forks). +# - If changes are needed and the branch is writable, commits and pushes them. +# - If changes are needed but the branch is protected or from a fork, +# fails the check with a clear error so the contributor can run the script locally. +# +# Permissions: +# - contents:write - Required to commit changes back to the PR branch + +on: + pull_request: + branches: + - '**' + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to validate. + required: false + type: string + base_ref: + description: Pull request base branch. + required: false + type: string + pr_title: + description: Pull request title. + required: false + type: string + +permissions: + actions: write + 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 + runs-on: windows-latest + timeout-minutes: 10 + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref || github.ref_name }} + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Check if auto-commit is possible + id: check-auto-commit + shell: pwsh + env: + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + PR_HEAD_REF: ${{ github.head_ref }} + run: | + $isFork = "$env:PR_HEAD_REPO" -ne "${{ github.repository }}" + $isProtected = "$env:PR_HEAD_REF" -match '^(main|dev)(?:$|-)' + $canAutoCommit = (-not $isFork) -and (-not $isProtected) + "can_auto_commit=$canAutoCommit" >> $env:GITHUB_OUTPUT + "is_fork=$isFork" >> $env:GITHUB_OUTPUT + "is_protected=$isProtected" >> $env:GITHUB_OUTPUT + if ($isFork) { Write-Host "::notice::PR is from a fork. License header fixes must be applied manually." } + if ($isProtected) { Write-Host "::notice::Target branch ($($env:GITHUB_HEAD_REF)) is protected. License header fixes must be applied manually." } + + - name: Configure Git + shell: pwsh + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Apply LGPL license headers + shell: pwsh + run: '& .\tools\Update-LicenseHeaders.ps1' + + - name: Check for changes + id: check-changes + shell: pwsh + run: | + git add -A + $diff = git diff --cached --name-only + if ([string]::IsNullOrWhiteSpace($diff)) { + "has_changes=false" >> $env:GITHUB_OUTPUT + Write-Host "No license header changes needed." + } else { + "has_changes=true" >> $env:GITHUB_OUTPUT + Write-Host "License header changes needed in:`n$diff" + "changed_files<> $env:GITHUB_OUTPUT + "$diff" >> $env:GITHUB_OUTPUT + "EOF" >> $env:GITHUB_OUTPUT + } + + - name: Commit and push changes + if: steps.check-changes.outputs.has_changes == 'true' && steps.check-auto-commit.outputs.can_auto_commit == 'true' + shell: pwsh + env: + PR_HEAD_REF: ${{ github.head_ref }} + run: | + Write-Host "Committing license header updates..." + git commit -m "chore(ci): update license headers" + # 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 = "$env:PR_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 "::error::Failed to push license header updates after 3 attempts. Please run 'tools/Update-LicenseHeaders.ps1' locally and commit the changes." + exit 1 + } + + - name: Dispatch required PR checks + if: steps.check-changes.outputs.has_changes == 'true' && steps.check-auto-commit.outputs.can_auto_commit == 'true' && github.event_name == 'pull_request' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ github.head_ref }} + pr-number: ${{ github.event.pull_request.number }} + base-ref: ${{ github.event.pull_request.base.ref }} + pr-title: ${{ github.event.pull_request.title }} + + - name: Fail if changes cannot be committed + if: steps.check-changes.outputs.has_changes == 'true' && steps.check-auto-commit.outputs.can_auto_commit != 'true' + shell: pwsh + run: | + $reason = @() + if ("${{ steps.check-auto-commit.outputs.is_fork }}" -eq "True") { $reason += "PR originates from a fork" } + if ("${{ steps.check-auto-commit.outputs.is_protected }}" -eq "True") { $reason += "branch '$($env:GITHUB_HEAD_REF)' is protected" } + $reasonText = $reason -join " and " + + Write-Error "::error::License header updates are required but cannot be committed automatically ($reasonText)." + Write-Error "::error::Please run 'tools/Update-LicenseHeaders.ps1' locally and commit the changes." + Write-Error "Files requiring updates:`n${{ steps.check-changes.outputs.changed_files }}" + exit 1 diff --git a/.github/workflows/pr-manifest-validation.yml b/.github/workflows/pr-manifest-validation.yml deleted file mode 100644 index ac23d71ab..000000000 --- a/.github/workflows/pr-manifest-validation.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: 📋 PR - Validate Manifest Text - -# Description: This workflow validates that the manifest text in manifest.yml -# matches the version type (alpha/beta/stable) based on the version in Solution.props. -# It blocks PRs to main if the manifest text doesn't match the version type. -# -# Triggers: -# - Automatically when a PR to main modifies manifest.yml or Solution.props -# -# Permissions: -# - contents:read - Required to read repository content - -on: - pull_request: - branches: - - main - - 'main-*' - paths: - - 'yak-package/manifest.yml' - - 'Solution.props' - -permissions: - contents: read - -jobs: - validate-manifest: - name: 📋 Validate Manifest Text Matches Version - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Get version from Solution.props - id: version - uses: ./.github/actions/versioning/get-version - - - name: Determine expected manifest text - id: expected - 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 - - - name: Extract actual manifest text - run: | - # Extract the note/description section between the description block markers - sed -n '/----/,/----/p' yak-package/manifest.yml | \ - grep -v -e '----' | \ - grep -v '^$' | \ - sed 's/^ //' | \ - tr '\n' ' ' | \ - sed 's/ */ /g' | \ - sed 's/^ *//;s/ *$//' > actual.txt - - - name: Compare manifest text - id: compare - run: | - EXPECTED=$(cat expected.txt) - ACTUAL=$(cat actual.txt) - - echo "Expected text: $EXPECTED" - echo "Actual text: $ACTUAL" - - if [ "$EXPECTED" = "$ACTUAL" ]; then - 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 "match=false" >> $GITHUB_OUTPUT - fi - - - name: Fail if manifest text doesn't match - if: steps.compare.outputs.match == 'false' - run: | - 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 "" - echo "Expected text:" - cat expected.txt - echo "" - echo "Actual text:" - cat actual.txt - echo "" - echo "Please update yak-package/manifest.yml to match the version type." - exit 1 diff --git a/.github/workflows/pr-milestone.yml b/.github/workflows/pr-milestone.yml index f432f934c..addee3aae 100644 --- a/.github/workflows/pr-milestone.yml +++ b/.github/workflows/pr-milestone.yml @@ -13,9 +13,14 @@ 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 + timeout-minutes: 5 if: github.event.pull_request.head.repo.full_name == github.repository steps: @@ -75,11 +80,13 @@ jobs: console.log('Processed version for milestone:', processedVersion); - // Find milestone with matching title - const { data: milestones } = await github.rest.issues.listMilestones({ + // Find milestone with matching title — paginate to walk through ALL + // milestones (default page size is 30, and the repo has more than that). + const milestones = await github.paginate(github.rest.issues.listMilestones, { owner: context.repo.owner, repo: context.repo.repo, - state: 'all' // Include both open and closed milestones + state: 'all', // Include both open and closed milestones + per_page: 100 }); let targetMilestone = milestones.find(milestone => milestone.title === processedVersion); @@ -103,9 +110,30 @@ jobs: console.log(`Successfully created milestone: ${targetMilestone.title}`); } catch (error) { - console.error('Error creating milestone:', error); - core.setFailed(`Failed to create milestone: ${error.message}`); - return; + // GitHub returns 422 with error code "already_exists" when a milestone + // with the same title already exists. This can happen if pagination + // missed it or another job created it concurrently — recover by + // re-listing and picking up the existing one instead of failing. + const msg = error.message || ''; + if (error.status === 422 && /already[_ ]exists/.test(msg)) { + console.log(`ℹ️ Milestone ${processedVersion} already exists — re-listing to find it`); + const refreshed = await github.paginate(github.rest.issues.listMilestones, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'all', + per_page: 100 + }); + targetMilestone = refreshed.find(m => m.title === processedVersion); + if (!targetMilestone) { + core.setFailed(`Milestone ${processedVersion} reported as already_exists but could not be found after re-listing.`); + return; + } + console.log(`Found existing milestone after re-list: ${targetMilestone.title} (${targetMilestone.state})`); + } else { + 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})`); diff --git a/.github/workflows/pr-update-changelog-issues.yml b/.github/workflows/pr-update-changelog-issues.yml index d98978b30..2cae81318 100644 --- a/.github/workflows/pr-update-changelog-issues.yml +++ b/.github/workflows/pr-update-changelog-issues.yml @@ -28,9 +28,10 @@ jobs: update-changelog: name: 📝 Update Changelog with Closed Issues runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.event.pull_request.head.ref }} diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index c89323c68..f50d9ef27 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -1,5 +1,14 @@ name: 📦 Pull Request Validation +# Related workflows (PR validation suite): +# - pr-validation.yml (version, code style, changelog, title checks) +# - pr-license-headers.yml (license header compliance) +# - pr-build-hash-validation.yml (build hash integrity) +# - pr-version-validation.yml (version consistency) +# - pr-dependency-validation.yml (dependency checks) +# (pr-manifest-validation.yml was removed — manifest text uses {{NOTE_TEXT}} placeholder resolved at build time) +# - ci-dotnet-tests.yml (.NET build and test) + # Description: This workflow validates pull requests to ensure they meet project standards. # It checks for version conflicts, validates formatting, and performs other quality checks # to maintain code integrity before merging. @@ -20,18 +29,37 @@ on: - 'dev-*' - 'hotfix/**' - 'release/**' + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to validate. + required: false + type: string + base_ref: + description: Pull request base branch. + required: false + type: string + pr_title: + description: Pull request title. + required: false + type: string 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 runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -57,6 +85,7 @@ jobs: code-style-check: name: 🖌️ Code Style Check runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 with: @@ -72,58 +101,62 @@ jobs: with: mode: check - license-headers-check: - name: 📜 License Headers Check - runs-on: windows-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Check LGPL license headers - shell: pwsh - run: | - pwsh -ExecutionPolicy Bypass -File .\tools\Update-LicenseHeaders.ps1 -Check - changelog-check: name: 📝 Changelog Check runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read pull-requests: read steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Check PR type + id: pr-type + run: | + PR_TITLE="${{ github.event.inputs.pr_title || github.event.pull_request.title || '' }}" + # Skip changelog check for non-user-facing PR types + if [[ "$PR_TITLE" =~ ^(chore|ci|style|build|revert|docs)(\(.*\))?:\ .+ ]]; then + echo "skip=true" >> $GITHUB_OUTPUT + echo "::notice::Skipping changelog check for non-user-facing PR: $PR_TITLE" + else + echo "skip=false" >> $GITHUB_OUTPUT + fi + + - uses: actions/checkout@v4 + if: steps.pr-type.outputs.skip != 'true' - uses: dorny/paths-filter@v3 + if: steps.pr-type.outputs.skip != 'true' id: filter with: + base: ${{ github.event.inputs.base_ref || github.event.pull_request.base.ref || 'main' }} + ref: ${{ github.ref }} filters: | src: - - 'src/**' - - - name: Check CHANGELOG.md is Updated - if: steps.filter.outputs.src == 'true' - uses: tj-actions/changed-files@2f7c5bfce28377bc069a65ba478de0a74aa0ca32 # v46.0.1 - id: changelog-check - with: - files: CHANGELOG.md + - 'src/SmartHopper.Components/**' + - 'src/SmartHopper.Core/**' + - 'src/SmartHopper.Core.Grasshopper/**' + - 'src/SmartHopper.Infrastructure/**' + - 'src/SmartHopper.Menu/**' + - 'src/SmartHopper.Providers.*/**' + changelog: + - 'CHANGELOG.md' - name: Validate Changelog Update - if: steps.filter.outputs.src == 'true' && steps.changelog-check.outputs.any_changed != 'true' - env: - CHANGED_FILES: ${{ steps.changelog-check.outputs.all_changed_files }} + if: steps.pr-type.outputs.skip != 'true' && steps.filter.outputs.src == 'true' && steps.filter.outputs.changelog != 'true' run: | - echo "::error file=CHANGELOG.md::CHANGELOG.md must be updated with pull request changes" - exit 1 + echo "::warning file=CHANGELOG.md::CHANGELOG.md should be updated when user-facing source files change" title-check: name: 🏷️ PR Title Style Check runs-on: ubuntu-latest + timeout-minutes: 10 permissions: pull-requests: read steps: - name: Validate PR Title Style + env: + PR_TITLE: ${{ github.event.inputs.pr_title || github.event.pull_request.title }} run: | - PR_TITLE="${{ github.event.pull_request.title }}" echo "Checking PR title: $PR_TITLE" # Valid types based on conventional commits @@ -163,3 +196,25 @@ jobs: else echo "PR title format is valid! ✅" fi + + required-check: + name: 📦 Pull Request Validation + needs: [version-check, code-style-check, changelog-check, title-check] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Verify required workflow jobs + shell: bash + run: | + for result in \ + "${{ needs.version-check.result }}" \ + "${{ needs.code-style-check.result }}" \ + "${{ needs.changelog-check.result }}" \ + "${{ needs.title-check.result }}" + do + if [[ "$result" != "success" ]]; then + echo "::error::A required PR validation job finished with $result" + exit 1 + fi + done diff --git a/.github/workflows/pr-version-validation.yml b/.github/workflows/pr-version-validation.yml index 0c3aec5f6..1a98fb8bf 100644 --- a/.github/workflows/pr-version-validation.yml +++ b/.github/workflows/pr-version-validation.yml @@ -1,5 +1,14 @@ name: 🔍 PR Version Validation +# Related workflows (PR validation suite): +# - pr-validation.yml (version, code style, changelog, title checks) +# - pr-license-headers.yml (license header compliance) +# - pr-build-hash-validation.yml (build hash integrity) +# - pr-version-validation.yml (version consistency) +# - pr-dependency-validation.yml (dependency checks) +# (pr-manifest-validation.yml was removed — manifest text uses {{NOTE_TEXT}} placeholder resolved at build time) +# - ci-dotnet-tests.yml (.NET build and test) + # Description: Prevents PRs from downgrading the version in Solution.props. # Ensures version numbers always move forward to maintain proper versioning. @@ -12,14 +21,33 @@ on: - 'dev-*' - 'hotfix/**' - 'release/**' + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to validate. + required: false + type: string + base_ref: + description: Pull request base branch. + required: false + type: string + pr_title: + description: Pull request title. + required: false + type: string 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 + timeout-minutes: 10 steps: - name: Checkout PR Branch uses: actions/checkout@v4 @@ -33,7 +61,7 @@ jobs: - name: Checkout Target Branch Solution.props shell: bash run: | - TARGET_BRANCH="${{ github.base_ref }}" + TARGET_BRANCH="${{ github.event.inputs.base_ref || github.base_ref }}" echo "Target branch: $TARGET_BRANCH" git fetch origin "$TARGET_BRANCH" git checkout "origin/$TARGET_BRANCH" -- Solution.props @@ -193,3 +221,18 @@ jobs: Write-Host "Result: $result" Write-Host "Reason: $reason" Write-Host "" + + required-check: + name: 🔍 PR Version Validation + needs: validate-version + if: always() + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Verify required workflow jobs + shell: bash + run: | + if [[ "${{ needs.validate-version.result }}" != "success" ]]; then + echo "::error::Version validation finished with ${{ needs.validate-version.result }}" + exit 1 + fi diff --git a/.github/workflows/release-1-milestone.yml b/.github/workflows/release-1-milestone.yml index f7949a4e7..f3a41fcd9 100644 --- a/.github/workflows/release-1-milestone.yml +++ b/.github/workflows/release-1-milestone.yml @@ -1,5 +1,13 @@ name: 🏁 1 Prepare Release Branch +# Related workflows (release pipeline — execute in numbered order): +# - release-1-milestone.yml (creates milestone and branches) +# - release-2-pr-to-dev-closed.yml (creates PR from dev→main on release merge) +# - release-3-pr-to-main-closed.yml (creates GitHub Release on main merge) +# - release-4-build.yml (builds platform artifacts on release publish) +# - release-5-deploy-pages.yml (deploys documentation pages) +# - release-6-upload-yak.yml (uploads to Yak package manager) + # 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. @@ -13,6 +21,7 @@ name: 🏁 1 Prepare Release Branch # - pull-requests:write - Required to create pull requests permissions: + actions: write contents: write issues: read pull-requests: write @@ -25,9 +34,14 @@ on: required: true type: string +concurrency: + group: "release-${{ inputs.milestone-title }}" + cancel-in-progress: false + jobs: release-preparation: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Detect stabilization branch id: detect-branch @@ -103,6 +117,13 @@ 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 }} + manifest-path: yak-package/manifest.yml + update-file: 'true' + - name: Include missing issues in changelog uses: ./.github/actions/documentation/update-changelog-issues with: @@ -129,7 +150,7 @@ jobs: - name: Normalize LGPL license headers shell: pwsh run: | - pwsh -ExecutionPolicy Bypass -File .\tools\Update-LicenseHeaders.ps1 + & .\tools\Update-LicenseHeaders.ps1 # - name: Fix code style # uses: ./.github/actions/code-style @@ -139,7 +160,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 }} @@ -151,10 +172,14 @@ jobs: --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' + --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' 2>&1 || true - # Capture PR number + # Capture PR number (works whether PR was just created or already existed) PR_NUMBER=$(gh pr list --base "$BASE_BRANCH" --head release/${{ inputs.milestone-title }} --json number --jq '.[0].number') + if [ -z "$PR_NUMBER" ]; then + echo "::error::Failed to create or find release PR" + exit 1 + fi echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -164,4 +189,44 @@ jobs: uses: ./.github/actions/milestone/assign-pr with: pr-number: ${{ steps.create-pr.outputs.pr_number }} - token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Dispatch required PR checks + if: steps.create-pr.outputs.pr_number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: release/${{ inputs.milestone-title }} + pr-number: ${{ steps.create-pr.outputs.pr_number }} + base-ref: ${{ steps.detect-branch.outputs.base-branch }} + pr-title: "chore: prepare release ${{ inputs.milestone-title }} with version update and code style fixes" + + notify-on-failure: + needs: [release-preparation] + if: failure() + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write + steps: + - name: Create failure issue + uses: actions/github-script@v7 + with: + script: | + const title = `🔴 release-1 (Prepare Release) failed for ${context.payload.inputs?.['milestone-title'] || 'unknown'} (run #${context.runNumber})`; + const body = [ + `The **Prepare Release Branch** workflow failed.`, + ``, + `**Run:** ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + `**Milestone:** \`${context.payload.inputs?.['milestone-title'] || 'unknown'}\``, + `**Triggered by:** ${context.actor}`, + ``, + `This is a critical release pipeline failure. Please investigate immediately.`, + ].join('\n'); + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + labels: ['automated', 'needs-attention'], + }); diff --git a/.github/workflows/release-2-pr-to-dev-closed.yml b/.github/workflows/release-2-pr-to-dev-closed.yml index 28aadd234..21f8f6e0d 100644 --- a/.github/workflows/release-2-pr-to-dev-closed.yml +++ b/.github/workflows/release-2-pr-to-dev-closed.yml @@ -1,5 +1,13 @@ name: 🏁 2 PR Release to Main from Dev +# Related workflows (release pipeline — execute in numbered order): +# - release-1-milestone.yml (creates milestone and branches) +# - release-2-pr-to-dev-closed.yml (creates PR from dev→main on release merge) +# - release-3-pr-to-main-closed.yml (creates GitHub Release on main merge) +# - release-4-build.yml (builds platform artifacts on release publish) +# - release-5-deploy-pages.yml (deploys documentation pages) +# - release-6-upload-yak.yml (uploads to Yak package manager) + # 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. # @@ -26,11 +34,23 @@ on: required: true type: string +permissions: + actions: write + contents: write + issues: read + pull-requests: write + +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' }} runs-on: ubuntu-latest + timeout-minutes: 10 permissions: + actions: write contents: write issues: read pull-requests: write @@ -38,6 +58,10 @@ jobs: - name: Create PR (on PR closed) if: ${{ github.event_name == 'pull_request' }} id: create-pr-event + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SOURCE_PR_TITLE: ${{ github.event.pull_request.head.ref }} + SOURCE_PR_BODY: ${{ github.event.pull_request.body }} run: | DEV_BRANCH="${{ github.event.pull_request.base.ref }}" # If merging into dev-X.Y.Z, target main-X.Y.Z; otherwise target main @@ -48,19 +72,21 @@ jobs: TARGET_BRANCH="main" fi MAIN_HEAD="$DEV_BRANCH" + echo "target_branch=$TARGET_BRANCH" >> $GITHUB_OUTPUT gh pr create \ --repo ${{ github.repository }} \ --base "$TARGET_BRANCH" \ --head "$MAIN_HEAD" \ - --title "${{ github.event.pull_request.head.ref }}" \ - --body "${{ github.event.pull_request.body }}" + --title "$SOURCE_PR_TITLE" \ + --body "$SOURCE_PR_BODY" 2>&1 || true - # Capture PR number - PR_NUMBER=$(gh pr list --base "$TARGET_BRANCH" --head "$MAIN_HEAD" --json number --jq '.[0].number') + # Capture PR number (works whether PR was just created or already existed) + PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --base "$TARGET_BRANCH" --head "$MAIN_HEAD" --json number --jq '.[0].number') + if [ -z "$PR_NUMBER" ]; then + echo "::error::Failed to create or find PR" + exit 1 + fi echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Assign PR to Milestone (on PR closed) if: ${{ github.event_name == 'pull_request' && steps.create-pr-event.outputs.pr_number != '' }} uses: ./.github/actions/milestone/assign-pr @@ -68,6 +94,16 @@ jobs: pr-number: ${{ steps.create-pr-event.outputs.pr_number }} token: ${{ secrets.GITHUB_TOKEN }} + - name: Dispatch required PR checks (on PR closed) + if: ${{ github.event_name == 'pull_request' && steps.create-pr-event.outputs.pr_number != '' }} + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ github.event.pull_request.base.ref }} + pr-number: ${{ steps.create-pr-event.outputs.pr_number }} + base-ref: ${{ steps.create-pr-event.outputs.target_branch }} + pr-title: ${{ github.event.pull_request.head.ref }} + - name: Create PR (manual dispatch) if: ${{ github.event_name == 'workflow_dispatch' }} id: create-pr-dispatch @@ -77,10 +113,14 @@ jobs: --base main \ --head dev \ --title "${{ github.event.inputs['pr-title'] }}" \ - --body "${{ github.event.inputs['pr-body'] }}" + --body "${{ github.event.inputs['pr-body'] }}" 2>&1 || true - # Capture PR number (manual dispatch always targets main) - PR_NUMBER=$(gh pr list --base main --head dev --json number --jq '.[0].number') + # Capture PR number (works whether PR was just created or already existed) + PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --base main --head dev --json number --jq '.[0].number') + if [ -z "$PR_NUMBER" ]; then + echo "::error::Failed to create or find PR" + exit 1 + fi echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -91,3 +131,13 @@ jobs: with: pr-number: ${{ steps.create-pr-dispatch.outputs.pr_number }} token: ${{ secrets.GITHUB_TOKEN }} + + - name: Dispatch required PR checks (manual dispatch) + if: ${{ github.event_name == 'workflow_dispatch' && steps.create-pr-dispatch.outputs.pr_number != '' }} + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: dev + pr-number: ${{ steps.create-pr-dispatch.outputs.pr_number }} + base-ref: main + pr-title: ${{ github.event.inputs['pr-title'] }} diff --git a/.github/workflows/release-3-pr-to-main-closed.yml b/.github/workflows/release-3-pr-to-main-closed.yml index c06826b59..8373cb488 100644 --- a/.github/workflows/release-3-pr-to-main-closed.yml +++ b/.github/workflows/release-3-pr-to-main-closed.yml @@ -1,5 +1,13 @@ name: 🏁 3 Create Release on Release PR Close +# Related workflows (release pipeline — execute in numbered order): +# - release-1-milestone.yml (creates milestone and branches) +# - release-2-pr-to-dev-closed.yml (creates PR from dev→main on release merge) +# - release-3-pr-to-main-closed.yml (creates GitHub Release on main merge) +# - release-4-build.yml (builds platform artifacts on release publish) +# - release-5-deploy-pages.yml (deploys documentation pages) +# - release-6-upload-yak.yml (uploads to Yak package manager) + # 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. @@ -21,11 +29,25 @@ 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' }} + # Only run for release/hotfix/dev-* source branches (not chore/docs PRs) + if: | + (github.event_name == 'workflow_dispatch') || + (github.event.pull_request.merged == true && ( + startsWith(github.event.pull_request.head.ref, 'release/') || + startsWith(github.event.pull_request.head.ref, 'hotfix/') || + startsWith(github.event.pull_request.head.ref, 'dev-') || + startsWith(github.event.pull_request.head.ref, 'dev') + )) runs-on: ubuntu-latest + timeout-minutes: 10 permissions: + actions: write contents: write issues: read pull-requests: read @@ -49,7 +71,15 @@ jobs: with: branch: ${{ steps.target-branch.outputs.branch }} + - name: Abort if dev version + id: dev_check + if: steps.release_info.outputs.stage == 'dev' + run: | + echo "::notice::Version ${{ steps.release_info.outputs.version }} is a dev build. Skipping release creation." + echo "skip=true" >> $GITHUB_OUTPUT + - name: Generate Release Notes + if: steps.dev_check.outputs.skip != 'true' id: issues uses: actions/github-script@v7 env: @@ -68,6 +98,7 @@ jobs: await core.setOutput('changelog', releaseNotes.data.body); - name: Check Prerelease Status + if: steps.dev_check.outputs.skip != 'true' id: prerelease run: | title="${{ steps.release_info.outputs.version }}" @@ -77,17 +108,142 @@ jobs: echo "IS_PRERELEASE=false" >> $GITHUB_ENV fi + - name: Check if Release Exists and is Not Prerelease + if: steps.dev_check.outputs.skip != 'true' + 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: Generate AI Release Notes + if: steps.dev_check.outputs.skip != 'true' + id: ai_release_notes + uses: ./.github/actions/documentation/generate-release-notes + with: + version: ${{ steps.release_info.outputs.version }} + major: ${{ steps.release_info.outputs.major }} + minor: ${{ steps.release_info.outputs.minor }} + patch: ${{ steps.release_info.outputs.patch }} + suffix: ${{ steps.release_info.outputs.suffix }} + mistral-api-key: ${{ secrets.MISTRAL_API_KEY }} + - name: Create Release + if: steps.dev_check.outputs.skip != 'true' uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ steps.release_info.outputs.version }} - name: "SmartHopper ${{ steps.release_info.outputs.version }}: " + name: ${{ steps.ai_release_notes.outputs.title }} body: | - ${{ github.event.pull_request.body }} + ${{ steps.ai_release_notes.outputs.body }} + + --- + +
Auto-generated release notes ${{ steps.issues.outputs.changelog }} + +
draft: true prerelease: ${{ env.IS_PRERELEASE }} target_commitish: ${{ steps.target-branch.outputs.branch }} + + - name: Update Issue Template Version Dropdown + if: steps.dev_check.outputs.skip != 'true' + id: update_template + shell: pwsh + run: | + # The softprops action above doesn't fetch tags immediately into the local workspace, + # but the new tag exists remotely. We fetch it first. + git fetch --tags + & .\tools\Update-VersionVerificationTemplate.ps1 -MinMajor 1 + $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-VersionVerificationTemplate.ps1 failed with exit code $code" + } + + - name: Create Pull Request for Template Update + if: steps.dev_check.outputs.skip != 'true' && steps.update_template.outputs.changed == 'true' + id: create-pr + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "chore(templates): add ${{ steps.release_info.outputs.version }} to model-verification template" + title: "chore(templates): add ${{ steps.release_info.outputs.version }} to model-verification dropdown" + body: | + Auto-generated PR from `.github/workflows/release-3-pr-to-main-closed.yml`. + + Adds the newly released version `${{ steps.release_info.outputs.version }}` to the version dropdown in the model verification issue template. + branch: "chore/update-version-dropdown-${{ steps.release_info.outputs.version }}" + base: ${{ steps.target-branch.outputs.branch }} + delete-branch: true + labels: | + automated + + - name: Dispatch required PR checks + if: steps.dev_check.outputs.skip != 'true' && steps.update_template.outputs.changed == 'true' && steps.create-pr.outputs.pull-request-number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: chore/update-version-dropdown-${{ steps.release_info.outputs.version }} + pr-number: ${{ steps.create-pr.outputs.pull-request-number }} + base-ref: ${{ steps.target-branch.outputs.branch }} + pr-title: "chore(templates): add ${{ steps.release_info.outputs.version }} to model-verification dropdown" + + notify-on-failure: + needs: [create-release] + if: failure() + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write + steps: + - name: Create failure issue + uses: actions/github-script@v7 + with: + script: | + const title = `🔴 release-3 (Create Release) failed (run #${context.runNumber})`; + const body = [ + `The **Create Release on Release PR Close** workflow failed.`, + ``, + `**Run:** ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + `**Ref:** \`${context.ref}\``, + `**Triggered by:** ${context.actor}`, + ``, + `This is a critical release pipeline failure. Please investigate immediately.`, + ].join('\n'); + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + labels: ['automated', 'needs-attention'], + }); diff --git a/.github/workflows/release-4-build.yml b/.github/workflows/release-4-build.yml index 5e8e9e716..86995bf04 100644 --- a/.github/workflows/release-4-build.yml +++ b/.github/workflows/release-4-build.yml @@ -1,5 +1,13 @@ name: 🏁 4 Build Project (Dual Platform) +# Related workflows (release pipeline — execute in numbered order): +# - release-1-milestone.yml (creates milestone and branches) +# - release-2-pr-to-dev-closed.yml (creates PR from dev→main on release merge) +# - release-3-pr-to-main-closed.yml (creates GitHub Release on main merge) +# - release-4-build.yml (builds platform artifacts on release publish) +# - release-5-deploy-pages.yml (deploys documentation pages) +# - release-6-upload-yak.yml (uploads to Yak package manager) + # Description: This workflow builds the project on both Windows and macOS runners # to ensure native compilation and accurate SHA-256 hash generation for each platform. # @@ -33,17 +41,23 @@ on: types: [published] permissions: + actions: write contents: write issues: write pages: write 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 prep: runs-on: windows-latest + timeout-minutes: 45 outputs: version: ${{ steps.determine_version.outputs.VERSION }} is_release: ${{ steps.determine_version.outputs.IS_RELEASE }} @@ -162,11 +176,13 @@ jobs: build-windows: needs: prep runs-on: windows-latest + timeout-minutes: 45 steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ needs.prep.outputs.version }} - name: Download pre-processed source uses: actions/download-artifact@v4 @@ -209,11 +225,13 @@ jobs: build-macos: needs: prep runs-on: macos-latest + timeout-minutes: 45 steps: - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 + ref: ${{ needs.prep.outputs.version }} - name: Download pre-processed source uses: actions/download-artifact@v4 @@ -254,6 +272,7 @@ jobs: merge-and-release: needs: [prep, build-windows, build-macos] runs-on: windows-latest + timeout-minutes: 45 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -399,7 +418,7 @@ jobs: git fetch origin main # Create a temporary branch from main - $tempBranch = "hash-update-$version-${{ github.run_number }}" + $tempBranch = "hash-update/$version-${{ github.run_number }}" git checkout -b $tempBranch origin/main # Copy the hash file @@ -420,15 +439,16 @@ jobs: Write-Host "Creating PR to merge hash file to main..." $prBody = "Auto-generated PR to add provider hash manifest for version $version (native builds on Windows and macOS).`n`n**Hash File:** \`hashes/$version.json\``n**Build:** #${{ github.run_number }}`n**Platforms:** net7.0-windows (Windows runner), net7.0 (macOS runner)" - gh pr create --base main --head $tempBranch --title "chore: add provider hash manifest for $version" --body $prBody + gh pr create --base main --head $tempBranch --title "chore: add provider hash manifest for $version" --body $prBody 2>&1 | Out-Default + if ($LASTEXITCODE -ne 0) { Write-Host "gh pr create exited $LASTEXITCODE (PR may already exist)" } + # Capture PR number (works whether PR was just created or already existed) $prNumber = gh pr list --head $tempBranch --json number --jq '.[0].number' - - # Assign PR to milestone before auto-merge - if ($prNumber) { - # Output PR number for next step - echo "hash_pr_number=$prNumber" >> $env:GITHUB_OUTPUT + if (-not $prNumber) { + Write-Error "Failed to create or find hash PR" + exit 1 } + echo "hash_pr_number=$prNumber" >> $env:GITHUB_OUTPUT } id: hash-pr env: @@ -440,6 +460,17 @@ jobs: with: pr-number: ${{ steps.hash-pr.outputs.hash_pr_number }} token: ${{ secrets.GITHUB_TOKEN }} + version: ${{ needs.prep.outputs.version }} + + - name: Dispatch required PR checks for hash PR + if: needs.prep.outputs.is_release == 'true' && steps.hash-pr.outputs.hash_pr_number != '' + uses: ./.github/actions/dispatch-required-pr-checks + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: hash-update/${{ needs.prep.outputs.version }}-${{ github.run_number }} + pr-number: ${{ steps.hash-pr.outputs.hash_pr_number }} + base-ref: main + pr-title: "chore: add provider hash manifest for ${{ needs.prep.outputs.version }}" - name: Auto-merge Hash PR if: needs.prep.outputs.is_release == 'true' && steps.hash-pr.outputs.hash_pr_number != '' @@ -447,7 +478,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 }} @@ -497,6 +528,7 @@ jobs: needs: [merge-and-release] if: github.event_name == 'release' && !github.event.release.prerelease runs-on: ubuntu-latest + timeout-minutes: 45 permissions: actions: write contents: read diff --git a/.github/workflows/release-5-deploy-pages.yml b/.github/workflows/release-5-deploy-pages.yml index 3c9d2487a..af5faeaaa 100644 --- a/.github/workflows/release-5-deploy-pages.yml +++ b/.github/workflows/release-5-deploy-pages.yml @@ -1,12 +1,20 @@ name: 🏁 5 Deploy GitHub Pages on Hash PR Merge -# Description: This workflow deploys GitHub Pages after a hash manifest PR is merged to main. +# Related workflows (release pipeline — execute in numbered order): +# - release-1-milestone.yml (creates milestone and branches) +# - release-2-pr-to-dev-closed.yml (creates PR from dev→main on release merge) +# - release-3-pr-to-main-closed.yml (creates GitHub Release on main merge) +# - release-4-build.yml (builds platform artifacts on release publish) +# - release-5-deploy-pages.yml (deploys documentation pages) +# - release-6-upload-yak.yml (uploads to Yak package manager) + +# 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 +22,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: @@ -30,14 +39,16 @@ permissions: pages: write id-token: write +concurrency: + group: pages + cancel-in-progress: true + 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 + timeout-minutes: 45 environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/release-6-upload-yak.yml b/.github/workflows/release-6-upload-yak.yml index be27258a4..7ccbc17de 100644 --- a/.github/workflows/release-6-upload-yak.yml +++ b/.github/workflows/release-6-upload-yak.yml @@ -1,5 +1,13 @@ name: 🚀 6 Upload to Yak Rhino Server +# Related workflows (release pipeline — execute in numbered order): +# - release-1-milestone.yml (creates milestone and branches) +# - release-2-pr-to-dev-closed.yml (creates PR from dev→main on release merge) +# - release-3-pr-to-main-closed.yml (creates GitHub Release on main merge) +# - release-4-build.yml (builds platform artifacts on release publish) +# - release-5-deploy-pages.yml (deploys documentation pages) +# - release-6-upload-yak.yml (uploads to Yak package manager) + # Description: This workflow uploads the release artifacts to the Yak Rhino Server. # It must be triggered manually after the release is built. # @@ -49,6 +57,7 @@ jobs: upload-to-yak: name: Upload to Yak runs-on: windows-latest + timeout-minutes: 45 permissions: contents: read @@ -80,6 +89,12 @@ jobs: } echo "version=$version" >> $env:GITHUB_OUTPUT + - name: Update manifest text for version + uses: ./.github/actions/versioning/update-manifest-text + with: + version: ${{ steps.get_version.outputs.version }} + update-file: 'true' + - name: Create version label if doesn't exist if: inputs.upload_to_yak == 'true' uses: ./.github/actions/versioning/create-version-label diff --git a/.github/workflows/release-promotion.yml b/.github/workflows/release-promotion.yml index eac4d0bf1..6033fcedf 100644 --- a/.github/workflows/release-promotion.yml +++ b/.github/workflows/release-promotion.yml @@ -43,6 +43,7 @@ permissions: jobs: determine-versions-to-check: runs-on: ubuntu-latest + timeout-minutes: 10 outputs: versions: ${{ steps.get-versions.outputs.versions }} has-versions: ${{ steps.get-versions.outputs.has_versions }} @@ -77,14 +78,14 @@ jobs: versionsToCheck = [specificVersion]; } else { // Discover active stabilization paths via open no-suffix milestones - const [openMilestones, { data: releases }] = await Promise.all([ + const [openMilestones, 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({ + github.paginate(github.rest.repos.listReleases, { owner: context.repo.owner, repo: context.repo.repo, per_page: 100 @@ -115,6 +116,12 @@ jobs: } } + // Skip if stable version already exists (milestone still open after release) + if (releaseTags.has(base)) { + console.log(`Stabilization path ${base}: stable release ${base} already exists, skipping.`); + continue; + } + if (currentStaged) { versionsToCheck.push(currentStaged); } else { @@ -131,6 +138,10 @@ jobs: needs: determine-versions-to-check if: needs.determine-versions-to-check.outputs.has-versions == 'true' runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: "promotion-${{ matrix.version }}" + cancel-in-progress: false strategy: matrix: version: ${{ fromJson(needs.determine-versions-to-check.outputs.versions) }} @@ -149,6 +160,33 @@ jobs: days-lookback: '30' token: ${{ secrets.GITHUB_TOKEN }} + - name: Check for promotion freeze + id: check-freeze + uses: actions/github-script@v7 + with: + script: | + const version = '${{ matrix.version }}'; + const match = version.match(/^(\d+\.\d+\.\d+)/); + const baseVersion = match ? match[1] : version; + + // Check if any open issue has both a "promotion: freeze" label and a version label for this base version + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + labels: 'promotion: freeze', + state: 'open', + per_page: 100 + }); + const frozen = issues.some(i => + i.labels.some(l => l.name === `version: ${baseVersion}`) + ); + core.setOutput('frozen', frozen.toString()); + if (frozen) { + console.log(`\u26d4 Promotion frozen for ${baseVersion} \u2014 found open issue with 'promotion: freeze' label`); + } else { + console.log(`\u2705 No promotion freeze for ${baseVersion}`); + } + - name: Determine if promotion needed id: should-promote shell: pwsh @@ -157,6 +195,7 @@ jobs: $forcePromote = "${{ github.event.inputs.force-promote }}" -eq "true" $blockingReason = "${{ steps.check-issues.outputs.blocking-reason }}" $version = "${{ matrix.version }}" + $frozen = "${{ steps.check-freeze.outputs.frozen }}" -eq "true" Write-Host "=== Promotion Decision for $version ===" Write-Host "" @@ -168,9 +207,17 @@ jobs: if (-not $canPromote) { Write-Host " • Blocking reason: $blockingReason" } + Write-Host " • Promotion frozen: $frozen" Write-Host " • Force promote: $forcePromote" Write-Host "" + if ($frozen -and -not $forcePromote) { + Write-Host "\u26d4 PROMOTION FROZEN - A 'promotion: freeze' label is active for this version" + Add-Content -Path $env:GITHUB_OUTPUT -Value "should-promote=false" + Add-Content -Path $env:GITHUB_OUTPUT -Value "reason=promotion_frozen" + return + } + if ($forcePromote) { Write-Host "⚠️ FORCE PROMOTION ENABLED - Bypassing all validation checks" Add-Content -Path $env:GITHUB_OUTPUT -Value "should-promote=true" @@ -232,7 +279,11 @@ jobs: }); console.log(`✅ Created milestone: ${nextVersion}`); } catch (e) { - if (e.message.includes('already exists')) { + // GitHub returns 422 with error code "already_exists" when a milestone + // with the same title already exists. Older Octokit versions surfaced + // this as "already exists" in the message — handle both forms. + const msg = e.message || ''; + if (e.status === 422 && /already[_ ]exists/.test(msg)) { console.log(`ℹ️ Milestone ${nextVersion} already exists`); } else { throw e; @@ -303,6 +354,7 @@ jobs: 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 "| **Promotion Freeze** | ${{ steps.check-freeze.outputs.frozen == 'false' && '✅ Pass' || '⛔ Frozen' }} | freeze label active: ${{ steps.check-freeze.outputs.frozen }} |" echo "" echo "### Decision" echo "" @@ -324,3 +376,53 @@ jobs: echo "" echo "Version ${{ matrix.version }} cannot be promoted at this time." fi + + - name: Create or update promotion-blocked issue + if: | + steps.should-promote.outputs.should-promote == 'false' && + steps.check-issues.outputs.release-old-enough == 'true' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const version = '${{ matrix.version }}'; + const reason = '${{ steps.should-promote.outputs.reason }}'; + const title = `\u26d4 Promotion blocked: ${version}`; + + // Check if issue already exists + const existing = await github.rest.search.issuesAndPullRequests({ + q: `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open "${title}" in:title` + }); + + if (existing.data.total_count === 0) { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: `## Promotion Blocked\n\n` + + `**Version:** \`${version}\`\n` + + `**Reason:** ${reason}\n` + + `**Release age:** ${{ steps.check-issues.outputs.release-age-days }} days\n` + + `**Open issues:** ${{ steps.check-issues.outputs.issue-count }}\n` + + `**Last closed issue age:** ${{ steps.check-issues.outputs.last-closed-age-days }} days\n\n` + + `This issue was auto-created because the release is past the 30-day window but cannot be promoted.\n\n` + + `### What to do\n` + + `- Resolve the blocking condition (close open issues, wait for cooldown, etc.)\n` + + `- Or add a \`promotion: freeze\` label to intentionally pause promotion\n` + + `- Promotion will proceed automatically on the next daily run once conditions are met\n` + + `- This issue will NOT be auto-closed \u2014 close it manually once resolved`, + labels: ['automated', 'promotion: blocked'] + }); + console.log(`Created promotion-blocked issue for ${version}`); + } else { + // Update the existing issue body with latest stats + const issue = existing.data.items[0]; + const updateBody = issue.body + `\n\n---\n**Update (${new Date().toISOString().split('T')[0]}):** Still blocked. Reason: ${reason}. Release age: ${{ steps.check-issues.outputs.release-age-days }}d. Open issues: ${{ steps.check-issues.outputs.issue-count }}.`; + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: updateBody + }); + console.log(`Updated existing promotion-blocked issue #${issue.number} for ${version}`); + } diff --git a/.github/workflows/stabilization-0-init.yml b/.github/workflows/stabilization-0-init.yml index afec5ab2c..ba2f60c8e 100644 --- a/.github/workflows/stabilization-0-init.yml +++ b/.github/workflows/stabilization-0-init.yml @@ -1,5 +1,13 @@ name: Stabilization 0 - 🌿 Init Path +# Related workflows (stabilization & hotfix paths): +# - stabilization-0-init.yml (creates dev-X.Y.Z and main-X.Y.Z branches) +# - stabilization-1-cancel.yml (cancels stabilization path) +# - stabilization-2-complete.yml (completes stabilization) +# - hotfix-0-new-branch.yml (creates hotfix branch from main) +# - hotfix-1-release-hotfix.yml (builds and releases hotfix) +# - main-sync-to-dev.yml (syncs main changes to dev and dev-* branches) + # 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. # @@ -17,9 +25,14 @@ on: permissions: contents: read +concurrency: + group: stabilization-init-${{ github.event.milestone.number }} + cancel-in-progress: false + jobs: init-stabilization-path: runs-on: ubuntu-latest + timeout-minutes: 10 if: ${{ github.event_name == 'milestone' && !contains(github.event.milestone.title, '-') && github.event.milestone.title != '' }} steps: - name: Validate milestone title format @@ -98,14 +111,25 @@ jobs: 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}`); + + // Dereference annotated tags to get the commit SHA + if (tagRef.data.object.type === 'tag') { + const tagObj = await github.rest.git.getTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_sha: tagRef.data.object.sha + }); + sha = tagObj.data.object.sha; + console.log(`Tag ${tag} is annotated, dereferenced to commit SHA: ${sha}`); + } else { + sha = tagRef.data.object.sha; + console.log(`Tag ${tag} is lightweight, points to SHA: ${sha}`); + } } else { // Get SHA from main branch const branchRef = await github.rest.git.getRef({ diff --git a/.github/workflows/stabilization-1-cancel.yml b/.github/workflows/stabilization-1-cancel.yml index c36660492..fbb4f9cff 100644 --- a/.github/workflows/stabilization-1-cancel.yml +++ b/.github/workflows/stabilization-1-cancel.yml @@ -1,5 +1,13 @@ name: Stabilization 1 - 🛑 Cancel Path +# Related workflows (stabilization & hotfix paths): +# - stabilization-0-init.yml (creates dev-X.Y.Z and main-X.Y.Z branches) +# - stabilization-1-cancel.yml (cancels stabilization path) +# - stabilization-2-complete.yml (completes stabilization) +# - hotfix-0-new-branch.yml (creates hotfix branch from main) +# - hotfix-1-release-hotfix.yml (builds and releases hotfix) +# - main-sync-to-dev.yml (syncs main changes to dev and dev-* branches) + # 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, @@ -24,6 +32,10 @@ permissions: jobs: cancel-stabilization-path: runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: "stabilization-close-${{ github.event.milestone.title }}" + cancel-in-progress: false steps: - name: Check if this is a cancellation scenario id: check diff --git a/.github/workflows/stabilization-2-complete.yml b/.github/workflows/stabilization-2-complete.yml index 97518fe7c..20f3cea37 100644 --- a/.github/workflows/stabilization-2-complete.yml +++ b/.github/workflows/stabilization-2-complete.yml @@ -1,5 +1,13 @@ name: Stabilization 2 - ✅ Complete Path +# Related workflows (stabilization & hotfix paths): +# - stabilization-0-init.yml (creates dev-X.Y.Z and main-X.Y.Z branches) +# - stabilization-1-cancel.yml (cancels stabilization path) +# - stabilization-2-complete.yml (completes stabilization) +# - hotfix-0-new-branch.yml (creates hotfix branch from main) +# - hotfix-1-release-hotfix.yml (builds and releases hotfix) +# - main-sync-to-dev.yml (syncs main changes to dev and dev-* branches) + # 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. @@ -27,6 +35,10 @@ permissions: jobs: complete-stabilization-path: runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: "stabilization-close-${{ github.event.milestone.title }}" + cancel-in-progress: false if: github.event_name == 'milestone' steps: - name: Check if this is a successful completion scenario @@ -130,6 +142,13 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Assign PR to Milestone + if: steps.check.outputs.should-complete == 'true' && steps.check-branches.outputs.branches-exist == 'true' && steps.create-pr.outputs.pr_number != '' + uses: ./.github/actions/milestone/assign-pr + with: + pr-number: ${{ steps.create-pr.outputs.pr_number }} + token: ${{ secrets.GITHUB_TOKEN }} + - name: Summary if: steps.check.outputs.should-complete == 'true' run: | @@ -147,6 +166,7 @@ jobs: # Delete stabilization branches after the backport PR is merged into main cleanup-on-pr-merge: runs-on: ubuntu-latest + timeout-minutes: 10 if: | github.event_name == 'pull_request' && github.event.pull_request.merged == true && @@ -162,7 +182,20 @@ jobs: VERSION="${BRANCH#main-}" echo "version=$VERSION" >> $GITHUB_OUTPUT + - name: Validate version format + id: validate + run: | + VERSION="${{ steps.extract-version.outputs.version }}" + if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "valid=true" >> $GITHUB_OUTPUT + echo "Version '$VERSION' is valid semver format" + else + echo "valid=false" >> $GITHUB_OUTPUT + echo "::warning::Version '$VERSION' does not match X.Y.Z format — skipping branch cleanup" + fi + - name: Delete stabilization branches + if: steps.validate.outputs.valid == 'true' uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/user-build-and-hash.yml b/.github/workflows/user-build-and-hash.yml index 0764f2d2b..8503a2b49 100644 --- a/.github/workflows/user-build-and-hash.yml +++ b/.github/workflows/user-build-and-hash.yml @@ -18,6 +18,7 @@ permissions: jobs: build-and-hash: runs-on: windows-latest + timeout-minutes: 30 steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.github/workflows/user-code-style.yml b/.github/workflows/user-code-style.yml index 8fa93a670..f3b74facf 100644 --- a/.github/workflows/user-code-style.yml +++ b/.github/workflows/user-code-style.yml @@ -25,8 +25,9 @@ jobs: trailing-whitespace: name: 🧹 Trailing Whitespace runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + - uses: actions/checkout@v4 - name: Process trailing whitespace uses: ./.github/actions/code-style/trailing-whitespace @@ -45,8 +46,9 @@ jobs: using-sorter: name: 🔀 Using Sorter runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + - uses: actions/checkout@v4 - name: Process using directives uses: ./.github/actions/code-style/using-sorter @@ -65,8 +67,9 @@ jobs: namespace-check: name: 📂 Namespaces Check runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + - uses: actions/checkout@v4 - name: Run Namespace Fixer uses: ./.github/actions/code-style/namespace-fixer @@ -87,9 +90,10 @@ jobs: needs: [trailing-whitespace, using-sorter, namespace-check] if: ${{ github.event.inputs.mode == 'fix' }} runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + uses: actions/checkout@v4 with: ref: ${{ github.ref }} - name: Configure Git @@ -116,12 +120,22 @@ jobs: for p in *.patch; do if [ -s "$p" ]; then echo "Applying $p"; git apply --unidiff-zero "$p"; fi; done - name: Remove patch files run: rm *.patch - - name: Commit and Push code style fixes + - name: Stage changes + run: git add . + - name: Commit and push code style fixes + id: safe-commit + uses: ./.github/actions/utils/safe-commit + with: + commit-message: 'chore(ci): apply code style fixes' + pr-title: 'chore(ci): apply code style fixes' + pr-body: 'Automated code style fixes applied by the Fix Code Style workflow.' + token: ${{ secrets.GITHUB_TOKEN }} + - name: Report result run: | - git add . - if ! git diff --cached --quiet; then - git commit -m "chore(ci): apply code style fixes" - git push + if [ "${{ steps.safe-commit.outputs.method }}" = "pr" ]; then + echo "::notice::Changes were pushed via PR ${{ steps.safe-commit.outputs.pr-url }} (branch is protected)" + elif [ "${{ steps.safe-commit.outputs.method }}" = "direct" ]; then + echo "::notice::Changes committed and pushed directly" else - echo "No changes to commit" + echo "::notice::No changes to commit" fi diff --git a/.windsurf/rules/ai-provider-conventions.md b/.windsurf/rules/ai-provider-conventions.md index 7632e4c5b..6bd9b88ff 100644 --- a/.windsurf/rules/ai-provider-conventions.md +++ b/.windsurf/rules/ai-provider-conventions.md @@ -4,7 +4,17 @@ globs: **/SmartHopper.Providers.*/*.cs --- # AI Provider conventions -- Check other providers to see how they implement required code -- Implement AIProvider, AIProviderModels, AIProviderFactory and AIProviderSettings -- Use native AIRequest, AIToolCall, AIInteractionText, AIInteractionImage, AIInteractionToolCall, AIInteractionToolResult... methods and models -- UI settings are created in _controlFactories in SettingsDialog.cs by parsing SettingsDescriptor \ No newline at end of file + +- Check existing providers before adding or changing provider behavior. +- A provider project should expose: + - `Provider` deriving from `AIProvider` or `AIProvider`. + - `ProviderModels` deriving from `AIProviderModels`. + - `ProviderFactory` implementing `IAIProviderFactory`. + - `ProviderSettings` deriving from `AIProviderSettings`. +- Keep provider-specific API differences inside the provider project: endpoint selection, payload encoding, response decoding, schema adaptation, streaming parsing, and provider-specific headers. +- Use infrastructure contracts and models: `AIRequestCall`, `AIBody`, `AIReturn`, `AIToolCall`, `AIInteractionText`, `AIInteractionImage`, `AIInteractionToolCall`, `AIInteractionToolResult`, and `AIModelCapabilities`. +- Do not bypass the provider pipeline. Prefer `PreCall()` for request setup, base `CallApi()` for HTTP execution, and `PostCall()`/`Decode()` for normalization. +- Mark secret settings with `SettingDescriptor.IsSecret`; settings UI is generated from descriptors through `SettingsDialog`. +- Do not place API keys or secret headers on `AIRequestCall.Headers`, `AIReturn`, logs, or source code. Set `AIRequestCall.Authentication` and let provider internals apply secrets just-in-time. +- Register model capabilities in `AIProviderModels.RetrieveModels()` using concrete API-ready model names. Use `Verified`, `Deprecated`, `Rank`, aliases, streaming support, and capability flags to guide model selection. +- Callers should select models through `provider.SelectModel(requiredCapability, requestedModel)` rather than directly calling `ModelManager.SelectBestModel`. diff --git a/.windsurf/rules/ai-tool-conventions.md b/.windsurf/rules/ai-tool-conventions.md index 7d0e66597..9bea61d2d 100644 --- a/.windsurf/rules/ai-tool-conventions.md +++ b/.windsurf/rules/ai-tool-conventions.md @@ -4,9 +4,19 @@ globs: **/SmartHopper.Core.Grasshopper/AITools/*.cs --- # AI Tool conventions -- Check other similar tools to understand how they implement the code -- Implement IAIToolProvider in SmartHopper.Core.Grasshopper.AITools -- File name: scope_action.cs -- Define AITool metadata (Name, Description, schema) -- Auto-discover via AIToolManager -- Structure the file in (1) tool registration via GetTools(), (2) a region for specific tool methods \ No newline at end of file + +- AI tools live in `src/SmartHopper.Core.Grasshopper/AITools/` and implement `IAIToolProvider`. +- Check similar tools before adding or changing behavior. +- Prefer established tool-family names, for example `gh_get.cs`, `text2json.cs`, `text2text.cs`, `text2textlist.cs`, `list_filter.cs`, or `script_review.cs`. Match existing names when renaming or extending tool families. +- Each tool provider exposes tools through `GetTools()` with: + - Stable tool name. + - Clear description and category. + - JSON parameter schema. + - Required capabilities when model/provider support matters. + - Async execution delegate. +- Tools are discovered by `AIToolManager`; do not hand-register duplicates. +- Execute tools through `AIToolCall.Exec()` or `AIToolManager.ExecuteTool(...)`; do not call an `AITool.Execute` delegate directly from unrelated code. +- Validate and sanitize tool arguments from `AIInteractionToolCall.Arguments` before side effects. +- For JSON tool results, keep payloads at predictable keys (`result`, `list`, or documented domain-specific keys such as `json` or `description`) and attach `ToolResultEnvelope` metadata under `__envelope`. +- For canvas mutations, follow the Grasshopper undo rule before changing objects, wires, positions, preview, locks, or parameters. +- Keep long-running or UI-thread-sensitive work off background threads unless explicitly marshaled to the Rhino UI thread. diff --git a/.windsurf/rules/architecture-thinking.md b/.windsurf/rules/architecture-thinking.md index 9e67d99d1..6939d7c49 100644 --- a/.windsurf/rules/architecture-thinking.md +++ b/.windsurf/rules/architecture-thinking.md @@ -4,16 +4,18 @@ description: When adding new major components or features - When refactoring exi globs: ["src/**", "docs/**"] --- -# Architecture-First Thinking +# Architecture-first thinking + +## Core principles -## Core Principles - Proactively propose sound base-architecture decisions before implementation - Maintain concise AI-oriented documentation in `/docs/*` -- Use documentation in /docs/* as primary guide for coherent, maintainable edits +- Use documentation in `/docs/*` as primary guide for coherent, maintainable edits - Co-design solutions with user, suggesting improvements with rationale - Keep docs synchronized with actual code structure in `/src/` -## Documentation Standards +## Documentation standards + - Keep markdown docs simple and human-readable - Optimize for AI token usage (concise, not verbose) - Focus on relationships, patterns, and design decisions @@ -21,12 +23,14 @@ globs: ["src/**", "docs/**"] - Document architecture decisions with brief context and rationale ## Before Implementation + 1. Analyze how changes fit within existing architecture -2. Propose architecture improvements with clear rationale +2. Propose architecture improvements with clear rationale 3. Seek user confirmation before significant structural changes 4. Update relevant documentation first, then implement code -## During Reviews -- Check if implementation follows documented architecture in /docs/* -- Identify patterns in /docs/* that should be standardized -- Suggest documentation updates when code diverges from /docs/* \ No newline at end of file +## During reviews + +- Check if implementation follows documented architecture in `/docs/*` +- Identify patterns in `/docs/*` that should be standardized +- Suggest documentation updates when code diverges from `/docs/*` diff --git a/.windsurf/rules/code-dedup-architecture.md b/.windsurf/rules/code-dedup-architecture.md index 89095a7d9..8fd989714 100644 --- a/.windsurf/rules/code-dedup-architecture.md +++ b/.windsurf/rules/code-dedup-architecture.md @@ -61,7 +61,7 @@ Template: - Add/adjust unit tests at the base level; keep child tests green. - Preserve logs and error messages; avoid weakening diagnostics. -# Commit Behavior +# Commit behavior - Default: targeted change only. - If user confirms refactor: implement minimal viable parent extraction + child wiring, backed by tests. -- If user declines: proceed with user's instructions, whether targeted changes or suggest a deeper-reasoned new architectural approach. \ No newline at end of file +- If user declines: proceed with user's instructions, whether targeted changes or suggest a deeper-reasoned new architectural approach. diff --git a/.windsurf/rules/component-conventions.md b/.windsurf/rules/component-conventions.md index 5bec6852c..ccff9df82 100644 --- a/.windsurf/rules/component-conventions.md +++ b/.windsurf/rules/component-conventions.md @@ -4,7 +4,19 @@ globs: **/SmartHopper.Components/*.cs --- # Component conventions -- Inherit from ComponentBase or derived class (e.g. AIStatefulAsyncComponentBase) -- File name: [Category][Action][Type]Component.cs (e.g. AITextGenerateComponent.cs) -- Override RegisterInputParams(), RegisterOutputParams() -- Provide unique Guid, ComponentName, Nickname, Description \ No newline at end of file + +- Production components live in `src/SmartHopper.Components//`. +- Test-only components live in `src/SmartHopper.Components.Test/` and must not be shipped in Release. +- File names use `[Category][Action][Type]Component.cs` where practical, for example `AITextGenerateComponent.cs`. +- Inherit from the narrowest existing base that fits: + - `ComponentBase` or `StatefulComponentBase` for non-AI component behavior. + - `AIProviderComponentBase` when provider/model selection is needed but no AI tool orchestration is required. + - `AIStatefulAsyncComponentBase` for long-running AI components. + - Selecting variants when the component manages Grasshopper canvas selections. +- Keep components focused on UI contract, parameter registration, state, and worker creation. Put reusable logic in base classes, workers, tools, or services. +- Do not manually reimplement async state, debouncing, persistent output storage, provider selection, model capability checks, metrics, or runtime-message plumbing if a base class already provides it. +- Register inputs/outputs through the relevant base-class methods (`RegisterInputParams`, `RegisterOutputParams`, or `RegisterAdditional*Params`). +- Provide stable `ComponentGuid`, `Icon`, `Exposure`, name, nickname, description, category, and subcategory. Never change a released component GUID. +- When first scaffolding a new unreleased component, use the zeroed GUID placeholder (`00000000-0000-0000-0000-000000000000`) so the maintainer can assign the final stable GUID before release. +- Choose `RunOnlyOnInputChanges` intentionally and document unusual run semantics in the component description. +- Use `DataTreeProcessor`/processing topologies for data-tree mechanics instead of manual path fan-out in component code. diff --git a/.windsurf/rules/data-tree-processing.md b/.windsurf/rules/data-tree-processing.md new file mode 100644 index 000000000..a384453f4 --- /dev/null +++ b/.windsurf/rules/data-tree-processing.md @@ -0,0 +1,35 @@ +--- +trigger: model_decision +description: When adding or changing Grasshopper components, workers, data-tree handling, branch matching, grafting, flattening, broadcasting, or output paths +globs: ["src/SmartHopper.Components/**/*.cs", "src/SmartHopper.Core/DataTree/**/*.cs", "src/SmartHopper.Core.Grasshopper/**/*.cs"] +--- + +# Data-tree processing + +Use `DataTreeProcessor` as the single source of truth for Grasshopper data-tree mechanics. + +## Responsibilities + +- Components define UI contracts, parameters, options, state, and processing intent. +- Workers gather inputs, prepare semantic data, call runner APIs, report progress, and set persistent outputs. +- `DataTreeProcessor` handles paths, branch matching, grouping, length normalization, grafting, flattening, and output path strategies. +- Tool/model calls process one logical unit and should not know about `GH_Path` fan-out unless the tool's purpose is explicitly data-tree manipulation. + +## Processing topology + +Choose a `ProcessingTopology` instead of writing custom branch loops: + +- `ItemToItem`: each input item creates a corresponding output item at the same path/index. +- `ItemGraft`: each item creates its own output branch. +- `BranchToBranch`: each branch is one logical unit and keeps its path. +- `BranchFlatten`: each branch is one logical unit and outputs to a flattened result. + +## Broadcasting expectations + +Flat `{0}` trees have special broadcasting behavior: + +- A single flat `{0}` tree does not broadcast to another single same-depth path such as `{1}`. +- It broadcasts when the other input has multiple same-depth paths or deeper/mixed topology. +- A direct `{0}` match with deeper `{0;...}` paths matches only `{0}` unless multiple top-level paths trigger broadcasting. + +See `docs/Components/ComponentBase/DataTreeProcessingSchema.md` and `docs/Components/ComponentBase/FlatTreeBroadcasting.md`. diff --git a/.windsurf/rules/documentation-and-changelog.md b/.windsurf/rules/documentation-and-changelog.md index caeb2afe7..44ffc6468 100644 --- a/.windsurf/rules/documentation-and-changelog.md +++ b/.windsurf/rules/documentation-and-changelog.md @@ -3,7 +3,10 @@ trigger: always_on --- # Documentation and changelog -- IMPORTANT! Keep /docs folder up-to-date with latest changes. KEep files in /docs organized in meaningful folders. Create an index.md in each folder. Link files using md links. -- Log API changes in CHANGELOG.md under **Unreleased** in one of: Added, Changed, Deprecated, Removed, Fixed, Security -- Use PR template per .github/PULL_REQUEST_TEMPLATE.md -- Add docstrings to all members, inheritdoc when possible \ No newline at end of file + +- Keep `/docs` synchronized with architectural and behavioral changes in `src/`. +- Organize `/docs` in meaningful folders. Each folder should have an `index.md` that links to child pages with relative Markdown links. +- Prefer concise, AI-friendly documentation: relationships, contracts, data flows, decisions, and gotchas over long prose. +- Log user-visible, API, architecture, component, provider, tool, workflow, and security changes in `CHANGELOG.md` under `[Unreleased]` using Keep a Changelog sections: Added, Changed, Deprecated, Removed, Fixed, Security. +- Use `.github/PULL_REQUEST_TEMPLATE.md` for PR bodies and Conventional Commit style for PR titles. +- Add XML docstrings to C# members; use `` when a parent/interface member already has accurate documentation. diff --git a/.windsurf/rules/general-guidelines.md b/.windsurf/rules/general-guidelines.md index aae5e6f42..12af7799c 100644 --- a/.windsurf/rules/general-guidelines.md +++ b/.windsurf/rules/general-guidelines.md @@ -4,36 +4,36 @@ trigger: always_on # General guidelines -1. Follow established best practices (e.g. SOLID, naming conventions, error handling, tests), unless explicitly asked not to. -2. Decision matrix (in priority order): - 1. Meet the user’s requirements - 2. Follow best practices - 3. Avoid patching symptoms, identify root causes and fix them instead - 4. Maximize security (see OWASP Top 10) - 5. Improve performance - 6. Ease future maintenance -3. When you get stuck in a maze of reasoning, you should stop, give the user a full summary of what you've found, and ask for help. -4. Conduct a brief threat review on all external inputs and secrets; reference OWASP/T12 checklist. -5. In your post-edit summary, include: - - Alternative solutions considered, highlighting the rationale for the chosen approach - - Best practices applied - - Suggestions for future improvements -6. Persist rich context into the Memory DB: - 1. Capture key docs & code snippets (with URLs & summaries) - 2. Document high-level architecture & module roles - 3. Record project conventions (standards, naming, structure, configs) - 4. Log public APIs/interfaces (signatures, purpose, usage) - 5. Archive design decisions (alternatives, rationale, commit refs) - 6. List 3rd‑party deps & integration patterns - 7. Store user preferences & recurring workflows - 8. Maintain consistency of memories: dedupe, refresh stale entries, remove obsolete - -# Project specific guidelines - -- Use native Grasshopper types & methods when possible. -- Refer to https://developer.rhino3d.com/ as the official documentation. -- Check /docs folder for local documentation on existing code. -- Use English only. -- Prefer copy/pasting, renaming, and removing files via PowerShell commands. -- You are running on Windows - use windows commands in terminal, prefered PowerShell commands. -- Never add unit tests that require Rhino or Grasshopper references. There is a license conflict that doesn't allow testing these functions unless Rhino is running and license is verified. \ No newline at end of file +1. Follow established best practices (SOLID, clear naming, error handling, tests, and security) unless the user explicitly asks otherwise. +2. Use this decision priority: + 1. Meet the user requirements. + 2. Follow the documented SmartHopper architecture. + 3. Fix root causes instead of patching symptoms. + 4. Protect security and privacy, especially external inputs and secrets. + 5. Preserve performance and responsiveness in Rhino/Grasshopper. + 6. Keep future maintenance simple. +3. If implementation options become ambiguous or contradictory, stop, summarize the concrete evidence, and ask the user to choose a direction. +4. Conduct a brief threat review for changes that touch external inputs, provider calls, secrets, file/network access, or AI tool execution. +5. In post-edit summaries, include alternatives considered, why the chosen approach fits, best practices applied, and follow-up improvement suggestions when useful. + +## Project-specific guidelines + +- Use English only in code, documentation, rules, and user-facing text. +- Prefer native Grasshopper/Rhino types and APIs when working with canvas, data-tree, or geometry logic. +- Use https://developer.rhino3d.com/ as the official Rhino/Grasshopper API reference. +- Check `/docs` before changing existing architecture; those docs are the local source of truth for module responsibilities and data flows. +- Use commands appropriate to the current execution environment. Windows-only build/signing flows require Developer PowerShell for Visual Studio; do not assume every assistant or CI runner is on Windows. +- Never add unit tests that require Rhino/Grasshopper references. For tests that require Rhino/Grasshopper references, create a testing component in the `SmartHopper.Components.Test` project. +- Do not commit secrets, signing keys, local provider API keys, or generated private credentials. + +## Context persistence + +Persist durable project knowledge when it is general enough to help future work: + +1. High-level architecture and module responsibilities. +2. Stable public APIs, extension points, and contracts. +3. Project conventions, naming, workflows, and configuration rules. +4. Design decisions with rationale and alternatives. +5. Third-party dependencies and integration patterns. + +Dedupe or refresh stale knowledge instead of creating conflicting entries. diff --git a/.windsurf/rules/infrastructure-aicall.md b/.windsurf/rules/infrastructure-aicall.md index 4c6fd9177..680e0258f 100644 --- a/.windsurf/rules/infrastructure-aicall.md +++ b/.windsurf/rules/infrastructure-aicall.md @@ -3,50 +3,47 @@ trigger: model_decision description: Information about AICall, AIRequest, AIToolCall, AIBody, AIAgent, AIInteraction*, AIReturn... Related with AI response generation logic --- -# AICall Infrastructure - -- **Location** - - `src/SmartHopper.Infrastructure/AICall/` - -- **Purpose** - - Provide a provider‑agnostic foundation to build, validate, execute, and capture results of AI calls. - - Normalize diverse provider behaviors into a consistent request/response model with metrics and status. - -- **Core Concepts** - - **Agents & Status** - - AIAgent: Who speaks (Context/System/User/Assistant/ToolCall/ToolResult). - - AICallStatus: Call lifecycle (Idle/Processing/Streaming/CallingTools/Finished). - - **Contracts** - - IAIInteraction: Common metadata for any message (time/agent/metrics). - - IAIRequest: What a request must expose (`Provider`, Model, `Capability`, Body, IsValid(), Exec()). - - IAIReturn: What a result must expose (normalized body, raw payload, metrics, status, error). - - **Interactions** - - AIInteractionText: Text + optional reasoning. - - AIInteractionImage: Image request/result (URL/data, size, quality, style). - - AIInteractionToolCall / AIInteractionToolResult): Function/tool call and its result. - - **Request Body** - - AIBody: Conversation history + optional JSON schema + context/tool filters. Injects context messages dynamically when filters are set; validates body consistency. - - **Execution** - - AIRequestBase: Validates provider/model/capabilities; defines the shape of an executable request. - - AIRequestCall: Adds HTTP specifics, computes effective capabilities (e.g., needs structured output or tools), encodes and executes async. - - AIToolCall: Executes a specific tool via `AIToolManager` (used during tool-calling loops). - - **Result** - - `AIReturn`: Normalized result object with processed body, raw provider data, metrics, status, and error details. - -- **Typical Flow** - 1. Build AIBody with interactions (text/image/tool), optional predefined output JSON schema, and filters. - 2. Create AIRequestCall (provider, model, capability, endpoint, body); IsValid() is automatically triggered on Exec(). - 3. Exec() to get `AIReturn` with results + metrics + raw payload. - 4. If tool calls are returned, execute them (AIToolCall), append AIInteractionToolResult to Body.Interactions, and re‑invoke until no pending tools remain. // TODO: execute tools automatically in Exec(). - -- **Why This Design** - - **Provider‑agnostic**: Centralizes capability checks and request formatting while allowing provider‑specific encoding. - - **Reliability**: Normalized results with clear status, metrics, and raw fallbacks for debugging. - - **Extensibility**: New providers and interaction types plug into existing contracts. - - **Safety & Validation**: Capability validation prevents unsupported features at runtime (e.g., structured output or tool‑calling). - -- **Where to Look** - - Requests: AIRequestBase.cs, AIRequestCall.cs, AIToolCall.cs - - Results: AIReturn.cs - - Body & Interactions: AIBody.cs, AIInteractionText.cs, AIInteractionImage.cs, AIInteractionToolCall.cs, AIInteractionToolResult.cs - - Enums & Interfaces: AIAgent.cs, AICallStatus.cs, IAIRequest.cs, IAIReturn.cs, IAIInteraction.cs \ No newline at end of file +# AICall infrastructure + +## Location + +- `src/SmartHopper.Infrastructure/AICall/` +- Detailed docs: `docs/Providers/AICall/` + +## Purpose + +- Provide a provider-agnostic foundation to build, validate, execute, stream, and capture AI calls. +- Normalize provider-specific behavior into common request, interaction, return, metrics, status, and runtime-message models. + +## Core concepts + +- `AIAgent`: Context, System, User, Assistant, ToolCall, ToolResult. +- `AICallStatus`: Idle, Processing, Streaming, CallingTools, Finished. +- `IAIInteraction`: Common metadata for every interaction. +- `AIRequestCall`: Provider/model/capability/body plus HTTP details for one provider call. +- `AIBody`: Conversation history plus optional JSON schema, context filter, and tool filter. Context injection is non-mutating. +- `AIReturn`: Normalized result with body, raw provider payload, metrics, status, diagnostics, and errors. +- `AIToolCall`: Executes one tool call through the tool manager. +- `ConversationSession`: Orchestrates multi-turn calls, tool loops, streaming, observer callbacks, and final stable results. + +## Execution guidance + +1. Build an `AIBody` with interactions and optional context/tool/schema filters. +2. Create an `AIRequestCall` for a single provider turn. +3. Use `AIRequestCall.Exec()` for one provider call only. +4. Use `ConversationSession` when a workflow needs tool processing, bounded turns, streaming, observers, cancellation, or stable history persistence. +5. Use `AIToolCall.Exec()` or `AIToolManager` for exactly one tool call. + +## Streaming guidance + +- Provider streaming support is exposed through `IStreamingAdapter`. +- `ConversationSession.Stream(...)` gates streaming by provider/model/settings capabilities and falls back to non-streaming when appropriate. +- Streaming deltas should be emitted promptly, honor cancellation, and avoid unbounded buffering. +- UI consumers should prefer `IConversationObserver` callbacks for incremental rendering. + +## Design priorities + +- Keep provider-specific encoding/decoding in provider projects. +- Keep orchestration in `ConversationSession`, not components or providers. +- Attach structured diagnostics with `AIReturn.AddRuntimeMessage(...)` instead of raw log-only errors. +- Preserve metrics and raw payloads for debugging. diff --git a/.windsurf/rules/infrastructure-aiprovider.md b/.windsurf/rules/infrastructure-aiprovider.md index 1e5bcb301..165db2f97 100644 --- a/.windsurf/rules/infrastructure-aiprovider.md +++ b/.windsurf/rules/infrastructure-aiprovider.md @@ -22,9 +22,9 @@ description: Information about the Provider Manager (AIProvider) - **Model Management** - AIProviderModels: Base for provider model operations (`IAIProviderModels` is in `AIModels`). - - RetrieveAvailable() [all models, and single model with wildcard resolution], RetrieveDefault(). + - Retrieves provider model metadata and concrete API-ready model names. - GetModel(requestedModel) chooses user-requested or provider default. - - Initialization path registers capabilities/defaults with `ModelManager` and resolves defaults (supports wildcard patterns and concrete names). + - Initialization path registers capabilities/defaults with `ModelManager`. - **Discovery, Registration & Trust** - ProviderManager (ProviderManager.cs) @@ -48,12 +48,13 @@ description: Information about the Provider Manager (AIProvider) - **Typical Usage** 1. External provider DLL supplies IAIProviderFactory to create IAIProvider and IAIProviderSettings. 2. ProviderManager discovers, verifies, loads, and registers them. - 3. App asks provider for model via `Models.GetModel()` and capabilities via `Models.RetrieveCapabilities()`. + 3. App asks provider for a model via `provider.SelectModel(requiredCapability, requestedModel)`. 4. AI requests flow through provider hooks; encoding/decoding and tools are normalized. - **Best Practices** - Keep InitializeProviderAsync() non-blocking; register capabilities before resolving defaults. - Use GetSettingDescriptors() to describe required keys and mark secrets. - - Prefer concrete model names for API calls; support wildcard fallback. - - `CallApi()` shouldn't be overriden by AIProvider implementations, use base class. Override PreCall and PostCall if needed. - - Keep `Decode()` resilient to provider payload variations; include tool calls and metrics where applicable. \ No newline at end of file + - Use concrete model names for API calls and model metadata. No wildcard resolution. + - Do not call `ModelManager.SelectBestModel()` directly from requests or components; go through `IAIProvider.SelectModel(...)`. + - `CallApi()` should not be overridden by AIProvider implementations; use the base class. Override PreCall and PostCall if needed. + - Keep `Decode()` resilient to provider payload variations; include tool calls and metrics where applicable. diff --git a/.windsurf/rules/infrastructure-aitools.md b/.windsurf/rules/infrastructure-aitools.md index 038c26861..0637069c8 100644 --- a/.windsurf/rules/infrastructure-aitools.md +++ b/.windsurf/rules/infrastructure-aitools.md @@ -3,7 +3,7 @@ trigger: model_decision description: Information about the Tool Manager (AITools) --- -# AITools +# AI tools - **Purpose** - Define AI-callable tools and a central manager to discover, register, and execute them. @@ -30,7 +30,7 @@ description: Information about the Tool Manager (AITools) - **Execution Flow** 1. ExecuteTool(AIToolCall) ensures discovery and fetches the target tool. 2. Validates the AIToolCall via `toolCall.IsValid()`. - 3. Calls the tool’s Execute(toolCall) to get an AIReturn ( + 3. Calls the tool's `Execute(toolCall)` delegate to get an `AIReturn`. 4. Wraps the result in a manager-level AIReturn with `Request = toolCall` and SetBody(result.Body). Errors are captured in `ErrorMessage`. - **Integration** @@ -49,4 +49,4 @@ description: Information about the Tool Manager (AITools) 2. Each AITool defines metadata, JSON schema, and an async Execute. 3. The AI model issues a tool call; the system constructs an AIToolCall. 4. `AIToolManager.ExecuteTool(toolCall)` runs the tool and returns an AIReturn to the tool-calling loop. - 5. Don't call Execute(toolCall) directly in code, use AIToolCall.Exec() instead. \ No newline at end of file + 5. Do not call `Execute(toolCall)` directly in code; use `AIToolCall.Exec()` instead. diff --git a/.windsurf/rules/provider-authentication.md b/.windsurf/rules/provider-authentication.md new file mode 100644 index 000000000..989253828 --- /dev/null +++ b/.windsurf/rules/provider-authentication.md @@ -0,0 +1,23 @@ +--- +trigger: glob +globs: **/SmartHopper.Providers.*/*.cs +--- + +# Provider authentication and secrets + +Provider authentication is request-scoped for behavior and provider-scoped for secrets. + +## Rules + +- Do not put API keys, bearer tokens, or secret headers on `AIRequestCall`, `AIRequestCall.Headers`, `AIReturn`, logs, exceptions, docs, or source code. +- In `PreCall(...)`, set `AIRequestCall.Authentication` to the required scheme: + - `none` + - `bearer` + - `x-api-key` +- Add only non-secret provider headers to `AIRequestCall.Headers`. +- Let `AIProvider.CallApi(...)` or provider streaming adapters apply secrets just-in-time from provider settings. +- Mark secret settings with `SettingDescriptor.IsSecret = true`. +- Mask or omit secret setting values in diagnostics. +- For streaming adapters, resolve API keys from provider internals and use shared authentication/header helpers. + +See `docs/Providers/Authentication.md`. diff --git a/.windsurf/rules/refactor-stop-and-confirm.md b/.windsurf/rules/refactor-stop-and-confirm.md index 616535c8f..4496848f5 100644 --- a/.windsurf/rules/refactor-stop-and-confirm.md +++ b/.windsurf/rules/refactor-stop-and-confirm.md @@ -3,16 +3,30 @@ trigger: model_decision description: Only for /src/**. Duplicated code with diverging logic paths that cannot be reconciled without a refactor. Enum/flag contradictions or mismatched defaults that change behavior. Divergent implementations of the same contract in different modules. --- -# Incoherence Gate & Stop-Processing Policy - -## Assistant Behavior -1) Emit a clear warning with concrete evidence: - - Files, symbols, brief diffs/snippets demonstrating the inconsistency. -2) Stop processing further edits. -3) Ask for explicit confirmation: - - Provide a short plan outlining the minimal coherent fix vs the refactor option (non-breaking vs breaking). -4) Resume only after user confirmation; otherwise keep changes targeted and localized. - -## Notes -- Prefer parent/base extraction when resolving duplication. -- Avoid masking incoherences with local fixes; surface and gate on user intent. \ No newline at end of file +# Incoherence gate and stop-processing policy + +Use this rule with `code-dedup-architecture.md`. It exists to prevent local fixes that hide architectural contradictions. + +## Stop immediately when + +- Duplicated code has diverging behavior and the correct shared behavior is unclear. +- Enum values, flags, schema keys, defaults, or settings contradict each other across modules. +- Different providers, tools, or components implement the same contract with incompatible semantics. +- A fix would require breaking a public API, component contract, persisted data format, tool schema, or provider response shape. + +## Assistant behavior + +1. Emit a clear warning with concrete evidence: + - File paths. + - Symbols or schema keys. + - Short snippets or behavior summaries. +2. Stop further edits in the affected area. +3. Ask for explicit confirmation and present: + - The minimal coherent fix. + - The broader refactor option. + - Risk level: non-breaking or breaking. +4. Resume only after user confirmation, or keep the change targeted and localized if the user declines refactoring. + +## Default preference + +Prefer parent/base extraction for confirmed duplication fixes, but do not force provider-specific or platform-specific quirks into a shared abstraction if it would obscure behavior. diff --git a/.windsurf/rules/solution-structure.md b/.windsurf/rules/solution-structure.md index 9a4abe34e..c6b540d68 100644 --- a/.windsurf/rules/solution-structure.md +++ b/.windsurf/rules/solution-structure.md @@ -3,11 +3,22 @@ trigger: always_on --- # Solution structure -- SmartHopper.Core: Contains the core functionality -- SmartHopper.Core.Grasshopper: Type converters, utilities & tool definitions -- SmartHopper.Components: Grasshopper components (inherit ComponentBase/AIStatefulAsyncComponentBase) -- SmartHopper.Components.Test: This project is not build in Release. Defines components that are used for testing purposes, not for production. -- SmartHopper.Infrastructure: Settings, Provider manager, Context manager and AITool manager -- SmartHopper.Menu: Menu bar setup -- SmartHopper.Infrastructure.Test: xUnit tests (not for production) -- SmartHopper.Providers.*: AI provider projects \ No newline at end of file + +- `SmartHopper.Core`: Core UI, component base classes, chat UI host, context providers, and shared component infrastructure. +- `SmartHopper.Core.Grasshopper`: Grasshopper-specific utilities, converters, canvas helpers, GhJSON integration, and AI tool definitions. +- `SmartHopper.Components`: Production Grasshopper components. Components generally inherit from base classes in `SmartHopper.Core/ComponentBase`. +- `SmartHopper.Components.Test`: Test-only Grasshopper components. This project is not built in Release and must not contain production components. +- `SmartHopper.Infrastructure`: Provider manager, model manager, context manager, AI tool manager, AICall contracts, settings, dialogs, security, and shared infrastructure. +- `SmartHopper.Menu`: Rhino/Grasshopper menu bar setup and settings entry points. +- `SmartHopper.Infrastructure.Tests`, `SmartHopper.Core.Tests`, `SmartHopper.Core.Grasshopper.Tests`: xUnit test projects. Avoid tests that require Rhino runtime licensing. +- `SmartHopper.Providers.*`: AI provider plugin projects. Each provider owns API-specific request/response adaptation while using infrastructure contracts. + +## Placement rule + +Put shared logic at the highest layer that owns the concern: + +- Core UI/component behavior → `SmartHopper.Core`. +- Grasshopper canvas, data tree, and GhJSON behavior → `SmartHopper.Core.Grasshopper`. +- End-user component wiring → `SmartHopper.Components`. +- Provider/model/context/tool orchestration and settings → `SmartHopper.Infrastructure`. +- API-specific quirks → the matching `SmartHopper.Providers.` project. diff --git a/.windsurf/rules/target-platform-and-frameworks.md b/.windsurf/rules/target-platform-and-frameworks.md index 0b74ffde9..664805b5e 100644 --- a/.windsurf/rules/target-platform-and-frameworks.md +++ b/.windsurf/rules/target-platform-and-frameworks.md @@ -3,6 +3,7 @@ trigger: always_on --- # Target platforms & frameworks + - Rhino 8 (Grasshopper 1) -- Windows: .NET 7-windows -- macOS: .NET 7 \ No newline at end of file +- Windows: `net7.0-windows` +- macOS: `net7.0` diff --git a/.windsurf/rules/testing-and-build.md b/.windsurf/rules/testing-and-build.md new file mode 100644 index 000000000..5d217390e --- /dev/null +++ b/.windsurf/rules/testing-and-build.md @@ -0,0 +1,21 @@ +--- +trigger: always_on +--- + +# Testing and build expectations + +- SmartHopper targets Rhino 8 / Grasshopper 1 with .NET 7: + - Windows: `net7.0-windows`. + - macOS: `net7.0`. +- Do not add unit tests that require Rhino/Grasshopper references. +- If validation needs Rhino/Grasshopper references, create a testing component in `SmartHopper.Components.Test` instead of a unit test. +- Test projects must stay runnable in CI without Rhino or Grasshopper runtime activation. +- CI behavior: + - Windows runs all tests after Release build. + - macOS runs only `SmartHopper.Infrastructure.Tests` for `net7.0`; Core and Grasshopper tests depend on WindowsDesktop/WinForms APIs. +- Local official build/signing flows are Windows-oriented and require Developer PowerShell for Visual Studio when using scripts that call Strong Name or Windows SDK tools. +- Do not commit generated signing keys, local certificates, provider API keys, or other local credentials. +- Prefer focused tests at the lowest layer that owns the behavior: + - Infrastructure contracts and managers → `SmartHopper.Infrastructure.Tests`. + - Core non-Rhino behavior → `SmartHopper.Core.Tests`. + - Grasshopper helper logic that does not require Rhino/Grasshopper references or runtime activation → `SmartHopper.Core.Grasshopper.Tests`. diff --git a/.windsurf/rules/tool-result-envelope.md b/.windsurf/rules/tool-result-envelope.md new file mode 100644 index 000000000..b5fbab8d5 --- /dev/null +++ b/.windsurf/rules/tool-result-envelope.md @@ -0,0 +1,34 @@ +--- +trigger: glob +globs: **/SmartHopper.Core.Grasshopper/AITools/*.cs +--- + +# Tool result envelope + +AI tools that return JSON should attach a metadata envelope under the reserved root key `__envelope`. + +## Pattern + +1. Build a root `JObject`. +2. Place the payload at a predictable key such as `result`, `list`, `json`, `description`, `image`, `components`, or another documented domain-specific key. +3. Attach `ToolResultEnvelope` with `WithEnvelope(...)` or the extension helpers. +4. Add the wrapped payload to the interaction stream with `AIBodyBuilder.AddToolResult(...)`/the current body-builder API, or return it through `AIReturn` when the caller wraps it. + +## Envelope content + +Include useful metadata when available: + +- Tool name. +- Provider and model. +- Tool-call identifier. +- Content type. +- Payload path. +- Schema reference or compatibility keys for structured outputs. + +## Compatibility + +- Do not move existing payload keys only to add metadata. +- Keep `__envelope` additive and non-breaking. +- Consumers that do not understand the envelope must still be able to read the payload. + +See `docs/Tools/ToolResultEnvelope.md`. diff --git a/.windsurf/rules/webchat-architecture.md b/.windsurf/rules/webchat-architecture.md new file mode 100644 index 000000000..3b98acb15 --- /dev/null +++ b/.windsurf/rules/webchat-architecture.md @@ -0,0 +1,32 @@ +--- +trigger: glob +globs: src/SmartHopper.Core/UI/Chat/** +--- + +# WebChat architecture + +The chat UI is a full WebView interface. Preserve the existing separation of concerns: + +- `ConversationSession`: conversation history, provider calls, tool loops, streaming, cancellation, and stable final results. +- `HtmlChatRenderer`: converts interactions to HTML. +- `WebChatDialog`: hosts the WebView, intercepts JS-to-host URL schemes, and injects serialized DOM updates. +- `WebChatObserver`: maps `IConversationObserver` events to incremental DOM updates. +- `Resources/`: local HTML, CSS, JavaScript, templates, and third-party assets. + +## Bridge rules + +- JavaScript sends actions through intercepted URL schemes: + - `sh://event?type=send&text=...` + - `sh://event?type=clear` + - `sh://event?type=cancel` + - `clipboard://copy?text=...` +- URL query keys and values must be encoded in JavaScript and decoded in C#. +- Host actions triggered from `DocumentLoading` must be deferred to the next UI tick to avoid WebView re-entrancy. +- All UI updates must run on Rhino's UI thread via `RhinoApp.InvokeOnUiThread(...)`. +- Serialize and batch DOM updates; avoid one WebView script injection per streaming token. + +## Dependencies + +Host third-party WebChat JavaScript and CSS locally. Do not use external CDNs. + +See `docs/UI/Chat/index.md` and `docs/UI/Chat/WebView-Bridge.md`. diff --git a/.windsurf/rules/webchat-cdn.md b/.windsurf/rules/webchat-cdn.md deleted file mode 100644 index 1113a638b..000000000 --- a/.windsurf/rules/webchat-cdn.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -trigger: glob -globs: src/SmartHopper.Core/UI/Chat/* ---- - -Always host third-party WebChat JS/CSS dependencies locally rather than using external CDNs (e.g. MathJax). \ No newline at end of file diff --git a/.windsurf/rules/windsurf-rules.md b/.windsurf/rules/windsurf-rules.md index 5b56079c0..5ed031ea8 100644 --- a/.windsurf/rules/windsurf-rules.md +++ b/.windsurf/rules/windsurf-rules.md @@ -3,4 +3,8 @@ trigger: model_decision description: When generating windsurf rules, stored in .windsurf/rules --- -Windsurf rules cannot be automatically written. Return them in the chat, wrapping content in a codeblock. Escape the ` character. \ No newline at end of file +Do not modify Windsurf rule files unless the user explicitly asks for rule changes or a PR that updates rules. + +When the user only asks for rule suggestions, return the proposed rule content in chat, wrapped in code blocks. Escape the ` character inside those code blocks. + +When the user explicitly asks for a PR or direct rule cleanup, edit `.windsurf/rules/*.md` directly and keep each rule focused on one concern. diff --git a/.windsurf/workflows/changelog-review.md b/.windsurf/workflows/changelog-review.md index 7d29b01de..15f521622 100644 --- a/.windsurf/workflows/changelog-review.md +++ b/.windsurf/workflows/changelog-review.md @@ -10,4 +10,4 @@ Omit this workflow if current branch is main or dev. 3. Compare the list of commits against the [Unreleased] section in CHANGELOG.md. -4. Suggest the user for missing mentions in CHANGELOG \ No newline at end of file +4. Suggest missing `CHANGELOG.md` entries to the user. diff --git a/.windsurf/workflows/changelog-simplification.md b/.windsurf/workflows/changelog-simplification.md new file mode 100644 index 000000000..268e62dc9 --- /dev/null +++ b/.windsurf/workflows/changelog-simplification.md @@ -0,0 +1,110 @@ +--- +description: Prepare an end-user focused changelog +--- + +# Changelog Simplification + +When preparing changelog entries, focus on features and changes that matter to end users. Keep entries concise and avoid overly technical details. + +## Guidelines + +### Focus on End-User Value + +**Include:** + +- New AI models and capabilities +- New components or features +- UI/UX changes +- Breaking changes that affect saved files +- Performance improvements users will notice +- Deprecated features or models +- GitHub issue references in the format `[#123](https://github.com/architects-toolkit/SmartHopper/issues/123)` (keep issue IDs for compatibility with automated workflows) + +**Briefly mention:** + +- CI/CD improvements (one-liner) +- Infrastructure stability improvements (one-liner) +- Code quality improvements (only if significant) + +**Exclude:** + +- Detailed CI workflow specifications +- Concurrency settings and race condition prevention +- Auto-commit hardening specifics +- Per-provider technical minutiae +- Internal tooling details +- Implementation details unless they affect user experience + +### Simplification Examples + +**Before (too technical):** + +```text +- 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. +``` + +**After (user-focused):** + +```text +- **CI/CD**: Enhanced workflow automation for model verification, provider discovery, and stabilization branch management +``` + +**Before (too detailed):** + +```text +- **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). +``` + +**After (user-focused):** + +```text +- **AI model rankings**: Adjusted default models and rankings across providers based on official documentation +``` + +### Entry Structure + +Keep each entry to 1-2 lines maximum. Use clear, simple language. + +**Good format:** + +```text +- **Category**: Brief description of the change +``` + +**Bad format:** + +```text +- **Category**: Detailed technical explanation with implementation details, file paths, and internal workflow specifications that users don't need to know about +``` + +### When to Simplify + +Simplify changelog entries when: + +- The release is primarily infrastructure/CI improvements +- The changes are technical and not user-facing +- The entry exceeds 2-3 lines +- The entry contains file paths, workflow names, or implementation details + +Keep detailed entries when: + +- The change introduces new user-facing features +- The change is a breaking change that affects saved files +- The change deprecates features users rely on + +### Workflow Compatibility + +This simplification workflow is compatible with the following automated workflows: + +- **chore-update-contributors.yml**: Automatically updates the contributors section under `[Unreleased]` with GitHub username links. Ensure you don't remove the contributors section when simplifying. +- **pr-update-changelog-issues.yml**: Automatically adds closed issues to the `### Fixed` section under `[Unreleased]` in the format `[#123](https://github.com/architects-toolkit/SmartHopper/issues/123)`. Always preserve these issue references when simplifying - do not remove issue IDs or links. + +When simplifying the `### Fixed` section, ensure you: + +- Keep all issue references in the format `[#123](link-to-issue)` +- Do not remove issue IDs even if simplifying the description +- Group related fixes by issue when possible for clarity diff --git a/.windsurf/workflows/code-style.md b/.windsurf/workflows/code-style.md index 87d9e829b..5dabe99f7 100644 --- a/.windsurf/workflows/code-style.md +++ b/.windsurf/workflows/code-style.md @@ -1,26 +1,19 @@ --- -description: Clean code style based on general rules +description: Clean C# code style based on project rules auto_execution_mode: 1 --- -1. Ask the user which files they want to clean the code style, if not provided. +1. Ask the user which files they want to clean if no file list is provided. -2. With the given list of files, perform de following cleaning in *.cs files: - - Add docstrings to all elements (public, private, internal, protected...). - - Ensure correct indention for all elements. - - When using `/// `, ensure the parent has docstring. - - There must be a blank line between elements. - - Code should not contain multiple whitespace characters in a row, such as: - Min = 1, -> Min = 1, - - Use trailing comma in multi-line initializers. - - Single-line comment must be preceded by blank line. - - Remove multiple consecutive blank lines. - - Closing brace should be followed by blank line. - - Add omitted braces in if, for, while, try... - - Closing parenthesis should not be followed by a space. - - Prefix local calls with "this." - - Using statements should be sorted alphabetically, keeping the system ones at the top. - - The heading of the file should be as follows, replacing YYYY with the creation year of the file: +2. For the selected `*.cs` files, apply the repository style: + - Add XML docstrings to members when missing. Use `/// ` only when the parent/interface member has accurate documentation. + - Ensure 4-space indentation and remove repeated whitespace that is not meaningful alignment. + - Keep one blank line between members and remove multiple consecutive blank lines. + - Use braces for `if`, `for`, `foreach`, `while`, `try`, and similar blocks. + - Use trailing commas in multi-line initializers. + - Prefix instance member access with `this.` where appropriate. + - Sort using directives alphabetically, with `System` namespaces first. + - Preserve or add the standard file header, replacing `YYYY` with the file creation year or range: /* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) YYYY Marc Roca Musach @@ -29,4 +22,6 @@ auto_execution_mode: 1 * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 3 of the License, or (at your option) any later version. - */ \ No newline at end of file + */ + +3. Do not perform broad refactors during a style cleanup unless the user explicitly asks for them. diff --git a/.windsurf/workflows/commit-message.md b/.windsurf/workflows/commit-message.md index b90a7926f..8e201ebd6 100644 --- a/.windsurf/workflows/commit-message.md +++ b/.windsurf/workflows/commit-message.md @@ -1,11 +1,11 @@ --- -description: Write a commit message for the currently staged/uncommited changes +description: Write a commit message for the currently staged or uncommitted changes --- -The aim is to return a semantic commit message "(): " for the current staged or uncommited changes. +The aim is to return a Conventional Commit message `(): ` for the current staged or uncommitted changes. -1. Check for staged changes or, if none, all uncommited changes +1. Check for staged changes or, if none, all uncommitted changes. -2. Analyze them +2. Analyze them. -3. Return a semantinc commit message \ No newline at end of file +3. Return one semantic commit message. diff --git a/.windsurf/workflows/docs-update.md b/.windsurf/workflows/docs-update.md index eea6bc3c5..d44908778 100644 --- a/.windsurf/workflows/docs-update.md +++ b/.windsurf/workflows/docs-update.md @@ -7,7 +7,7 @@ auto_execution_mode: 1 1. **Inventory the codebase** - Scan `src/` projects and namespaces. - - Github workflows and other code not in `src/` are out of scope in the documentation. + - GitHub workflows and other code outside `src/` are out of scope unless the user explicitly asks for workflow documentation. 2. **Map core abstractions** - List interfaces, base classes, public APIs, and extension points. @@ -34,7 +34,7 @@ auto_execution_mode: 1 ``` 5. **Add diagrams (Mermaid)** - - Provide graphs when relevant to help undestand relationships, dependencies and flows. + - Provide graphs when relevant to explain relationships, dependencies, and flows. ``` ```mermaid graph TD @@ -46,7 +46,7 @@ auto_execution_mode: 1 ``` 6. **Component docs** - - For each main component, create/update `docs/components/.md` with: + - For each main component, create/update `docs/Components//.md` with: - Purpose - Public APIs (signatures) - Dependencies @@ -56,4 +56,4 @@ auto_execution_mode: 1 - Completeness: major components covered. - Accuracy: interfaces/methods match code. - Decisions: key architectural trade‑offs captured. - - Token efficiency: bullets, deduplication, link instead of repeat. \ No newline at end of file + - Token efficiency: bullets, deduplication, link instead of repeat. diff --git a/.windsurf/workflows/fix-compilation-messages.md b/.windsurf/workflows/fix-compilation-messages.md index 09f8644ca..e7b1a182a 100644 --- a/.windsurf/workflows/fix-compilation-messages.md +++ b/.windsurf/workflows/fix-compilation-messages.md @@ -1,9 +1,9 @@ --- -description: Fix compilation errors, warnings and informations +description: Fix compilation errors, warnings, and informational messages auto_execution_mode: 1 --- -Fix the following compilation messages (errors, warnings and informations). +Fix the following compilation messages (errors, warnings, and informational messages). Do not cause breaking changes. @@ -11,4 +11,4 @@ Do not ask for confirmation before applying changes that directly fix the messag Summarize the type of issues fixed, do not list all changes. -Do not recompile at the end. \ No newline at end of file +Do not run a full rebuild at the end unless the user asks for it; this workflow is for applying fixes from already-provided compiler output. diff --git a/.windsurf/workflows/new-branch.md b/.windsurf/workflows/new-branch.md index 2bfc34ef0..6dcc41425 100644 --- a/.windsurf/workflows/new-branch.md +++ b/.windsurf/workflows/new-branch.md @@ -8,7 +8,7 @@ description: Add a new git branch based on the description provided by the user The user should provide a description of the purpose of the new branch. If no description is provided, ask for it and stop the workflow. -Check that current branch is `dev`. If not, switch to `dev` before continuing. +Check that the current branch is `dev`. If not, switch to `dev` before continuing. ## Branch Naming Convention @@ -67,7 +67,7 @@ Where: 1. Ensure there are no changes pending to commit. Stop the execution if so and ask the user to commit them. -2. Ensure we are in `dev` branch and it is synced with remote. +2. Ensure the current branch is `dev` and synced with remote. git checkout dev git pull public dev @@ -78,4 +78,4 @@ Where: 4. Tell the user that the new branch was created, and explain why you chose the target version number. -Stop here, do not implement any change in files. \ No newline at end of file +Stop here, do not implement any change in files. diff --git a/.windsurf/workflows/pr-description.md b/.windsurf/workflows/pr-description.md index bfcd0512a..0a0b76993 100644 --- a/.windsurf/workflows/pr-description.md +++ b/.windsurf/workflows/pr-description.md @@ -14,4 +14,4 @@ The aim is to return the title and the description for a PR, following the rules 5. Return the PR title in a codeblock as plain text -6. Return the PR description in a seperate codeblock in markdown format \ No newline at end of file +6. Return the PR description in a separate code block in Markdown format. diff --git a/.windsurf/workflows/review.md b/.windsurf/workflows/review.md index 6595c4a6c..c9f775bcf 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 764e9b630..e75c8dc65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,22 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.3-rc] - 2026-05-18 + +### Added + +- **Component Name Aliases**: Added `ComponentNameAliases` utility that maps informal AI-emitted component names (e.g., "python", "csharp", "slider") to their canonical Grasshopper names (e.g., "Python 3 Script", "C# Script", "Number Slider") before GhJSON placement. Aliases are resolved against the live Grasshopper component server to obtain actual GUIDs, preserving the original name for handler matching. + +### Fixed + +- Fixed ScriptGenerate output not being placed by GhPlace component. +- Fixed `gh_put` failing to instantiate components when AI uses informal names (e.g., "Python" instead of "Python 3 Script"). +- Fixed script code not being applied to placed script components: alias resolution now sets `ComponentGuid` from the live component server while preserving the original name so GhJSON's deserialization handlers still match and apply extensions (e.g., script code). + +## [1.4.2-rc] - 2026-05-17 + Many thanks to the following contributors to this release: - [marc-romu](https://github.com/marc-romu) ---- +### Added + +- **New AI models** across all providers (Apr 2026 update): + - OpenAI: `gpt-5.5`, `gpt-image-2` (new image flagship) + - Anthropic: `claude-opus-4-7` + - DeepSeek: `deepseek-v4-pro`, `deepseek-v4-flash` + - MistralAI: multiple dated aliases and new `devstral-2-25-12` code-agent model + - OpenRouter: mirrored models from native providers + ### 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 +- **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 -### Fixed +## [1.4.2-beta] - 2026-04-15 + +Many thanks to the following contributors to this release: + +- [marc-romu](https://github.com/marc-romu) + +---- + +### Changed -- `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 +- **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 @@ -50,36 +81,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 @@ -91,52 +106,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 @@ -154,896 +146,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 @@ -1066,332 +471,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 @@ -1402,54 +610,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 456838002..177e6770d 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.3--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 3ee46eaa9..c58622358 100644 --- a/Solution.props +++ b/Solution.props @@ -1,5 +1,5 @@ - 1.4.2-beta + 1.4.3-rc \ No newline at end of file diff --git a/docs/Development/patch-propagation.md b/docs/Development/patch-propagation.md new file mode 100644 index 000000000..0ab208b06 --- /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 952ef52a2..9cf7acbc7 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/hashes/1.4.2-beta.json b/hashes/1.4.2-beta.json new file mode 100644 index 000000000..8320b8229 --- /dev/null +++ b/hashes/1.4.2-beta.json @@ -0,0 +1,26 @@ +{ + "version": "1.4.2-beta", + "generated": "2026-04-15T10:25:34Z", + "providers": { + "SmartHopper.Providers.OpenRouter.dll-net7.0-windows": "7cb75f6ead63f7fc21cedde1215b49f3661a15e63b1491d7db1fde01a9c6f6eb", + "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": "d4c7522d36b645e3869e42d733ba4b4ad2e3b421dcdaf054dff5a6c8ba3c46b1", + "SmartHopper.Providers.DeepSeek.dll-net7.0-windows": "7d2f1a937e06f2ebb7bc45e6b31065ac062b26979a0d54bbe4cd789793cebfb2", + "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" + } +} diff --git a/hashes/1.4.2-rc.json b/hashes/1.4.2-rc.json new file mode 100644 index 000000000..2469c4b53 --- /dev/null +++ b/hashes/1.4.2-rc.json @@ -0,0 +1,26 @@ +{ + "metadata": { + "buildNumber": "47", + "commitSha": "6ca58f27977b54614a0140521f8af4c17c7072dd", + "platforms": [ + "net7.0-windows", + "net7.0" + ], + "repository": "architects-toolkit/SmartHopper" + }, + "generated": "2026-05-17T14:29:30Z", + "version": "1.4.2-rc", + "algorithm": "SHA-256", + "providers": { + "SmartHopper.Providers.MistralAI.dll-net7.0-windows": "b24f557d6db0298e42e44b86968984567908b738b686983e29a539d6bada9dcd", + "SmartHopper.Providers.DeepSeek.dll-net7.0-windows": "f6c2cb87ef0ec04aa58109e69d890b6d04f7471e420b7fd44f07b0fc59db3afd", + "SmartHopper.Providers.OpenAI.dll-net7.0": "cd94d2b066016ba37622a231d8d9ab1d0d12921907ea932ae44ac00c858e7dd3", + "SmartHopper.Providers.OpenRouter.dll-net7.0": "044bf34a6c310dd0e01fb6b79634dccf3471ee21094a0cef2d37b7878df91d81", + "SmartHopper.Providers.OpenRouter.dll-net7.0-windows": "5c3784781b1428289064c6aaa549e2fa6ba0072c00a56ad4d9fc5956cd50c5e6", + "SmartHopper.Providers.OpenAI.dll-net7.0-windows": "e75fe279fbb7440820276513f9aa8df9aa87ec159e05c771be8abca501303a27", + "SmartHopper.Providers.Anthropic.dll-net7.0": "a06cc7830ceed9b210f0017e2184507d42d1e916848519aee19f1cf4db1e8b5c", + "SmartHopper.Providers.Anthropic.dll-net7.0-windows": "223f54cde6b7f70c4b038756de56f7f511b2d3b3839bdb24837ed69fa293b59c", + "SmartHopper.Providers.DeepSeek.dll-net7.0": "831bba83f7d557609f04c55e8ec761172b21d2643fd79982f5f2c2830ca3f85f", + "SmartHopper.Providers.MistralAI.dll-net7.0": "c1bf1feb2bb3a50d292aa5dbcce2397735c3ff971dce215d2782a7ff73213702" + } +} diff --git a/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs b/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs index 20a5327e1..d3785e53e 100644 --- a/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs +++ b/src/SmartHopper.Components/Grasshopper/GhPutComponents.cs @@ -181,7 +181,7 @@ public override async Task DoWorkAsync(CancellationToken token) } // Success path: read tool result payload from gh_put - var toolResultInteraction = aiResult.Body?.GetLastInteraction() as AIInteractionToolResult; + var toolResultInteraction = aiResult.Body?.GetLastInteraction(AIAgent.ToolResult) as AIInteractionToolResult; var toolResult = toolResultInteraction?.Result; this.analysis = toolResult?["analysis"]?.ToString(); diff --git a/src/SmartHopper.Core.Grasshopper/AITools/gh_put.cs b/src/SmartHopper.Core.Grasshopper/AITools/gh_put.cs index ac3b73baa..288da2d03 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/gh_put.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/gh_put.cs @@ -90,6 +90,16 @@ private async Task GhPutToolAsync(AIToolCall toolCall) GhJson.IsValid(json, out analysisMsg); var document = GhJson.FromJson(json); + // Resolve informal AI-emitted component names ("csharp", "slider", ...) + // by looking up canonical names in the live Grasshopper component server. + // Sets both ComponentGuid and Name from the server so GhJSON can + // instantiate by GUID and match handlers by canonical name. + var aliasSubstitutions = ComponentNameAliases.ResolveFromServer(document); + if (aliasSubstitutions > 0) + { + Debug.WriteLine($"[gh_put] Resolved {aliasSubstitutions} component-name alias(es)."); + } + // Apply fixes to normalize AI-generated JSON var fixResult = GhJson.Fix(document); document = fixResult.Document; @@ -463,11 +473,46 @@ IGH_DocumentObject FindOwner(IGH_Param param) .Select(o => o.Name) .ToList(); + // Build a combined analysis message that surfaces silent failures + // (FailedComponents / Warnings) which CanvasPlacer collects when + // SkipInvalidComponents is true. + var analysisSections = new List(); + if (!string.IsNullOrWhiteSpace(analysisMsg)) + { + analysisSections.Add(analysisMsg); + } + + if (putResult.FailedComponents != null && putResult.FailedComponents.Count > 0) + { + var lines = new List { "Errors:" }; + foreach (var failed in putResult.FailedComponents) + { + lines.Add($"- Could not instantiate component '{failed}'. Component is unknown to the active Grasshopper installation."); + } + + analysisSections.Add(string.Join("\n", lines)); + } + + if (putResult.Warnings != null && putResult.Warnings.Count > 0) + { + var lines = new List { "Warnings:" }; + foreach (var w in putResult.Warnings) + { + lines.Add($"- {w}"); + } + + analysisSections.Add(string.Join("\n", lines)); + } + + var combinedAnalysis = analysisSections.Count > 0 + ? string.Join("\n", analysisSections) + : null; + var toolResult = new JObject { ["components"] = JArray.FromObject(placedNames), ["instanceGuids"] = JArray.FromObject(placedGuids), - ["analysis"] = analysisMsg, + ["analysis"] = combinedAnalysis, }; var body = AIBodyBuilder.Create() diff --git a/src/SmartHopper.Core.Grasshopper/AITools/script_generate.cs b/src/SmartHopper.Core.Grasshopper/AITools/script_generate.cs index 366f6c6a5..a0a77b6a7 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/script_generate.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/script_generate.cs @@ -108,6 +108,63 @@ You MUST use ONLY RhinoCommon geometry types from the `Rhino.Geometry` namespace - description: Parameter description - dataMapping: 'None', 'Flatten', 'Graft' - reverse, simplify, invert: Same as inputs + + ## INPUT/OUTPUT Variable Matching + + Grasshopper script components require EXACT variable name matching between parameters and script code: + + **INPUT VARIABLES:** + - Each input parameter MUST have a corresponding variable in the script with the SAME name + - Example: If input parameter is "Numbers", the script will automatically have access to a variable named "Numbers" + - Example: If input parameter is "Point", the script will automatically have access to a variable named "Point" + - The variable is automatically available - just use it directly in your code + + **OUTPUT VARIABLES:** + - Each output parameter MUST have a corresponding variable in the script with the SAME name + - Example: If output parameter is "Total", the script MUST assign a value to a variable named "Total" + - Example: If output parameter is "Result", the script MUST have "Result = ..." somewhere + - This is how Grasshopper knows what to output in each component output + + **LANGUAGE-SPECIFIC EXAMPLES:** + + Python: + ```python + # Inputs: Numbers (list), Factor (double) + # Outputs: Scaled (list), Sum (double) + + # Use input variables directly - they are already available + scaled = [x * Factor for x in Numbers] # Uses "Numbers" and "Factor" inputs + total = sum(Numbers) * Factor # Uses "Numbers" and "Factor" inputs + + # Assign to output variables - this is REQUIRED + Scaled = scaled # Output variable must match parameter name "Scaled" + Total = total # Output variable must match parameter name "Total" + ``` + + C#: + ```csharp + // Inputs: Numbers (list), Factor (double) + // Outputs: Scaled (list), Sum (double) + + var scaled = new List(); + foreach (var x in Numbers) { + scaled.Add(x * Factor); // Uses "Numbers" and "Factor" inputs + } + + double sum = 0; + foreach (var n in Numbers) { + sum += n * Factor; + } + + Scaled = scaled; // Output variable must match parameter name "Scaled" + Total = sum; // Output variable must match parameter name "Total" + ``` + + **IMPORTANT:** + - Input variables are automatically available - just read from them + - Output variables MUST be assigned - this is how data leaves the script + - Always use EXACT parameter names for outputs (e.g., "Total" not "total") + The JSON object will be parsed programmatically, so it must be valid JSON with no additional text. """; diff --git a/src/SmartHopper.Core.Grasshopper/AITools/script_review.cs b/src/SmartHopper.Core.Grasshopper/AITools/script_review.cs index 898574a86..20e8a1bb1 100644 --- a/src/SmartHopper.Core.Grasshopper/AITools/script_review.cs +++ b/src/SmartHopper.Core.Grasshopper/AITools/script_review.cs @@ -22,6 +22,7 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using GhJSON.Core.SchemaModels; using GhJSON.Grasshopper; using GhJSON.Grasshopper.Serialization; using Grasshopper.Kernel; @@ -178,6 +179,7 @@ private async Task ScriptReviewToolAsync(AIToolCall toolCall) string scriptCode = string.Empty; string language = "unknown"; var componentData = new JObject(); + GhJsonComponent? props = null; try { @@ -190,7 +192,7 @@ private async Task ScriptReviewToolAsync(AIToolCall toolCall) var componentsList = new List { activeTarget }; var document = GhJsonGrasshopper.Serialize(componentsList, SerializationOptions.Default); - var props = document.Components.FirstOrDefault(); + props = document.Components.FirstOrDefault(); if (props != null) { @@ -224,6 +226,103 @@ private async Task ScriptReviewToolAsync(AIToolCall toolCall) // Coded static checks by language var codedIssues = new List(); + // Extract input and output parameter names from component + var inputNames = new List(); + var outputNames = new List(); + + if (props?.InputSettings != null) + { + foreach (var inParam in props.InputSettings) + { + if (!string.IsNullOrWhiteSpace(inParam.ParameterName)) + { + inputNames.Add(inParam.ParameterName); + } + } + } + + if (props?.OutputSettings != null) + { + foreach (var outParam in props.OutputSettings) + { + if (!string.IsNullOrWhiteSpace(outParam.ParameterName)) + { + outputNames.Add(outParam.ParameterName); + } + } + } + + // Check input variable usage - inputs should be referenced in the script + foreach (var inputName in inputNames) + { + // For Python, check if the variable is used (referenced) + if (language == "python" || language == "ironpython") + { + // Check for variable reference (not just definition) + var pattern = $@"\b{Regex.Escape(inputName)}\b"; + var matches = Regex.Matches(scriptCode, pattern); + if (matches.Count == 0) + { + codedIssues.Add($"Input parameter '{inputName}' is not used in the script."); + } + } + else if (language == "c#") + { + // In C#, check if the variable is referenced (not just in the RunScript signature) + var escapedName = Regex.Escape(inputName); + var pattern = $@"(?<' to output data."); + } + } + // Always check for TODO comments if (scriptCode.Contains("TODO", StringComparison.OrdinalIgnoreCase)) codedIssues.Add("Found TODO comments in script."); @@ -275,6 +374,15 @@ private async Task ScriptReviewToolAsync(AIToolCall toolCall) .WithContextFilter(contextFilter) .AddSystem(systemPrompt); + // Build coded issues summary to pass to AI review + string codedIssuesSummary = string.Empty; + if (codedIssues.Count > 0) + { + codedIssuesSummary = "\n\n## Static Analysis Findings\nThe following issues were detected by automated checks:\n" + + string.Join("\n", codedIssues.Select(i => $"- {i}")) + + "\n\nPlease address these findings in your review."; + } + string userPrompt; if (string.IsNullOrWhiteSpace(question)) { @@ -286,6 +394,8 @@ private async Task ScriptReviewToolAsync(AIToolCall toolCall) userPrompt = userPrompt.Replace("", scriptCode); } + userPrompt += codedIssuesSummary; + builder.AddUser(userPrompt); var immutableBody = builder.Build(); diff --git a/src/SmartHopper.Core.Grasshopper/Utils/Canvas/ComponentNameAliases.cs b/src/SmartHopper.Core.Grasshopper/Utils/Canvas/ComponentNameAliases.cs new file mode 100644 index 000000000..4a811e8bb --- /dev/null +++ b/src/SmartHopper.Core.Grasshopper/Utils/Canvas/ComponentNameAliases.cs @@ -0,0 +1,258 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using GhJSON.Core.SchemaModels; +using Grasshopper.Kernel; + +namespace SmartHopper.Core.Grasshopper.Utils.Canvas +{ + /// + /// Maps informal or abbreviated component names commonly emitted by AI models + /// to their canonical Grasshopper names so that GhJSON can instantiate them. + /// This is a lightweight orchestration-layer pre-pass; full fuzzy resolution + /// lives in GhJSON.Core.NameResolution (available from GhJSON 1.1.0+). + /// + public static class ComponentNameAliases + { + /// + /// Case-insensitive dictionary mapping informal names to canonical Grasshopper names. + /// + public static readonly IReadOnlyDictionary Aliases = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // Script components + { "c#", "C# Script" }, + { "c#script", "C# Script" }, + { "csharp", "C# Script" }, + { "cscript", "C# Script" }, + { "python", "Python 3 Script" }, + { "py", "Python 3 Script" }, + { "pythonscript", "Python 3 Script" }, + { "python3", "Python 3 Script" }, + { "python3script", "Python 3 Script" }, + { "ironpython", "IronPython 2 Script" }, + { "vb", "VB Script" }, + { "vbscript", "VB Script" }, + + // Parameter components + { "slider", "Number Slider" }, + { "numberslider", "Number Slider" }, + { "numslider", "Number Slider" }, + { "panel", "Panel" }, + { "point", "Point" }, + { "pt", "Point" }, + { "curve", "Curve" }, + { "crv", "Curve" }, + { "number", "Number" }, + { "num", "Number" }, + { "integer", "Integer" }, + { "int", "Integer" }, + { "boolean", "Boolean" }, + { "bool", "Boolean" }, + { "toggle", "Boolean Toggle" }, + { "booleantoggle", "Boolean Toggle" }, + + // Basic geometry components + { "line", "Line" }, + { "ln", "Line" }, + { "circle", "Circle" }, + { "circ", "Circle" }, + { "rectangle", "Rectangle" }, + { "rect", "Rectangle" }, + { "box", "Box" }, + { "cube", "Box" }, + { "sphere", "Sphere" }, + { "cylinder", "Cylinder" }, + { "cyl", "Cylinder" }, + { "cone", "Cone" }, + + // Plane components + { "plane", "XY Plane" }, + { "xyplane", "XY Plane" }, + { "xy", "XY Plane" }, + { "xzplane", "XZ Plane" }, + { "xz", "XZ Plane" }, + { "yzplane", "YZ Plane" }, + { "yz", "YZ Plane" }, + + // Construct components + { "constructpoint", "Construct Point" }, + { "ptxyz", "Construct Point" }, + { "xyz", "Construct Point" }, + { "constructplane", "Construct Plane" }, + { "constructvector", "Construct Vector" }, + { "vec", "Vector XYZ" }, + { "vector", "Vector XYZ" }, + { "vectorxyz", "Vector XYZ" }, + + // Math components + { "add", "Addition" }, + { "addition", "Addition" }, + { "plus", "Addition" }, + { "sub", "Subtraction" }, + { "subtraction", "Subtraction" }, + { "minus", "Subtraction" }, + { "mul", "Multiplication" }, + { "multiplication", "Multiplication" }, + { "multiply", "Multiplication" }, + { "div", "Division" }, + { "division", "Division" }, + { "divide", "Division" }, + { "abs", "Absolute" }, + { "absolute", "Absolute" }, + { "neg", "Negative" }, + { "negative", "Negative" }, + { "pow", "Power" }, + { "power", "Power" }, + { "sqrt", "Square Root" }, + { "squareroot", "Square Root" }, + + // List components + { "listitem", "List Item" }, + { "listlength", "List Length" }, + { "reverse", "Reverse List" }, + { "reverselist", "Reverse List" }, + { "sort", "Sort List" }, + { "sortlist", "Sort List" }, + { "flatten", "Flatten" }, + { "graft", "Graft Tree" }, + { "grafttree", "Graft Tree" }, + + // Set components + { "series", "Series" }, + { "range", "Range" }, + { "random", "Random" }, + { "domain", "Construct Domain" }, + { "constructdomain", "Construct Domain" }, + + // Transform components + { "move", "Move" }, + { "rotate", "Rotate" }, + { "scale", "Scale" }, + { "mirror", "Mirror" }, + { "orient", "Orient" }, + + // Surface components + { "loft", "Loft" }, + { "extrude", "Extrude" }, + { "extrudepoint", "Extrude Point" }, + { "sweep", "Sweep1" }, + { "sweep1", "Sweep1" }, + { "sweep2", "Sweep2" }, + + // Mesh components + { "mesh", "Mesh" }, + { "meshbox", "Mesh Box" }, + { "meshsphere", "Mesh Sphere" }, + + // Display components + { "colour", "Colour Swatch" }, + { "color", "Colour Swatch" }, + { "colourswatch", "Colour Swatch" }, + { "colorswatch", "Colour Swatch" }, + }; + + /// + /// Resolves a single component name to its canonical form. + /// Returns the canonical name if an alias is found, or the trimmed input otherwise. + /// + /// The component name or alias to resolve. + /// The canonical component name, or the trimmed input if no alias matches. + public static string Resolve(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return name; + } + + var trimmed = name.Trim(); + return ((IDictionary)Aliases).TryGetValue(trimmed, out var canonical) + ? canonical + : trimmed; + } + + /// + /// Resolves informal component aliases in a GhJSON document by looking up + /// canonical names in the live Grasshopper component server. + /// + /// When a component lacks a ComponentGuid and its name matches a known + /// alias, the canonical name is looked up in to + /// obtain the real GUID. Both ComponentGuid and Name are set on + /// the component so GhJSON can instantiate it by GUID and match handlers by + /// canonical name. + /// + /// + /// The GhJSON document to resolve (mutated in place). + /// The number of components that were resolved. + public static int ResolveFromServer(GhJsonDocument? document) + { + if (document?.Components == null) + { + return 0; + } + + var resolved = 0; + foreach (var component in document.Components) + { + if (component == null || string.IsNullOrWhiteSpace(component.Name)) + { + continue; + } + + // If a ComponentGuid is already provided, trust it. + if (component.ComponentGuid.HasValue) + { + continue; + } + + var canonical = Resolve(component.Name); + if (string.Equals(canonical, component.Name, StringComparison.Ordinal)) + { + continue; + } + + // Look up the canonical name in the live Grasshopper component server. + IGH_ObjectProxy proxy = ObjectFactory.FindProxy(canonical); + if (proxy != null) + { + component.ComponentGuid = proxy.Guid; + component.Name = canonical; + Debug.WriteLine( + $"[ComponentNameAliases] Alias '{component.Name}' -> '{canonical}' " + + $"(GUID {proxy.Guid})"); + resolved++; + } + else + { + // Component not installed; fall back to setting the canonical + // name so GhJSON can report a meaningful error. + Debug.WriteLine( + $"[ComponentNameAliases] Alias '{component.Name}' -> '{canonical}' " + + "(component not found in server, falling back to name change)"); + component.Name = canonical; + resolved++; + } + } + + return resolved; + } + } +} diff --git a/src/SmartHopper.Core.Grasshopper/Utils/Parsing/AIResponseParser.cs b/src/SmartHopper.Core.Grasshopper/Utils/Parsing/AIResponseParser.cs index 812f9f268..a3d4a0cc0 100644 --- a/src/SmartHopper.Core.Grasshopper/Utils/Parsing/AIResponseParser.cs +++ b/src/SmartHopper.Core.Grasshopper/Utils/Parsing/AIResponseParser.cs @@ -20,6 +20,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using Grasshopper.Kernel.Types; using Newtonsoft.Json; @@ -40,6 +41,12 @@ public static partial class AIResponseParser [GeneratedRegex(@"```(?:json|txt|text)?\s*\n?(.*?)\n?```", RegexOptions.Singleline)] private static partial Regex MarkdownCodeBlockRegex(); + /// + /// Regex pattern for removing trailing commas before a closing object or array bracket. + /// + [GeneratedRegex(@",(\s*[\]}])")] + private static partial Regex TrailingCommaRegex(); + /// /// Regex pattern for extracting the first bracketed array from text. /// @@ -552,7 +559,12 @@ public static string NormalizeJsonArrayString(List values) /// /// Attempts to extract and parse a JSON object from an AI response that may contain /// markdown formatting, HTML tags, or other non-JSON wrapping. - /// Uses brace-depth tracking to correctly extract the first complete JSON object. + /// Uses a multi-stage recovery pipeline: + /// 1. Direct parse + /// 2. Strip markdown code-block fences, then parse + /// 3. Extract first JSON object by brace-depth tracking, then parse + /// 4. Sanitize common AI malformations (control chars, trailing commas, smart quotes, + /// unbalanced containers), then parse each candidate /// /// Raw response from the AI. /// Parsed . @@ -564,17 +576,17 @@ public static JObject SanitizeAndParseJson(string response) throw new JsonException("AI response is empty."); } - // Try direct parse first + // Strategy 1: Try direct parse first try { return JObject.Parse(response); } catch (JsonException) { - // Continue with sanitization attempts + // Continue with recovery strategies } - // Try extracting JSON from markdown code blocks + // Strategy 2: Try extracting JSON from markdown code blocks var codeBlockContent = ExtractFromMarkdownCodeBlock(response); if (!string.IsNullOrEmpty(codeBlockContent)) { @@ -588,7 +600,7 @@ public static JObject SanitizeAndParseJson(string response) } } - // Try extracting the first complete JSON object by tracking brace depth + // Strategy 3: Try extracting the first complete JSON object by tracking brace depth var jsonCandidate = ExtractFirstJsonObject(response); if (!string.IsNullOrEmpty(jsonCandidate)) { @@ -598,7 +610,26 @@ public static JObject SanitizeAndParseJson(string response) } catch (JsonException) { - // Continue + // Continue with sanitization + } + } + + // Strategy 4: Sanitize common AI malformations and retry each candidate + foreach (var candidate in new[] { codeBlockContent, jsonCandidate, response }) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + continue; + } + + var sanitized = SanitizeJsonString(candidate); + try + { + return JObject.Parse(sanitized); + } + catch (JsonException) + { + // Continue to next candidate } } @@ -613,6 +644,149 @@ public static JObject SanitizeAndParseJson(string response) throw new JsonException($"AI response is not valid JSON. Preview: {preview}"); } + /// + /// Sanitizes a JSON-like string produced by an AI, making a best-effort attempt to + /// recover from common malformations: + /// - Unescaped control characters inside string literals are escaped + /// - Smart/curly quotes are normalized to standard quotes + /// - Zero-width spaces are stripped + /// - Trailing commas before closing brackets are removed + /// - Unterminated string literals are closed + /// - Unbalanced containers are closed in correct LIFO order + /// + /// The potentially malformed JSON string. + /// A sanitized JSON string. + private static string SanitizeJsonString(string json) + { + if (string.IsNullOrEmpty(json)) + { + return json; + } + + var sb = new StringBuilder(json.Length + 16); + var containers = new Stack(); + bool inString = false; + bool escape = false; + + for (int i = 0; i < json.Length; i++) + { + char c = json[i]; + + // Normalize smart quotes and BOM/ZWSP outside of string literals. + if (!inString) + { + switch (c) + { + case '\u201C': // left double quotation mark + case '\u201D': // right double quotation mark + sb.Append('"'); + inString = true; + continue; + case '\uFEFF': // BOM / zero-width no-break space + case '\u200B': // zero-width space + continue; + } + } + + if (escape) + { + sb.Append(c); + escape = false; + continue; + } + + if (inString && c == '\\') + { + sb.Append(c); + escape = true; + continue; + } + + if (c == '"') + { + sb.Append(c); + inString = !inString; + continue; + } + + // Smart closing quote inside a string also closes the string. + if (inString && (c == '\u201C' || c == '\u201D')) + { + sb.Append('"'); + inString = false; + continue; + } + + if (inString) + { + switch (c) + { + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + default: + if (c < 0x20) + { + sb.Append("\\u").Append(((int)c).ToString("X4")); + } + else + { + sb.Append(c); + } + + break; + } + } + else + { + switch (c) + { + case '{': + case '[': + containers.Push(c); + break; + case '}': + if (containers.Count > 0 && containers.Peek() == '{') + { + containers.Pop(); + } + + break; + case ']': + if (containers.Count > 0 && containers.Peek() == '[') + { + containers.Pop(); + } + + break; + } + + sb.Append(c); + } + } + + // Close unterminated string literal. + if (inString) + { + sb.Append('"'); + } + + // Close unbalanced containers in LIFO order. + while (containers.Count > 0) + { + sb.Append(containers.Pop() == '{' ? '}' : ']'); + } + + var result = sb.ToString(); + + // Remove trailing commas before closing } or ]. + result = TrailingCommaRegex().Replace(result, "$1"); + + return result; + } + /// /// Truncates text for display purposes, appending "..." if truncated. /// @@ -630,12 +804,13 @@ private static string TruncateForDisplay(string text, int maxLength = 200) } /// - /// Extracts the first complete JSON object from text by tracking brace depth. - /// Correctly handles nested objects and string literals with escape sequences. + /// Extracts the first complete JSON container (object or array) from text by tracking + /// brace/bracket depth. Correctly handles nested containers and string literals with + /// escape sequences. /// - /// The text to search for a JSON object. - /// The first complete JSON object string, or null if none found. - private static string ExtractFirstJsonObject(string text) + /// The text to search for a JSON container. + /// The first complete JSON container string, or null if none found. + private static string ExtractFirstJsonContainer(string text) { if (string.IsNullOrEmpty(text)) { @@ -644,14 +819,15 @@ private static string ExtractFirstJsonObject(string text) bool inString = false; bool escapeNext = false; - int braceDepth = 0; + int depth = 0; int startIndex = -1; + char opener = '\0'; + char closer = '\0'; for (int i = 0; i < text.Length; i++) { char c = text[i]; - // Handle escape sequences within strings if (escapeNext) { escapeNext = false; @@ -664,33 +840,47 @@ private static string ExtractFirstJsonObject(string text) continue; } - // Toggle string state on unescaped quotes if (c == '"') { inString = !inString; continue; } - // Skip everything inside strings if (inString) { continue; } - // Track brace depth to find complete JSON objects - if (c == '{') + if (depth == 0 && startIndex < 0) { - if (braceDepth == 0) + if (c == '{') + { + opener = '{'; + closer = '}'; + } + else if (c == '[') { - startIndex = i; + opener = '['; + closer = ']'; } + else + { + continue; + } + + startIndex = i; + depth = 1; + continue; + } - braceDepth++; + if (c == opener) + { + depth++; } - else if (c == '}' && braceDepth > 0) + else if (c == closer && depth > 0) { - braceDepth--; - if (braceDepth == 0 && startIndex >= 0) + depth--; + if (depth == 0) { return text.Substring(startIndex, i - startIndex + 1); } @@ -700,6 +890,18 @@ private static string ExtractFirstJsonObject(string text) return null; } + /// + /// Extracts the first complete JSON object from text. + /// Convenience wrapper that filters out array roots. + /// + /// The text to search for a JSON object. + /// The first complete JSON object string, or null if none found. + private static string ExtractFirstJsonObject(string text) + { + var container = ExtractFirstJsonContainer(text); + return (container != null && container.Length > 0 && container[0] == '{') ? container : null; + } + #endregion } } diff --git a/src/SmartHopper.Infrastructure/AIModels/AICapability.cs b/src/SmartHopper.Infrastructure/AIModels/AICapability.cs index 69b45a0eb..d2df58712 100644 --- a/src/SmartHopper.Infrastructure/AIModels/AICapability.cs +++ b/src/SmartHopper.Infrastructure/AIModels/AICapability.cs @@ -56,9 +56,9 @@ public enum AICapability AudioInput = SpeechInput | (1 << 2), /// - /// Supports accepting structured JSON input. + /// Supports accepting video input (video understanding or analysis). /// - JsonInput = 1 << 3, + VideoInput = 1 << 3, // Output capabilities @@ -100,6 +100,16 @@ public enum AICapability /// Reasoning = 1 << 9, + /// + /// Can generate video as output. + /// + VideoOutput = 1 << 12, + + /// + /// Can produce embedding vectors as output (for similarity search, clustering, etc.). + /// + EmbedOutput = 1 << 13, + // Composite capabilities for default definition /// @@ -218,14 +228,24 @@ public static string ToDetailedString(this AICapability capabilities) flags.Add("AudioInput"); } + if ((capabilities & AICapability.VideoInput) == AICapability.VideoInput) + { + flags.Add("VideoInput"); + } + if ((capabilities & AICapability.AudioOutput) == AICapability.AudioOutput) { flags.Add("AudioOutput"); } - if ((capabilities & AICapability.JsonInput) == AICapability.JsonInput) + if ((capabilities & AICapability.VideoOutput) == AICapability.VideoOutput) + { + flags.Add("VideoOutput"); + } + + if ((capabilities & AICapability.EmbedOutput) == AICapability.EmbedOutput) { - flags.Add("JsonInput"); + flags.Add("EmbedOutput"); } if ((capabilities & AICapability.JsonOutput) == AICapability.JsonOutput) @@ -257,7 +277,7 @@ public static bool HasInput(this AICapability capability) (capability & AICapability.ImageInput) == AICapability.ImageInput || (capability & AICapability.AudioInput) == AICapability.AudioInput || (capability & AICapability.SpeechInput) == AICapability.SpeechInput || - (capability & AICapability.JsonInput) == AICapability.JsonInput; + (capability & AICapability.VideoInput) == AICapability.VideoInput; } /// @@ -271,7 +291,9 @@ public static bool HasOutput(this AICapability capability) (capability & AICapability.ImageOutput) == AICapability.ImageOutput || (capability & AICapability.AudioOutput) == AICapability.AudioOutput || (capability & AICapability.SpeechOutput) == AICapability.SpeechOutput || - (capability & AICapability.JsonOutput) == AICapability.JsonOutput; + (capability & AICapability.JsonOutput) == AICapability.JsonOutput || + (capability & AICapability.VideoOutput) == AICapability.VideoOutput || + (capability & AICapability.EmbedOutput) == AICapability.EmbedOutput; } /// diff --git a/src/SmartHopper.Infrastructure/AIModels/AIModelCapabilities.cs b/src/SmartHopper.Infrastructure/AIModels/AIModelCapabilities.cs index 43a44ab08..5824f65d7 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/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs b/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs index f74963d3d..31ef7c833 100644 --- a/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs +++ b/src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs @@ -48,217 +48,171 @@ public override Task> RetrieveModels() var models = new List { - new AIModelCapabilities - { - Provider = providerName, - Model = "claude-opus-4-1", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, - SupportsStreaming = true, - Verified = false, - Deprecated = true, - Rank = 15, - 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, - }, + // Released between February 2026 and May 2026 + 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, + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + Default = AICapability.Text2Json, SupportsStreaming = true, Verified = false, - Rank = 85, - ContextLimit = 200000, + Rank = 10000, + ContextLimit = 1000000, }, + new AIModelCapabilities { Provider = providerName, - Model = "claude-haiku-4-6", + Model = "claude-opus-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" }, + Rank = 9995, + 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-7", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 75, - ContextLimit = 200000, + Rank = 9990, + ContextLimit = 1000000, }, + + + + // Released between November 2025 and February 2026 + new AIModelCapabilities { Provider = providerName, - Model = "claude-opus-4-0", + 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 = 20, + Rank = 9985, ContextLimit = 200000, + Aliases = new List { "claude-opus-4-5", "claude-opus-4-5-latest" }, }, + + + + // Released between August 2025 and November 2025 + 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.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + Default = AICapability.Text2Text | AICapability.ReasoningChat | AICapability.ToolReasoningChat | AICapability.ToolChat | AICapability.Image2Text, SupportsStreaming = true, Verified = true, - Rank = 80, - ContextLimit = 200000, - }, - new AIModelCapabilities - { - Provider = providerName, - Model = "claude-sonnet-4-0", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, - SupportsStreaming = true, - Verified = false, - Deprecated = true, - Rank = 70, - ContextLimit = 200000, - }, - new AIModelCapabilities - { - Provider = providerName, - Model = "claude-3-7-sonnet-latest", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, - SupportsStreaming = true, - Verified = false, - Deprecated = true, - Rank = 60, - ContextLimit = 200000, - }, - new AIModelCapabilities - { - Provider = providerName, - Model = "claude-3-5-haiku-latest", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, - SupportsStreaming = true, - Verified = false, - Deprecated = true, - Rank = 60, + Rank = 9980, ContextLimit = 200000, + Aliases = new List { "claude-haiku-4-5", "claude-haiku-4-5-latest" }, DiscouragedForTools = new List { "script_generate", "script_edit" }, }, + new AIModelCapabilities { Provider = providerName, - Model = "claude-haiku-4-5", + Model = "claude-sonnet-4-5-20250929", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, - Default = AICapability.Text2Text | AICapability.ReasoningChat | AICapability.ToolReasoningChat, SupportsStreaming = true, Verified = true, - Rank = 95, + Rank = 9975, ContextLimit = 200000, - DiscouragedForTools = new List { "script_generate", "script_edit" }, + Aliases = new List { "claude-sonnet-4-5", "claude-sonnet-4-5-latest" }, }, + + + + // Deprecated models + new AIModelCapabilities { Provider = providerName, - Model = "claude-3-5-haiku-20241022", + 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 = 90, + Rank = 0, ContextLimit = 200000, - DiscouragedForTools = new List { "script_generate", "script_edit" }, + Aliases = new List { "claude-opus-4-1", "claude-opus-4-1-latest" }, }, + new AIModelCapabilities { Provider = providerName, - Model = "claude-3-7-sonnet-20250219", + Model = "claude-sonnet-4-20250514", Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, - Rank = 60, + Rank = -5, ContextLimit = 200000, + Aliases = new List { "claude-sonnet-4", "claude-sonnet-4-latest", "claude-sonnet-4-0", "claude-sonnet-4-0-latest" }, }, + new AIModelCapabilities { Provider = providerName, - Model = "claude-3-haiku-20240307", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Model = "claude-opus-4-20250514", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 70, Deprecated = true, + Rank = -10, ContextLimit = 200000, - DiscouragedForTools = new List { "script_generate", "script_edit" }, - }, - new AIModelCapabilities - { - Provider = providerName, - Model = "claude-haiku-4-5-20251001", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, - SupportsStreaming = true, - Verified = true, - Rank = 85, - ContextLimit = 200000, - DiscouragedForTools = new List { "script_generate", "script_edit" }, + Aliases = new List { "claude-opus-4", "claude-opus-4-latest", "claude-opus-4-0", "claude-opus-4-0-latest" }, }, + 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-20250219", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, - Rank = 20, + Rank = -15, ContextLimit = 200000, + Aliases = new List { "claude-3-7-sonnet", "claude-3-7-sonnet-latest" }, }, + new AIModelCapabilities { Provider = providerName, - Model = "claude-opus-4-20250514", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Model = "claude-3-5-haiku-20241022", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, - Rank = 20, + Rank = -20, ContextLimit = 200000, + Aliases = new List { "claude-3-5-haiku", "claude-3-5-haiku-latest" }, + DiscouragedForTools = new List { "script_generate", "script_edit" }, }, + new AIModelCapabilities { Provider = providerName, - Model = "claude-sonnet-4-20250514", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Model = "claude-3-haiku-20240307", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, Deprecated = true, - Rank = 75, - 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, - SupportsStreaming = true, - Verified = true, - Rank = 80, + Rank = -25, ContextLimit = 200000, - }, + Aliases = new List { "claude-3-haiku", "claude-3-haiku-latest" }, + DiscouragedForTools = new List { "script_generate", "script_edit" }, + } }; return Task.FromResult(models); diff --git a/src/SmartHopper.Providers.DeepSeek/DeepSeekProviderModels.cs b/src/SmartHopper.Providers.DeepSeek/DeepSeekProviderModels.cs index bb171f586..b46e45073 100644 --- a/src/SmartHopper.Providers.DeepSeek/DeepSeekProviderModels.cs +++ b/src/SmartHopper.Providers.DeepSeek/DeepSeekProviderModels.cs @@ -47,26 +47,57 @@ 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.ReasoningChat | AICapability.ToolReasoningChat | AICapability.Text2Json, SupportsStreaming = true, - Rank = 80, - ContextLimit = 64000, + Verified = false, + Rank = 10000, + 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 = 9995, + ContextLimit = 1048576, }, + + + + // Deprecated models + new AIModelCapabilities { Provider = provider, Model = "deepseek-chat", 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); diff --git a/src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs b/src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs index 33f9c1fd6..99b985088 100644 --- a/src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs +++ b/src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs @@ -53,190 +53,368 @@ 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, + Default = AICapability.Text2Text | AICapability.ToolChat | AICapability.Text2Json | AICapability.Image2Text, SupportsStreaming = true, Verified = true, - Rank = 90, + Rank = 10000, 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" }, }, + new AIModelCapabilities { Provider = provider, - Model = "mistral-medium-latest", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "mistral-medium-3-5", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.Reasoning | AICapability.FunctionCalling | AICapability.JsonOutput, + Default = AICapability.ReasoningChat | AICapability.ToolReasoningChat, SupportsStreaming = true, - Verified = true, - Rank = 80, - ContextLimit = 131072, - Aliases = new List { "mistral-medium" }, + Verified = false, + Rank = 9995, + 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 between November 2025 and February 2026 + new AIModelCapabilities { Provider = provider, - Model = "mistral-large-latest", + Model = "ministral-3b-2512", Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, - SupportsStreaming = true, + SupportsStreaming = false, Verified = false, - Rank = 60, + Rank = 9990, ContextLimit = 131072, - Aliases = new List { "mistral-large" }, + Aliases = new List { "ministral-3b-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "ministral-8b-latest", + Model = "ministral-8b-2512", Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, SupportsStreaming = false, Verified = false, - Rank = 50, + Rank = 9985, ContextLimit = 131072, + Aliases = new List { "ministral-8b-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "ministral-3b-latest", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "ministral-14b-2512", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, SupportsStreaming = false, Verified = false, - Rank = 30, + Rank = 9980, + ContextLimit = 262144, + Aliases = new List { "ministral-14b-latest" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-large-2512", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 9975, ContextLimit = 131072, + Aliases = new List { "mistral-large", "mistral-large-latest" }, }, + 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 = "devstral-2512", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, SupportsStreaming = true, Verified = false, - Rank = 85, - ContextLimit = 40000, + Rank = 9970, + ContextLimit = 262144, + Aliases = new List { "devstral-medium-latest", "devstral-latest" }, + }, + + + + // Released between May 2025 and August 2025 + + new AIModelCapabilities + { + Provider = provider, + Model = "codestral-2508", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9965, + 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 = 9960, + ContextLimit = 131072, + Aliases = new List { "mistral-medium", "mistral-medium-latest", "mistral-vibe-cli-with-tools" }, }, + + new AIModelCapabilities + { + Provider = provider, + Model = "codestral-embed", + Capabilities = AICapability.TextInput | AICapability.EmbedOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9955, + Aliases = new List { "codestral-embed-2505" }, + }, + new AIModelCapabilities { Provider = provider, - Model = "magistral-medium-latest", + Model = "labs-leanstral-2603", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9950, + ContextLimit = 256000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "magistral-medium-2509", Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 75, + Rank = 9945, ContextLimit = 40000, + Aliases = new List { "magistral-medium-latest" }, }, - // Speech models new AIModelCapabilities { Provider = provider, - Model = "voxtral-small-latest", - Capabilities = AICapability.SpeechInput | AICapability.TextOutput, - Default = AICapability.Speech2Text, + Model = "mistral-embed-2312", + Capabilities = AICapability.TextInput | AICapability.EmbedOutput, SupportsStreaming = false, Verified = false, - Rank = 70, - ContextLimit = 32000, + Rank = 9940, + Aliases = new List { "mistral-embed" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-medium-2505", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9935, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-ocr-2512", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.ImageOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9930, + Aliases = new List { "mistral-ocr-latest" }, + DiscouragedForTools = new List { "*" }, }, + new AIModelCapabilities { Provider = provider, - Model = "voxtral-mini-latest", - Capabilities = AICapability.SpeechInput | AICapability.TextOutput, + Model = "open-mistral-nemo", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9925, + 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, Default = AICapability.Speech2Text, SupportsStreaming = false, Verified = false, - Rank = 60, + Rank = 9920, ContextLimit = 32000, + Aliases = new List { "voxtral-mini-latest" }, }, - // 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, + Model = "voxtral-mini-tts-2603", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.AudioOutput, + Default = AICapability.Text2Speech, + SupportsStreaming = false, Verified = false, - Rank = 88, - ContextLimit = 131072, + Rank = 9910, + Aliases = new List { "voxtral-mini-tts-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "mistral-large-3-25-12", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "voxtral-small-2507", + Capabilities = AICapability.AudioInput | AICapability.TextOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9905, + 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, - Rank = 65, + Deprecated = true, + Rank = 0, ContextLimit = 131072, }, + new AIModelCapabilities { Provider = provider, - Model = "ministral-3-14b-25-12", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "pixtral-large-2411", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, SupportsStreaming = false, Verified = false, - Rank = 55, + Deprecated = true, + Rank = -5, ContextLimit = 131072, + Aliases = new List { "pixtral-large-latest", "mistral-large-pixtral-2411" }, }, + new AIModelCapabilities { Provider = provider, - Model = "ministral-3-8b-25-12", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, - SupportsStreaming = false, + Model = "devstral-medium-2507", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, Verified = false, - Rank = 45, - ContextLimit = 131072, + Deprecated = true, + Rank = -10, }, + new AIModelCapabilities { Provider = provider, - Model = "ministral-3-3b-25-12", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, - SupportsStreaming = false, + Model = "devstral-small-2507", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, Verified = false, - Rank = 35, - ContextLimit = 131072, + Deprecated = true, + Rank = -15, }, + new AIModelCapabilities { Provider = provider, - Model = "codestral-25-08", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "magistral-small-2509", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 68, - ContextLimit = 131072, + Deprecated = true, + Rank = -20, }, + new AIModelCapabilities { Provider = provider, - Model = "voxtral-tts-26-03", - Capabilities = AICapability.TextInput | AICapability.SpeechOutput, - Default = AICapability.Text2Speech, + Model = "mistral-moderation-2411", + Capabilities = AICapability.TextInput, SupportsStreaming = false, Verified = false, - Rank = 72, - ContextLimit = 32000, + Deprecated = true, + Rank = -25, + ContextLimit = 128000, + Aliases = new List { "mistral-moderation-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "voxtral-mini-transcribe-25-07", - Capabilities = AICapability.SpeechInput | AICapability.TextOutput, - Default = AICapability.Speech2Text, + Model = "mistral-moderation-2603", + Capabilities = AICapability.TextInput, SupportsStreaming = false, Verified = false, - Rank = 62, - ContextLimit = 32000, + Deprecated = true, + Rank = -30, }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-ocr-2505", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.ImageOutput, + SupportsStreaming = false, + Verified = false, + Deprecated = true, + Rank = -35, + DiscouragedForTools = new List { "*" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistral-small-2506", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = false, + Verified = false, + Deprecated = true, + Rank = -40, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "voxtral-mini-transcribe-2507", + Capabilities = AICapability.AudioInput | AICapability.TextOutput, + SupportsStreaming = false, + Verified = false, + Deprecated = true, + Rank = -45, + Aliases = new List { "voxtral-mini-2507" }, + } }; return Task.FromResult(models); diff --git a/src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs b/src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs index 26cd3e2ff..e0fb82ff2 100644 --- a/src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs +++ b/src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs @@ -52,119 +52,144 @@ public override Task> RetrieveModels() var models = new List { + // Released between February 2026 and May 2026 + new AIModelCapabilities { Provider = provider, - Model = "gpt-5-nano", + Model = "gpt-5.4-nano-2026-03-17", Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - Default = AICapability.Text2Text, SupportsStreaming = true, Verified = false, - Rank = 70, + Rank = 10000, ContextLimit = 400000, + Aliases = new List { "gpt-5.4-nano", "gpt-5.4-nano-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5-mini", + Model = "gpt-5.4-mini-2026-03-17", 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, + Verified = false, + Default = AICapability.Text2Text | AICapability.ToolChat | AICapability.ReasoningChat | AICapability.ToolReasoningChat | AICapability.Text2Json | AICapability.Image2Text, + Rank = 9995, ContextLimit = 400000, + Aliases = new List { "gpt-5.4-mini", "gpt-5.4-mini-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5", + Model = "gpt-5.3-chat-latest", Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 70, + Rank = 9990, ContextLimit = 400000, + Aliases = new List { "gpt-5.3-chat" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5.4", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + Model = "gpt-5.3-codex", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 95, + Rank = 9985, ContextLimit = 400000, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5.4-mini", + 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 = 100, - ContextLimit = 400000, + Rank = 9980, + ContextLimit = 1050000, + Aliases = new List { "gpt-5.4", "gpt-5.4-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5.4-nano", + 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 = 80, - ContextLimit = 400000, + Rank = 9975, + ContextLimit = 1050000, + Aliases = new List { "gpt-5.5", "gpt-5.5-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "codex-mini-latest", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + 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, - Deprecated = true, - Rank = 60, - ContextLimit = 200000, + Rank = 9970, + ContextLimit = 1050000, + Aliases = new List { "gpt-5.4-pro", "gpt-5.4-pro-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5-codex", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + 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 = 70, - ContextLimit = 400000, + Rank = 9965, + ContextLimit = 1050000, + Aliases = new List { "gpt-5.5-pro", "gpt-5.5-pro-latest" }, }, + + + + // Released between November 2025 and February 2026 + 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-5.1-codex-mini", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 80, - ContextLimit = 128000, + Rank = 9960, + ContextLimit = 400000, }, + 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-audio-mini-2025-12-15", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + Default = AICapability.Text2Speech | AICapability.Speech2Text, + SupportsStreaming = false, Verified = false, - Rank = 90, - ContextLimit = 400000, + Rank = 9955, + ContextLimit = 128000, + Aliases = new List { "gpt-audio-mini", "gpt-audio-mini-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5.1", + 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 = 80, + Rank = 9950, ContextLimit = 400000, + Aliases = new List { "gpt-5.1", "gpt-5.1-latest" }, }, + new AIModelCapabilities { Provider = provider, @@ -172,9 +197,11 @@ public override Task> RetrieveModels() Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 70, + Rank = 9945, ContextLimit = 128000, + Aliases = new List { "gpt-5.1-chat" }, }, + new AIModelCapabilities { Provider = provider, @@ -182,113 +209,184 @@ public override Task> RetrieveModels() Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 75, + Rank = 9940, ContextLimit = 400000, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-5.1-codex-mini", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + Model = "gpt-5.1-codex-max", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 75, + Rank = 9935, ContextLimit = 400000, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4.1", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + 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 = 75, - ContextLimit = 1047576, + Rank = 9930, + ContextLimit = 400000, + Aliases = new List { "gpt-5.2", "gpt-5.2-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4.1-mini", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "gpt-5.2-chat-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 85, - ContextLimit = 1047576, + Rank = 9925, + ContextLimit = 128000, + Aliases = new List { "gpt-5.2-chat" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4.1-nano", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "gpt-5.2-codex", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 65, - ContextLimit = 1047576, + Rank = 9920, + ContextLimit = 400000, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4-turbo", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling, - SupportsStreaming = true, + Model = "gpt-audio-2025-08-28", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + SupportsStreaming = false, Verified = false, - Rank = 40, + Rank = 9915, ContextLimit = 128000, + Aliases = new List { "gpt-audio", "gpt-audio-latest" }, + }, + + 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 = 9910, + ContextLimit = 400000, + Aliases = new List { "gpt-5.2-pro", "gpt-5.2-pro-latest" }, }, + + + + // Released between August 2025 and November 2025 + new AIModelCapabilities { Provider = provider, - Model = "gpt-4", - Capabilities = AICapability.TextInput | AICapability.TextOutput, + 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, - Deprecated = true, - Rank = 40, - ContextLimit = 8192, + Rank = 9905, + ContextLimit = 200000, + Aliases = new List { "o4-mini-deep-research", "o4-mini-deep-research-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-3.5-turbo", - Capabilities = AICapability.TextInput | AICapability.TextOutput, + Model = "gpt-5-codex", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9900, + 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 = 9895, + ContextLimit = 200000, + Aliases = new List { "o3-deep-research", "o3-deep-research-latest" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-audio-preview-2025-06-03", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, SupportsStreaming = false, Verified = false, - Deprecated = true, - Rank = 40, - ContextLimit = 16385, + Rank = 9890, + ContextLimit = 128000, + Aliases = new List { "gpt-4o-audio-preview", "gpt-4o-audio-preview-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "o4-mini", + 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 = 70, - ContextLimit = 200000, + Rank = 9885, + ContextLimit = 400000, + Aliases = new List { "gpt-5-pro", "gpt-5-pro-latest" }, + }, + + + + // Released between May 2025 and August 2025 + + 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, + Default = AICapability.Text2Text | AICapability.ToolChat | AICapability.ReasoningChat | AICapability.ToolReasoningChat | AICapability.Text2Json | AICapability.Image2Text, + Rank = 9880, + ContextLimit = 400000, + Aliases = new List { "gpt-5-mini", "gpt-5-mini-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "o3", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + Model = "gpt-5-nano-2025-08-07", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Deprecated = true, - Rank = 40, - ContextLimit = 200000, + Rank = 9875, + ContextLimit = 400000, + Aliases = new List { "gpt-5-nano", "gpt-5-nano-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "o3-mini", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + Model = "gpt-5-2025-08-07", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Deprecated = true, - Rank = 50, - ContextLimit = 200000, + Rank = 9870, + ContextLimit = 400000, + Aliases = new List { "gpt-5", "gpt-5-latest" }, }, + new AIModelCapabilities { Provider = provider, @@ -296,196 +394,689 @@ public override Task> RetrieveModels() Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 40, + Rank = 9865, ContextLimit = 128000, + Aliases = new List { "gpt-5-chat" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4o-mini", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "o3-pro-2025-06-10", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 30, - ContextLimit = 128000, + Rank = 9860, + ContextLimit = 200000, + Aliases = new List { "o3-pro", "o3-pro-latest" }, }, + + + + // Released between February 2025 and May 2025 + new AIModelCapabilities { Provider = provider, - Model = "gpt-4o", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "gpt-4.1-nano-2025-04-14", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, SupportsStreaming = true, Verified = false, - Rank = 30, - ContextLimit = 128000, + Rank = 9855, + ContextLimit = 1047576, + Aliases = new List { "gpt-4.1-nano", "gpt-4.1-nano-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "chatgpt-4o-latest", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput, + Model = "gpt-4o-mini-search-preview-2025-03-11", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, SupportsStreaming = true, Verified = false, - Deprecated = true, - Rank = 30, + Rank = 9850, ContextLimit = 128000, + Aliases = new List { "gpt-4o-mini-search-preview", "gpt-4o-mini-search-preview-latest" }, }, - // Image new AIModelCapabilities { Provider = provider, - Model = "dall-e-3", - Capabilities = AICapability.TextInput | AICapability.ImageOutput, - Default = AICapability.Text2Image, - SupportsStreaming = false, - Verified = true, - Rank = 80, + Model = "gpt-4.1-mini-2025-04-14", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 9845, + ContextLimit = 1047576, + Aliases = new List { "gpt-4.1-mini", "gpt-4.1-mini-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "dall-e-2", - Capabilities = AICapability.TextInput | AICapability.ImageOutput, - SupportsStreaming = false, + Model = "o4-mini-2025-04-16", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, Verified = false, - Deprecated = true, - Rank = 60, + Rank = 9840, + ContextLimit = 200000, + Aliases = new List { "o4-mini", "o4-mini-latest" }, }, + 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.1-2025-04-14", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = true, Verified = false, - Rank = 70, + Rank = 9835, + ContextLimit = 1047576, + Aliases = new List { "gpt-4.1", "gpt-4.1-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-image-1", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.ImageOutput, - SupportsStreaming = false, + Model = "o3-2025-04-16", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, Verified = false, - Rank = 60, + Rank = 9830, + ContextLimit = 200000, + Aliases = new List { "o3", "o3-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-image-1.5", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.ImageOutput, - SupportsStreaming = false, + Model = "gpt-4o-search-preview-2025-03-11", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, Verified = false, - Rank = 75, + Rank = 9825, + ContextLimit = 128000, + Aliases = new List { "gpt-4o-search-preview", "gpt-4o-search-preview-latest" }, }, - // Speech/TTS new AIModelCapabilities { Provider = provider, - Model = "gpt-4o-mini-tts", - Capabilities = AICapability.TextInput | AICapability.SpeechOutput, - Default = AICapability.Text2Speech, - SupportsStreaming = false, + Model = "o1-pro-2025-03-19", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, Verified = false, - Rank = 60, - ContextLimit = 2000, + Rank = 9820, + ContextLimit = 200000, + Aliases = new List { "o1-pro", "o1-pro-latest" }, }, + + + + // Released between November 2024 and February 2025 + new AIModelCapabilities { Provider = provider, - Model = "gpt-4o-mini-transcribe", - Capabilities = AICapability.SpeechInput | AICapability.TextOutput, - Default = AICapability.Speech2Text, - SupportsStreaming = false, + Model = "o3-mini-2025-01-31", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, Verified = false, - Rank = 70, - ContextLimit = 16000, + Rank = 9815, + ContextLimit = 200000, + Aliases = new List { "o3-mini", "o3-mini-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4o-transcribe", - Capabilities = AICapability.SpeechInput | AICapability.TextOutput, - Default = AICapability.Speech2Text, - SupportsStreaming = false, + Model = "o1-2024-12-17", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, Verified = false, - Rank = 60, - ContextLimit = 16000, + Rank = 9810, + ContextLimit = 200000, + Aliases = new List { "o1", "o1-latest" }, }, + + + + // Released between May 2024 and August 2024 + new AIModelCapabilities { Provider = provider, - Model = "gpt-audio-mini", - Capabilities = AICapability.TextInput | AICapability.SpeechInput | AICapability.TextOutput | AICapability.SpeechOutput | AICapability.FunctionCalling, - SupportsStreaming = false, + Model = "gpt-4o-mini-2024-07-18", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = true, Verified = false, - Rank = 60, + Rank = 9805, ContextLimit = 128000, + Aliases = new List { "gpt-4o-mini", "gpt-4o-mini-latest" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-audio", - Capabilities = AICapability.TextInput | AICapability.SpeechInput | AICapability.TextOutput | AICapability.SpeechOutput | AICapability.FunctionCalling, - SupportsStreaming = false, + Model = "gpt-4o-2024-08-06", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, Verified = false, - Rank = 40, + Rank = 9800, ContextLimit = 128000, + Aliases = new List { "gpt-4o-latest", "gpt-4o" }, }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4o-mini-audio-preview", - Capabilities = AICapability.TextInput | AICapability.SpeechInput | AICapability.TextOutput | AICapability.SpeechOutput | AICapability.FunctionCalling, + Model = "gpt-4o-2024-05-13", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, SupportsStreaming = true, Verified = false, - Deprecated = true, - Rank = 45, + Rank = 9795, 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 = 9790, + }, + new AIModelCapabilities { Provider = provider, - Model = "gpt-4o-audio-preview", - Capabilities = AICapability.TextInput | AICapability.SpeechInput | AICapability.TextOutput | AICapability.SpeechOutput | AICapability.FunctionCalling, + Model = "gpt-3.5-turbo-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, SupportsStreaming = true, Verified = false, - Deprecated = true, - Rank = 40, + Rank = 9785, + 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 = 9780, + ContextLimit = 16385, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "chat-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 9775, ContextLimit = 128000, + Aliases = new List { "chat" }, }, + new AIModelCapabilities { Provider = provider, - Model = "whisper-1", - Capabilities = AICapability.SpeechInput | AICapability.TextOutput, - Default = AICapability.Speech2Text, - SupportsStreaming = false, + Model = "chatgpt-image-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput, Verified = false, - Rank = 40, + Rank = 9770, + ContextLimit = 128000, + Aliases = new List { "chatgpt-image" }, }, + new AIModelCapabilities { Provider = provider, - Model = "tts-1", - Capabilities = AICapability.TextInput | AICapability.SpeechOutput, - Default = AICapability.Text2Speech, + Model = "gpt-4o-audio-preview-2024-12-17", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, SupportsStreaming = false, Verified = false, - Rank = 50, + Rank = 9765, + ContextLimit = 128000, }, + new AIModelCapabilities { Provider = provider, - Model = "tts-1-hd", - Capabilities = AICapability.TextInput | AICapability.SpeechOutput, + Model = "gpt-4o-mini-audio-preview-2024-12-17", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, SupportsStreaming = false, Verified = false, - Rank = 60, + Rank = 9760, + ContextLimit = 128000, + Aliases = new List { "gpt-4o-mini-audio-preview", "gpt-4o-mini-audio-preview-latest" }, }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-mini-transcribe-2025-03-20", + Capabilities = AICapability.AudioInput | AICapability.TextOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9750, + ContextLimit = 16000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-mini-transcribe-2025-12-15", + Capabilities = AICapability.AudioInput | AICapability.TextOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9745, + ContextLimit = 16000, + Aliases = new List { "gpt-4o-mini-transcribe", "gpt-4o-mini-transcribe-latest" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-mini-tts-2025-03-20", + Capabilities = AICapability.TextInput | AICapability.AudioOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9740, + ContextLimit = 2000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-mini-tts-2025-12-15", + Capabilities = AICapability.TextInput | AICapability.AudioOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9735, + ContextLimit = 2000, + Aliases = new List { "gpt-4o-mini-tts", "gpt-4o-mini-tts-latest" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-transcribe", + Capabilities = AICapability.AudioInput | AICapability.TextOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9720, + ContextLimit = 16000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4o-transcribe-diarize", + Capabilities = AICapability.AudioInput | AICapability.TextOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9715, + ContextLimit = 16000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-5-search-api-2025-10-14", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9710, + ContextLimit = 400000, + Aliases = new List { "gpt-5-search-api", "gpt-5-search-api-latest" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-audio-1.5", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + SupportsStreaming = false, + Verified = false, + Rank = 9705, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-audio-mini-2025-10-06", + Capabilities = AICapability.TextInput | AICapability.AudioInput | AICapability.TextOutput | AICapability.AudioOutput | AICapability.FunctionCalling, + SupportsStreaming = false, + Verified = false, + Rank = 9700, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-image-1", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.ImageOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9695, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-image-1-mini", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.ImageOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9690, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-image-1.5", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.ImageOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9685, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-image-2-2026-04-21", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.ImageOutput, + Default = AICapability.Image2Image, + SupportsStreaming = false, + Verified = false, + Rank = 9680, + Aliases = new List { "gpt-image-2", "gpt-image-2-latest" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "omni-moderation-2024-09-26", + Capabilities = AICapability.TextInput | AICapability.ImageInput, + SupportsStreaming = false, + Verified = false, + Rank = 9640, + Aliases = new List { "omni-moderation-latest", "omni-moderation" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "sora-2", + Capabilities = AICapability.TextInput | AICapability.VideoOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9635, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "sora-2-pro", + Capabilities = AICapability.TextInput | AICapability.VideoOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9630, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "text-embedding-3-large", + Capabilities = AICapability.TextInput | AICapability.EmbedOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9625, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "text-embedding-3-small", + Capabilities = AICapability.TextInput | AICapability.EmbedOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9620, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "tts-1", + Capabilities = AICapability.TextInput | AICapability.AudioOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9615, + ContextLimit = 2000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "tts-1-1106", + Capabilities = AICapability.TextInput | AICapability.AudioOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9610, + ContextLimit = 2000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "tts-1-hd", + Capabilities = AICapability.TextInput | AICapability.AudioOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9605, + ContextLimit = 2000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "tts-1-hd-1106", + Capabilities = AICapability.TextInput | AICapability.AudioOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9600, + ContextLimit = 2000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "whisper-1", + Capabilities = AICapability.AudioInput | AICapability.TextOutput, + SupportsStreaming = false, + Verified = false, + Rank = 9595, + }, + + + + // 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 = 0, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-3.5-turbo", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = false, + Verified = false, + Deprecated = true, + Rank = -5, + ContextLimit = 16385, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4-turbo-2024-04-09", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -10, + ContextLimit = 128000, + Aliases = new List { "gpt-4-turbo", "gpt-4-turbo-latest" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-4", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -15, + ContextLimit = 8192, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "babbage-002", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = false, + Verified = false, + Deprecated = true, + Rank = -20, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "chatgpt-4o-latest", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.ImageInput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -25, + ContextLimit = 128000, + Aliases = new List { "chatgpt-4o" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "codex-mini-latest", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -30, + ContextLimit = 200000, + Aliases = new List { "codex-mini" }, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "dall-e-2", + Capabilities = AICapability.TextInput | AICapability.ImageOutput, + SupportsStreaming = false, + Verified = false, + Deprecated = true, + Rank = -35, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "davinci-002", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = false, + Verified = false, + Deprecated = true, + Rank = -40, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-3.5-turbo-0125", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -45, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-3.5-turbo-1106", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -50, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gpt-3.5-turbo-instruct-0914", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + 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 = "text-embedding-ada-002", + Capabilities = AICapability.TextInput | AICapability.EmbedOutput, + SupportsStreaming = false, + Verified = false, + Deprecated = true, + Rank = -65, + } }; return Task.FromResult(models); diff --git a/src/SmartHopper.Providers.OpenRouter/OpenRouterProviderModels.cs b/src/SmartHopper.Providers.OpenRouter/OpenRouterProviderModels.cs index 70c644504..e9b4c47a1 100644 --- a/src/SmartHopper.Providers.OpenRouter/OpenRouterProviderModels.cs +++ b/src/SmartHopper.Providers.OpenRouter/OpenRouterProviderModels.cs @@ -48,123 +48,4236 @@ public override Task> RetrieveModels() { var provider = this.openRouterProvider.Name.ToLowerInvariant(); + // Sample curated models exposed via OpenRouter var models = new List { + // Released between February 2026 and May 2026 + + new AIModelCapabilities + { + Provider = provider, + Model = "openrouter/pareto-code", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 10000, + ContextLimit = 2000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "baidu/cobuddy:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + Verified = false, + Rank = 9995, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "baidu/qianfan-ocr-fast:free", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9990, + ContextLimit = 65536, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-4-26b-a4b-it:free", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9985, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-4-31b-it:free", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9980, + 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 = 9975, + 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 = 9970, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "inclusionai/ring-2.6-1t:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + Verified = false, + Rank = 9965, + ContextLimit = 262144, + }, + + 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 = 9960, + ContextLimit = 196608, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9955, + 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 = 9950, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openrouter/owl-alpha", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9945, + 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 = 9940, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "poolside/laguna-xs.2:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9935, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "ibm-granite/granite-4.1-8b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9930, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "rekaai/reka-edge", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9925, + ContextLimit = 16384, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "liquid/lfm-2-24b-a2b", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9920, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.5-9b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9915, + 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 = 9910, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.5-flash-02-23", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9905, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "tencent/hy3-preview", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + Verified = false, + Rank = 9900, + ContextLimit = 262144, + }, + + 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 = 9895, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-4-26b-a4b-it", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9890, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-4-31b-it", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9885, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "bytedance-seed/seed-2.0-mini", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9880, + 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 = 9875, + 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 = 9870, + 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 = 9865, + ContextLimit = 128000, + }, + + 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 = 9860, + 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 = 9855, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.5-35b-a3b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9850, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.6-35b-a3b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9845, + 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 = 9840, + 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 = 9835, + 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 = 9830, + 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 = 9825, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.6-flash", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9820, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.5-27b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9815, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.5-plus-02-15", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9810, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "aion-labs/aion-2.0", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9805, + ContextLimit = 131072, + }, + + 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 = 9800, + ContextLimit = 202752, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.6-plus", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9795, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "bytedance-seed/seed-2.0-lite", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9790, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "xiaomi/mimo-v2-omni", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9785, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "xiaomi/mimo-v2.5", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9780, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.5-122b-a10b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9775, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-3.1-flash-lite", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + Verified = false, + Rank = 9770, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-3.1-flash-lite-preview", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9765, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.5-397b-a17b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9760, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.5-plus-20260420", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9755, + ContextLimit = 1000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "inclusionai/ling-2.6-1t", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + Verified = false, + Rank = 9750, + ContextLimit = 262144, + }, + + 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 = 9745, + 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 = 9740, + 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 = 9735, + 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 = 9730, + 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 = 9725, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3.6-27b", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9720, + 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 = 9715, + ContextLimit = 262144, + }, + + 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 = 9710, + ContextLimit = 262144, + }, + + 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 = 9705, + ContextLimit = 202752, + }, + + 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 = 9700, + ContextLimit = 202752, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "z-ai/glm-5v-turbo", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9695, + ContextLimit = 202752, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "~google/gemini-flash-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9690, + 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 = 9685, + 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 = 9680, + 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 = 9675, + 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 = 9670, + 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 = 9665, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-medium-3-5", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + Verified = false, + Rank = 9660, + 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 = 9655, + 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 = 9650, + 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 = 9645, + 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 = 9640, + 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 = 9635, + 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 = 9630, + ContextLimit = 272000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "~google/gemini-pro-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9625, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-3.1-pro-preview", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9620, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-3.1-pro-preview-customtools", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9615, + 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 = 9610, + 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 = 9605, + 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 = 9600, + 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 = 9595, + ContextLimit = 1050000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-chat-latest", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + Verified = false, + Rank = 9590, + ContextLimit = 400000, + }, + + 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 = 9585, + 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 = 9580, + 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 = 9575, + 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 = 9570, + 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 = 9565, + 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 = 9560, + 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 = 9555, + 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 = 9550, + 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 = 9545, + 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 = 9540, + 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 = 9535, + 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 = 9530, + 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 = 9525, + 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 = 9520, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "bytedance-seed/seed-1.6-flash", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9515, + 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 = 9510, + 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 = 9505, + 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 = 9500, + 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 = 9495, + ContextLimit = 202752, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek/deepseek-v3.2-speciale", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9490, + ContextLimit = 163840, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "arcee-ai/trinity-large-preview", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9485, + 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 = 9480, + 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 = 9475, + ContextLimit = 131072, + }, + + 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 = 9470, + 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 = 9465, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "z-ai/glm-4.6v", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9460, + 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 = 9455, + 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 = 9450, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "minimax/minimax-m2-her", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9445, + 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 = 9440, + 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 = 9435, + 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 = 9430, + ContextLimit = 202752, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "bytedance-seed/seed-1.6", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9425, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/devstral-2512", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9420, + 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 = 9415, + 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 = 9410, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "amazon/nova-2-lite-v1", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9405, + ContextLimit = 1000000, + }, + + 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 = 9400, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "relace/relace-search", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 9395, + ContextLimit = 256000, + }, + + 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 = 9390, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-3-flash-preview", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9385, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "writer/palmyra-x5", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9380, + 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 = 9375, + 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 = 9370, + 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 = 9365, + 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 = 9360, + 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 = 9355, + 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 = 9350, + 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 = 9345, + 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 = 9340, + 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 = 9335, + ContextLimit = 200000, + }, + + 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 = 9330, + ContextLimit = 1000000, + }, + + 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 = 9325, + 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 = 9320, + 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.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9315, + 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 = 9310, + ContextLimit = 128000, + }, + + 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 = 9305, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "ibm-granite/granite-4.0-h-micro", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9300, + ContextLimit = 131000, + }, + + 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 = 9295, + 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 = 9290, + 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 = 9285, + 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 = 9280, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "microsoft/phi-4-mini-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + Verified = false, + Rank = 9275, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nousresearch/hermes-4-70b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9270, + 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 = 9265, + ContextLimit = 131072, + }, + + 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 = 9260, + 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 = 9255, + 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 = 9250, + 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 = 9245, + 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 = 9240, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "thedrummer/cydonia-24b-v4.1", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9235, + ContextLimit = 131072, + }, + + 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.VideoInput | 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 = "qwen/qwen3-vl-30b-a3b-thinking", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9160, + 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 = 9155, + ContextLimit = 65536, + }, + + 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 = 9150, + ContextLimit = 131072, + }, + + 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 = 9145, + ContextLimit = 400000, + }, + + 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 = 9140, + ContextLimit = 262144, + }, + + 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 = 9135, + 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 = 9130, + 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 = 9125, + 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 = 9120, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-max", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9115, + 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 = 9110, + ContextLimit = 200000, + }, + + 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 = 9105, + ContextLimit = 200000, + }, + + 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 = 9100, + 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 = 9095, + 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 = 9090, + 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 = 9085, + 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 = 9080, + 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 = 9075, + 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 = 9070, + ContextLimit = 128000, + }, + + 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 = 9065, + 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 = 9060, + 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 = 9055, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-oss-120b:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 9050, + 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 = 9045, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-coder:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 9040, + 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 = 9035, + 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 = 9030, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "z-ai/glm-4-32b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 9025, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-3n-e4b-it", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 9020, + ContextLimit = 32768, + }, + + 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 = 9015, + 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 = 9010, + 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 = 9005, + 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 = 9000, + 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 = 8995, + ContextLimit = 160000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/devstral-small", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8990, + 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 = 8985, + ContextLimit = 262144, + }, + + 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 = 8980, + ContextLimit = 400000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "tencent/hunyuan-a13b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8975, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-2.5-flash-lite", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8970, + 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 = 8965, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/codestral-2508", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8960, + ContextLimit = 256000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "baidu/ernie-4.5-300b-a47b", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8955, + ContextLimit = 123000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "morph/morph-v3-fast", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8950, + 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 = 8945, + 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 = 8940, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen3-coder", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8935, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "morph/morph-v3-large", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8930, + ContextLimit = 262144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/devstral-medium", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8925, + ContextLimit = 131072, + }, + + 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 = 8920, + ContextLimit = 400000, + }, + + 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 = 8915, + ContextLimit = 163840, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "minimax/minimax-m1", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8910, + 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 = 8905, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "moonshotai/kimi-k2", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 8900, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "switchpoint/router", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8895, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-2.5-flash", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8890, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "ai21/jamba-large-1.7", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8885, + ContextLimit = 256000, + }, + + 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 = 8880, + 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 = 8875, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-2.5-pro", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8870, + 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 = 8865, + 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 = 8860, + ContextLimit = 1000000, + }, + + 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 = 8855, + ContextLimit = 200000, + }, + + 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 = 8850, + 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 = 8845, + ContextLimit = 200000, + }, + + + + // Released between February 2025 and May 2025 + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-guard-3-8b", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8840, + 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 = 8835, + 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 = 8830, + 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 = 8825, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "arcee-ai/spotlight", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8820, + 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 = 8815, + ContextLimit = 163840, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "rekaai/reka-flash-3", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8810, + 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 = 8805, + 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 = 8800, + 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 = 8795, + 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 = 8790, + 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 = 8785, + ContextLimit = 40960, + }, + + 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 = 8780, + ContextLimit = 40960, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-small-3.1-24b-instruct", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8775, + 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 = 8770, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-saba", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8765, + 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 = 8760, + 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 = 8755, + ContextLimit = 163840, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "arcee-ai/coder-large", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8750, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "thedrummer/skyfall-36b-v2", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8745, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "alfredpros/codellama-7b-instruct-solidity", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8740, + ContextLimit = 4096, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "arcee-ai/virtuoso-large", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 8735, + ContextLimit = 131072, + }, + + 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 = 8730, + 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 = 8725, + 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 = 8720, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "arcee-ai/maestro-reasoning", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8715, + 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 = 8710, + 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 = 8705, + 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 = 8700, + ContextLimit = 200000, + }, + + 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 = 8695, + 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 = 8690, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "perplexity/sonar-deep-research", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8685, + 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 = 8680, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "cohere/command-a", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8675, + ContextLimit = 256000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4o-search-preview", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8670, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-2.5-pro-preview-05-06", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8665, + ContextLimit = 1048576, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "perplexity/sonar-pro", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8660, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/o1-pro", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8655, + 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 = 8650, + 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 = 8645, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen-turbo", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8640, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "amazon/nova-micro-v1", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 8635, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "microsoft/phi-4", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8630, + ContextLimit = 16384, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "cohere/command-r7b-12-2024", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8625, + 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 = 8620, + 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 = 8615, + 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 = 8610, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen-vl-plus", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8605, + ContextLimit = 131072, + }, + + 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 = 8600, + ContextLimit = 32000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "sao10k/l3.3-euryale-70b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8595, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen-plus", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8590, + 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 = 8585, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek/deepseek-chat", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8580, + ContextLimit = 163840, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "perplexity/sonar", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8575, + ContextLimit = 127072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen-2.5-coder-32b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8570, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "minimax/minimax-01", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8565, + ContextLimit = 1000192, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "aion-labs/aion-1.0-mini", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8560, + 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 = 8555, + ContextLimit = 32768, + }, + + 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 = 8550, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "deepseek/deepseek-r1", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8545, + ContextLimit = 64000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "sao10k/l3.1-70b-hanami-x1", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8540, + 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 = 8535, + ContextLimit = 300000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "qwen/qwen-max", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8530, + 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 = 8525, + 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 = 8520, + 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 = 8515, + 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 = 8510, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "aion-labs/aion-1.0", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8505, + 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 = 8500, + 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 = 8495, + 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 = 8490, + 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 = 8485, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "sao10k/l3-lunaris-8b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8480, + 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 = 8475, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-3.2-1b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8470, + 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 = 8465, + 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 = 8460, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-3.2-3b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8455, + 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 = 8450, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "thedrummer/unslopnemo-12b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8445, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "thedrummer/rocinante-12b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8440, + 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 = 8435, + 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 = 8430, + 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 = 8425, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthropic/claude-3.5-haiku", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 8420, + ContextLimit = 200000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "anthracite-org/magnum-v4-72b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8415, + 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 = 8410, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "inflection/inflection-3-pi", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8405, + ContextLimit = 8000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "inflection/inflection-3-productivity", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8400, + ContextLimit = 8000, + }, + + + + // 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 = 8395, + 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 = 8390, + 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 = 8385, + 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 = 8380, + 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 = 8375, + 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 = 8370, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-2-27b-it", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8365, + ContextLimit = 8192, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "sao10k/l3-euryale-70b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling, + SupportsStreaming = true, + Verified = false, + Rank = 8360, + 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 = 8355, + ContextLimit = 128000, + }, + + 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 = 8350, + 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 = 8345, + 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.VideoInput | AICapability.TextOutput | AICapability.ImageOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Rank = 8340, + ContextLimit = 2000000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "gryphe/mythomax-l2-13b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8335, + ContextLimit = 4096, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "microsoft/wizardlm-2-8x22b", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8330, + ContextLimit = 65535, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "undi95/remm-slerp-l2-13b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8325, + ContextLimit = 6144, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "meta-llama/llama-3-70b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8320, + ContextLimit = 8192, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mancer/weaver", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8315, + ContextLimit = 8000, + }, + new AIModelCapabilities { Provider = provider, - Model = "openai/gpt-5.4", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - Default = AICapability.Text2Text | AICapability.Text2Json, + Model = "anthropic/claude-3-haiku", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling, SupportsStreaming = true, Verified = false, - Rank = 95, - ContextLimit = 400000, + Rank = 8310, + ContextLimit = 200000, }, + new AIModelCapabilities { Provider = provider, - Model = "openai/gpt-5.4-mini", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, - Default = AICapability.Text2Text | AICapability.Text2Json, + Model = "openai/gpt-3.5-turbo", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, SupportsStreaming = true, Verified = false, - Rank = 100, - ContextLimit = 400000, + Rank = 8305, + ContextLimit = 16385, }, + new AIModelCapabilities { Provider = provider, - Model = "openai/gpt-5.4-nano", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.Reasoning, + Model = "openai/gpt-3.5-turbo-0613", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, SupportsStreaming = true, Verified = false, - Rank = 85, - ContextLimit = 400000, + Rank = 8300, + ContextLimit = 4095, }, + 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 = "openai/gpt-3.5-turbo-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, SupportsStreaming = true, Verified = false, - Rank = 90, - ContextLimit = 400000, + Rank = 8295, + ContextLimit = 4095, }, + new AIModelCapabilities { Provider = provider, - Model = "anthropic/claude-opus-4-6", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling | AICapability.ImageInput | AICapability.Reasoning, + Model = "openai/gpt-3.5-turbo-16k", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, SupportsStreaming = true, Verified = false, - Rank = 80, - ContextLimit = 200000, + Rank = 8290, + ContextLimit = 16385, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "mistralai/mistral-large", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8285, + 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 = 8280, + ContextLimit = 65536, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "alpindale/goliath-120b", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8275, + 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 = 8270, + 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 = 8265, + 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 = 8260, + ContextLimit = 128000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "openai/gpt-4", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Rank = 8255, + 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 = 8250, + ContextLimit = 8191, + }, + + + + // Deprecated models + + 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, + Deprecated = true, + Rank = 0, + ContextLimit = 2000000, + }, + + 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, + Deprecated = true, + Rank = -5, + ContextLimit = 2000000, + }, + + 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, + Deprecated = true, + Rank = -10, + ContextLimit = 256000, + }, + + 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, + Deprecated = true, + Rank = -15, + ContextLimit = 204800, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "moonshotai/kimi-k2-0905", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -20, + 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, + Deprecated = true, + Rank = -25, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "x-ai/grok-3", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -30, + 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, + Deprecated = true, + Rank = -35, + ContextLimit = 256000, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemini-2.0-flash-lite-001", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -40, + ContextLimit = 1048576, + }, + + 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, + Deprecated = true, + Rank = -45, + ContextLimit = 131072, }, + 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, + Model = "anthropic/claude-3.7-sonnet", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 90, + Deprecated = true, + Rank = -50, 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, + Model = "anthropic/claude-3.7-sonnet:thinking", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.Reasoning, SupportsStreaming = true, Verified = false, - Rank = 95, + Deprecated = true, + Rank = -55, ContextLimit = 200000, }, + new AIModelCapabilities { Provider = provider, - Model = "mistralai/mistral-medium-3.1", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "x-ai/grok-3-beta", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, SupportsStreaming = true, Verified = false, - Rank = 85, + Deprecated = true, + Rank = -60, ContextLimit = 131072, }, + new AIModelCapabilities { Provider = provider, - Model = "deepseek/deepseek-chat-v3.1", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "google/gemini-2.0-flash-001", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.AudioInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput, SupportsStreaming = true, Verified = false, - Rank = 80, - ContextLimit = 60000, + Deprecated = true, + Rank = -65, + ContextLimit = 1000000, }, + new AIModelCapabilities { Provider = provider, - Model = "google/gemini-2.5-flash", - Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "meta-llama/llama-3-8b-instruct", + Capabilities = AICapability.TextInput | AICapability.TextOutput, SupportsStreaming = true, Verified = false, - Rank = 85, - ContextLimit = 1048576, + Deprecated = true, + Rank = -70, + ContextLimit = 8192, }, + new AIModelCapabilities { Provider = provider, - Model = "moonshotai/kimi-k2", - Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.FunctionCalling, + Model = "mistralai/mistral-7b-instruct-v0.1", + Capabilities = AICapability.TextInput | AICapability.TextOutput, SupportsStreaming = true, Verified = false, - Rank = 70, - ContextLimit = 128000, + Deprecated = true, + Rank = -75, + ContextLimit = 2824, + }, + + 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 = -80, + ContextLimit = 65536, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-3-12b-it:free", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.TextOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -85, + 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, + Deprecated = true, + Rank = -90, + 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, + Deprecated = true, + Rank = -95, + ContextLimit = 32768, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-3n-e2b-it:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -100, + ContextLimit = 8192, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "google/gemma-3n-e4b-it:free", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.JsonOutput, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -105, + ContextLimit = 8192, + }, + + 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 = -110, + ContextLimit = 262144, + }, + + 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 = -115, + ContextLimit = 32768, + }, + + 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 = -120, + ContextLimit = 131072, + }, + + new AIModelCapabilities + { + Provider = provider, + Model = "nvidia/nemotron-nano-12b-v2-vl", + Capabilities = AICapability.TextInput | AICapability.ImageInput | AICapability.VideoInput | AICapability.TextOutput | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -125, + ContextLimit = 131072, + }, + + 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 = -130, + ContextLimit = 262144, }, + + new AIModelCapabilities + { + Provider = provider, + Model = "tngtech/deepseek-r1t2-chimera", + Capabilities = AICapability.TextInput | AICapability.TextOutput | AICapability.FunctionCalling | AICapability.JsonOutput | AICapability.Reasoning, + SupportsStreaming = true, + Verified = false, + Deprecated = true, + Rank = -135, + ContextLimit = 163840, + } }; return Task.FromResult(models); diff --git a/tools/Update-ModelVerified.ps1 b/tools/Update-ModelVerified.ps1 new file mode 100644 index 000000000..032491f7d --- /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 diff --git a/tools/Update-ProviderModels.ps1 b/tools/Update-ProviderModels.ps1 new file mode 100644 index 000000000..ddec57706 --- /dev/null +++ b/tools/Update-ProviderModels.ps1 @@ -0,0 +1,1461 @@ +<# +.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 = $false)][string] $ApiKey = "", + [Parameter(Mandatory = $false)][string] $ProviderApiKey = "", + [Parameter(Mandatory = $false)][string] $TargetFile = "", + [Parameter(Mandatory = $false)][switch] $UpdateFile, + [Parameter(Mandatory = $false)][switch] $FailOnValidationErrors, + [Parameter(Mandatory = $false)][switch] $ValidateOnly +) + +$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' = '' +} + +# 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 +} + +$CompositeDefaultCapabilities = [ordered]@{ + Text2Text = @('TextInput', 'TextOutput') + ToolChat = @('TextInput', 'TextOutput', 'FunctionCalling') + ReasoningChat = @('TextInput', 'TextOutput', 'Reasoning') + ToolReasoningChat = @('TextInput', 'TextOutput', 'Reasoning', 'FunctionCalling') + Text2Json = @('TextInput', 'JsonOutput') + Text2Image = @('TextInput', 'ImageOutput') + Text2Speech = @('TextInput', 'AudioOutput') + Speech2Text = @('AudioInput', 'TextOutput') + Image2Text = @('ImageInput', 'TextOutput') + Image2Image = @('ImageInput', 'ImageOutput') +} + +# 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' } } } +} + +# --------------------------------------------------------------------------- +# 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)) { + $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') { + $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 ($inputModalities -contains 'video') { $caps.Add('AICapability.VideoInput') } + + 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 ($outputModalities -contains 'video') { $caps.Add('AICapability.VideoOutput') } + + 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') + } + + # Embed output is indicated by embedding-specific endpoints or model naming patterns + # OpenRouter: embedding models expose 'embeddings' in output_modalities per the API spec + if ($outputModalities -contains 'embeddings' -or + $supportedParams -contains 'embeddings' -or + $openRouterModel.id -match 'embed' -or + $openRouterModel.description -match 'embed') { + $caps.Add('AICapability.EmbedOutput') + } + + if ($caps.Count -eq 0) { + return 'AICapability.None' + } + return ($caps -join ' | ') +} + +function Test-CapabilityExpressionContains($expression, $capabilityName) { + return -not [string]::IsNullOrWhiteSpace($expression) -and $expression -match "(^|[^A-Za-z0-9_])AICapability\.$([regex]::Escape($capabilityName))([^A-Za-z0-9_]|$)" +} + +function Test-CapabilityExpressionHasAll($expression, $capabilityNames) { + foreach ($capabilityName in $capabilityNames) { + if (-not (Test-CapabilityExpressionContains $expression $capabilityName)) { + return $false + } + } + return $true +} + +function Test-RealtimeModelName($modelName) { + return -not [string]::IsNullOrWhiteSpace($modelName) -and $modelName -match '(?i)realtime' +} + +function Test-ProviderModelValidation($models) { + $validationErrors = [System.Collections.Generic.List[string]]::new() + $missingDefaultCapabilities = [System.Collections.Generic.List[string]]::new() + $pendingCapabilityModels = [System.Collections.Generic.List[string]]::new() + $realtimeModels = [System.Collections.Generic.List[string]]::new() + + $nonDeprecatedModelsForValidation = @($models | Where-Object { $_.Deprecated -ne 'true' }) + + foreach ($composite in $CompositeDefaultCapabilities.GetEnumerator()) { + $capableModels = @($nonDeprecatedModelsForValidation | Where-Object { + Test-CapabilityExpressionHasAll $_.Capabilities $composite.Value + }) + + if ($capableModels.Count -eq 0) { continue } + + $defaultModels = @($nonDeprecatedModelsForValidation | Where-Object { + Test-CapabilityExpressionContains $_.Default $composite.Key + }) + + if ($defaultModels.Count -eq 0) { + [void]$missingDefaultCapabilities.Add($composite.Key) + [void]$validationErrors.Add("Missing default for AICapability.$($composite.Key) while $($capableModels.Count) non-deprecated model(s) support $($composite.Value -join ', ').") + } + } + + foreach ($m in $models) { + if ($m.Deprecated -ne 'true' -and ( + [string]::IsNullOrWhiteSpace($m.Capabilities) -or + $m.Capabilities -eq 'AICapability.None' -or + $m.RawBlock -match '//\s*TODO:\s*retrieve capabilities')) { + [void]$pendingCapabilityModels.Add($m.Model) + [void]$validationErrors.Add("Model '$($m.Model)' is non-deprecated but has pending capability definition.") + } + + if (Test-RealtimeModelName $m.Model) { + [void]$realtimeModels.Add($m.Model) + [void]$validationErrors.Add("Realtime model '$($m.Model)' is present in the provider model list.") + } + } + + return [ordered]@{ + success = ($validationErrors.Count -eq 0) + errors = @($validationErrors) + missingDefaultCapabilities = @($missingDefaultCapabilities) + pendingCapabilityModels = @($pendingCapabilityModels) + realtimeModels = @($realtimeModels) + } +} + +# --------------------------------------------------------------------------- +# 1. Read and parse the existing C# file +# --------------------------------------------------------------------------- +# Read as UTF-8 text (handles BOM correctly), matching Update-LicenseHeaders.ps1 +$sourceContent = [System.IO.File]::ReadAllText($TargetFile, [System.Text.Encoding]::UTF8) +# Normalize: strip BOM if present so regexes don't see \uFEFF +$sourceContent = $sourceContent -replace '^[\uFEFF]', '' + +# 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)." + +if ($ValidateOnly) { + $validation = Test-ProviderModelValidation @($existingModels.Values) + $report = [ordered]@{ + provider = $Provider + apiUrl = $null + providerApiQueried = $false + providerApiUrl = $null + apiModels = @() + openrouterModels = @() + providerApiModels = $null + sourceModels = @($existingModels.Keys | Sort-Object) + newModels = @() + deprecatedModels = @() + unchangedModels = @() + fileUpdated = $false + validation = $validation + } + + Write-Output ($report | ConvertTo-Json -Depth 10) + + if (-not $validation.success) { + # Surface each validation issue as a GitHub Actions error annotation so + # the message is visible in the run log. We deliberately use Write-Host + # (not Write-Error) here: Write-Error under $ErrorActionPreference = + # 'Stop' throws a terminating exception which propagates out of the + # script as an opaque [System.Management.Automation.RuntimeException], + # making downstream catch blocks (e.g. the fetch-models action wrapper) + # surface a generic "script failed" message instead of the actual + # validation details. + foreach ($validationError in $validation.errors) { + Write-Host "::error title=$Provider provider model validation::$validationError" + } + } + + if ($FailOnValidationErrors -and -not $validation.success) { + exit 9 + } + + exit 0 +} + +# --------------------------------------------------------------------------- +# 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) +} + +function Get-LogicalModelKey($modelName) { + if ([string]::IsNullOrWhiteSpace($modelName)) { return $modelName } + return ([string]$modelName).ToLowerInvariant() -replace '(?<=\d)[\.-](?=\d)', '.' +} + +$openRouterModels = [System.Collections.Generic.List[psobject]]::new() +foreach ($item in $response.data) { + $fullId = $item.id + if ([string]::IsNullOrWhiteSpace($fullId)) { continue } + + if ($fullId.StartsWith('ft:')) { continue } + if (Test-RealtimeModelName $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'." + +# 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 '\.', '-' +} + +# 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)) + } + } + + # 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 (-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) } + } + + 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) +$openRouterLookup = @{ } +foreach ($orm in $openRouterModels) { + $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 + } +} + +# --------------------------------------------------------------------------- +# 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) -and -not $pm.id.StartsWith('ft:')) { + if (Test-RealtimeModelName $pm.id) { continue } + [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." + } +} + +# --------------------------------------------------------------------------- +# Build provider-API alias + deprecation maps. +# +# 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. +# 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) +$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() + } + } + 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 = Get-LogicalModelKey ($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 + } + + # 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 + # 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 } + } + + # 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 ([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 bare baseKey and baseKey-latest as aliases on the canonical + # dated id, regardless of whether the API listed them as separate entries. + foreach ($implied in @($baseKey, "$baseKey-latest")) { + if (-not [string]::Equals($implied, $canonical, 'OrdinalIgnoreCase') -and + -not ($aliasList | Where-Object { [string]::Equals($_, $implied, 'OrdinalIgnoreCase') })) { + [void]$aliasList.Add($implied) + } + } + + if ($aliasList.Count -gt 0) { + $apiAliasesByCanonical[$canonical] = $aliasList.ToArray() + } + } + } + else { + foreach ($pmId in $providerApiModelNames) { + $pm = $providerApiLookup[$pmId] + if ($pm.aliases -and $pm.aliases.Count -gt 0) { + $apiAliasesByCanonical[$pmId] = @($pm.aliases | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + } + } + + foreach ($srcKey in @($existingModels.Keys)) { + $logicalSourceKey = Get-LogicalModelKey $srcKey + $canonicalVersionPeer = $providerApiModelNames | Where-Object { + -not [string]::Equals($_, $srcKey, 'OrdinalIgnoreCase') -and + [string]::Equals((Get-LogicalModelKey $_), $logicalSourceKey, 'OrdinalIgnoreCase') + } | Select-Object -First 1 + + if (-not $canonicalVersionPeer) { continue } + + $apiCanonicalByAlias[$srcKey] = $canonicalVersionPeer + if (-not $apiAliasesByCanonical.ContainsKey($canonicalVersionPeer)) { + $apiAliasesByCanonical[$canonicalVersionPeer] = @() + } + + $versionAliases = [System.Collections.Generic.List[string]]::new() + foreach ($a in @($apiAliasesByCanonical[$canonicalVersionPeer])) { [void]$versionAliases.Add($a) } + if (-not ($versionAliases | Where-Object { [string]::Equals($_, $srcKey, 'OrdinalIgnoreCase') })) { + [void]$versionAliases.Add($srcKey) + } + $apiAliasesByCanonical[$canonicalVersionPeer] = $versionAliases.ToArray() + } + + # 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 + } + } + } + + # Supplement: source-file -latest models that the provider API no longer lists + # as standalone entries but belong to a dated canonical family. + # E.g. "claude-3-5-haiku-latest" is in the source but not in the API response; + # we still want to fold it into "claude-3-5-haiku-20241022". + if ($ProviderAliasSuffix.ContainsKey($Provider) -and $ProviderAliasSuffix[$Provider]) { + $suffixRxSupplement = [regex]::new($ProviderAliasSuffix[$Provider]) + $latestRxSupplement = [regex]::new('-latest$') + $dateRxSupplement = [regex]::new('-(\d{8}|\d{4}-\d{2}-\d{2})$') + + foreach ($srcKey in @($existingModels.Keys)) { + if (-not $latestRxSupplement.IsMatch($srcKey)) { continue } + if ($apiCanonicalByAlias.ContainsKey($srcKey)) { continue } + + $baseKey = $suffixRxSupplement.Replace($srcKey, '') + $logicalBaseKey = Get-LogicalModelKey $baseKey + + # Find all dated members of the same family in the provider API + $datedSiblings = @($providerApiModelNames | Where-Object { + $dateRxSupplement.IsMatch($_) -and + [string]::Equals((Get-LogicalModelKey ($suffixRxSupplement.Replace($_, ''))), $logicalBaseKey, 'OrdinalIgnoreCase') + }) + + if ($datedSiblings.Count -eq 0) { continue } + + # Pick the most recent dated sibling as canonical + $datedCanonical = $datedSiblings | Sort-Object -Property @{ + Expression = { $dateRxSupplement.Match($_).Groups[1].Value } + } -Descending | Select-Object -First 1 + + # Skip if the -latest id is itself the canonical (shouldn't happen, but guard) + if ([string]::Equals($srcKey, $datedCanonical, 'OrdinalIgnoreCase')) { continue } + + $apiCanonicalByAlias[$srcKey] = $datedCanonical + + # Register the bare baseKey too if not already mapped + if (-not $apiCanonicalByAlias.ContainsKey($baseKey) -and + -not [string]::Equals($baseKey, $datedCanonical, 'OrdinalIgnoreCase')) { + $apiCanonicalByAlias[$baseKey] = $datedCanonical + } + + # Ensure the dated canonical has the -latest (and bare) in its alias list + if (-not $apiAliasesByCanonical.ContainsKey($datedCanonical)) { + $apiAliasesByCanonical[$datedCanonical] = @() + } + $existingAliases = [System.Collections.Generic.List[string]]::new() + foreach ($a in @($apiAliasesByCanonical[$datedCanonical])) { [void]$existingAliases.Add($a) } + if (-not ($existingAliases | Where-Object { [string]::Equals($_, $srcKey, 'OrdinalIgnoreCase') })) { + [void]$existingAliases.Add($srcKey) + } + if (-not ($existingAliases | Where-Object { [string]::Equals($_, $baseKey, 'OrdinalIgnoreCase') }) -and + -not [string]::Equals($baseKey, $datedCanonical, 'OrdinalIgnoreCase')) { + [void]$existingAliases.Add($baseKey) + } + $apiAliasesByCanonical[$datedCanonical] = $existingAliases.ToArray() + } + } +} + +# --------------------------------------------------------------------------- +# 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 + } +} + +# --------------------------------------------------------------------------- +# 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. +# --------------------------------------------------------------------------- +if ($providerApiQueried) { + 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 = 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) + # and propagate provider deprecation flag. + $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) } + } + # 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 = Resolve-OpenRouterEntry $pmId $apiAliases + $enrichment = Get-OpenRouterEnrichment -orm $ormLookup + + $caps = if ($enrichment -and -not [string]::IsNullOrWhiteSpace($enrichment.Capabilities) -and $enrichment.Capabilities -ne 'AICapability.None') { + $enrichment.Capabilities + } else { + 'AICapability.None' + } + + $deprecated = if ($isApiDeprecated -or ($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 = $null + 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 } +} + +# --------------------------------------------------------------------------- +# 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. +# +# 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 +} +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' + } +} + +foreach ($key in @($mergedModels.Keys)) { + if (Test-RealtimeModelName $key) { + [void]$mergedModels.Remove($key) + } +} + +# --------------------------------------------------------------------------- +# Reassign generic alias (bare baseKey) to latest non-deprecated family member +# --------------------------------------------------------------------------- +if ($ProviderAliasSuffix.ContainsKey($Provider) -and $ProviderAliasSuffix[$Provider]) { + $suffixRxAlias = [regex]::new($ProviderAliasSuffix[$Provider]) + $byBaseAlias = @{} + foreach ($m in $mergedModels.Values) { + $bk = $suffixRxAlias.Replace($m.Model, '') + if (-not $byBaseAlias.ContainsKey($bk)) { + $byBaseAlias[$bk] = [System.Collections.Generic.List[object]]::new() + } + [void]$byBaseAlias[$bk].Add($m) + } + + foreach ($kvp in $byBaseAlias.GetEnumerator()) { + $siblings = @($kvp.Value) + $baseKey = $kvp.Key + + # Skip if the only member is the bare baseKey itself (no dated variant present) + if ($siblings.Count -eq 1 -and [string]::Equals($siblings[0].Model, $baseKey, 'OrdinalIgnoreCase')) { continue } + + # Latest non-deprecated: sort by model-ID date string first (most reliable), + # then by OpenRouter epoch as tiebreaker (Created not yet computed at this stage). + $dateRxSort = [regex]::new('-(\d{8}|\d{4}-\d{2}-\d{2})$') + $getDateKey = { param($m) + $dm = $dateRxSort.Match($m.Model) + if ($dm.Success) { $dm.Groups[1].Value } else { '' } + } + $getCreatedEpoch = { param($m) + $orm = Resolve-OpenRouterEntry $m.Model $m.Aliases + if ($orm -and $orm.created) { [long]$orm.created } else { [long]0 } + } + + $candidates = $siblings | + Where-Object { $_.Deprecated -ne 'true' } | + Sort-Object -Property @( + @{ Expression = { & $getDateKey $_ }; Descending = $true } + @{ Expression = { & $getCreatedEpoch $_ }; Descending = $true } + ) + + # Winner = most recent non-deprecated; fallback = most recent deprecated + $winner = if ($candidates) { + $candidates | Select-Object -First 1 + } else { + $siblings | Sort-Object -Property @( + @{ Expression = { & $getDateKey $_ }; Descending = $true } + @{ Expression = { & $getCreatedEpoch $_ }; Descending = $true } + ) | Select-Object -First 1 + } + + # Implied rolling aliases for this family + $impliedAliases = @($baseKey, "$baseKey-latest") + + foreach ($m in $siblings) { + $current = [System.Collections.Generic.List[string]]::new() + if ($m.Aliases) { foreach ($a in @($m.Aliases)) { [void]$current.Add($a) } } + + foreach ($implied in $impliedAliases) { + # Never add an alias that is identical to the model name itself + if ([string]::Equals($implied, $m.Model, 'OrdinalIgnoreCase')) { continue } + + $hasImplied = $current | Where-Object { [string]::Equals($_, $implied, 'OrdinalIgnoreCase') } + if ([System.Object]::ReferenceEquals($m, $winner)) { + if (-not $hasImplied) { [void]$current.Add($implied) } + } else { + if ($hasImplied) { + $current = [System.Collections.Generic.List[string]]( + $current | Where-Object { -not [string]::Equals($_, $implied, 'OrdinalIgnoreCase') } + ) + } + } + } + + $m.Aliases = if ($current.Count -gt 0) { $current.ToArray() } else { $null } + } + } +} + +# --------------------------------------------------------------------------- +# Compute sorting keys (Created, OutputPrice) for every merged model +# --------------------------------------------------------------------------- +foreach ($m in $mergedModels.Values) { + $orm = Resolve-OpenRouterEntry $m.Model $m.Aliases + 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' } + +$validation = Test-ProviderModelValidation @($mergedModels.Values) + +# --------------------------------------------------------------------------- +# 5. Optional file update +# --------------------------------------------------------------------------- +$fileUpdated = $false +if ($UpdateFile) { + # 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 = { $_.TermIndex }; Ascending = $true } + @{ Expression = { if ($_.Verified -eq 'true') { 0 } else { 1 } }; Ascending = $true } + @{ Expression = { $_.OutputPrice }; Ascending = $true } + @{ Expression = { $_.Model }; Ascending = $true } + ) + + # 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 = (10000 - ($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() + } + + 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)) + } + + # 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 + + [System.IO.File]::WriteAllText($TargetFile, $newFileContent, [System.Text.Encoding]::UTF8) + $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 + validation = $validation +} + +Write-Output ($report | ConvertTo-Json -Depth 10) + +if (-not $validation.success) { + # Surface each validation issue as a GitHub Actions error annotation so the + # message is visible in the run log. See the matching block in the + # -ValidateOnly path above for the rationale (Write-Error + EAP=Stop would + # throw and obscure the cause). + foreach ($validationError in $validation.errors) { + Write-Host "::error title=$Provider provider model validation::$validationError" + } +} + +if ($FailOnValidationErrors -and -not $validation.success) { + exit 9 +} + diff --git a/yak-package/.manifest.json b/yak-package/.manifest.json new file mode 100644 index 000000000..fde7b78d2 --- /dev/null +++ b/yak-package/.manifest.json @@ -0,0 +1,9 @@ +{ + "notes": { + "dev": "NOTE: This is an alpha release and you might find bugs :/ Please, report them at ", + "alpha": "NOTE: This is an alpha release and you might find bugs :/ Please, report them at ", + "beta": "NOTE: This is a beta release and you might find bugs :/ Please, report them at ", + "rc": "NOTE: This is a release candidate. Please test thoroughly and report any issues at ", + "stable": "Please report any issues at " + } +} diff --git a/yak-package/manifest.yml b/yak-package/manifest.yml index 3920e97ce..deb25e15d 100644 --- a/yak-package/manifest.yml +++ b/yak-package/manifest.yml @@ -7,7 +7,7 @@ description: > ---- - NOTE: This is an alpha release and you might find bugs :/ Please, report them at + Please report any issues at ----