mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 18:16:29 +00:00
Merge remote-tracking branch 'origin/dev' into kai/fix/websearch-hook-install-settings-profiles
This commit is contained in:
@@ -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
|
||||
@@ -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 <n> --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/<owner>/<repo>/issues/comments/<id> -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.**
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { runSync } from './ccs-backlog-sync-lib.mjs';
|
||||
|
||||
runSync().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
},
|
||||
},
|
||||
{
|
||||
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<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -718,6 +718,16 @@ const MODEL_PRICING_ALIASES: Record<string, string> = {
|
||||
'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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<string, string>) => {
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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<ModelConfigSectionProps['catalog']>['models'][number];
|
||||
@@ -62,8 +62,13 @@ export function ModelConfigSection({
|
||||
.filter((model): model is NonNullable<typeof model> => 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;
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
+208
-23
@@ -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<string, ProviderCatalog> = {
|
||||
@@ -39,26 +142,26 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
},
|
||||
},
|
||||
{
|
||||
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<string, ProviderCatalog> = {
|
||||
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<ModelEntry['presetMapping']>,
|
||||
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<string>();
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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(
|
||||
<ModelConfigSection
|
||||
catalog={MODEL_CATALOGS.agy}
|
||||
savedPresets={[]}
|
||||
currentModel="claude-opus-4-6-thinking"
|
||||
opusModel="claude-opus-4-6-thinking"
|
||||
sonnetModel="gemini-3.9-pro-preview"
|
||||
haikuModel="gemini-3-9-flash-preview"
|
||||
providerModels={[
|
||||
{ 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' },
|
||||
]}
|
||||
provider="agy"
|
||||
onExtendedContextToggle={vi.fn()}
|
||||
onApplyPreset={onApplyPreset}
|
||||
onUpdateEnvValue={vi.fn()}
|
||||
onOpenCustomPreset={vi.fn()}
|
||||
onDeletePreset={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
/* Path alias */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@shared/*": ["../src/shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': path.resolve(REPO_ROOT, './src/shared'),
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -45,6 +45,7 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': path.resolve(__dirname, '../src/shared'),
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@tests': path.resolve(__dirname, './tests'),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user