diff --git a/.github/workflows/sync-ccs-backlog-project.yml b/.github/workflows/sync-ccs-backlog-project.yml new file mode 100644 index 00000000..cd48a014 --- /dev/null +++ b/.github/workflows/sync-ccs-backlog-project.yml @@ -0,0 +1,40 @@ +name: Sync CCS Backlog Project + +on: + issues: + types: + - opened + - reopened + - closed + - labeled + - unlabeled + workflow_dispatch: + schedule: + - cron: '17 3 * * *' + +concurrency: + group: sync-ccs-backlog-project + cancel-in-progress: false + +permissions: + contents: read + issues: read + +jobs: + sync-project: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Sync CCS Backlog project + env: + GH_TOKEN: ${{ secrets.CCS_PROJECT_AUTOMATION_TOKEN }} + CCS_PROJECT_OWNER: kaitranntt + CCS_PROJECT_NUMBER: '3' + run: node scripts/github/ccs-backlog-sync.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 6cdab761..7a98ed6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,110 @@ CLI wrapper for instant switching between multiple provider accounts and alterna | Forgetting `--help` update | CLI docs out of sync | Update `src/commands/help-command.ts` | | Forgetting docs update | User docs out of sync | Update `docs/` and CCS docs submodule | +## GitHub Issue Operations (CCS-Specific) + +These rules apply when the task is issue triage, backlog cleanup, labels, comments, Projects, or milestones for this repo. + +### Scope Boundary + +- Treat issue triage as a **GitHub-only workflow** unless the user explicitly asks for implementation. +- Do **NOT** create a worktree, branch, PR, or run `/fix`, `/cook`, or `kai:maintainer` just to tag issues, post follow-up comments, close duplicates, or clean up backlog state. +- Escalate into code workflow only when: + - the user explicitly asks to fix/implement an issue, or + - triage proves the same task now requires code changes. + +### Read Before Mutating + +- Always inspect live issue state first with `gh issue view --json ...` or `gh api`. +- Never rely on stale memory, screenshots, or issue titles alone. +- Before closing as resolved, cross-check repo evidence in at least one of: + - `README.md` + - `docs/` + - `CHANGELOG.md` + - relevant source/help handlers +- If the `gh` query would touch Projects fields, verify token scope first. Missing `read:project` is a real blocker, not something to hand-wave around. + +### Labeling Standard + +- Every **open** issue should end triage with: + - one primary type label: `bug`, `enhancement`, `question`, `documentation`, `duplicate`, `invalid`, or `wontfix` + - one area label: + - `area:cli-runtime` + - `area:dashboard-ui` + - `area:config-auth` + - `area:provider-integration` + - `area:install-packaging` + - `area:documentation` + - `area:contributor-workflow` +- Add routing labels only when they materially change handling: + - `upstream-blocked` + - `needs-repro` + - `needs-split` + - `docs-gap` +- Use release-state labels for shipped work: + - `pending-release` + - `released-dev` + - `released` +- Do **NOT** create or use status labels like `todo`, `doing`, `blocked`, `done`. +- Do **NOT** create provider-name labels unless there is a proven long-term need. Provider names belong in titles/issues, not label spam. + +### Commenting Rules + +- Keep issue comments short, technical, and neutral. +- State the decision plainly: close, keep open, retag, needs repro, duplicate, blocked upstream. +- Include exact evidence when relevant: version, doc path, changelog release, canonical issue, upstream link. +- Do **NOT** reference internal plans, local report files, agent prompts, or private reasoning. +- Post **one** maintainer follow-up comment per triage pass. If accidental duplicates are created, delete them with `gh api repos///issues/comments/ -X DELETE`. + +### Closure Rules + +- Close immediately when: + - the issue is an obvious duplicate and you can point to the canonical issue + - the feature/fix is clearly shipped and documented + - a previously `pending-release` issue is now clearly past release and no longer needs tracking +- Keep open and retag when: + - upstream dependency still blocks CCS adoption -> `upstream-blocked` + - latest-release behavior is unclear -> `needs-repro` + - issue contains multiple independent asks -> `needs-split` + - feature likely exists but discoverability/docs are weak -> `docs-gap` +- Do **NOT** close just because an issue is old, vague, or inconvenient. Close only with evidence. + +### Projects And Milestones + +- Preferred project model for this repo: one project, `CCS Backlog`. +- Use Projects for workflow state and priority. Use labels for meaning and routing. +- Milestones are for real ship windows only, not generic categorization buckets. +- If `gh` token lacks `read:project`, say so explicitly and stop short of pretending Projects data is available. +- Active project: + - owner: `kaitranntt` + - number: `3` + - URL: `https://github.com/users/kaitranntt/projects/3` +- Active project fields: + - `Status` -> use for work state (`Todo`, `In Progress`, `Done`) + - `Priority` -> `P1` for bugs, `P2` default backlog, `P3` for broad `needs-split` buckets unless explicitly reprioritized + - `Follow-up` -> `Ready`, `Needs repro`, `Blocked upstream`, `Needs split`, `Docs follow-up` + - `Next review` -> date only for issues that need a follow-up checkpoint +- When triaging an open issue, make sure it exists in `CCS Backlog` and the project fields match the routing labels. +- Do **NOT** create a second backlog project unless the user explicitly wants a project split and gives a reason. +- Current automation path: + - workflow file: `.github/workflows/sync-ccs-backlog-project.yml` + - sync script: `scripts/github/ccs-backlog-sync.mjs` + - required Actions secret: `CCS_PROJECT_AUTOMATION_TOKEN` +- Automation mapping must stay aligned with labels: + - `upstream-blocked` -> `Follow-up=Blocked upstream` + - `needs-repro` -> `Follow-up=Needs repro` + - `needs-split` -> `Follow-up=Needs split` + - `docs-gap` -> `Follow-up=Docs follow-up` + - otherwise -> `Follow-up=Ready` + +### New Or Updated Issue Creation + +- When creating issues for this repo: + - assign `@kaitranntt` + - use conventional issue titles: `bug: ...`, `feat: ...`, `docs: ...` + - keep bodies factual and technical + - avoid personal info and internal-only context + ## Quality Gates (MANDATORY) Quality gates MUST pass before pushing. **Both projects have identical workflow.** diff --git a/package.json b/package.json index df1e2e28..716443de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.61.1", + "version": "7.61.1-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", diff --git a/scripts/github/ccs-backlog-sync-lib.mjs b/scripts/github/ccs-backlog-sync-lib.mjs new file mode 100644 index 00000000..e5783ae2 --- /dev/null +++ b/scripts/github/ccs-backlog-sync-lib.mjs @@ -0,0 +1,379 @@ +const REQUIRED_PROJECT_FIELDS = ['Status', 'Priority', 'Follow-up', 'Next review']; +const DEFAULT_REPO_FULL_NAME = 'kaitranntt/ccs'; +const DEFAULT_CLOSED_LOOKBACK_DAYS = 14; +const PRIORITY_FOR = { bug: 'P1', default: 'P2', split: 'P3' }; +const FOLLOW_UP_FOR = { + ready: 'Ready', + repro: 'Needs repro', + upstream: 'Blocked upstream', + split: 'Needs split', + docs: 'Docs follow-up', +}; + +const PROJECT_QUERY = `query($owner: String!, $number: Int!, $itemCursor: String) { + user(login: $owner) { + projectV2(number: $number) { + id + fields(first: 50) { nodes { __typename ... on ProjectV2Field { id name } ... on ProjectV2SingleSelectField { id name options { id name } } } } + items(first: 100, after: $itemCursor) { + pageInfo { hasNextPage endCursor } + nodes { id content { __typename ... on Issue { number id repository { nameWithOwner } } } } + } + } + } +}`; +const ADD_ITEM_MUTATION = `mutation($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) { item { id } } +}`; +const SET_SINGLE_SELECT_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $optionId } + }) { projectV2Item { id } } +}`; +const SET_DATE_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { date: $date } + }) { projectV2Item { id } } +}`; +const CLEAR_FIELD_MUTATION = `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!) { + clearProjectV2ItemFieldValue(input: {projectId: $projectId, itemId: $itemId, fieldId: $fieldId}) { projectV2Item { id } } +}`; + +export function isoDate(daysFromNow, now = new Date()) { + const date = new Date(now); + date.setUTCDate(date.getUTCDate() + daysFromNow); + return date.toISOString().slice(0, 10); +} + +export function classify(labels, state, now = new Date()) { + const names = new Set(labels.map((label) => label.name)); + const priority = names.has('bug') + ? PRIORITY_FOR.bug + : names.has('needs-split') + ? PRIORITY_FOR.split + : PRIORITY_FOR.default; + if (state === 'closed') + return { priority, followUp: FOLLOW_UP_FOR.ready, nextReview: null, status: 'Done' }; + if (names.has('upstream-blocked')) + return { + priority, + followUp: FOLLOW_UP_FOR.upstream, + nextReview: isoDate(7, now), + status: 'Todo', + }; + if (names.has('needs-repro')) + return { + priority, + followUp: FOLLOW_UP_FOR.repro, + nextReview: isoDate(14, now), + status: 'Todo', + }; + if (names.has('needs-split')) + return { + priority, + followUp: FOLLOW_UP_FOR.split, + nextReview: isoDate(14, now), + status: 'Todo', + }; + if (names.has('docs-gap')) + return { priority, followUp: FOLLOW_UP_FOR.docs, nextReview: isoDate(7, now), status: 'Todo' }; + return { priority, followUp: FOLLOW_UP_FOR.ready, nextReview: null, status: 'Todo' }; +} + +export function parseRepoFullName(repoFullName = DEFAULT_REPO_FULL_NAME) { + const [repoOwner, repoName, extra] = String(repoFullName).split('/'); + if (!repoOwner || !repoName || extra) { + throw new Error(`Invalid GITHUB_REPOSITORY value "${repoFullName}". Expected OWNER/REPO.`); + } + return { repoOwner, repoName, repoFullName: `${repoOwner}/${repoName}` }; +} + +export function parseNextLink(linkHeader) { + if (!linkHeader) return null; + for (const segment of linkHeader.split(',')) { + const match = segment.match(/<([^>]+)>\s*;\s*rel="([^"]+)"/); + if (match?.[2] === 'next') return match[1]; + } + return null; +} + +function getHeader(headers, name) { + if (typeof headers?.get === 'function') return headers.get(name); + return headers?.[name] || headers?.[name.toLowerCase()] || null; +} + +function buildCutoffTimestamp(now, days) { + const cutoff = new Date(now); + cutoff.setUTCDate(cutoff.getUTCDate() - days); + return cutoff.toISOString(); +} + +function isRecentlyClosed(issue, now, days) { + if (issue.state !== 'closed' || !issue.closed_at) return false; + return Date.parse(issue.closed_at) >= Date.parse(buildCutoffTimestamp(now, days)); +} + +export function validateProjectFields(fields) { + const missing = REQUIRED_PROJECT_FIELDS.filter((name) => !fields.has(name)); + if (missing.length > 0) { + throw new Error( + `Missing required project field${missing.length > 1 ? 's' : ''}: ${missing.map((name) => `"${name}"`).join(', ')}` + ); + } + return { + statusField: fields.get('Status'), + priorityField: fields.get('Priority'), + followUpField: fields.get('Follow-up'), + nextReviewField: fields.get('Next review'), + }; +} + +export async function listGithubCollection(initialPath, githubRequest) { + const items = []; + let nextPath = initialPath; + while (nextPath) { + const { body, headers } = await githubRequest(nextPath); + if (!Array.isArray(body)) throw new Error(`Expected array response for ${nextPath}`); + items.push(...body); + nextPath = parseNextLink(getHeader(headers, 'link')); + } + return items; +} + +export async function getProjectContext({ owner, projectNumber, repoFullName, graphqlRequest }) { + const fields = new Map(); + const itemsByNumber = new Map(); + let projectId = null; + let itemCursor = null; + + do { + const data = await graphqlRequest(PROJECT_QUERY, { owner, number: projectNumber, itemCursor }); + const project = data.user?.projectV2; + if (!project) throw new Error(`Project ${owner}/${projectNumber} not found`); + projectId = projectId || project.id; + + if (fields.size === 0) { + for (const node of project.fields.nodes) { + if (!node?.name) continue; + fields.set(node.name, { + id: node.id, + options: new Map((node.options || []).map((opt) => [opt.name, opt.id])), + }); + } + } + + for (const node of project.items.nodes) { + if ( + node?.content?.__typename === 'Issue' && + node.content.repository.nameWithOwner === repoFullName + ) { + itemsByNumber.set(node.content.number, node.id); + } + } + + itemCursor = project.items.pageInfo.hasNextPage ? project.items.pageInfo.endCursor : null; + } while (itemCursor); + + return { projectId, itemsByNumber, ...validateProjectFields(fields) }; +} + +export async function listIssuesForSync({ + repoOwner, + repoName, + githubRequest, + eventPath, + now = new Date(), + closedLookbackDays = DEFAULT_CLOSED_LOOKBACK_DAYS, +}) { + if (eventPath) { + const event = JSON.parse( + await import('node:fs/promises').then((fs) => fs.readFile(eventPath, 'utf8')) + ); + if (event.issue && !event.issue.pull_request) return [event.issue]; + } + + const openIssues = await listGithubCollection( + `/repos/${repoOwner}/${repoName}/issues?state=open&per_page=100`, + githubRequest + ); + const recentlyClosedIssues = await listGithubCollection( + `/repos/${repoOwner}/${repoName}/issues?state=closed&per_page=100&since=${encodeURIComponent(buildCutoffTimestamp(now, closedLookbackDays))}`, + githubRequest + ); + + const byNumber = new Map(); + for (const issue of openIssues) { + if (!issue.pull_request) byNumber.set(issue.number, issue); + } + for (const issue of recentlyClosedIssues) { + if (!issue.pull_request && isRecentlyClosed(issue, now, closedLookbackDays)) + byNumber.set(issue.number, issue); + } + return [...byNumber.values()]; +} + +async function ensureProjectItem(projectId, itemsByNumber, issue, graphqlRequest) { + const existing = itemsByNumber.get(issue.number); + if (existing) return existing; + if (!issue.node_id) throw new Error(`Issue #${issue.number} is missing node_id`); + const data = await graphqlRequest(ADD_ITEM_MUTATION, { projectId, contentId: issue.node_id }); + const itemId = data.addProjectV2ItemById.item.id; + itemsByNumber.set(issue.number, itemId); + return itemId; +} + +async function setSingleSelect(projectId, itemId, field, optionName, graphqlRequest) { + const optionId = field.options.get(optionName); + if (!optionId) throw new Error(`Missing option "${optionName}" on field ${field.id}`); + await graphqlRequest(SET_SINGLE_SELECT_MUTATION, { + projectId, + itemId, + fieldId: field.id, + optionId, + }); +} + +async function setDate(projectId, itemId, fieldId, date, graphqlRequest) { + if (!date) { + await graphqlRequest(CLEAR_FIELD_MUTATION, { projectId, itemId, fieldId }); + return; + } + await graphqlRequest(SET_DATE_MUTATION, { projectId, itemId, fieldId, date }); +} + +export async function syncIssues({ + issues, + context, + graphqlRequest, + logger = console, + now = new Date(), +}) { + const failures = []; + for (const issue of issues) { + try { + if (issue.state === 'closed' && !context.itemsByNumber.has(issue.number)) { + logger.log( + `skipped #${issue.number}: closed issue is not currently tracked in the project` + ); + continue; + } + const itemId = await ensureProjectItem( + context.projectId, + context.itemsByNumber, + issue, + graphqlRequest + ); + const plan = classify(issue.labels || [], issue.state, now); + await setSingleSelect( + context.projectId, + itemId, + context.statusField, + plan.status, + graphqlRequest + ); + await setSingleSelect( + context.projectId, + itemId, + context.priorityField, + plan.priority, + graphqlRequest + ); + await setSingleSelect( + context.projectId, + itemId, + context.followUpField, + plan.followUp, + graphqlRequest + ); + await setDate( + context.projectId, + itemId, + context.nextReviewField.id, + plan.nextReview, + graphqlRequest + ); + logger.log( + `synced #${issue.number}: ${plan.status} / ${plan.priority} / ${plan.followUp}${plan.nextReview ? ` / ${plan.nextReview}` : ''}` + ); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + failures.push(`#${issue.number} (${detail})`); + logger.error(`[X] Failed to sync #${issue.number}: ${detail}`); + } + } + if (failures.length > 0) + throw new Error(`Failed to sync ${failures.length} issue(s): ${failures.join(', ')}`); +} + +function formatGraphqlError(errors) { + const raw = JSON.stringify(errors); + if (/resource not accessible|insufficient|forbidden|project/i.test(raw)) { + return `GitHub Project access failed. Ensure GH_TOKEN or GITHUB_TOKEN has project scope and access to the target project. Raw: ${raw}`; + } + return `GitHub GraphQL failed: ${raw}`; +} + +function buildRuntimeConfig(env = process.env) { + const token = env.GH_TOKEN || env.GITHUB_TOKEN; + if (!token) throw new Error('Missing GH_TOKEN or GITHUB_TOKEN'); + const projectNumber = Number(env.CCS_PROJECT_NUMBER || '3'); + if (!Number.isInteger(projectNumber) || projectNumber <= 0) + throw new Error('CCS_PROJECT_NUMBER must be a positive integer'); + return { + token, + owner: env.CCS_PROJECT_OWNER || 'kaitranntt', + projectNumber, + eventPath: env.GITHUB_EVENT_PATH, + closedLookbackDays: Number( + env.CCS_PROJECT_RECENTLY_CLOSED_DAYS || String(DEFAULT_CLOSED_LOOKBACK_DAYS) + ), + ...parseRepoFullName(env.GITHUB_REPOSITORY || DEFAULT_REPO_FULL_NAME), + }; +} + +export async function runSync({ env = process.env, logger = console, fetchImpl = fetch } = {}) { + const config = buildRuntimeConfig(env); + const githubRequest = async (path, init = {}) => { + const response = await fetchImpl( + path.startsWith('http') ? path : `https://api.github.com${path}`, + { + ...init, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${config.token}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...(init.headers || {}), + }, + } + ); + const body = await response.json(); + if (!response.ok) throw new Error(`GitHub REST ${response.status}: ${JSON.stringify(body)}`); + return { body, headers: response.headers }; + }; + const graphqlRequest = async (query, variables = {}) => { + const response = await fetchImpl('https://api.github.com/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.token}` }, + body: JSON.stringify({ query, variables }), + }); + const body = await response.json(); + if (!response.ok || body.errors) throw new Error(formatGraphqlError(body.errors || body)); + return body.data; + }; + + const issues = await listIssuesForSync({ + repoOwner: config.repoOwner, + repoName: config.repoName, + githubRequest, + eventPath: config.eventPath, + now: new Date(), + closedLookbackDays: config.closedLookbackDays, + }); + const context = await getProjectContext({ + owner: config.owner, + projectNumber: config.projectNumber, + repoFullName: config.repoFullName, + graphqlRequest, + }); + await syncIssues({ issues, context, graphqlRequest, logger, now: new Date() }); +} diff --git a/scripts/github/ccs-backlog-sync.mjs b/scripts/github/ccs-backlog-sync.mjs new file mode 100644 index 00000000..33d69966 --- /dev/null +++ b/scripts/github/ccs-backlog-sync.mjs @@ -0,0 +1,6 @@ +import { runSync } from './ccs-backlog-sync-lib.mjs'; + +runSync().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index dd4e65a7..61b565a5 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -35,8 +35,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; * v11: Migrated deprecated claude-sonnet-4-6-thinking aliases to claude-sonnet-4-6 * v12: Removed denylisted Antigravity Claude 4.5 aliases * v13: Removed aggressive Gemini alias expansion to reduce model list noise in Control Panel + * v14: Added Gemini 3.1 Flash Antigravity aliases for upcoming rollout compatibility */ -export const CLIPROXY_CONFIG_VERSION = 13; +export const CLIPROXY_CONFIG_VERSION = 14; interface OAuthModelAliasEntry { name: string; @@ -61,6 +62,7 @@ const DEFAULT_ANTIGRAVITY_ALIASES: OAuthModelAliasEntry[] = [ { name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview' }, { name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview-customtools' }, { name: 'gemini-3-flash', alias: 'gemini-3-flash-preview' }, + { name: 'gemini-3-flash', alias: 'gemini-3.1-flash-preview' }, { name: 'claude-sonnet-4-6', alias: 'claude-sonnet-4-6', fork: true }, // Backward compatibility: legacy sonnet thinking alias now routes to canonical model ID. { name: 'claude-sonnet-4-6-thinking', alias: 'claude-sonnet-4-6', fork: true }, diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index 3988ccf7..d9f2b39f 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -65,7 +65,7 @@ export function detectTierFromModel(modelName: string): ModelTier { * * @param model - Base model name * @param thinkingValue - Level name (e.g., 'high') or numeric budget - * @returns Model name with thinking suffix, e.g., "gemini-3-pro-preview(high)" + * @returns Model name with thinking suffix, e.g., "gemini-3.1-pro-preview(high)" */ export function applyThinkingSuffix(model: string, thinkingValue: string | number): string { return applyThinkingSuffixForProvider(model, thinkingValue); diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index ec772b15..dfef56f5 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -34,7 +34,13 @@ import { DEFAULT_BACKEND } from '../platform-detector'; import { configureProviderModel, getCurrentModel } from '../model-config'; import { reconcileCodexModelForActivePlan } from '../codex-plan-compatibility'; import { resolveProxyConfig, PROXY_CLI_FLAGS } from '../proxy-config-resolver'; -import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from '../model-catalog'; +import { + supportsModelConfig, + isModelBroken, + getModelIssueUrl, + findModel, + getSuggestedReplacementModel, +} from '../model-catalog'; import { CodexReasoningProxy } from '../codex-reasoning-proxy'; import { ToolSanitizationProxy } from '../tool-sanitization-proxy'; import { @@ -714,9 +720,14 @@ export async function execClaudeWithCLIProxy( if (currentModel && isModelBroken(provider, currentModel)) { const modelEntry = findModel(provider, currentModel); const issueUrl = getModelIssueUrl(provider, currentModel); + const replacementModel = getSuggestedReplacementModel(provider, currentModel); console.error(''); console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`)); - console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.'); + if (replacementModel) { + console.error(` Tool calls will fail. Use "${replacementModel}" instead.`); + } else { + console.error(' Tool calls will fail. Consider changing the model in config.yaml.'); + } if (issueUrl) { console.error(` Tracking: ${issueUrl}`); } diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index a9d7970e..67a0812f 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -11,6 +11,8 @@ import { migrateDeniedAntigravityModelAliases, normalizeModelIdForProvider, } from './model-id-normalizer'; +import { stripModelConfigurationSuffixes } from '../shared/extended-context-utils'; +import { GEMINI_MINOR_VERSION_COMPATIBILITY_IDS } from '../shared/gemini-minor-version-compatibility'; /** * Thinking support configuration for a model. @@ -108,9 +110,9 @@ export const MODEL_CATALOG: Partial> = }, }, { - id: 'gemini-3-pro-preview', - name: 'Gemini 3 Pro', - description: 'Google latest model via Antigravity', + id: 'gemini-3.1-pro-preview', + name: 'Gemini 3.1 Pro', + description: 'Google latest Gemini Pro model via Antigravity', thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, extendedContext: true, }, @@ -122,10 +124,10 @@ export const MODEL_CATALOG: Partial> = defaultModel: 'gemini-2.5-pro', models: [ { - id: 'gemini-3-pro-preview', - name: 'Gemini 3 Pro', + id: 'gemini-3.1-pro-preview', + name: 'Gemini 3.1 Pro', tier: 'pro', - description: 'Latest model, requires paid Google account', + description: 'Latest Gemini Pro model, requires paid Google account', thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, extendedContext: true, }, @@ -383,6 +385,26 @@ export function getProviderCatalog(provider: CLIProxyProvider): ProviderCatalog return MODEL_CATALOG[provider]; } +/** + * Suggest a supported replacement model from the provider catalog. + * Prefers the provider default unless it matches the excluded model or is itself broken. + */ +export function getSuggestedReplacementModel( + provider: CLIProxyProvider, + excludedModelId?: string +): string | undefined { + const catalog = MODEL_CATALOG[provider]; + if (!catalog) return undefined; + + const excludedId = excludedModelId ? findModel(provider, excludedModelId)?.id : undefined; + const defaultModel = findModel(provider, catalog.defaultModel); + if (defaultModel && !defaultModel.broken && defaultModel.id !== excludedId) { + return defaultModel.id; + } + + return catalog.models.find((model) => !model.broken && model.id !== excludedId)?.id; +} + /** * Find model entry by ID * Note: Model IDs are normalized to lowercase for case-insensitive comparison @@ -390,7 +412,7 @@ export function getProviderCatalog(provider: CLIProxyProvider): ProviderCatalog export function findModel(provider: CLIProxyProvider, modelId: string): ModelEntry | undefined { const catalog = MODEL_CATALOG[provider]; if (!catalog || !modelId) return undefined; - const normalizedId = modelId.trim().toLowerCase(); + const normalizedId = stripModelConfigurationSuffixes(modelId).trim().toLowerCase(); const providerNormalizedId = normalizeModelIdForProvider(normalizedId, provider) .trim() .toLowerCase(); @@ -404,6 +426,16 @@ export function findModel(provider: CLIProxyProvider, modelId: string): ModelEnt lookupCandidates.add(migratedProvider); } + for (const candidate of [...lookupCandidates]) { + const compatibilityId = + GEMINI_MINOR_VERSION_COMPATIBILITY_IDS[ + candidate as keyof typeof GEMINI_MINOR_VERSION_COMPATIBILITY_IDS + ]; + if (compatibilityId) { + lookupCandidates.add(compatibilityId); + } + } + return catalog.models.find((m) => lookupCandidates.has(m.id.toLowerCase())); } diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index f8364a91..c8034ebc 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -32,11 +32,16 @@ const GEMINI_CLI_GROUPS: Record< > = { 'gemini-flash-series': { label: 'Gemini Flash Series', - models: ['gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'], + models: [ + 'gemini-3-flash-preview', + 'gemini-3.1-flash-preview', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', + ], }, 'gemini-pro-series': { label: 'Gemini Pro Series', - models: ['gemini-3-pro-preview', 'gemini-2.5-pro'], + models: ['gemini-3-pro-preview', 'gemini-3.1-pro-preview', 'gemini-2.5-pro'], }, }; diff --git a/src/shared/gemini-minor-version-compatibility.ts b/src/shared/gemini-minor-version-compatibility.ts new file mode 100644 index 00000000..19364bd3 --- /dev/null +++ b/src/shared/gemini-minor-version-compatibility.ts @@ -0,0 +1,10 @@ +/** + * Shared Gemini preview aliases for minor-version rollouts. + * Keep CLIProxy backend and dashboard model resolution on the same compatibility pairs. + */ +export const GEMINI_MINOR_VERSION_COMPATIBILITY_IDS = Object.freeze({ + 'gemini-3-pro-preview': 'gemini-3.1-pro-preview', + 'gemini-3.1-pro-preview': 'gemini-3-pro-preview', + 'gemini-3-flash-preview': 'gemini-3.1-flash-preview', + 'gemini-3.1-flash-preview': 'gemini-3-flash-preview', +}); diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 935ecccf..d8e40679 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -718,6 +718,16 @@ const MODEL_PRICING_ALIASES: Record = { 'qwen3-235b': 'qwen3-max', 'qwen3-vl-plus': 'qwen3.5-plus', 'qwen3-32b': 'qwen3.5-plus', + 'gemini-3-flash-preview': 'gemini-2.5-flash', + 'gemini-3-flash-preview-customtools': 'gemini-2.5-flash', + 'gemini-3.1-pro-preview': 'gemini-3-pro-preview', + 'gemini-3.1-flash-preview': 'gemini-2.5-flash', + 'gemini-3.1-pro-preview-customtools': 'gemini-3-pro-preview', + 'gemini-3.1-flash-preview-customtools': 'gemini-2.5-flash', + 'gemini-3-1-pro-preview': 'gemini-3-pro-preview', + 'gemini-3-1-flash-preview': 'gemini-2.5-flash', + 'gemini-3-1-pro-preview-customtools': 'gemini-3-pro-preview', + 'gemini-3-1-flash-preview-customtools': 'gemini-2.5-flash', }; // Default pricing for unknown models diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index 8830bff3..4445c807 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -640,6 +640,8 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" const gemini31AliasLines = [ 'alias: gemini-3.1-pro-preview', 'alias: gemini-3.1-pro-preview-customtools', + 'alias: gemini-3.1-flash-preview', + 'alias: gemini-3.1-flash-preview-customtools', ]; for (const aliasLine of gemini31AliasLines) { diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 0b2b47f2..4d675641 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -91,11 +91,11 @@ describe('Model Catalog', () => { assert.strictEqual(ids.includes('claude-sonnet-4-5'), false); }); - it('includes Gemini 3 Pro (free via Antigravity)', () => { + it('includes Gemini 3.1 Pro (free via Antigravity)', () => { const { MODEL_CATALOG } = modelCatalog; - const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3-pro-preview'); - assert(gem3, 'Should include Gemini 3 Pro'); - assert.strictEqual(gem3.name, 'Gemini 3 Pro'); + const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3.1-pro-preview'); + assert(gem3, 'Should include Gemini 3.1 Pro'); + assert.strictEqual(gem3.name, 'Gemini 3.1 Pro'); // AGY models are all free - no paid tier assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier'); }); @@ -139,11 +139,11 @@ describe('Model Catalog', () => { assert.strictEqual(MODEL_CATALOG.gemini.defaultModel, 'gemini-2.5-pro'); }); - it('includes Gemini 3 Pro with pro tier', () => { + it('includes Gemini 3.1 Pro with pro tier', () => { const { MODEL_CATALOG } = modelCatalog; - const gem3 = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3-pro-preview'); - assert(gem3, 'Should include Gemini 3 Pro'); - assert.strictEqual(gem3.name, 'Gemini 3 Pro'); + const gem3 = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3.1-pro-preview'); + assert(gem3, 'Should include Gemini 3.1 Pro'); + assert.strictEqual(gem3.name, 'Gemini 3.1 Pro'); assert.strictEqual(gem3.tier, 'pro'); }); @@ -246,6 +246,36 @@ describe('Model Catalog', () => { assert.strictEqual(legacySonnet?.id, 'claude-sonnet-4-6'); }); + it('treats Gemini 3 and 3.1 preview IDs as the same catalog family', () => { + const { findModel, getSuggestedReplacementModel } = modelCatalog; + const legacyAgyGemini = findModel('agy', 'gemini-3-pro-preview'); + const legacyGemini = findModel('gemini', 'gemini-3-pro-preview'); + const currentGemini = findModel('gemini', 'gemini-3.1-pro-preview'); + + assert.strictEqual(legacyAgyGemini?.id, 'gemini-3.1-pro-preview'); + assert.strictEqual(legacyGemini?.id, 'gemini-3.1-pro-preview'); + assert.strictEqual(currentGemini?.id, 'gemini-3.1-pro-preview'); + assert.strictEqual( + getSuggestedReplacementModel('gemini', 'gemini-3.1-pro-preview'), + 'gemini-2.5-pro' + ); + }); + + it('falls back to the next supported model when the default is excluded', () => { + const { getSuggestedReplacementModel } = modelCatalog; + + expect(getSuggestedReplacementModel('agy', 'claude-opus-4-6-thinking')).toBe( + 'claude-sonnet-4-6' + ); + expect(getSuggestedReplacementModel('agy')).toBe('claude-opus-4-6-thinking'); + }); + + it('returns undefined when no provider catalog exists', () => { + const { getSuggestedReplacementModel } = modelCatalog; + + expect(getSuggestedReplacementModel('qwen')).toBeUndefined(); + }); + it('returns undefined for unknown model', () => { const { findModel } = modelCatalog; const model = findModel('agy', 'unknown-model'); @@ -325,7 +355,7 @@ describe('Model Catalog', () => { const sonnetThinkingIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-6'); // Find indices of the remaining non-Claude model - const geminiIdx = models.findIndex((m) => m.id === 'gemini-3-pro-preview'); + const geminiIdx = models.findIndex((m) => m.id === 'gemini-3.1-pro-preview'); // Primary Claude choices should appear ahead of Gemini fallback. assert(opusIdx < geminiIdx, 'Opus should be above Gemini'); diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index e8cb4e46..66262b3c 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -168,6 +168,23 @@ describe('Gemini CLI Quota Fetcher', () => { expect(proBucket!.remainingFraction).toBe(0.9); }); + it('should recognize Gemini 3.1 preview IDs during the rollout', () => { + const rawBuckets = [ + { model_id: 'gemini-3.1-flash-preview', remaining_fraction: 0.7 }, + { model_id: 'gemini-3.1-pro-preview', remaining_fraction: 0.4 }, + ]; + + const buckets = buildGeminiCliBuckets(rawBuckets); + + const flashBucket = buckets.find((b) => b.label === 'Gemini Flash Series'); + const proBucket = buckets.find((b) => b.label === 'Gemini Pro Series'); + + expect(flashBucket).toBeDefined(); + expect(flashBucket!.modelIds).toContain('gemini-3.1-flash-preview'); + expect(proBucket).toBeDefined(); + expect(proBucket!.modelIds).toContain('gemini-3.1-pro-preview'); + }); + it('should handle camelCase API response', () => { const rawBuckets = [{ modelId: 'gemini-3-flash-preview', remainingFraction: 0.75 }]; diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 3b483563..470ced04 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -76,6 +76,22 @@ describe('model-pricing', () => { expect(pricing).not.toEqual(getModelPricing('unknown-model-xyz')); }); + it('should map Gemini 3 and 3.1 Flash preview variants to flash pricing', () => { + const canonical = getModelPricing('gemini-2.5-flash'); + const aliases = [ + 'gemini-3-flash-preview', + 'gemini-3-flash-preview-customtools', + 'gemini-3.1-flash-preview', + 'gemini-3.1-flash-preview-customtools', + 'gemini-3-1-flash-preview', + 'gemini-3-1-flash-preview-customtools', + ]; + + for (const model of aliases) { + expect(getModelPricing(model)).toEqual(canonical); + } + }); + it('should return different pricing for different model tiers', () => { const sonnet = getModelPricing('claude-sonnet-4-5'); const opus = getModelPricing('claude-opus-4-5-20251101'); diff --git a/tests/unit/scripts/github/ccs-backlog-sync.test.ts b/tests/unit/scripts/github/ccs-backlog-sync.test.ts new file mode 100644 index 00000000..01c34beb --- /dev/null +++ b/tests/unit/scripts/github/ccs-backlog-sync.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from 'bun:test'; +import { + classify, + getProjectContext, + listIssuesForSync, + parseRepoFullName, + syncIssues, + validateProjectFields, +} from '../../../../scripts/github/ccs-backlog-sync-lib.mjs'; + +describe('ccs backlog sync helpers', () => { + it('maps closed issues to Done and clears follow-up state', () => { + const plan = classify( + [{ name: 'bug' }, { name: 'upstream-blocked' }], + 'closed', + new Date('2026-03-28T00:00:00Z') + ); + + expect(plan).toEqual({ + priority: 'P1', + followUp: 'Ready', + nextReview: null, + status: 'Done', + }); + }); + + it('rejects malformed repository identifiers with a clear error', () => { + expect(() => parseRepoFullName('ccs')).toThrow( + 'Invalid GITHUB_REPOSITORY value "ccs". Expected OWNER/REPO.' + ); + }); + + it('validates required project fields before syncing', () => { + const fields = new Map([['Status', { id: 'status', options: new Map() }]]); + expect(() => validateProjectFields(fields)).toThrow( + 'Missing required project fields: "Priority", "Follow-up", "Next review"' + ); + }); + + it('paginates project items across multiple GraphQL pages', async () => { + const graphqlRequest = async (_query: string, variables: { itemCursor?: string | null }) => { + if (!variables.itemCursor) { + return { + user: { + projectV2: { + id: 'project-1', + fields: { + nodes: [ + { + id: 'status', + name: 'Status', + options: [ + { id: 'todo', name: 'Todo' }, + { id: 'done', name: 'Done' }, + ], + }, + { + id: 'priority', + name: 'Priority', + options: [ + { id: 'p1', name: 'P1' }, + { id: 'p2', name: 'P2' }, + { id: 'p3', name: 'P3' }, + ], + }, + { id: 'follow', name: 'Follow-up', options: [{ id: 'ready', name: 'Ready' }] }, + { id: 'review', name: 'Next review', options: [] }, + ], + }, + items: { + pageInfo: { hasNextPage: true, endCursor: 'cursor-2' }, + nodes: [ + { + id: 'item-1', + content: { + __typename: 'Issue', + number: 1, + repository: { nameWithOwner: 'kaitranntt/ccs' }, + }, + }, + ], + }, + }, + }, + }; + } + + return { + user: { + projectV2: { + id: 'project-1', + fields: { nodes: [] }, + items: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + { + id: 'item-2', + content: { + __typename: 'Issue', + number: 2, + repository: { nameWithOwner: 'kaitranntt/ccs' }, + }, + }, + ], + }, + }, + }, + }; + }; + + const context = await getProjectContext({ + owner: 'kaitranntt', + projectNumber: 3, + repoFullName: 'kaitranntt/ccs', + graphqlRequest, + }); + + expect(context.projectId).toBe('project-1'); + expect(context.itemsByNumber.get(1)).toBe('item-1'); + expect(context.itemsByNumber.get(2)).toBe('item-2'); + expect(context.statusField.id).toBe('status'); + }); + + it('includes recently closed issues during scheduled reconciliation while skipping stale closures', async () => { + const headers = new Headers(); + const githubRequest = async (path: string) => { + if (path.includes('state=open')) { + return { body: [{ number: 10, state: 'open', labels: [], node_id: 'node-10' }], headers }; + } + + return { + body: [ + { + number: 11, + state: 'closed', + closed_at: '2026-03-25T00:00:00Z', + labels: [], + node_id: 'node-11', + }, + { + number: 12, + state: 'closed', + closed_at: '2026-02-01T00:00:00Z', + labels: [], + node_id: 'node-12', + }, + ], + headers, + }; + }; + + const issues = await listIssuesForSync({ + repoOwner: 'kaitranntt', + repoName: 'ccs', + githubRequest, + now: new Date('2026-03-28T00:00:00Z'), + closedLookbackDays: 14, + }); + + expect(issues.map((issue) => issue.number)).toEqual([10, 11]); + }); + + it('continues syncing remaining issues after an individual failure', async () => { + const logs: string[] = []; + const errors: string[] = []; + const syncedItems: number[] = []; + const context = { + projectId: 'project-1', + itemsByNumber: new Map(), + statusField: { + id: 'status', + options: new Map([ + ['Todo', 'todo'], + ['Done', 'done'], + ]), + }, + priorityField: { + id: 'priority', + options: new Map([ + ['P1', 'p1'], + ['P2', 'p2'], + ['P3', 'p3'], + ]), + }, + followUpField: { id: 'follow', options: new Map([['Ready', 'ready']]) }, + nextReviewField: { id: 'review', options: new Map() }, + }; + const issues = [ + { number: 1, state: 'open', labels: [], node_id: 'node-1' }, + { number: 2, state: 'open', labels: [], node_id: 'node-2' }, + { number: 3, state: 'open', labels: [], node_id: 'node-3' }, + ]; + const graphqlRequest = async (query: string, variables: Record) => { + if (query.includes('addProjectV2ItemById')) + return { addProjectV2ItemById: { item: { id: `item-${variables.contentId}` } } }; + if (variables.itemId === 'item-node-2' && variables.fieldId === 'priority') + throw new Error('priority write failed'); + syncedItems.push(Number(variables.itemId.replace('item-node-', ''))); + return {}; + }; + + await expect( + syncIssues({ + issues, + context, + graphqlRequest, + logger: { + log: (message: string) => logs.push(message), + error: (message: string) => errors.push(message), + }, + now: new Date('2026-03-28T00:00:00Z'), + }) + ).rejects.toThrow('Failed to sync 1 issue(s): #2 (priority write failed)'); + + expect(logs.some((message) => message.includes('synced #1'))).toBe(true); + expect(logs.some((message) => message.includes('synced #3'))).toBe(true); + expect(errors).toEqual(['[X] Failed to sync #2: priority write failed']); + expect(syncedItems).toContain(3); + }); + + it('skips untracked closed issues during scheduled reconciliation', async () => { + const logs: string[] = []; + const context = { + projectId: 'project-1', + itemsByNumber: new Map([[9, 'item-9']]), + statusField: { + id: 'status', + options: new Map([ + ['Todo', 'todo'], + ['Done', 'done'], + ]), + }, + priorityField: { + id: 'priority', + options: new Map([ + ['P1', 'p1'], + ['P2', 'p2'], + ['P3', 'p3'], + ]), + }, + followUpField: { id: 'follow', options: new Map([['Ready', 'ready']]) }, + nextReviewField: { id: 'review', options: new Map() }, + }; + + await syncIssues({ + issues: [{ number: 10, state: 'closed', labels: [], node_id: 'node-10' }], + context, + graphqlRequest: async () => { + throw new Error('should not attempt to mutate project state'); + }, + logger: { + log: (message: string) => logs.push(message), + error: () => {}, + }, + now: new Date('2026-03-28T00:00:00Z'), + }); + + expect(logs).toEqual(['skipped #10: closed issue is not currently tracked in the project']); + }); +}); diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index d93df27a..7b010bbd 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -11,7 +11,7 @@ import { Sparkles, Zap, Star, X, Plus } from 'lucide-react'; import { FlexibleModelSelector } from '../provider-model-selector'; import { ExtendedContextToggle } from '../extended-context-toggle'; import { stripExtendedContextSuffix } from '@/lib/extended-context-utils'; -import { findCatalogModel } from '@/lib/model-catalogs'; +import { findCatalogModel, getResolvedCatalogModels } from '@/lib/model-catalogs'; import type { ModelConfigSectionProps } from './types'; type CatalogPresetModel = NonNullable['models'][number]; @@ -62,8 +62,13 @@ export function ModelConfigSection({ .filter((model): model is NonNullable => Boolean(model?.extendedContext)); }, [catalog, currentModel, opusModel, sonnetModel, haikuModel]); + const resolvedCatalogModels = useMemo( + () => getResolvedCatalogModels(catalog, providerModels), + [catalog, providerModels] + ); + const presetGroups = useMemo(() => { - const presetModels = (catalog?.models ?? []).filter((model) => model.presetMapping); + const presetModels = resolvedCatalogModels.filter((model) => model.presetMapping); if (presetModels.length === 0) return []; const hasPaidPresets = presetModels.some((model) => model.tier === 'paid'); @@ -89,7 +94,7 @@ export function ModelConfigSection({ models: presetModels.filter((model) => model.tier === 'paid'), }, ].filter((group) => group.models.length > 0); - }, [catalog]); + }, [resolvedCatalogModels]); const showPresets = presetGroups.length > 0 || savedPresets.length > 0; diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index af2a1afb..845c2910 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -12,6 +12,7 @@ import { Badge } from '@/components/ui/badge'; import { SearchableSelect } from '@/components/ui/searchable-select'; import { Skeleton } from '@/components/ui/skeleton'; import { getCodexEffortDisplay } from '@/lib/codex-effort'; +import { getResolvedCatalogModels } from '@/lib/model-catalogs'; import { cn } from '@/lib/utils'; /** Model entry from catalog */ @@ -303,10 +304,14 @@ export function FlexibleModelSelector({ disabled, }: FlexibleModelSelectorProps) { const { t } = useTranslation(); - const catalogModelIds = new Set(catalog?.models.map((model) => model.id) || []); const isCodexProvider = catalog?.provider === 'codex'; + const resolvedCatalogModels = useMemo( + () => getResolvedCatalogModels(catalog, allModels), + [allModels, catalog] + ); + const catalogModelIds = new Set(resolvedCatalogModels.map((model) => model.id)); - const recommendedOptions = (catalog?.models ?? []).map((model) => ({ + const recommendedOptions = resolvedCatalogModels.map((model) => ({ value: model.id, groupKey: 'recommended', searchText: `${model.id} ${model.name}`, diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 8d9f64a2..27b78d27 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -3,8 +3,111 @@ * Shared data for Quick Setup Wizard and Provider Editor */ -import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector'; +import type { ModelEntry, ProviderCatalog } from '@/components/cliproxy/provider-model-selector'; import { stripModelConfigurationSuffixes } from '@/lib/extended-context-utils'; +import { GEMINI_MINOR_VERSION_COMPATIBILITY_IDS } from '@shared/gemini-minor-version-compatibility'; + +const GEMINI_PREVIEW_MODEL_ID_PATTERN = + /^gemini-(\d+(?:[.-]\d+)*)-(pro|flash)-preview(-customtools)?$/i; + +export type CatalogAvailableModel = { + id: string; + owned_by: string; +}; + +type GeminiPreviewFamily = 'pro' | 'flash'; + +type GeminiPreviewModelInfo = { + normalizedId: string; + version: number[]; + family: GeminiPreviewFamily; + customtools: boolean; + dottedVersion: boolean; +}; + +function normalizeModelId(modelId: string): string { + return stripModelConfigurationSuffixes(modelId).toLowerCase(); +} + +function parseGeminiPreviewModelId(modelId: string): GeminiPreviewModelInfo | null { + const normalizedId = normalizeModelId(modelId); + const match = normalizedId.match(GEMINI_PREVIEW_MODEL_ID_PATTERN); + if (!match) return null; + + const [, versionString, family, customtoolsSuffix] = match; + + return { + normalizedId, + version: versionString.split(/[.-]/).map((segment) => Number(segment)), + family: family as GeminiPreviewFamily, + customtools: Boolean(customtoolsSuffix), + dottedVersion: versionString.includes('.'), + }; +} + +function compareGeminiVersions(a: number[], b: number[]): number { + const maxLength = Math.max(a.length, b.length); + for (let index = 0; index < maxLength; index += 1) { + const left = a[index] ?? 0; + const right = b[index] ?? 0; + if (left === right) continue; + return left > right ? 1 : -1; + } + + return 0; +} + +function compareGeminiPreviewCandidates( + left: GeminiPreviewModelInfo, + right: GeminiPreviewModelInfo, + target: GeminiPreviewModelInfo +): number { + if (left.customtools !== right.customtools) { + return left.customtools ? 1 : -1; + } + + const versionComparison = compareGeminiVersions(left.version, right.version); + if (versionComparison !== 0) { + return versionComparison > 0 ? -1 : 1; + } + + const leftStyleMatch = Number(left.dottedVersion === target.dottedVersion); + const rightStyleMatch = Number(right.dottedVersion === target.dottedVersion); + if (leftStyleMatch !== rightStyleMatch) { + return rightStyleMatch - leftStyleMatch; + } + + return left.normalizedId.localeCompare(right.normalizedId); +} + +function findAvailableModelId( + availableModels: CatalogAvailableModel[], + modelId: string +): string | undefined { + const normalizedModelId = normalizeModelId(modelId); + return availableModels.find((model) => normalizeModelId(model.id) === normalizedModelId)?.id; +} + +function resolveGeminiPreviewModelId( + modelId: string, + availableModels: CatalogAvailableModel[] +): string | undefined { + const targetModel = parseGeminiPreviewModelId(modelId); + if (!targetModel || availableModels.length === 0) return undefined; + + const bestMatch = availableModels + .map((model) => { + const info = parseGeminiPreviewModelId(model.id); + if (!info || info.family !== targetModel.family) return null; + return { id: model.id, info }; + }) + .filter((candidate): candidate is { id: string; info: GeminiPreviewModelInfo } => + Boolean(candidate) + ) + .sort((left, right) => compareGeminiPreviewCandidates(left.info, right.info, targetModel))[0]; + + return bestMatch?.id; +} /** Model catalog data - mirrors src/cliproxy/model-catalog.ts */ export const MODEL_CATALOGS: Record = { @@ -39,26 +142,26 @@ export const MODEL_CATALOGS: Record = { }, }, { - id: 'gemini-3-pro-preview', - name: 'Gemini 3 Pro', - description: 'Google latest model via Antigravity', + id: 'gemini-3.1-pro-preview', + name: 'Gemini Pro', + description: 'Resolves to the best advertised Gemini Pro preview via Antigravity', extendedContext: true, presetMapping: { - default: 'gemini-3-pro-preview', - opus: 'gemini-3-pro-preview', - sonnet: 'gemini-3-pro-preview', + default: 'gemini-3.1-pro-preview', + opus: 'gemini-3.1-pro-preview', + sonnet: 'gemini-3.1-pro-preview', haiku: 'gemini-3-flash-preview', }, }, { id: 'gemini-3-flash-preview', - name: 'Gemini 3 Flash', - description: 'Fast Gemini model via Antigravity', + name: 'Gemini Flash', + description: 'Resolves to the best advertised Gemini Flash preview via Antigravity', extendedContext: true, presetMapping: { default: 'gemini-3-flash-preview', - opus: 'gemini-3-pro-preview', - sonnet: 'gemini-3-pro-preview', + opus: 'gemini-3.1-pro-preview', + sonnet: 'gemini-3.1-pro-preview', haiku: 'gemini-3-flash-preview', }, }, @@ -70,28 +173,28 @@ export const MODEL_CATALOGS: Record = { defaultModel: 'gemini-2.5-pro', models: [ { - id: 'gemini-3-pro-preview', - name: 'Gemini 3 Pro', + id: 'gemini-3.1-pro-preview', + name: 'Gemini Pro', tier: 'paid', - description: 'Latest model, requires paid Google account', + description: 'Uses the best advertised Gemini Pro preview when Google exposes one', extendedContext: true, presetMapping: { - default: 'gemini-3-pro-preview', - opus: 'gemini-3-pro-preview', - sonnet: 'gemini-3-pro-preview', + default: 'gemini-3.1-pro-preview', + opus: 'gemini-3.1-pro-preview', + sonnet: 'gemini-3.1-pro-preview', haiku: 'gemini-3-flash-preview', }, }, { id: 'gemini-3-flash-preview', - name: 'Gemini 3 Flash', + name: 'Gemini Flash', tier: 'paid', - description: 'Fast Gemini 3 model, requires paid Google account', + description: 'Uses the best advertised Gemini Flash preview when Google exposes one', extendedContext: true, presetMapping: { default: 'gemini-3-flash-preview', - opus: 'gemini-3-pro-preview', - sonnet: 'gemini-3-pro-preview', + opus: 'gemini-3.1-pro-preview', + sonnet: 'gemini-3.1-pro-preview', haiku: 'gemini-3-flash-preview', }, }, @@ -559,8 +662,90 @@ export function findCatalogModel(provider: string, modelId: string) { const catalog = MODEL_CATALOGS[provider.toLowerCase()]; if (!catalog) return undefined; - const normalizedModelId = stripModelConfigurationSuffixes(modelId); - return catalog.models.find((model) => model.id === normalizedModelId); + const normalizedModelId = normalizeModelId(modelId); + const compatibilityModelId = + GEMINI_MINOR_VERSION_COMPATIBILITY_IDS[ + normalizedModelId.toLowerCase() as keyof typeof GEMINI_MINOR_VERSION_COMPATIBILITY_IDS + ]; + + const exactMatch = catalog.models.find( + (model) => model.id === normalizedModelId || model.id === compatibilityModelId + ); + if (exactMatch) return exactMatch; + + const geminiModelInfo = parseGeminiPreviewModelId(normalizedModelId); + if (!geminiModelInfo) return undefined; + + return catalog.models + .map((model) => ({ model, info: parseGeminiPreviewModelId(model.id) })) + .filter( + ( + candidate + ): candidate is { + model: ModelEntry; + info: GeminiPreviewModelInfo; + } => Boolean(candidate.info && candidate.info.family === geminiModelInfo.family) + ) + .sort((left, right) => compareGeminiVersions(right.info.version, left.info.version))[0]?.model; +} + +export function resolveCatalogModelId(modelId: string, availableModels: CatalogAvailableModel[] = []): string { + const normalizedModelId = normalizeModelId(modelId); + const liveGeminiModelId = resolveGeminiPreviewModelId(normalizedModelId, availableModels); + if (liveGeminiModelId) return liveGeminiModelId; + + const exactLiveModelId = findAvailableModelId(availableModels, normalizedModelId); + if (exactLiveModelId) return exactLiveModelId; + + const compatibilityModelId = + GEMINI_MINOR_VERSION_COMPATIBILITY_IDS[ + normalizedModelId as keyof typeof GEMINI_MINOR_VERSION_COMPATIBILITY_IDS + ]; + const compatibleLiveModelId = compatibilityModelId + ? findAvailableModelId(availableModels, compatibilityModelId) + : undefined; + + return compatibleLiveModelId ?? normalizedModelId; +} + +export function resolvePresetMapping( + presetMapping: NonNullable, + availableModels: CatalogAvailableModel[] = [] +) { + return { + default: resolveCatalogModelId(presetMapping.default, availableModels), + opus: resolveCatalogModelId(presetMapping.opus, availableModels), + sonnet: resolveCatalogModelId(presetMapping.sonnet, availableModels), + haiku: resolveCatalogModelId(presetMapping.haiku, availableModels), + }; +} + +export function getResolvedCatalogModels( + catalog: ProviderCatalog | undefined, + availableModels: CatalogAvailableModel[] = [] +) { + if (!catalog) return []; + + const seenModelIds = new Set(); + + return catalog.models + .map((model) => { + const resolvedModelId = resolveCatalogModelId(model.id, availableModels); + const resolvedPresetModelMapping = model.presetMapping + ? resolvePresetMapping(model.presetMapping, availableModels) + : undefined; + + return { + ...model, + id: resolvedModelId, + presetMapping: resolvedPresetModelMapping, + }; + }) + .filter((model) => { + if (seenModelIds.has(model.id)) return false; + seenModelIds.add(model.id); + return true; + }); } export function supportsExtendedContext(provider: string, modelId: string): boolean { diff --git a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx index ea31f449..6ecbdf4b 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx @@ -56,8 +56,8 @@ describe('ModelConfigSection presets', () => { savedPresets={[]} currentModel="claude-opus-4-6-thinking" opusModel="claude-opus-4-6-thinking" - sonnetModel="gemini-3-pro-preview" - haikuModel="gemini-3-flash-preview" + sonnetModel="gemini-3.9-pro-preview" + haikuModel="gemini-3-9-flash-preview" providerModels={[]} provider="agy" onExtendedContextToggle={vi.fn()} @@ -71,6 +71,43 @@ describe('ModelConfigSection presets', () => { expect(screen.queryByText('Free Tier')).not.toBeInTheDocument(); expect(screen.queryByText('Paid Tier')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Claude Opus 4.6 Thinking' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Gemini Pro' })).toBeInTheDocument(); expect(screen.getByTestId('extended-context-toggle')).toBeInTheDocument(); }); + + it('applies Antigravity Gemini presets using the best live Gemini family ids', async () => { + const onApplyPreset = vi.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Gemini Pro' })); + + expect(onApplyPreset).toHaveBeenCalledWith({ + ANTHROPIC_MODEL: 'gemini-3.9-pro-preview', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3.9-pro-preview', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-3.9-pro-preview', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-9-flash-preview', + }); + }); }); diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index 460c1e1f..e4d0c618 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { MODEL_CATALOGS } from '@/lib/model-catalogs'; +import { + MODEL_CATALOGS, + findCatalogModel, + getResolvedCatalogModels, + resolveCatalogModelId, +} from '@/lib/model-catalogs'; import { applyDefaultPreset } from '@/lib/preset-utils'; describe('claude preset utils', () => { @@ -41,4 +46,63 @@ describe('claude preset utils', () => { ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }); }); + + it('keeps Gemini presets on 3.1 Pro while resolving 3/3.1 alias variants', () => { + const geminiCatalog = MODEL_CATALOGS.gemini; + const latestPro = geminiCatalog.models.find((model) => model.id === 'gemini-3.1-pro-preview'); + + expect(latestPro?.name).toBe('Gemini Pro'); + expect(latestPro?.presetMapping?.default).toBe('gemini-3.1-pro-preview'); + expect(findCatalogModel('gemini', 'gemini-3-pro-preview')?.id).toBe('gemini-3.1-pro-preview'); + expect(findCatalogModel('gemini', 'gemini-3.1-flash-preview')?.id).toBe( + 'gemini-3-flash-preview' + ); + }); + + it('resolves Gemini preview presets to the best live family match', () => { + const availableModels = [ + { id: 'gemini-3.9-pro-preview-customtools', owned_by: 'antigravity' }, + { id: 'gemini-3.9-pro-preview', owned_by: 'antigravity' }, + { id: 'gemini-3-9-flash-preview-customtools', owned_by: 'antigravity' }, + { id: 'gemini-3-9-flash-preview', owned_by: 'antigravity' }, + { id: 'gemini-3.1-pro-preview', owned_by: 'antigravity' }, + ]; + + expect(resolveCatalogModelId('gemini-3.1-pro-preview', availableModels)).toBe( + 'gemini-3.9-pro-preview' + ); + expect(resolveCatalogModelId('gemini-3-flash-preview', availableModels)).toBe( + 'gemini-3-9-flash-preview' + ); + expect(findCatalogModel('agy', 'gemini-3.9-pro-preview')?.id).toBe('gemini-3.1-pro-preview'); + + const resolvedAgyModels = getResolvedCatalogModels(MODEL_CATALOGS.agy, availableModels); + expect(resolvedAgyModels.find((model) => model.name === 'Gemini Pro')?.id).toBe( + 'gemini-3.9-pro-preview' + ); + expect(resolvedAgyModels.find((model) => model.name === 'Gemini Flash')?.id).toBe( + 'gemini-3-9-flash-preview' + ); + }); + + it('does not silently swap Gemini Flash presets to flash-lite', () => { + const availableModels = [{ id: 'gemini-3.1-flash-lite-preview', owned_by: 'google' }]; + + expect(resolveCatalogModelId('gemini-3-flash-preview', availableModels)).toBe( + 'gemini-3-flash-preview' + ); + }); + + it('passes through non-Gemini model ids unchanged', () => { + expect(resolveCatalogModelId('claude-sonnet-4-6')).toBe('claude-sonnet-4-6'); + }); + + it('falls back to the catalog id when no live model matches', () => { + expect(resolveCatalogModelId('gemini-3.1-pro-preview', [])).toBe('gemini-3.1-pro-preview'); + expect( + resolveCatalogModelId('gemini-3.1-pro-preview', [ + { id: 'gemini-2.5-pro', owned_by: 'google' }, + ]) + ).toBe('gemini-3.1-pro-preview'); + }); }); diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json index 99fbd39c..a751b0ca 100644 --- a/ui/tsconfig.app.json +++ b/ui/tsconfig.app.json @@ -26,7 +26,8 @@ /* Path alias */ "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@shared/*": ["../src/shared/*"] } }, "include": ["src"] diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 39c8d654..4a31e9f5 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { alias: { + '@shared': path.resolve(REPO_ROOT, './src/shared'), '@': path.resolve(__dirname, './src'), }, }, diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index a0501bb5..0b3f2697 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ }, resolve: { alias: { + '@shared': path.resolve(__dirname, '../src/shared'), '@': path.resolve(__dirname, './src'), '@tests': path.resolve(__dirname, './tests'), },