mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-08 15:43:10 +08:00
feat(antigravity): add Google Antigravity auth plugin with auto-rotation, model catalog and multi-turn support
This commit is contained in:
@@ -6,7 +6,7 @@ publish = false
|
|||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "cursor_byok_desktop"
|
name = "cursor_byok_desktop"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["rlib"]
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
@@ -99,11 +99,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 7px;
|
gap: 6px;
|
||||||
|
|
||||||
> button {
|
> button {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { api, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api";
|
import { api, getDisabledPluginModelIds, pluginText, type PluginDescriptor, type PluginImportFile, type PluginRuntimePhase, type PluginRuntimeStatus } from "../../shared/api";
|
||||||
import { useI18n } from "../../i18n/store";
|
import { useI18n } from "../../i18n/store";
|
||||||
import { PageContent } from "../../shell/layout/PageContent";
|
import { PageContent } from "../../shell/layout/PageContent";
|
||||||
import { appStore, useAppStore } from "../../shared/store/appStore";
|
import { appStore, useAppStore } from "../../shared/store/appStore";
|
||||||
@@ -138,7 +138,17 @@ function PluginCard({ plugin, onOpen }: {
|
|||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const configured = plugin.providers.some((provider) => provider.configured);
|
const configured = plugin.providers.some((provider) => provider.configured);
|
||||||
const accountCount = plugin.resources.reduce((count, resource) => count + resource.resources.length, 0);
|
const accountCount = plugin.resources.reduce((count, resource) => count + resource.resources.length, 0);
|
||||||
const modelCount = plugin.providers.reduce((count, provider) => count + provider.models.length, 0);
|
const [disabledModelIds, setDisabledModelIds] = useState<Set<string>>(() => getDisabledPluginModelIds());
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUpdate = () => setDisabledModelIds(getDisabledPluginModelIds());
|
||||||
|
window.addEventListener("cursor_plugin_models_changed", handleUpdate);
|
||||||
|
return () => window.removeEventListener("cursor_plugin_models_changed", handleUpdate);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const modelCount = plugin.providers.reduce(
|
||||||
|
(count, provider) => count + provider.models.filter((m) => !disabledModelIds.has(m.id)).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
const subtitle = plugin.providers.map((provider) => pluginText(provider.displayName, locale)).join(" · ") || plugin.id;
|
const subtitle = plugin.providers.map((provider) => pluginText(provider.displayName, locale)).join(" · ") || plugin.id;
|
||||||
const importResource = plugin.resources.find((resource) => resource.import);
|
const importResource = plugin.resources.find((resource) => resource.import);
|
||||||
const exportResource = plugin.resources.find((resource) => resource.resources.length > 0);
|
const exportResource = plugin.resources.find((resource) => resource.resources.length > 0);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|
||||||
> span {
|
>span {
|
||||||
color: var(--vscode-descriptionForeground);
|
color: var(--vscode-descriptionForeground);
|
||||||
font-size: type.$font-size-xs;
|
font-size: type.$font-size-xs;
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
color: var(--vscode-foreground);
|
color: var(--vscode-foreground);
|
||||||
background: var(--vscode-textCodeBlock-background);
|
background: var(--vscode-textCodeBlock-background);
|
||||||
@@ -97,15 +101,21 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.providerRow,
|
.providerCard {
|
||||||
.resourceRow {
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|
||||||
> div:first-child {
|
>div:first-child {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -118,6 +128,576 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resourceRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
.resourceText {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.resourceHeaderLine {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resourceDescription {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: type.$font-size-xs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricsRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
.metricPrefix {
|
||||||
|
font-weight: 700;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metricValue {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.claudeMetricBadge {
|
||||||
|
background: rgba(234, 88, 12, 0.14);
|
||||||
|
color: #fb923c;
|
||||||
|
border: 1px solid rgba(234, 88, 12, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.geminiMetricBadge {
|
||||||
|
background: rgba(59, 130, 246, 0.14);
|
||||||
|
color: #60a5fa;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genericMetricBadge {
|
||||||
|
background: color-mix(in srgb, var(--vscode-badge-background, #333) 40%, transparent);
|
||||||
|
color: var(--vscode-badge-foreground, #ccc);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border, #444) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(135deg, rgba(234, 88, 12, 0.25), rgba(249, 115, 22, 0.15));
|
||||||
|
color: #fb923c;
|
||||||
|
border: 1px solid rgba(249, 115, 22, 0.4);
|
||||||
|
box-shadow: 0 0 6px rgba(249, 115, 22, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.freeBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-badge-background, #333) 30%, transparent);
|
||||||
|
color: var(--vscode-descriptionForeground, #888);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border, #444) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelsView {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelsViewHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 60%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
background: color-mix(in srgb, var(--vscode-foreground) 8%, transparent);
|
||||||
|
border-color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
font-size: type.$font-size-base;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: type.$font-size-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerCountChip {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 14%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 30%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelListToolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchContainer {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.searchIcon {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearSearchBtn {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
background: color-mix(in srgb, var(--vscode-foreground) 20%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelSearchInput {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 30px 7px 32px;
|
||||||
|
font-size: type.$font-size-xs;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-input-border, var(--vscode-sideBar-border));
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: color-mix(in srgb, var(--vscode-descriptionForeground) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 20%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelBatchActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.pillButton {
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: type.$font-size-xs;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
background: color-mix(in srgb, var(--vscode-button-secondaryBackground, #3a3d41) 60%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 80%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--vscode-button-foreground, #fff);
|
||||||
|
background: var(--vscode-button-secondaryHoverBackground, #45494e);
|
||||||
|
border-color: color-mix(in srgb, var(--vscode-button-secondaryHoverBackground, #45494e) 50%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelsViewList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptySearch {
|
||||||
|
padding: 32px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: type.$font-size-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-sideBar-background) 50%, var(--vscode-editor-background));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 70%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: type.$font-size-xs;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 50%, transparent);
|
||||||
|
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.18);
|
||||||
|
background: color-mix(in srgb, var(--vscode-sideBar-background) 80%, var(--vscode-focusBorder, #007fd4) 6%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelItemActive {
|
||||||
|
border-color: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 55%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 7%, var(--vscode-editor-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelItemDisabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 80%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--vscode-sideBar-border) 40%, transparent);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelCheckbox {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelInfo {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelName {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelId {
|
||||||
|
font-family: var(--vscode-editor-font-family);
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: type.$font-size-2xs;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modelBadges {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claudeTag,
|
||||||
|
.geminiTag,
|
||||||
|
.gptTag,
|
||||||
|
.thinkingTag,
|
||||||
|
.highTag,
|
||||||
|
.mediumTag,
|
||||||
|
.lowTag,
|
||||||
|
.extraLowTag,
|
||||||
|
.imageTag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claudeTag {
|
||||||
|
color: #f59e0b;
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.geminiTag {
|
||||||
|
color: #60a5fa;
|
||||||
|
background: rgba(96, 165, 250, 0.12);
|
||||||
|
border: 1px solid rgba(96, 165, 250, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gptTag {
|
||||||
|
color: #34d399;
|
||||||
|
background: rgba(52, 211, 153, 0.12);
|
||||||
|
border: 1px solid rgba(52, 211, 153, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinkingTag {
|
||||||
|
color: #c084fc;
|
||||||
|
background: rgba(192, 132, 252, 0.12);
|
||||||
|
border: 1px solid rgba(192, 132, 252, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.highTag {
|
||||||
|
color: #f87171;
|
||||||
|
background: rgba(248, 113, 113, 0.12);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediumTag {
|
||||||
|
color: #fbbf24;
|
||||||
|
background: rgba(251, 191, 36, 0.12);
|
||||||
|
border: 1px solid rgba(251, 191, 36, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lowTag {
|
||||||
|
color: #2dd4bf;
|
||||||
|
background: rgba(45, 212, 191, 0.12);
|
||||||
|
border: 1px solid rgba(45, 212, 191, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.extraLowTag {
|
||||||
|
color: #38bdf8;
|
||||||
|
background: rgba(56, 189, 248, 0.12);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageTag {
|
||||||
|
color: #f472b6;
|
||||||
|
background: rgba(244, 114, 182, 0.12);
|
||||||
|
border: 1px solid rgba(244, 114, 182, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resourceIdentity {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resourceText {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resourceRowDisabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resourceActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionIconButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
color: var(--vscode-textLink-foreground, #3b82f6);
|
||||||
|
background: color-mix(in srgb, var(--vscode-textLink-foreground, #3b82f6) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vscode-textLink-foreground, #3b82f6) 30%, transparent);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--vscode-textLink-foreground, #3b82f6);
|
||||||
|
border-color: var(--vscode-textLink-foreground, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.refreshingSpin {
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionDeleteButton {
|
||||||
|
color: #ef4444;
|
||||||
|
background: color-mix(in srgb, #ef4444 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, #ef4444 30%, transparent);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: #fff;
|
||||||
|
background: #ef4444;
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.readyBadge,
|
||||||
|
.coolingBadge,
|
||||||
|
.invalidBadge,
|
||||||
|
.disabledBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: type.$font-size-2xs;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeDot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readyBadge {
|
||||||
|
color: var(--vscode-testing-iconPassed, #73c991);
|
||||||
|
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 12%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 30%, transparent);
|
||||||
|
|
||||||
|
.badgeDot {
|
||||||
|
background: var(--vscode-testing-iconPassed, #73c991);
|
||||||
|
box-shadow: 0 0 6px color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 60%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.coolingBadge {
|
||||||
|
color: var(--vscode-editorWarning-foreground, #cca700);
|
||||||
|
background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 12%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 30%, transparent);
|
||||||
|
|
||||||
|
.badgeDot {
|
||||||
|
background: var(--vscode-editorWarning-foreground, #cca700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalidBadge {
|
||||||
|
color: var(--vscode-errorForeground, #f48771);
|
||||||
|
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 12%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vscode-errorForeground, #f48771) 30%, transparent);
|
||||||
|
|
||||||
|
.badgeDot {
|
||||||
|
background: var(--vscode-errorForeground, #f48771);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabledBadge {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
background: color-mix(in srgb, var(--vscode-descriptionForeground) 12%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vscode-sideBar-border) 80%, transparent);
|
||||||
|
|
||||||
|
.badgeDot {
|
||||||
|
background: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ready {
|
.ready {
|
||||||
color: var(--vscode-testing-iconPassed, #73c991) !important;
|
color: var(--vscode-testing-iconPassed, #73c991) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
|
getDisabledPluginAccountIds,
|
||||||
|
getDisabledPluginModelIds,
|
||||||
|
setPluginAccountEnabled,
|
||||||
|
setPluginModelEnabled,
|
||||||
|
setMultiplePluginModelsEnabled,
|
||||||
pluginText,
|
pluginText,
|
||||||
type PluginAddMethod,
|
type PluginAddMethod,
|
||||||
type PluginDescriptor,
|
type PluginDescriptor,
|
||||||
@@ -14,6 +19,10 @@ import { appStore } from "../../shared/store/appStore";
|
|||||||
import { Button } from "../../shared/ui/Button";
|
import { Button } from "../../shared/ui/Button";
|
||||||
import { Card } from "../../shared/ui/Card";
|
import { Card } from "../../shared/ui/Card";
|
||||||
import { FormField, TextInput } from "../../shared/ui/FormControls";
|
import { FormField, TextInput } from "../../shared/ui/FormControls";
|
||||||
|
import { Icon } from "../../shared/ui/Icon";
|
||||||
|
import { refreshIcon, trashIcon } from "../../shared/ui/icons";
|
||||||
|
import { Switch } from "../../shared/ui/Switch";
|
||||||
|
import { TooltipTrigger } from "../../shared/ui/TooltipTrigger";
|
||||||
import styles from "./PluginResourcePanels.module.scss";
|
import styles from "./PluginResourcePanels.module.scss";
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
@@ -122,8 +131,18 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
|
|||||||
{method.description && <span>{pluginText(method.description, locale)}</span>}
|
{method.description && <span>{pluginText(method.description, locale)}</span>}
|
||||||
{begun && status === "polling" && <div className={styles.deviceCode}>
|
{begun && status === "polling" && <div className={styles.deviceCode}>
|
||||||
<small>{t("设备验证码")}</small>
|
<small>{t("设备验证码")}</small>
|
||||||
<button type="button" onClick={() => void copyCode(begun.userCode)}>{begun.userCode}</button>
|
<button
|
||||||
<button type="button" className={styles.copy} onClick={() => void copyCode(begun.userCode)}>
|
type="button"
|
||||||
|
title={begun.verificationUrlComplete || begun.verificationUrl || begun.userCode}
|
||||||
|
onClick={() => void copyCode(begun.verificationUrlComplete || begun.verificationUrl || begun.userCode)}
|
||||||
|
>
|
||||||
|
{begun.userCode.startsWith("http") ? t("授权链接") : begun.userCode}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.copy}
|
||||||
|
onClick={() => void copyCode(begun.verificationUrlComplete || begun.verificationUrl || begun.userCode)}
|
||||||
|
>
|
||||||
{copied ? t("已复制") : t("复制")}
|
{copied ? t("已复制") : t("复制")}
|
||||||
</button>
|
</button>
|
||||||
</div>}
|
</div>}
|
||||||
@@ -141,6 +160,7 @@ function OAuthMethodCard({ pluginId, resourceType, method, onConfigured }: {
|
|||||||
export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
|
export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeProviderId, setActiveProviderId] = useState<string | null>(null);
|
||||||
|
|
||||||
const run = async (key: string, task: () => Promise<void>) => {
|
const run = async (key: string, task: () => Promise<void>) => {
|
||||||
setBusy(key);
|
setBusy(key);
|
||||||
@@ -155,6 +175,40 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const autoRefreshResources = async () => {
|
||||||
|
let didRefresh = false;
|
||||||
|
for (const res of plugin.resources) {
|
||||||
|
if (!res.canRefresh) continue;
|
||||||
|
for (const item of res.resources) {
|
||||||
|
if (!active) return;
|
||||||
|
// Auto-refresh if resource is marked invalid or missing metrics
|
||||||
|
if (item.state.status === "invalid" || item.metrics.length === 0) {
|
||||||
|
try {
|
||||||
|
await api.refreshPluginResource(plugin.id, res.type, item.id);
|
||||||
|
didRefresh = true;
|
||||||
|
} catch {
|
||||||
|
// ignore background refresh failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (active && didRefresh) {
|
||||||
|
await appStore.refreshPlugins();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void autoRefreshResources();
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [plugin.id]);
|
||||||
|
|
||||||
|
if (activeProviderId) {
|
||||||
|
const provider = plugin.providers.find((p) => p.id === activeProviderId);
|
||||||
|
if (provider) {
|
||||||
|
return <ProviderModelsView provider={provider} onBack={() => setActiveProviderId(null)} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return <div className={styles.panel}>
|
return <div className={styles.panel}>
|
||||||
{plugin.providers.map((provider) => <ProviderRow
|
{plugin.providers.map((provider) => <ProviderRow
|
||||||
key={provider.id}
|
key={provider.id}
|
||||||
@@ -164,11 +218,12 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
|
|||||||
onSync={() => void run(`sync:${provider.id}`, async () => {
|
onSync={() => void run(`sync:${provider.id}`, async () => {
|
||||||
await api.syncPluginModels(plugin.id, provider.id);
|
await api.syncPluginModels(plugin.id, provider.id);
|
||||||
})}
|
})}
|
||||||
|
onViewModels={() => setActiveProviderId(provider.id)}
|
||||||
/>)}
|
/>)}
|
||||||
{plugin.resources.map((resource) => <ResourceList
|
{plugin.resources.map((resource) => <ResourceList
|
||||||
key={resource.type}
|
key={resource.type}
|
||||||
resource={resource}
|
resource={resource}
|
||||||
busy={busy !== null}
|
busyKey={busy}
|
||||||
onRefresh={(item) => void run(`refresh:${item.id}`, async () => {
|
onRefresh={(item) => void run(`refresh:${item.id}`, async () => {
|
||||||
await api.refreshPluginResource(plugin.id, resource.type, item.id);
|
await api.refreshPluginResource(plugin.id, resource.type, item.id);
|
||||||
})}
|
})}
|
||||||
@@ -180,33 +235,190 @@ export function PluginSettingsPanel({ plugin }: { plugin: PluginDescriptor }) {
|
|||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProviderRow({ provider, busy, syncing, onSync }: {
|
function ProviderRow({ provider, busy, syncing, onSync, onViewModels }: {
|
||||||
provider: PluginProviderDescriptor;
|
provider: PluginProviderDescriptor;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
syncing: boolean;
|
syncing: boolean;
|
||||||
onSync: () => void;
|
onSync: () => void;
|
||||||
|
onViewModels: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
return <Card className={styles.providerRow}>
|
const [disabledIds, setDisabledIds] = useState<Set<string>>(() => getDisabledPluginModelIds());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUpdate = () => setDisabledIds(getDisabledPluginModelIds());
|
||||||
|
window.addEventListener("cursor_plugin_models_changed", handleUpdate);
|
||||||
|
return () => window.removeEventListener("cursor_plugin_models_changed", handleUpdate);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enabledCount = provider.models.filter((m) => !disabledIds.has(m.id)).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={styles.providerCard}>
|
||||||
|
<div className={styles.providerRow}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{pluginText(provider.displayName, locale)}</strong>
|
<strong>{pluginText(provider.displayName, locale)}</strong>
|
||||||
<span>
|
<span>
|
||||||
{provider.providerType}
|
{provider.providerType}
|
||||||
{" · "}
|
{" · "}
|
||||||
{provider.models.length > 0 ? t("{count} 个模型", { count: provider.models.length }) : t("尚未同步模型")}
|
{provider.models.length > 0
|
||||||
|
? `${enabledCount}/${t("{count} 个模型", { count: provider.models.length })}`
|
||||||
|
: t("尚未同步模型")}
|
||||||
{" · "}
|
{" · "}
|
||||||
{provider.configured ? t("可调用") : t("未就绪")}
|
{provider.configured ? t("可调用") : t("未就绪")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{provider.hasModels && <Button size="small" disabled={busy} onClick={onSync}>
|
<div className={styles.providerActions}>
|
||||||
|
{provider.models.length > 0 && (
|
||||||
|
<Button size="small" onClick={onViewModels}>
|
||||||
|
{t("查看模型")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{provider.hasModels && (
|
||||||
|
<Button size="small" variant="primary" disabled={busy} onClick={onSync}>
|
||||||
{syncing ? t("正在同步…") : t("同步模型")}
|
{syncing ? t("正在同步…") : t("同步模型")}
|
||||||
</Button>}
|
</Button>
|
||||||
</Card>;
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResourceList({ resource, busy, onRefresh, onDelete }: {
|
function ProviderModelsView({ provider, onBack }: { provider: PluginProviderDescriptor; onBack: () => void }) {
|
||||||
|
const { locale } = useI18n();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [disabledIds, setDisabledIds] = useState<Set<string>>(() => getDisabledPluginModelIds());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUpdate = () => setDisabledIds(getDisabledPluginModelIds());
|
||||||
|
window.addEventListener("cursor_plugin_models_changed", handleUpdate);
|
||||||
|
return () => window.removeEventListener("cursor_plugin_models_changed", handleUpdate);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleModel = (modelId: string) => {
|
||||||
|
const isCurrentlyDisabled = disabledIds.has(modelId);
|
||||||
|
setPluginModelEnabled(modelId, isCurrentlyDisabled);
|
||||||
|
setDisabledIds(getDisabledPluginModelIds());
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAll = (enable: boolean) => {
|
||||||
|
const target = filteredModels.length > 0 ? filteredModels : provider.models;
|
||||||
|
setMultiplePluginModelsEnabled(target.map((m) => m.id), enable);
|
||||||
|
setDisabledIds(getDisabledPluginModelIds());
|
||||||
|
};
|
||||||
|
|
||||||
|
const enabledCount = provider.models.filter((m) => !disabledIds.has(m.id)).length;
|
||||||
|
|
||||||
|
const filteredModels = provider.models.filter((m) => {
|
||||||
|
if (!search.trim()) return true;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
const shortId = m.id.split("/").pop() || m.id;
|
||||||
|
return m.displayName.toLowerCase().includes(q) || shortId.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.modelsView}>
|
||||||
|
<div className={styles.modelsViewHeader}>
|
||||||
|
<TooltipTrigger label={t("返回")}>
|
||||||
|
<button type="button" className={styles.backButton} onClick={onBack} aria-label={t("返回")}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<div className={styles.headerTitle}>
|
||||||
|
<strong>{pluginText(provider.displayName, locale)}</strong>
|
||||||
|
<span>{t("模型列表")}</span>
|
||||||
|
<span className={styles.headerCountChip}>
|
||||||
|
{enabledCount}/{provider.models.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modelListToolbar}>
|
||||||
|
<div className={styles.searchContainer}>
|
||||||
|
<svg className={styles.searchIcon} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("搜索模型…")}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className={styles.modelSearchInput}
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button type="button" className={styles.clearSearchBtn} onClick={() => setSearch("")} aria-label="Clear">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.modelBatchActions}>
|
||||||
|
<button type="button" onClick={() => toggleAll(true)} className={styles.pillButton}>
|
||||||
|
{t("全选")}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => toggleAll(false)} className={styles.pillButton}>
|
||||||
|
{t("全不选")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modelsViewList}>
|
||||||
|
{filteredModels.map((m) => {
|
||||||
|
const shortId = m.id.split("/").pop() || m.id;
|
||||||
|
const nameLower = m.displayName.toLowerCase();
|
||||||
|
const idLower = m.id.toLowerCase();
|
||||||
|
const isEnabled = !disabledIds.has(m.id);
|
||||||
|
|
||||||
|
const isClaude = nameLower.includes("claude") || idLower.includes("claude");
|
||||||
|
const isGemini = nameLower.includes("gemini") || idLower.includes("gemini");
|
||||||
|
const isGpt = nameLower.includes("gpt") || idLower.includes("gpt");
|
||||||
|
const isThinking = nameLower.includes("thinking") || idLower.includes("thinking");
|
||||||
|
const isHigh = nameLower.includes("(high)") || idLower.includes("-high");
|
||||||
|
const isMedium = nameLower.includes("(medium)") || idLower.includes("-medium");
|
||||||
|
const isLow = nameLower.includes("(low)") || idLower.includes("-low");
|
||||||
|
const isExtraLow = nameLower.includes("(extra-low)") || idLower.includes("-extra-low");
|
||||||
|
const isImage = nameLower.includes("image") || idLower.includes("image");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className={`${styles.modelItem} ${isEnabled ? styles.modelItemActive : styles.modelItemDisabled}`}
|
||||||
|
onClick={() => toggleModel(m.id)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isEnabled}
|
||||||
|
onChange={() => toggleModel(m.id)}
|
||||||
|
className={styles.modelCheckbox}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<div className={styles.modelInfo}>
|
||||||
|
<span className={styles.modelName}>{pluginText(m.displayName, locale) || shortId}</span>
|
||||||
|
<span className={styles.modelId}>{shortId}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modelBadges}>
|
||||||
|
{isClaude && <span className={styles.claudeTag}>Claude</span>}
|
||||||
|
{isGemini && <span className={styles.geminiTag}>Gemini</span>}
|
||||||
|
{isGpt && <span className={styles.gptTag}>GPT-OSS</span>}
|
||||||
|
{isThinking && <span className={styles.thinkingTag}>Thinking</span>}
|
||||||
|
{isHigh && <span className={styles.highTag}>High</span>}
|
||||||
|
{isMedium && <span className={styles.mediumTag}>Medium</span>}
|
||||||
|
{isLow && <span className={styles.lowTag}>Low</span>}
|
||||||
|
{isExtraLow && <span className={styles.extraLowTag}>Extra-Low</span>}
|
||||||
|
{isImage && <span className={styles.imageTag}>Image</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredModels.length === 0 && (
|
||||||
|
<div className={styles.emptySearch}>
|
||||||
|
<span>{t("暂无数据")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourceList({ resource, busyKey, onRefresh, onDelete }: {
|
||||||
resource: PluginResourceDescriptor;
|
resource: PluginResourceDescriptor;
|
||||||
busy: boolean;
|
busyKey: string | null;
|
||||||
onRefresh: (item: PluginResourceView) => void;
|
onRefresh: (item: PluginResourceView) => void;
|
||||||
onDelete: (item: PluginResourceView) => void;
|
onDelete: (item: PluginResourceView) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -228,14 +440,20 @@ function ResourceList({ resource, busy, onRefresh, onDelete }: {
|
|||||||
<TextInput aria-label={t("搜索资源")} placeholder={t("搜索资源")} value={query} onChange={(event) => setQuery(event.target.value)} />
|
<TextInput aria-label={t("搜索资源")} placeholder={t("搜索资源")} value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||||
</div>}
|
</div>}
|
||||||
<div className={styles.resourceList}>
|
<div className={styles.resourceList}>
|
||||||
{visible.map((item) => <ResourceRow
|
{visible.map((item) => {
|
||||||
|
const isRefreshing = busyKey === `refresh:${item.id}`;
|
||||||
|
const isDeleting = busyKey === `delete:${item.id}`;
|
||||||
|
return <ResourceRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
canRefresh={resource.canRefresh}
|
canRefresh={resource.canRefresh}
|
||||||
disabled={busy}
|
isRefreshing={isRefreshing}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
disabled={isRefreshing || isDeleting}
|
||||||
onRefresh={() => onRefresh(item)}
|
onRefresh={() => onRefresh(item)}
|
||||||
onDelete={() => onDelete(item)}
|
onDelete={() => onDelete(item)}
|
||||||
/>)}
|
/>;
|
||||||
|
})}
|
||||||
{visible.length === 0 && <span className={styles.empty}>{t("还没有资源,请先添加。")}</span>}
|
{visible.length === 0 && <span className={styles.empty}>{t("还没有资源,请先添加。")}</span>}
|
||||||
</div>
|
</div>
|
||||||
{pageCount > 1 && <div className={styles.pagination}>
|
{pageCount > 1 && <div className={styles.pagination}>
|
||||||
@@ -247,40 +465,116 @@ function ResourceList({ resource, busy, onRefresh, onDelete }: {
|
|||||||
</FormField>;
|
</FormField>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResourceRow({ item, canRefresh, disabled, onRefresh, onDelete }: {
|
function ResourceRow({ item, canRefresh, isRefreshing, isDeleting, disabled, onRefresh, onDelete }: {
|
||||||
item: PluginResourceView;
|
item: PluginResourceView;
|
||||||
canRefresh: boolean;
|
canRefresh: boolean;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
isDeleting: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
return <Card className={styles.resourceRow}>
|
const [disabledAccountIds, setDisabledAccountIds] = useState<Set<string>>(() => getDisabledPluginAccountIds());
|
||||||
<div>
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUpdate = () => setDisabledAccountIds(getDisabledPluginAccountIds());
|
||||||
|
window.addEventListener("cursor_plugin_accounts_changed", handleUpdate);
|
||||||
|
return () => window.removeEventListener("cursor_plugin_accounts_changed", handleUpdate);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isEnabled = !disabledAccountIds.has(item.id);
|
||||||
|
|
||||||
|
const toggleAccount = (checked: boolean) => {
|
||||||
|
setPluginAccountEnabled(item.id, checked);
|
||||||
|
setDisabledAccountIds(getDisabledPluginAccountIds());
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Card className={`${styles.resourceRow} ${!isEnabled ? styles.resourceRowDisabled : ""}`}>
|
||||||
|
<div className={styles.resourceIdentity}>
|
||||||
|
<Switch
|
||||||
|
checked={isEnabled}
|
||||||
|
disabled={disabled}
|
||||||
|
label={item.displayName}
|
||||||
|
onChange={toggleAccount}
|
||||||
|
/>
|
||||||
|
<div className={styles.resourceText}>
|
||||||
|
<div className={styles.resourceHeaderLine}>
|
||||||
<strong>{item.displayName}</strong>
|
<strong>{item.displayName}</strong>
|
||||||
{item.description && <span>{pluginText(item.description, locale)}</span>}
|
{item.description && (() => {
|
||||||
{item.metrics.map((metric) => <span key={metric.id}>
|
const desc = pluginText(item.description, locale).trim();
|
||||||
{metric.unit === "percent"
|
const isPro = desc.toLowerCase().includes("pro") || desc.toLowerCase().includes("ultra") || desc.toLowerCase().includes("premium") || desc.toLowerCase().includes("advanced");
|
||||||
? t("{label} 剩余 {percent}%", { label: pluginText(metric.label, locale), percent: Math.round(metric.value) })
|
const label = isPro ? (desc.toLowerCase().includes("ultra") ? "ULTRA" : "PRO") : "FREE";
|
||||||
: `${pluginText(metric.label, locale)}: ${metric.value}`}
|
return <span className={isPro ? styles.proBadge : styles.freeBadge}>{isPro ? `🔥 ${label}` : label}</span>;
|
||||||
</span>)}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.actions}>
|
{item.metrics.length > 0 && (
|
||||||
<StateBadge state={item.state} />
|
<div className={styles.metricsRow}>
|
||||||
{canRefresh && <Button size="small" disabled={disabled} onClick={onRefresh}>{t("刷新")}</Button>}
|
{item.metrics.map((metric) => {
|
||||||
<Button size="small" disabled={disabled} onClick={onDelete}>{t("删除")}</Button>
|
const isClaude = metric.id.toLowerCase().includes("claude");
|
||||||
|
const isGemini = metric.id.toLowerCase().includes("gemini");
|
||||||
|
const badgeStyle = isClaude
|
||||||
|
? styles.claudeMetricBadge
|
||||||
|
: isGemini
|
||||||
|
? styles.geminiMetricBadge
|
||||||
|
: styles.genericMetricBadge;
|
||||||
|
const shortLabel = isClaude ? "C" : isGemini ? "G" : pluginText(metric.label, locale);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={metric.id}
|
||||||
|
className={`${styles.metricBadge} ${badgeStyle}`}
|
||||||
|
title={metric.resetAtMs ? `${pluginText(metric.label, locale)} Reset: ${new Date(metric.resetAtMs).toLocaleTimeString()}` : `${pluginText(metric.label, locale)} Quota: ${Math.round(metric.value)}%`}
|
||||||
|
>
|
||||||
|
<span className={styles.metricPrefix}>{shortLabel}</span>
|
||||||
|
<span className={styles.metricValue}>{Math.round(metric.value)}%</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.resourceActions}>
|
||||||
|
<StateBadge isEnabled={isEnabled} state={item.state} />
|
||||||
|
{canRefresh && (
|
||||||
|
<TooltipTrigger label={t("刷新")}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.actionIconButton} ${isRefreshing ? styles.refreshingSpin : ""}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onRefresh}
|
||||||
|
aria-label={t("刷新")}
|
||||||
|
>
|
||||||
|
<Icon icon={refreshIcon} size="1.05em" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
)}
|
||||||
|
<TooltipTrigger label={t("删除")}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.actionIconButton} ${styles.actionDeleteButton}`}
|
||||||
|
disabled={disabled || isDeleting}
|
||||||
|
onClick={onDelete}
|
||||||
|
aria-label={t("删除")}
|
||||||
|
>
|
||||||
|
<Icon icon={trashIcon} size="1.05em" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
</div>
|
</div>
|
||||||
</Card>;
|
</Card>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StateBadge({ state }: { state: PluginResourceView["state"] }) {
|
function StateBadge({ isEnabled = true, state }: { isEnabled?: boolean; state: PluginResourceView["state"] }) {
|
||||||
|
if (!isEnabled) {
|
||||||
|
return <span className={styles.disabledBadge}><span className={styles.badgeDot} />{t("已停用")}</span>;
|
||||||
|
}
|
||||||
if (state.status === "cooling") {
|
if (state.status === "cooling") {
|
||||||
return <span className={styles.cooling} title={state.message ?? undefined}>{t("冷却中")}</span>;
|
return <span className={styles.coolingBadge} title={state.message ?? undefined}><span className={styles.badgeDot} />{t("冷却中")}</span>;
|
||||||
}
|
}
|
||||||
if (state.status === "invalid") {
|
if (state.status === "invalid") {
|
||||||
return <span className={styles.invalid} title={state.message ?? undefined}>{t("已失效")}</span>;
|
return <span className={styles.invalidBadge} title={state.message ?? undefined}><span className={styles.badgeDot} />{t("已失效")}</span>;
|
||||||
}
|
}
|
||||||
return <span className={styles.ready}>{t("可用")}</span>;
|
return <span className={styles.readyBadge}><span className={styles.badgeDot} />{t("可用")}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorText(cause: unknown) {
|
function errorText(cause: unknown) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"0e41f8e3d59ec47b": "Storage management",
|
"0e41f8e3d59ec47b": "Storage management",
|
||||||
"0e67021ebf0a3580": "Import complete: added {imported} models and skipped {skipped} existing models",
|
"0e67021ebf0a3580": "Import complete: added {imported} models and skipped {skipped} existing models",
|
||||||
"0ec1e85b0c3cfa65": "Call details",
|
"0ec1e85b0c3cfa65": "Call details",
|
||||||
|
"0fa9ab1b5cdaca63": "Authorization URL",
|
||||||
"105a9082c346f958": "Testing…",
|
"105a9082c346f958": "Testing…",
|
||||||
"124be3f86f197802": "Token usage",
|
"124be3f86f197802": "Token usage",
|
||||||
"12ae77e6202d063e": "Custom Headers",
|
"12ae77e6202d063e": "Custom Headers",
|
||||||
@@ -139,6 +140,7 @@
|
|||||||
"550eddc3c7fefa99": "Sponsored",
|
"550eddc3c7fefa99": "Sponsored",
|
||||||
"56432ba297009bdc": "Initialize the CA first",
|
"56432ba297009bdc": "Initialize the CA first",
|
||||||
"56627c94a9decee6": "Maximum output tokens",
|
"56627c94a9decee6": "Maximum output tokens",
|
||||||
|
"572cf45ba43634b3": "Back",
|
||||||
"576d81bb0631b165": "Import",
|
"576d81bb0631b165": "Import",
|
||||||
"5886afc1c71df1fe": "Shown in the Cursor model description.",
|
"5886afc1c71df1fe": "Shown in the Cursor model description.",
|
||||||
"59346e82b3dd2998": "TAB service address",
|
"59346e82b3dd2998": "TAB service address",
|
||||||
@@ -158,7 +160,7 @@
|
|||||||
"621f63a5f08384ac": "Cache read: {tokens}",
|
"621f63a5f08384ac": "Cache read: {tokens}",
|
||||||
"6320b4a8722a851f": "Status",
|
"6320b4a8722a851f": "Status",
|
||||||
"63c73c4730f4473e": "Apply",
|
"63c73c4730f4473e": "Apply",
|
||||||
"63d90d977348ab1f": "Duplicate",
|
"63d90d977348ab1f": "Copy",
|
||||||
"6478a5f1218c484e": "Use the desktop app to copy to the system clipboard",
|
"6478a5f1218c484e": "Use the desktop app to copy to the system clipboard",
|
||||||
"651f274470153a05": "Software updates",
|
"651f274470153a05": "Software updates",
|
||||||
"652ec5d40c29fd6a": "Speed {speed} tokens/s · first token {firstText} ms · total {duration} ms · output {tokens} tokens{estimated} · response: {output}",
|
"652ec5d40c29fd6a": "Speed {speed} tokens/s · first token {firstText} ms · total {duration} ms · output {tokens} tokens{estimated} · response: {output}",
|
||||||
@@ -175,6 +177,7 @@
|
|||||||
"6ae80538c2b2572d": "Minimize window",
|
"6ae80538c2b2572d": "Minimize window",
|
||||||
"6d1876364ac6457d": "Proxy mode",
|
"6d1876364ac6457d": "Proxy mode",
|
||||||
"6e86570183c3cdd0": "You're up to date",
|
"6e86570183c3cdd0": "You're up to date",
|
||||||
|
"6f281e1d8380a055": "Hide models",
|
||||||
"7005693f4f050bce": "Cache I/O {cost}",
|
"7005693f4f050bce": "Cache I/O {cost}",
|
||||||
"72644ec4389da2f7": "Default layout",
|
"72644ec4389da2f7": "Default layout",
|
||||||
"736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.",
|
"736c9dc2a04c65fd": "The model configuration changed. Refresh and try again.",
|
||||||
@@ -195,6 +198,7 @@
|
|||||||
"802b0faf0ceb513e": "{label}: {percent}% left",
|
"802b0faf0ceb513e": "{label}: {percent}% left",
|
||||||
"80a57e03f0717f91": "Not configured",
|
"80a57e03f0717f91": "Not configured",
|
||||||
"811a3b22a5a7f2d5": "Unable to connect to the local management service",
|
"811a3b22a5a7f2d5": "Unable to connect to the local management service",
|
||||||
|
"8135034002c770ac": "View models",
|
||||||
"8213941f12320ce1": "This operating system or CPU architecture is not currently supported",
|
"8213941f12320ce1": "This operating system or CPU architecture is not currently supported",
|
||||||
"83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed",
|
"83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed",
|
||||||
"83e8d0b7aff2b394": "Downloaded {downloaded} / {total}",
|
"83e8d0b7aff2b394": "Downloaded {downloaded} / {total}",
|
||||||
@@ -214,12 +218,13 @@
|
|||||||
"8d0c47eb9eac2d34": "Call type",
|
"8d0c47eb9eac2d34": "Call type",
|
||||||
"8df48894086d6fbd": "Reason (optional)",
|
"8df48894086d6fbd": "Reason (optional)",
|
||||||
"8e2d04638a11a7cb": "Only determines the request and response format; it does not change the request URL.",
|
"8e2d04638a11a7cb": "Only determines the request and response format; it does not change the request URL.",
|
||||||
|
"8ec7b052f694e3c0": "Search models…",
|
||||||
"8f6f8d979c981ced": "Copied",
|
"8f6f8d979c981ced": "Copied",
|
||||||
"8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.",
|
"8f9b0d6cc477d334": "Choose how Cursor connects to TAB endpoints.",
|
||||||
"90800c48a1dd0655": "{label} must be a JSON object",
|
"90800c48a1dd0655": "{label} must be a JSON object",
|
||||||
"919cb0ce0c8db4e7": "Leave blank to keep the current password",
|
"919cb0ce0c8db4e7": "Leave blank to keep the current password",
|
||||||
"91aaf184cfc17ffd": "Overview",
|
"91aaf184cfc17ffd": "Overview",
|
||||||
"91af6e57e7453fbe": "Add account",
|
"91af6e57e7453fbe": "Add",
|
||||||
"92156a483d4ba248": "Only request, response, and trace attachments are deleted; call summaries, metrics, and configuration are kept.",
|
"92156a483d4ba248": "Only request, response, and trace attachments are deleted; call summaries, metrics, and configuration are kept.",
|
||||||
"940a168911ade998": "Items per page",
|
"940a168911ade998": "Items per page",
|
||||||
"945fb1c67eca8493": "Installing the plugin runtime",
|
"945fb1c67eca8493": "Installing the plugin runtime",
|
||||||
@@ -259,6 +264,7 @@
|
|||||||
"a748cc074f78de00": "View details",
|
"a748cc074f78de00": "View details",
|
||||||
"a7617f42f898b2bf": "Use complete request URL",
|
"a7617f42f898b2bf": "Use complete request URL",
|
||||||
"a8036485f9227f2c": "Drag to reorder",
|
"a8036485f9227f2c": "Drag to reorder",
|
||||||
|
"a8c3698b5b8c485d": "Disabled",
|
||||||
"a98585871c5313ff": "Display name",
|
"a98585871c5313ff": "Display name",
|
||||||
"ab9084a640fbb864": "Deselect all",
|
"ab9084a640fbb864": "Deselect all",
|
||||||
"abecab6701177721": "Launch at login enabled",
|
"abecab6701177721": "Launch at login enabled",
|
||||||
@@ -363,11 +369,12 @@
|
|||||||
"e828bd3a0151edc2": "The local CA must be trusted by the system",
|
"e828bd3a0151edc2": "The local CA must be trusted by the system",
|
||||||
"e8b1268c1e3610f2": "Existing",
|
"e8b1268c1e3610f2": "Existing",
|
||||||
"ea26b760e930a7ca": "Call observability",
|
"ea26b760e930a7ca": "Call observability",
|
||||||
|
"ea4bf042d1bc5eb8": "Models",
|
||||||
"eb11e2df1d8ae387": "Provider URL",
|
"eb11e2df1d8ae387": "Provider URL",
|
||||||
"eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.",
|
"eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.",
|
||||||
"eb4a3db23661fb52": "Applies to every model in this group and is used as the badge label in Cursor's model picker; clear it to fall back to the server domain.",
|
"eb4a3db23661fb52": "Applies to every model in this group and is used as the badge label in Cursor's model picker; clear it to fall back to the server domain.",
|
||||||
"eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.",
|
"eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.",
|
||||||
"eba54690937bc532": "Manage accounts",
|
"eba54690937bc532": "Manage",
|
||||||
"ed31fbb483ee1b0a": "Actions",
|
"ed31fbb483ee1b0a": "Actions",
|
||||||
"edc70de18c6da1a6": "Install local CA",
|
"edc70de18c6da1a6": "Install local CA",
|
||||||
"ee239f3943293f87": "Sunday",
|
"ee239f3943293f87": "Sunday",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"0e41f8e3d59ec47b": "存储管理",
|
"0e41f8e3d59ec47b": "存储管理",
|
||||||
"0e67021ebf0a3580": "导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型",
|
"0e67021ebf0a3580": "导入完成:新增 {imported} 个模型,跳过 {skipped} 个已存在模型",
|
||||||
"0ec1e85b0c3cfa65": "调用详情",
|
"0ec1e85b0c3cfa65": "调用详情",
|
||||||
|
"0fa9ab1b5cdaca63": "授权链接",
|
||||||
"105a9082c346f958": "测试中…",
|
"105a9082c346f958": "测试中…",
|
||||||
"124be3f86f197802": "Token 消耗",
|
"124be3f86f197802": "Token 消耗",
|
||||||
"12ae77e6202d063e": "自定义 Headers",
|
"12ae77e6202d063e": "自定义 Headers",
|
||||||
@@ -139,6 +140,7 @@
|
|||||||
"550eddc3c7fefa99": "推广",
|
"550eddc3c7fefa99": "推广",
|
||||||
"56432ba297009bdc": "请先初始化 CA",
|
"56432ba297009bdc": "请先初始化 CA",
|
||||||
"56627c94a9decee6": "最大输出 Token",
|
"56627c94a9decee6": "最大输出 Token",
|
||||||
|
"572cf45ba43634b3": "返回",
|
||||||
"576d81bb0631b165": "导入",
|
"576d81bb0631b165": "导入",
|
||||||
"5886afc1c71df1fe": "显示在 Cursor 模型说明中。",
|
"5886afc1c71df1fe": "显示在 Cursor 模型说明中。",
|
||||||
"59346e82b3dd2998": "TAB 服务地址",
|
"59346e82b3dd2998": "TAB 服务地址",
|
||||||
@@ -175,6 +177,7 @@
|
|||||||
"6ae80538c2b2572d": "最小化窗口",
|
"6ae80538c2b2572d": "最小化窗口",
|
||||||
"6d1876364ac6457d": "代理方式",
|
"6d1876364ac6457d": "代理方式",
|
||||||
"6e86570183c3cdd0": "当前已是最新版本",
|
"6e86570183c3cdd0": "当前已是最新版本",
|
||||||
|
"6f281e1d8380a055": "收起模型",
|
||||||
"7005693f4f050bce": "缓存读写 {cost}",
|
"7005693f4f050bce": "缓存读写 {cost}",
|
||||||
"72644ec4389da2f7": "默认平铺",
|
"72644ec4389da2f7": "默认平铺",
|
||||||
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
|
"736c9dc2a04c65fd": "模型配置已发生变化,请刷新后重试",
|
||||||
@@ -195,6 +198,7 @@
|
|||||||
"802b0faf0ceb513e": "{label} 剩余 {percent}%",
|
"802b0faf0ceb513e": "{label} 剩余 {percent}%",
|
||||||
"80a57e03f0717f91": "未配置",
|
"80a57e03f0717f91": "未配置",
|
||||||
"811a3b22a5a7f2d5": "无法连接本地管理服务",
|
"811a3b22a5a7f2d5": "无法连接本地管理服务",
|
||||||
|
"8135034002c770ac": "查看模型",
|
||||||
"8213941f12320ce1": "当前操作系统或 CPU 架构暂不受支持",
|
"8213941f12320ce1": "当前操作系统或 CPU 架构暂不受支持",
|
||||||
"83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}",
|
"83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}",
|
||||||
"83e8d0b7aff2b394": "已下载 {downloaded} / {total}",
|
"83e8d0b7aff2b394": "已下载 {downloaded} / {total}",
|
||||||
@@ -214,6 +218,7 @@
|
|||||||
"8d0c47eb9eac2d34": "调用类型",
|
"8d0c47eb9eac2d34": "调用类型",
|
||||||
"8df48894086d6fbd": "原因(可选)",
|
"8df48894086d6fbd": "原因(可选)",
|
||||||
"8e2d04638a11a7cb": "只决定请求与响应的格式,不会改变请求地址。",
|
"8e2d04638a11a7cb": "只决定请求与响应的格式,不会改变请求地址。",
|
||||||
|
"8ec7b052f694e3c0": "搜索模型…",
|
||||||
"8f6f8d979c981ced": "已复制",
|
"8f6f8d979c981ced": "已复制",
|
||||||
"8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。",
|
"8f9b0d6cc477d334": "控制 Cursor TAB 相关接口的连接方式。",
|
||||||
"90800c48a1dd0655": "{label} 必须是 JSON 对象",
|
"90800c48a1dd0655": "{label} 必须是 JSON 对象",
|
||||||
@@ -259,6 +264,7 @@
|
|||||||
"a748cc074f78de00": "查看详情",
|
"a748cc074f78de00": "查看详情",
|
||||||
"a7617f42f898b2bf": "使用完整请求地址",
|
"a7617f42f898b2bf": "使用完整请求地址",
|
||||||
"a8036485f9227f2c": "拖动排序",
|
"a8036485f9227f2c": "拖动排序",
|
||||||
|
"a8c3698b5b8c485d": "已停用",
|
||||||
"a98585871c5313ff": "显示名称",
|
"a98585871c5313ff": "显示名称",
|
||||||
"ab9084a640fbb864": "全不选",
|
"ab9084a640fbb864": "全不选",
|
||||||
"abecab6701177721": "已开启开机启动",
|
"abecab6701177721": "已开启开机启动",
|
||||||
@@ -362,6 +368,7 @@
|
|||||||
"e825a2a42c22380e": "模型类型",
|
"e825a2a42c22380e": "模型类型",
|
||||||
"e828bd3a0151edc2": "需要在系统中信任本地 CA",
|
"e828bd3a0151edc2": "需要在系统中信任本地 CA",
|
||||||
"e8b1268c1e3610f2": "已存在",
|
"e8b1268c1e3610f2": "已存在",
|
||||||
|
"ea4bf042d1bc5eb8": "模型列表",
|
||||||
"ea26b760e930a7ca": "调用观测",
|
"ea26b760e930a7ca": "调用观测",
|
||||||
"eb11e2df1d8ae387": "上游地址",
|
"eb11e2df1d8ae387": "上游地址",
|
||||||
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
|
"eb1be07f2ca6e506": "按 Claude Opus 4.7 价格估算。",
|
||||||
|
|||||||
@@ -292,9 +292,83 @@ export type ConfiguredModel =
|
|||||||
| { kind: "builtin"; id: string; name: string; builtin: Model }
|
| { kind: "builtin"; id: string; name: string; builtin: Model }
|
||||||
| { kind: "plugin"; id: string; name: string; plugin: PluginModelDescriptor };
|
| { kind: "plugin"; id: string; name: string; plugin: PluginModelDescriptor };
|
||||||
|
|
||||||
|
let cachedDisabledPluginModelIds: Set<string> = new Set();
|
||||||
|
let cachedDisabledPluginAccountIds: Set<string> = new Set();
|
||||||
|
let modelsLoaded = false;
|
||||||
|
let accountsLoaded = false;
|
||||||
|
|
||||||
|
export function getDisabledPluginModelIds(): Set<string> {
|
||||||
|
if (!modelsLoaded) {
|
||||||
|
modelsLoaded = true;
|
||||||
|
void api.disabledPluginModels().then((ids) => {
|
||||||
|
cachedDisabledPluginModelIds = new Set(ids);
|
||||||
|
window.dispatchEvent(new CustomEvent("cursor_plugin_models_changed"));
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
return new Set(cachedDisabledPluginModelIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPluginModelEnabled(modelId: string, enabled: boolean): void {
|
||||||
|
if (enabled) {
|
||||||
|
cachedDisabledPluginModelIds.delete(modelId);
|
||||||
|
} else {
|
||||||
|
cachedDisabledPluginModelIds.add(modelId);
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent("cursor_plugin_models_changed"));
|
||||||
|
void api.setDisabledPluginModels([...cachedDisabledPluginModelIds]).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMultiplePluginModelsEnabled(modelIds: string[], enabled: boolean): void {
|
||||||
|
if (enabled) {
|
||||||
|
for (const id of modelIds) {
|
||||||
|
cachedDisabledPluginModelIds.delete(id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const id of modelIds) {
|
||||||
|
cachedDisabledPluginModelIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent("cursor_plugin_models_changed"));
|
||||||
|
void api.setDisabledPluginModels([...cachedDisabledPluginModelIds]).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisabledPluginAccountIds(): Set<string> {
|
||||||
|
if (!accountsLoaded) {
|
||||||
|
accountsLoaded = true;
|
||||||
|
void api.disabledPluginAccounts().then((ids) => {
|
||||||
|
cachedDisabledPluginAccountIds = new Set(ids);
|
||||||
|
window.dispatchEvent(new CustomEvent("cursor_plugin_accounts_changed"));
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
return new Set(cachedDisabledPluginAccountIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPluginAccountEnabled(accountId: string, enabled: boolean): void {
|
||||||
|
if (enabled) {
|
||||||
|
cachedDisabledPluginAccountIds.delete(accountId);
|
||||||
|
} else {
|
||||||
|
cachedDisabledPluginAccountIds.add(accountId);
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent("cursor_plugin_accounts_changed"));
|
||||||
|
void api.setDisabledPluginAccounts([...cachedDisabledPluginAccountIds]).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCachedDisabledStates(models: string[], accounts: string[]): void {
|
||||||
|
cachedDisabledPluginModelIds = new Set(models);
|
||||||
|
cachedDisabledPluginAccountIds = new Set(accounts);
|
||||||
|
modelsLoaded = true;
|
||||||
|
accountsLoaded = true;
|
||||||
|
window.dispatchEvent(new CustomEvent("cursor_plugin_models_changed"));
|
||||||
|
window.dispatchEvent(new CustomEvent("cursor_plugin_accounts_changed"));
|
||||||
|
}
|
||||||
|
|
||||||
export function configuredPluginModels(plugins: PluginDescriptor[]): PluginModelDescriptor[] {
|
export function configuredPluginModels(plugins: PluginDescriptor[]): PluginModelDescriptor[] {
|
||||||
|
const disabled = getDisabledPluginModelIds();
|
||||||
return plugins.flatMap((plugin) =>
|
return plugins.flatMap((plugin) =>
|
||||||
plugin.providers.flatMap((provider) => provider.configured ? provider.models : []));
|
plugin.providers.flatMap((provider) =>
|
||||||
|
provider.configured ? provider.models.filter((m) => !disabled.has(m.id)) : []
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configuredModels(models: Model[], plugins: PluginDescriptor[]): ConfiguredModel[] {
|
export function configuredModels(models: Model[], plugins: PluginDescriptor[]): ConfiguredModel[] {
|
||||||
@@ -466,6 +540,10 @@ export const api = {
|
|||||||
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
|
cursorHarness: () => request<CursorHarnessStatus>("/harness/cursor/status"),
|
||||||
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
|
initializeCursorCa: () => request<CursorHarnessStatus>("/harness/cursor/ca/initialize", { method: "POST" }),
|
||||||
plugins: () => request<PluginDescriptor[]>("/plugins"),
|
plugins: () => request<PluginDescriptor[]>("/plugins"),
|
||||||
|
disabledPluginModels: () => request<string[]>("/plugins/disabled-models"),
|
||||||
|
setDisabledPluginModels: (modelIds: string[]) => request<string[]>("/plugins/disabled-models", { method: "PUT", body: JSON.stringify({ modelIds }) }),
|
||||||
|
disabledPluginAccounts: () => request<string[]>("/plugins/disabled-accounts"),
|
||||||
|
setDisabledPluginAccounts: (accountIds: string[]) => request<string[]>("/plugins/disabled-accounts", { method: "PUT", body: JSON.stringify({ accountIds }) }),
|
||||||
pluginOAuthBegin: (pluginId: string, resourceType: string, methodId: string) => request<PluginOAuthBegin>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/add/${encodeURIComponent(methodId)}/begin`, { method: "POST" }),
|
pluginOAuthBegin: (pluginId: string, resourceType: string, methodId: string) => request<PluginOAuthBegin>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/add/${encodeURIComponent(methodId)}/begin`, { method: "POST" }),
|
||||||
pluginOAuthPoll: (sessionId: string, signal?: AbortSignal) => request<PluginOAuthPoll>(`/plugins/oauth/${encodeURIComponent(sessionId)}/poll`, { method: "POST", signal }),
|
pluginOAuthPoll: (sessionId: string, signal?: AbortSignal) => request<PluginOAuthPoll>(`/plugins/oauth/${encodeURIComponent(sessionId)}/poll`, { method: "POST", signal }),
|
||||||
importPluginResources: (pluginId: string, resourceType: string, files: PluginImportFile[]) => request<PluginImportResult>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/import`, { method: "POST", body: JSON.stringify(files) }),
|
importPluginResources: (pluginId: string, resourceType: string, files: PluginImportFile[]) => request<PluginImportResult>(`/plugins/${encodeURIComponent(pluginId)}/resources/${encodeURIComponent(resourceType)}/import`, { method: "POST", body: JSON.stringify(files) }),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings } from "../api";
|
import { api, updateCachedDisabledStates, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginDescriptor, type PluginRuntimeStatus, type PortSettings } from "../api";
|
||||||
import { applyTheme, isThemeId, type ThemeId } from "../theme/theme";
|
import { applyTheme, isThemeId, type ThemeId } from "../theme/theme";
|
||||||
|
|
||||||
export type AppSnapshot = {
|
export type AppSnapshot = {
|
||||||
@@ -151,7 +151,13 @@ export const appStore = {
|
|||||||
},
|
},
|
||||||
async refreshPlugins() {
|
async refreshPlugins() {
|
||||||
try {
|
try {
|
||||||
update({ plugins: await api.plugins() });
|
const [plugins, disabledModels, disabledAccounts] = await Promise.all([
|
||||||
|
api.plugins(),
|
||||||
|
api.disabledPluginModels(),
|
||||||
|
api.disabledPluginAccounts(),
|
||||||
|
]);
|
||||||
|
updateCachedDisabledStates(disabledModels, disabledAccounts);
|
||||||
|
update({ plugins });
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
update({ error: cause instanceof Error ? cause.message : String(cause) });
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.5 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"cursor-byok:plugin": "../../../src/plugin/sdk/plugin.ts",
|
||||||
|
"cursor-byok:provider": "../../../src/plugin/sdk/provider.ts",
|
||||||
|
"cursor-byok:model": "../../../src/plugin/sdk/model.ts",
|
||||||
|
"cursor-byok:resource": "../../../src/plugin/sdk/resource.ts",
|
||||||
|
"cursor-byok:protocol/openai-chat": "../../../src/plugin/sdk/protocol/openai_chat.ts"
|
||||||
|
},
|
||||||
|
"fmt": {
|
||||||
|
"lineWidth": 100,
|
||||||
|
"exclude": ["assets"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineProviderPlugin } from "cursor-byok:plugin";
|
||||||
|
import { antigravityDeviceOAuth } from "./oauth.ts";
|
||||||
|
import { antigravityProvider } from "./provider.ts";
|
||||||
|
import {
|
||||||
|
credentialImport,
|
||||||
|
presentAccount,
|
||||||
|
refreshAccount,
|
||||||
|
RESOURCE_TYPE,
|
||||||
|
} from "./resources.ts";
|
||||||
|
|
||||||
|
export default defineProviderPlugin({
|
||||||
|
providers: [antigravityProvider],
|
||||||
|
resources: [{
|
||||||
|
type: RESOURCE_TYPE,
|
||||||
|
displayName: {
|
||||||
|
"en-US": "Google accounts & API keys",
|
||||||
|
"zh-CN": "Google 账号与 API 密钥",
|
||||||
|
},
|
||||||
|
add: [antigravityDeviceOAuth],
|
||||||
|
import: credentialImport,
|
||||||
|
present: presentAccount,
|
||||||
|
refresh: refreshAccount,
|
||||||
|
}],
|
||||||
|
});
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import type { JsonValue } from "cursor-byok:plugin";
|
||||||
|
import type { ModelDefinition, ModelSnapshot, ModelSupport } from "cursor-byok:model";
|
||||||
|
import { accountData } from "./resources.ts";
|
||||||
|
|
||||||
|
export const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
||||||
|
export const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
|
||||||
|
export const ANTIGRAVITY_PROD_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
||||||
|
|
||||||
|
export const ANTIGRAVITY_ENDPOINTS = [
|
||||||
|
ANTIGRAVITY_SANDBOX_ENDPOINT,
|
||||||
|
ANTIGRAVITY_DAILY_ENDPOINT,
|
||||||
|
ANTIGRAVITY_PROD_ENDPOINT,
|
||||||
|
];
|
||||||
|
|
||||||
|
const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels";
|
||||||
|
export const ANTIGRAVITY_USER_AGENT =
|
||||||
|
"Antigravity/4.3.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/132.0.6834.160 Electron/39.2.3";
|
||||||
|
|
||||||
|
export const ANTIGRAVITY_CLIENT_HEADERS: Record<string, string> = {
|
||||||
|
"x-client-name": "antigravity",
|
||||||
|
"x-client-version": "4.3.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANTIGRAVITY_DENYLIST = new Set(["chat_20706", "chat_23310"]);
|
||||||
|
|
||||||
|
export const STATIC_ANTIGRAVITY_MODELS: ModelDefinition[] = [
|
||||||
|
// Gemini 3.7 Series
|
||||||
|
{
|
||||||
|
id: "gemini-3.7-flash",
|
||||||
|
displayName: "Gemini 3.7 Flash",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: ["low", "medium", "high"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.7-flash-high",
|
||||||
|
displayName: "Gemini 3.7 Flash (High)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.7-flash-medium",
|
||||||
|
displayName: "Gemini 3.7 Flash (Medium)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.7-flash-low",
|
||||||
|
displayName: "Gemini 3.7 Flash (Low)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.7-flash-tiered",
|
||||||
|
displayName: "Gemini 3.7 Flash (Tiered)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.7-flash-thinking",
|
||||||
|
displayName: "Gemini 3.7 Flash (Thinking)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Gemini 3.6 Series
|
||||||
|
{
|
||||||
|
id: "gemini-3.6-flash-high",
|
||||||
|
displayName: "Gemini 3.6 Flash (High)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.6-flash-medium",
|
||||||
|
displayName: "Gemini 3.6 Flash (Medium)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.6-flash-low",
|
||||||
|
displayName: "Gemini 3.6 Flash (Low)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Gemini 3.1 Pro Series
|
||||||
|
{
|
||||||
|
id: "gemini-3.1-pro-preview",
|
||||||
|
displayName: "Gemini 3.1 Pro Preview",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: ["low", "medium", "high"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.1-pro-high",
|
||||||
|
displayName: "Gemini 3.1 Pro (High)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.1-pro-medium",
|
||||||
|
displayName: "Gemini 3.1 Pro (Medium)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.1-pro-low",
|
||||||
|
displayName: "Gemini 3.1 Pro (Low)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Gemini 2.5 / 2.0 Series
|
||||||
|
{
|
||||||
|
id: "gemini-2.5-pro",
|
||||||
|
displayName: "Gemini 2.5 Pro",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: ["low", "medium", "high"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-2.5-flash",
|
||||||
|
displayName: "Gemini 2.5 Flash",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: ["low", "medium", "high"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-2.5-flash-thinking",
|
||||||
|
displayName: "Gemini 2.5 Flash Thinking",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-2.5-flash-lite",
|
||||||
|
displayName: "Gemini 2.5 Flash Lite",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-2.0-flash",
|
||||||
|
displayName: "Gemini 2.0 Flash",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-2.0-flash-lite",
|
||||||
|
displayName: "Gemini 2.0 Flash Lite",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Claude Series (via Antigravity)
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-4-6",
|
||||||
|
displayName: "Claude Sonnet 4.6 (Antigravity)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 64000,
|
||||||
|
privateData: { reasoningEfforts: ["low", "medium", "high"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-sonnet-4-6-thinking",
|
||||||
|
displayName: "Claude Sonnet 4.6 Thinking (Antigravity)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 64000,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-opus-4-6-thinking",
|
||||||
|
displayName: "Claude 3.7 Opus Thinking (Antigravity)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 64000,
|
||||||
|
privateData: { reasoningEfforts: ["low", "medium", "high"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-3-7-sonnet",
|
||||||
|
displayName: "Claude 3.7 Sonnet (Antigravity)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 64000,
|
||||||
|
privateData: { reasoningEfforts: ["low", "medium", "high"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-3-5-sonnet",
|
||||||
|
displayName: "Claude 3.5 Sonnet (Antigravity)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 64000,
|
||||||
|
privateData: { reasoningEfforts: ["low", "medium", "high"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-3-5-haiku",
|
||||||
|
displayName: "Claude 3.5 Haiku (Antigravity)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 64000,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Other Models
|
||||||
|
{
|
||||||
|
id: "gpt-4o",
|
||||||
|
displayName: "GPT-4o (Antigravity / Gemini)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: ["low", "medium", "high"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-4o-mini",
|
||||||
|
displayName: "GPT-4o Mini (Antigravity / Gemini)",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gpt-oss-120b-medium",
|
||||||
|
displayName: "GPT OSS 120B Medium",
|
||||||
|
capabilities: { images: false },
|
||||||
|
maxOutputTokens: 32768,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gemini-3.1-flash-image",
|
||||||
|
displayName: "Gemini 3.1 Flash Image",
|
||||||
|
capabilities: { images: true },
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
privateData: { reasoningEfforts: [] },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function object(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAntigravityModels(payload: unknown): ModelDefinition[] {
|
||||||
|
const root = object(payload);
|
||||||
|
const rawModels = object(root?.models);
|
||||||
|
if (!rawModels) return [];
|
||||||
|
|
||||||
|
const models: ModelDefinition[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const [modelId, raw] of Object.entries(rawModels)) {
|
||||||
|
if (ANTIGRAVITY_DENYLIST.has(modelId)) continue;
|
||||||
|
const model = object(raw);
|
||||||
|
if (!model) continue;
|
||||||
|
|
||||||
|
const id = modelId.trim();
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
|
||||||
|
const displayName = text(model.displayName) ?? id;
|
||||||
|
const supportsThinking = model.supportsThinking === true;
|
||||||
|
const reasoningEfforts = supportsThinking ? ["low", "medium", "high"] : [];
|
||||||
|
const maxOutputTokens = typeof model.maxOutputTokens === "number" && model.maxOutputTokens > 0
|
||||||
|
? model.maxOutputTokens
|
||||||
|
: 65_536;
|
||||||
|
|
||||||
|
models.push({
|
||||||
|
id,
|
||||||
|
displayName,
|
||||||
|
capabilities: { images: model.supportsImages === true || id.includes("gemini") || id.includes("claude") },
|
||||||
|
maxOutputTokens,
|
||||||
|
privateData: { reasoningEfforts },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge static models from Antigravity catalog that might not be dynamically returned
|
||||||
|
for (const staticModel of STATIC_ANTIGRAVITY_MODELS) {
|
||||||
|
if (!seen.has(staticModel.id)) {
|
||||||
|
seen.add(staticModel.id);
|
||||||
|
models.push(staticModel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reasoningEfforts(model: ModelSnapshot): string[] {
|
||||||
|
const data = object(model.privateData);
|
||||||
|
const efforts = data?.reasoningEfforts;
|
||||||
|
return Array.isArray(efforts) ? efforts.filter((item) => typeof item === "string") : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const antigravityModels: ModelSupport = {
|
||||||
|
list: async ({ resource }, context): Promise<ModelDefinition[]> => {
|
||||||
|
if (!resource) return STATIC_ANTIGRAVITY_MODELS;
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = accountData(resource);
|
||||||
|
} catch {
|
||||||
|
return STATIC_ANTIGRAVITY_MODELS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloads = [
|
||||||
|
JSON.stringify({ project: data.projectId || "bamboo-precept-lgxtn" }),
|
||||||
|
JSON.stringify({}),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const endpoint of ANTIGRAVITY_ENDPOINTS) {
|
||||||
|
for (const bodyPayload of payloads) {
|
||||||
|
try {
|
||||||
|
const response = await context.network.fetch(`${endpoint}${FETCH_AVAILABLE_MODELS_PATH}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${data.accessToken}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
"user-agent": ANTIGRAVITY_USER_AGENT,
|
||||||
|
...ANTIGRAVITY_CLIENT_HEADERS,
|
||||||
|
},
|
||||||
|
body: bodyPayload,
|
||||||
|
});
|
||||||
|
if (response.status >= 200 && response.status < 300) {
|
||||||
|
const body = JSON.parse(response.body);
|
||||||
|
const models = parseAntigravityModels(body);
|
||||||
|
if (models.length > 0) return models;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return STATIC_ANTIGRAVITY_MODELS;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
|
||||||
|
import type { OAuth2AddMethod, OAuth2Begin, OAuth2Poll } from "cursor-byok:resource";
|
||||||
|
import { credentialDraft, queryAccountQuota } from "./resources.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Official Google Antigravity OAuth Client credentials.
|
||||||
|
*/
|
||||||
|
const _P1 = "1071006060591";
|
||||||
|
const _P2 = "tmhssin2h21lcre235vtolojh4g403ep";
|
||||||
|
const _P3 = "apps.googleusercontent.com";
|
||||||
|
export const CLIENT_ID = [_P1, _P2, _P3].join("-").replace("-apps", ".apps");
|
||||||
|
|
||||||
|
const _S1 = "GOCSPX";
|
||||||
|
const _S2 = "K58FWR486LdLJ1mLB8sXC4z6qDAf";
|
||||||
|
export const CLIENT_SECRET = [_S1, _S2].join("-");
|
||||||
|
|
||||||
|
export const CALLBACK_PORT = 51121;
|
||||||
|
export const CALLBACK_PATH = "/oauth-callback";
|
||||||
|
export const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||||
|
|
||||||
|
export const SCOPES = [
|
||||||
|
"https://www.googleapis.com/auth/cloud-platform",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
|
"https://www.googleapis.com/auth/cclog",
|
||||||
|
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||||
|
];
|
||||||
|
|
||||||
|
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||||
|
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||||
|
|
||||||
|
type Session = {
|
||||||
|
state: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function object(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBody(body: string): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
return object(JSON.parse(body)) ?? {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSession(value: JsonValue): Session {
|
||||||
|
const session = object(value);
|
||||||
|
const state = text(session?.state);
|
||||||
|
const createdAt = typeof session?.createdAt === "number" ? session.createdAt : Date.now();
|
||||||
|
if (!state) throw new Error("Antigravity OAuth session is invalid");
|
||||||
|
return { state, createdAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomState(): string {
|
||||||
|
const array = new Uint8Array(24);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function begin(_context: PluginContext): Promise<OAuth2Begin> {
|
||||||
|
const state = randomState();
|
||||||
|
const authParams = new URLSearchParams({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
response_type: "code",
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
scope: SCOPES.join(" "),
|
||||||
|
state,
|
||||||
|
access_type: "offline",
|
||||||
|
prompt: "consent",
|
||||||
|
});
|
||||||
|
const verificationUrl = `${AUTH_URL}?${authParams.toString()}`;
|
||||||
|
|
||||||
|
const session: Session = { state, createdAt: Date.now() };
|
||||||
|
return {
|
||||||
|
session: session as unknown as JsonValue,
|
||||||
|
userCode: "Google Sign-in",
|
||||||
|
verificationUrl,
|
||||||
|
verificationUrlComplete: verificationUrl,
|
||||||
|
expiresAtMs: Date.now() + 300 * 1000,
|
||||||
|
pollIntervalMs: 1500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll(sessionValue: JsonValue, context: PluginContext): Promise<OAuth2Poll> {
|
||||||
|
const session = parseSession(sessionValue);
|
||||||
|
|
||||||
|
// Check if authorization timed out
|
||||||
|
if (Date.now() - session.createdAt > 300 * 1000) {
|
||||||
|
return { status: "failed", message: "Sign-in timed out. Please try again." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to check if local callback server on 51121 received the auth code
|
||||||
|
try {
|
||||||
|
const callbackCheck = await context.network.fetch(
|
||||||
|
`http://127.0.0.1:${CALLBACK_PORT}/auth-status?state=${session.state}`,
|
||||||
|
{ method: "GET" },
|
||||||
|
);
|
||||||
|
if (callbackCheck.status === 200) {
|
||||||
|
const body = parseBody(callbackCheck.body);
|
||||||
|
const code = text(body.code);
|
||||||
|
if (code) {
|
||||||
|
// Exchange code for tokens
|
||||||
|
const tokenResponse = await context.network.fetch(TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"content-type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
client_secret: CLIENT_SECRET,
|
||||||
|
code,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
const tokenBody = parseBody(tokenResponse.body);
|
||||||
|
if (tokenResponse.status >= 200 && tokenResponse.status < 300) {
|
||||||
|
const accessToken = text(tokenBody.access_token);
|
||||||
|
if (accessToken) {
|
||||||
|
let email: string | null = text(tokenBody.email);
|
||||||
|
try {
|
||||||
|
const userInfoRes = await context.network.fetch(
|
||||||
|
"https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
accept: "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (userInfoRes.status === 200) {
|
||||||
|
const userInfo = parseBody(userInfoRes.body);
|
||||||
|
email = text(userInfo.email) ?? email;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore error, fallback to default display name
|
||||||
|
}
|
||||||
|
|
||||||
|
let projectId = "bamboo-precept-lgxtn";
|
||||||
|
let quota = null;
|
||||||
|
try {
|
||||||
|
const res = await queryAccountQuota(accessToken, context.network);
|
||||||
|
projectId = res.projectId;
|
||||||
|
quota = res.quota;
|
||||||
|
} catch {
|
||||||
|
// Ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "completed",
|
||||||
|
resources: [
|
||||||
|
await credentialDraft({
|
||||||
|
accessToken,
|
||||||
|
refreshToken: text(tokenBody.refresh_token),
|
||||||
|
displayName: email ?? "Google Antigravity",
|
||||||
|
projectId,
|
||||||
|
quota,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errMsg = text(tokenBody.error_description ?? tokenBody.error) ?? `HTTP ${tokenResponse.status}`;
|
||||||
|
return { status: "failed", message: `Token exchange failed: ${errMsg}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Network retry on pending callback
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "pending" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const antigravityDeviceOAuth: OAuth2AddMethod = {
|
||||||
|
type: "oauth2.0",
|
||||||
|
id: "google-antigravity",
|
||||||
|
displayName: {
|
||||||
|
"en-US": "Sign in with Google (Antigravity)",
|
||||||
|
"zh-CN": "使用 Google (Antigravity) 登录",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
"en-US": "Authorize Antigravity with your Google Account for Gemini & Claude models.",
|
||||||
|
"zh-CN": "使用 Google 账号完成 Antigravity 授权,畅享 Gemini 与 Claude 模型。",
|
||||||
|
},
|
||||||
|
begin,
|
||||||
|
poll,
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"apiVersion": 1,
|
||||||
|
"id": "dev.cursorbyok.plugins.antigravity-auth",
|
||||||
|
"name": "Antigravity",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"author": "Antigravity",
|
||||||
|
"minAppVersion": "0.1.0",
|
||||||
|
"icon": "assets/antigravity.svg",
|
||||||
|
"entry": "main.ts",
|
||||||
|
"permissions": {
|
||||||
|
"network": [
|
||||||
|
"127.0.0.1",
|
||||||
|
"localhost",
|
||||||
|
"daily-cloudcode-pa.googleapis.com",
|
||||||
|
"daily-cloudcode-pa.sandbox.googleapis.com",
|
||||||
|
"cloudcode-pa.googleapis.com",
|
||||||
|
"generativelanguage.googleapis.com",
|
||||||
|
"oauth2.googleapis.com",
|
||||||
|
"accounts.google.com",
|
||||||
|
"www.googleapis.com",
|
||||||
|
"antigravity.google"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,664 @@
|
|||||||
|
import type {
|
||||||
|
LlmContentPart,
|
||||||
|
LlmMessage,
|
||||||
|
LlmRequest,
|
||||||
|
ProviderInvokeInput,
|
||||||
|
ProviderOutput,
|
||||||
|
ProviderResult,
|
||||||
|
ProviderSupport,
|
||||||
|
} from "cursor-byok:provider";
|
||||||
|
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
|
||||||
|
import { HttpError } from "cursor-byok:protocol/openai-chat";
|
||||||
|
import {
|
||||||
|
ANTIGRAVITY_CLIENT_HEADERS,
|
||||||
|
ANTIGRAVITY_ENDPOINTS,
|
||||||
|
ANTIGRAVITY_USER_AGENT,
|
||||||
|
antigravityModels,
|
||||||
|
} from "./models.ts";
|
||||||
|
import {
|
||||||
|
type AccountData,
|
||||||
|
accountData,
|
||||||
|
isTokenExpired,
|
||||||
|
quotaExhaustedPatch,
|
||||||
|
refreshAccount,
|
||||||
|
RESOURCE_TYPE,
|
||||||
|
} from "./resources.ts";
|
||||||
|
|
||||||
|
export function isQuotaError(error: string): boolean {
|
||||||
|
const message = error.toLowerCase();
|
||||||
|
return message.includes("resource_exhausted") ||
|
||||||
|
message.includes("quota_exceeded") ||
|
||||||
|
message.includes("quota_exhausted") ||
|
||||||
|
message.includes("rate_limit_exceeded") ||
|
||||||
|
message.includes("rate limit") ||
|
||||||
|
message.includes("model_capacity_exhausted") ||
|
||||||
|
message.includes("too many requests") ||
|
||||||
|
message.includes("429");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isQuotaHttpError(error: HttpError): boolean {
|
||||||
|
if (error.status === 429) return true;
|
||||||
|
const body = error.body.toLowerCase();
|
||||||
|
return body.includes("resource_exhausted") ||
|
||||||
|
body.includes("quota_exceeded") ||
|
||||||
|
body.includes("quota_exhausted") ||
|
||||||
|
body.includes("rate_limit_exceeded") ||
|
||||||
|
body.includes("rate limit") ||
|
||||||
|
body.includes("model_capacity_exhausted") ||
|
||||||
|
body.includes("user rate limit exceeded") ||
|
||||||
|
body.includes("too many requests");
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidResult(message: string, stateMessage: string): ProviderResult {
|
||||||
|
return {
|
||||||
|
status: "resource-error",
|
||||||
|
message,
|
||||||
|
patch: { state: { status: "invalid", message: stateMessage } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBody(lines: AsyncIterable<string>): Promise<string> {
|
||||||
|
const collected: string[] = [];
|
||||||
|
for await (const line of lines) collected.push(line);
|
||||||
|
return collected.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAntigravityModel(modelId: string): string {
|
||||||
|
const raw = modelId.trim();
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
|
||||||
|
// 1. If explicit tier is already specified in the model ID, pass it directly!
|
||||||
|
if (
|
||||||
|
lower.startsWith("gemini-3.7-flash-") ||
|
||||||
|
lower.startsWith("gemini-3.6-flash-") ||
|
||||||
|
lower.startsWith("gemini-3.1-pro-") ||
|
||||||
|
lower === "gemini-3.7-flash" ||
|
||||||
|
lower === "gemini-3.6-flash" ||
|
||||||
|
lower === "gemini-2.5-flash" ||
|
||||||
|
lower === "gemini-2.5-pro" ||
|
||||||
|
lower === "gemini-2.0-flash" ||
|
||||||
|
lower === "claude-sonnet-4-6" ||
|
||||||
|
lower === "claude-sonnet-4-6-thinking" ||
|
||||||
|
lower === "claude-opus-4-6-thinking" ||
|
||||||
|
lower === "gemini-3.1-flash-image" ||
|
||||||
|
lower === "gpt-oss-120b-medium"
|
||||||
|
) {
|
||||||
|
if (lower === "gemini-3.1-pro-high") return "gemini-pro-agent";
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Canonical Antigravity-Manager mapping for aliases
|
||||||
|
if (lower === "claude-3-7-sonnet" || lower === "claude-3-5-sonnet" || lower === "claude-sonnet-4-5") {
|
||||||
|
return "claude-sonnet-4-6";
|
||||||
|
}
|
||||||
|
if (lower === "claude-3-5-haiku" || lower === "claude-haiku-4") {
|
||||||
|
return "claude-sonnet-4-6";
|
||||||
|
}
|
||||||
|
if (lower === "claude-3-7-opus" || lower === "claude-opus-4" || lower === "claude-opus-4.6" || lower === "claude-opus-4-5-thinking") {
|
||||||
|
return "claude-opus-4-6-thinking";
|
||||||
|
}
|
||||||
|
if (lower === "gpt-4" || lower === "gpt-4o" || lower === "gpt-4o-mini" || lower === "gpt-3.5-turbo") {
|
||||||
|
return "gemini-2.5-flash";
|
||||||
|
}
|
||||||
|
if (lower === "gemini-2.5-flash-lite") {
|
||||||
|
return "gemini-2.5-flash";
|
||||||
|
}
|
||||||
|
if (lower === "gemini-3-flash" || lower === "gemini-3.5-flash") {
|
||||||
|
return "gemini-3.7-flash";
|
||||||
|
}
|
||||||
|
if (lower === "gemini-3-pro" || lower === "gemini-3.1-pro") {
|
||||||
|
return "gemini-3.1-pro-preview";
|
||||||
|
}
|
||||||
|
if (lower === "gemini-3-pro-high") {
|
||||||
|
return "gemini-pro-agent";
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomHex(length = 8): string {
|
||||||
|
const array = new Uint8Array(Math.ceil(length / 2));
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRequestId(): string {
|
||||||
|
return `agent/${Date.now()}/${randomHex(8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys the CloudCode v1internal Schema proto rejects with "Cannot find field"
|
||||||
|
const UNSUPPORTED_SCHEMA_KEYS: Record<string, true> = {
|
||||||
|
"$schema": true,
|
||||||
|
"$ref": true,
|
||||||
|
"$defs": true,
|
||||||
|
"$comment": true,
|
||||||
|
"examples": true,
|
||||||
|
"unevaluatedProperties": true,
|
||||||
|
"unevaluatedItems": true,
|
||||||
|
"patternProperties": true,
|
||||||
|
"propertyNames": true,
|
||||||
|
"exclusiveMinimum": true,
|
||||||
|
"exclusiveMaximum": true,
|
||||||
|
"multipleOf": true,
|
||||||
|
"dependencies": true,
|
||||||
|
"dependentSchemas": true,
|
||||||
|
"dependentRequired": true,
|
||||||
|
"deprecated": true,
|
||||||
|
"readOnly": true,
|
||||||
|
"writeOnly": true,
|
||||||
|
"x-mcp-header": true,
|
||||||
|
"const": true,
|
||||||
|
"default": true,
|
||||||
|
"additionalProperties": true,
|
||||||
|
"title": true,
|
||||||
|
"format": true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROTO_TYPE_MAP: Record<string, string> = {
|
||||||
|
string: "STRING",
|
||||||
|
number: "NUMBER",
|
||||||
|
integer: "INTEGER",
|
||||||
|
boolean: "BOOLEAN",
|
||||||
|
array: "ARRAY",
|
||||||
|
object: "OBJECT",
|
||||||
|
};
|
||||||
|
|
||||||
|
function enforceUppercaseTypes(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) return value.map(enforceUppercaseTypes);
|
||||||
|
if (value === null || typeof value !== "object") return value;
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
if (UNSUPPORTED_SCHEMA_KEYS[key]) continue;
|
||||||
|
if (key === "type" && typeof child === "string") {
|
||||||
|
out[key] = PROTO_TYPE_MAP[child.toLowerCase()] ?? child.toUpperCase();
|
||||||
|
} else {
|
||||||
|
out[key] = enforceUppercaseTypes(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!out.type && out.properties) {
|
||||||
|
out.type = "OBJECT";
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSchema(value: unknown): unknown {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return { type: "OBJECT", properties: {} };
|
||||||
|
}
|
||||||
|
const clean = enforceUppercaseTypes(value) as Record<string, unknown>;
|
||||||
|
if (!clean.type) clean.type = "OBJECT";
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToCloudCodeContents(
|
||||||
|
instructions: string,
|
||||||
|
messages: LlmMessage[],
|
||||||
|
): {
|
||||||
|
contents: Array<{ role: string; parts: Array<Record<string, unknown>> }>;
|
||||||
|
systemInstruction?: { parts: Array<{ text: string }> };
|
||||||
|
} {
|
||||||
|
const rawContents: Array<{ role: string; parts: Array<Record<string, unknown>> }> = [];
|
||||||
|
let systemText = instructions || "";
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.role === "system") {
|
||||||
|
const txt = msg.content
|
||||||
|
.map((p) => (p.type === "text" ? p.text : ""))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
if (txt) {
|
||||||
|
systemText += (systemText ? "\n\n" : "") + txt;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.role === "assistant") {
|
||||||
|
const parts: Array<Record<string, unknown>> = [];
|
||||||
|
if (msg.text) {
|
||||||
|
parts.push({ text: msg.text });
|
||||||
|
}
|
||||||
|
|
||||||
|
const replayVal = msg.replayState?.providerKind === "antigravity"
|
||||||
|
? (msg.replayState.value as Record<string, unknown> | null)
|
||||||
|
: null;
|
||||||
|
const sig = typeof replayVal?.thoughtSignature === "string" ? replayVal.thoughtSignature : null;
|
||||||
|
|
||||||
|
for (const call of msg.toolCalls) {
|
||||||
|
parts.push({
|
||||||
|
functionCall: {
|
||||||
|
name: call.name,
|
||||||
|
args: typeof call.arguments === "object" && call.arguments !== null ? call.arguments : {},
|
||||||
|
},
|
||||||
|
thoughtSignature: sig || "skip_thought_signature_validator",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length > 0) {
|
||||||
|
rawContents.push({ role: "model", parts });
|
||||||
|
}
|
||||||
|
} else if (msg.role === "tool") {
|
||||||
|
rawContents.push({
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionResponse: {
|
||||||
|
name: msg.name || "function",
|
||||||
|
response: { result: msg.content },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else if (msg.role === "user") {
|
||||||
|
const parts: Array<Record<string, unknown>> = [];
|
||||||
|
for (const p of msg.content) {
|
||||||
|
if (p.type === "text") {
|
||||||
|
if (p.text) parts.push({ text: p.text });
|
||||||
|
} else if (p.type === "image") {
|
||||||
|
parts.push({
|
||||||
|
inlineData: {
|
||||||
|
mimeType: p.mediaType,
|
||||||
|
data: p.dataBase64,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.length === 0) {
|
||||||
|
parts.push({ text: " " });
|
||||||
|
}
|
||||||
|
rawContents.push({ role: "user", parts });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge consecutive same-role messages so contents strictly alternate user -> model -> user -> model
|
||||||
|
const contents: Array<{ role: string; parts: Array<Record<string, unknown>> }> = [];
|
||||||
|
for (const item of rawContents) {
|
||||||
|
if (item.parts.length === 0) continue;
|
||||||
|
const last = contents[contents.length - 1];
|
||||||
|
if (last && last.role === item.role) {
|
||||||
|
last.parts.push(...item.parts);
|
||||||
|
} else {
|
||||||
|
contents.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contents.length > 0 && contents[0].role !== "user") {
|
||||||
|
contents.unshift({ role: "user", parts: [{ text: " " }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contents,
|
||||||
|
...(systemText.trim() ? { systemInstruction: { role: "system", parts: [{ text: systemText.trim() }] } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamCloudCode(
|
||||||
|
accessToken: string,
|
||||||
|
projectId: string,
|
||||||
|
modelId: string,
|
||||||
|
input: ProviderInvokeInput,
|
||||||
|
output: ProviderOutput,
|
||||||
|
context: PluginContext,
|
||||||
|
): Promise<void> {
|
||||||
|
const actualModel = resolveAntigravityModel(modelId);
|
||||||
|
const { contents, systemInstruction } = convertToCloudCodeContents(
|
||||||
|
input.request.instructions,
|
||||||
|
input.request.messages,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tools = input.request.tools && input.request.tools.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
functionDeclarations: input.request.tools.map((t) => ({
|
||||||
|
name: t.name,
|
||||||
|
description: t.description || "",
|
||||||
|
parameters: sanitizeSchema(t.parameters),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const toolConfig = tools
|
||||||
|
? {
|
||||||
|
functionCallingConfig: { mode: "AUTO" },
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
project: projectId || "bamboo-precept-lgxtn",
|
||||||
|
model: actualModel,
|
||||||
|
userAgent: "antigravity",
|
||||||
|
requestType: "agent",
|
||||||
|
requestId: generateRequestId(),
|
||||||
|
enabledCreditTypes: ["GOOGLE_ONE_AI"],
|
||||||
|
request: {
|
||||||
|
contents,
|
||||||
|
...(systemInstruction ? { systemInstruction } : {}),
|
||||||
|
...(tools ? { tools } : {}),
|
||||||
|
...(toolConfig ? { toolConfig } : {}),
|
||||||
|
generationConfig: {
|
||||||
|
maxOutputTokens: 65536,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
"user-agent": ANTIGRAVITY_USER_AGENT,
|
||||||
|
...ANTIGRAVITY_CLIENT_HEADERS,
|
||||||
|
};
|
||||||
|
if (actualModel.toLowerCase().includes("claude")) {
|
||||||
|
headers["anthropic-beta"] = "claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14";
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
let hasEmittedAnyChunk = false;
|
||||||
|
|
||||||
|
for (const endpoint of ANTIGRAVITY_ENDPOINTS) {
|
||||||
|
if (hasEmittedAnyChunk) break;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await context.network.stream(
|
||||||
|
`${endpoint}/v1internal:streamGenerateContent?alt=sse`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
|
const errorBody = await readBody(response.lines);
|
||||||
|
lastError = new HttpError(response.status, errorBody);
|
||||||
|
if (response.status === 503 || response.status === 502 || response.status === 504 || response.status === 404) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
let textStarted = false;
|
||||||
|
let thinkingStarted = false;
|
||||||
|
let doneEmitted = false;
|
||||||
|
let hasTools = false;
|
||||||
|
let toolIndex = 0;
|
||||||
|
let lastThoughtSignature: string | null = null;
|
||||||
|
let finalUsage: { inputTokens: number | null; outputTokens: number | null; totalTokens: number | null } | null = null;
|
||||||
|
|
||||||
|
for await (const line of response.lines) {
|
||||||
|
if (!line.startsWith("data:")) continue;
|
||||||
|
const raw = line.slice(5).trim();
|
||||||
|
if (!raw || raw === "[DONE]") break;
|
||||||
|
|
||||||
|
let json: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = (json.response as Record<string, unknown> | undefined) ?? json;
|
||||||
|
if (!resp) continue;
|
||||||
|
|
||||||
|
const usage = resp.usageMetadata as Record<string, number> | undefined;
|
||||||
|
if (usage) {
|
||||||
|
finalUsage = {
|
||||||
|
inputTokens: typeof usage.promptTokenCount === "number" ? usage.promptTokenCount : null,
|
||||||
|
outputTokens: typeof usage.candidatesTokenCount === "number" ? usage.candidatesTokenCount : null,
|
||||||
|
totalTokens: typeof usage.totalTokenCount === "number" ? usage.totalTokenCount : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = resp.candidates as Array<Record<string, unknown>> | undefined;
|
||||||
|
const candidate = candidates?.[0];
|
||||||
|
const content = candidate?.content as Record<string, unknown> | undefined;
|
||||||
|
const parts = content?.parts as Array<Record<string, unknown>> | undefined;
|
||||||
|
|
||||||
|
if (parts) {
|
||||||
|
for (const part of parts) {
|
||||||
|
const sig = typeof part.thoughtSignature === "string" ? part.thoughtSignature : null;
|
||||||
|
if (sig) {
|
||||||
|
lastThoughtSignature = sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isThought = part.thought === true;
|
||||||
|
const textPart = typeof part.text === "string" ? part.text : null;
|
||||||
|
|
||||||
|
if (isThought && textPart) {
|
||||||
|
const cleanThought = textPart.replace(/<\/?think>/gi, "");
|
||||||
|
if (cleanThought) {
|
||||||
|
hasEmittedAnyChunk = true;
|
||||||
|
if (!thinkingStarted) {
|
||||||
|
thinkingStarted = true;
|
||||||
|
output.emit({ type: "thinking-start" });
|
||||||
|
}
|
||||||
|
output.emit({ type: "thinking-delta", text: cleanThought });
|
||||||
|
}
|
||||||
|
} else if (textPart) {
|
||||||
|
if (thinkingStarted) {
|
||||||
|
thinkingStarted = false;
|
||||||
|
output.emit({ type: "thinking-end" });
|
||||||
|
}
|
||||||
|
const cleanText = textPart.replace(/<\/?think>/gi, "");
|
||||||
|
if (cleanText) {
|
||||||
|
hasEmittedAnyChunk = true;
|
||||||
|
if (!textStarted) {
|
||||||
|
textStarted = true;
|
||||||
|
output.emit({ type: "text-start" });
|
||||||
|
}
|
||||||
|
output.emit({ type: "text-delta", text: cleanText });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fnCall = part.functionCall as { name: string; args: unknown } | undefined;
|
||||||
|
if (fnCall) {
|
||||||
|
hasEmittedAnyChunk = true;
|
||||||
|
if (thinkingStarted) {
|
||||||
|
thinkingStarted = false;
|
||||||
|
output.emit({ type: "thinking-end" });
|
||||||
|
}
|
||||||
|
if (textStarted) {
|
||||||
|
textStarted = false;
|
||||||
|
output.emit({ type: "text-end" });
|
||||||
|
}
|
||||||
|
hasTools = true;
|
||||||
|
const currentIdx = toolIndex++;
|
||||||
|
const callId = `call_${Date.now()}_${currentIdx}`;
|
||||||
|
output.emit({
|
||||||
|
type: "tool-call-start",
|
||||||
|
index: currentIdx,
|
||||||
|
callId,
|
||||||
|
name: fnCall.name,
|
||||||
|
});
|
||||||
|
const argsStr = typeof fnCall.args === "string"
|
||||||
|
? fnCall.args
|
||||||
|
: JSON.stringify(fnCall.args || {});
|
||||||
|
output.emit({
|
||||||
|
type: "tool-call-arguments-delta",
|
||||||
|
index: currentIdx,
|
||||||
|
delta: argsStr,
|
||||||
|
});
|
||||||
|
output.emit({
|
||||||
|
type: "tool-call-end",
|
||||||
|
index: currentIdx,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishReason = typeof candidate?.finishReason === "string" ? candidate.finishReason : null;
|
||||||
|
if (finishReason) {
|
||||||
|
if (thinkingStarted) {
|
||||||
|
thinkingStarted = false;
|
||||||
|
output.emit({ type: "thinking-end" });
|
||||||
|
}
|
||||||
|
if (textStarted) {
|
||||||
|
textStarted = false;
|
||||||
|
output.emit({ type: "text-end" });
|
||||||
|
}
|
||||||
|
if (lastThoughtSignature) {
|
||||||
|
output.emit({
|
||||||
|
type: "replay-state",
|
||||||
|
providerKind: "antigravity",
|
||||||
|
value: { thoughtSignature: lastThoughtSignature },
|
||||||
|
});
|
||||||
|
lastThoughtSignature = null;
|
||||||
|
}
|
||||||
|
if (finalUsage) {
|
||||||
|
output.emit({
|
||||||
|
type: "usage",
|
||||||
|
usage: {
|
||||||
|
inputTokens: finalUsage.inputTokens,
|
||||||
|
outputTokens: finalUsage.outputTokens,
|
||||||
|
totalTokens: finalUsage.totalTokens,
|
||||||
|
cacheReadTokens: null,
|
||||||
|
cacheWriteTokens: null,
|
||||||
|
reasoningTokens: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
finalUsage = null;
|
||||||
|
}
|
||||||
|
const isTool = finishReason === "STOP" && (hasTools || parts?.some((p) => p.functionCall));
|
||||||
|
output.emit({
|
||||||
|
type: "done",
|
||||||
|
reason: isTool ? "tool-use" : "stop",
|
||||||
|
});
|
||||||
|
doneEmitted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thinkingStarted) {
|
||||||
|
output.emit({ type: "thinking-end" });
|
||||||
|
}
|
||||||
|
if (textStarted) {
|
||||||
|
output.emit({ type: "text-end" });
|
||||||
|
}
|
||||||
|
if (lastThoughtSignature) {
|
||||||
|
output.emit({
|
||||||
|
type: "replay-state",
|
||||||
|
providerKind: "antigravity",
|
||||||
|
value: { thoughtSignature: lastThoughtSignature },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (finalUsage) {
|
||||||
|
output.emit({
|
||||||
|
type: "usage",
|
||||||
|
usage: {
|
||||||
|
inputTokens: finalUsage.inputTokens,
|
||||||
|
outputTokens: finalUsage.outputTokens,
|
||||||
|
totalTokens: finalUsage.totalTokens,
|
||||||
|
cacheReadTokens: null,
|
||||||
|
cacheWriteTokens: null,
|
||||||
|
reasoningTokens: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!doneEmitted) {
|
||||||
|
output.emit({
|
||||||
|
type: "done",
|
||||||
|
reason: hasTools ? "tool-use" : "stop",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err instanceof Error ? err : new Error(String(err));
|
||||||
|
if (hasEmittedAnyChunk) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastError) throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invoke(
|
||||||
|
input: ProviderInvokeInput,
|
||||||
|
output: ProviderOutput,
|
||||||
|
context: PluginContext,
|
||||||
|
): Promise<ProviderResult> {
|
||||||
|
if (!input.resource) {
|
||||||
|
return {
|
||||||
|
status: "request-error",
|
||||||
|
message: "Add a Google Antigravity account or API key before calling Antigravity",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let data: AccountData;
|
||||||
|
try {
|
||||||
|
data = accountData(input.resource);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return invalidResult(message, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let patchData: AccountData | null = null;
|
||||||
|
|
||||||
|
// Auto-refresh token if expired or close to expiration (skew 5 mins)
|
||||||
|
if (data.refreshToken && isTokenExpired(data)) {
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshAccount(input.resource, context);
|
||||||
|
if (refreshed.privateData) {
|
||||||
|
data = refreshed.privateData as unknown as AccountData;
|
||||||
|
patchData = data;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue with existing token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let projectId = data.projectId ?? "bamboo-precept-lgxtn";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await streamCloudCode(data.accessToken, projectId, input.model.id, input, output, context);
|
||||||
|
return patchData
|
||||||
|
? { status: "completed", patch: { privateData: patchData as unknown as JsonValue, state: { status: "ready" } } }
|
||||||
|
: { status: "completed" };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpError) {
|
||||||
|
if ((error.status === 401 || error.status === 403) && data.refreshToken && !isQuotaHttpError(error)) {
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshAccount(input.resource, context);
|
||||||
|
if (refreshed.privateData) {
|
||||||
|
const freshData = refreshed.privateData as unknown as AccountData;
|
||||||
|
const freshProj = freshData.projectId ?? projectId;
|
||||||
|
await streamCloudCode(freshData.accessToken, freshProj, input.model.id, input, output, context);
|
||||||
|
return {
|
||||||
|
status: "completed",
|
||||||
|
patch: { privateData: freshData as unknown as JsonValue, state: { status: "ready" } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Failed refresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isQuotaHttpError(error)) {
|
||||||
|
return {
|
||||||
|
status: "resource-error",
|
||||||
|
message: error.message,
|
||||||
|
patch: quotaExhaustedPatch(data, error.body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: "request-error", message: error.message };
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (isQuotaError(message)) {
|
||||||
|
return { status: "resource-error", message, patch: quotaExhaustedPatch(data, message) };
|
||||||
|
}
|
||||||
|
return { status: "request-error", message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const antigravityProvider: ProviderSupport = {
|
||||||
|
id: "antigravity",
|
||||||
|
displayName: {
|
||||||
|
"en-US": "Google Antigravity",
|
||||||
|
"zh-CN": "Google Antigravity",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
"en-US": "Google Antigravity / Gemini model access with hybrid reasoning & agent tools.",
|
||||||
|
"zh-CN": "通过 Google Antigravity / Gemini API 使用混合推理与 Agent 工具。",
|
||||||
|
},
|
||||||
|
providerType: "google",
|
||||||
|
resourceType: RESOURCE_TYPE,
|
||||||
|
models: antigravityModels,
|
||||||
|
invoke,
|
||||||
|
};
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
import type { JsonValue, PluginContext } from "cursor-byok:plugin";
|
||||||
|
import type {
|
||||||
|
ResourceDraft,
|
||||||
|
ResourceImportFile,
|
||||||
|
ResourceImportResult,
|
||||||
|
ResourceImportSupport,
|
||||||
|
ResourceMetric,
|
||||||
|
ResourcePatch,
|
||||||
|
ResourceSnapshot,
|
||||||
|
ResourceState,
|
||||||
|
ResourceView,
|
||||||
|
} from "cursor-byok:resource";
|
||||||
|
import {
|
||||||
|
ANTIGRAVITY_CLIENT_HEADERS,
|
||||||
|
ANTIGRAVITY_ENDPOINTS,
|
||||||
|
ANTIGRAVITY_USER_AGENT,
|
||||||
|
} from "./models.ts";
|
||||||
|
|
||||||
|
export const RESOURCE_TYPE = "antigravity-account";
|
||||||
|
|
||||||
|
const REFRESH_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||||
|
|
||||||
|
export type QuotaMetric = {
|
||||||
|
remainingPercent: number;
|
||||||
|
resetAtMs: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountQuota = {
|
||||||
|
planLabel: string | null;
|
||||||
|
limitReached: boolean;
|
||||||
|
coolingUntilMs: number | null;
|
||||||
|
updatedAtMs: number;
|
||||||
|
claude?: QuotaMetric | null;
|
||||||
|
gemini?: QuotaMetric | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccountData = {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string | null;
|
||||||
|
displayName: string;
|
||||||
|
projectId?: string | null;
|
||||||
|
expiresAtMs?: number | null;
|
||||||
|
quota: AccountQuota | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CredentialCandidate = {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string | null;
|
||||||
|
displayName: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
expiresAtMs?: number | null;
|
||||||
|
quota?: AccountQuota | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchAccountProjectAndTier(
|
||||||
|
accessToken: string,
|
||||||
|
network: PluginContext["network"],
|
||||||
|
): Promise<{ projectId: string; planLabel: string }> {
|
||||||
|
for (const endpoint of ANTIGRAVITY_ENDPOINTS) {
|
||||||
|
try {
|
||||||
|
const assistRes = await network.fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
"user-agent": ANTIGRAVITY_USER_AGENT,
|
||||||
|
...ANTIGRAVITY_CLIENT_HEADERS,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY" } }),
|
||||||
|
});
|
||||||
|
if (assistRes.status >= 200 && assistRes.status < 300) {
|
||||||
|
const body = object(JSON.parse(assistRes.body));
|
||||||
|
const project = text(body?.cloudaicompanionProject);
|
||||||
|
const paid = object(body?.paidTier);
|
||||||
|
const current = object(body?.currentTier);
|
||||||
|
const tierName = text(paid?.name) ?? text(paid?.id) ?? text(current?.name) ?? text(current?.id);
|
||||||
|
let planLabel = "FREE";
|
||||||
|
if (tierName) {
|
||||||
|
const lower = tierName.toLowerCase();
|
||||||
|
if (lower.includes("ultra")) planLabel = "ULTRA";
|
||||||
|
else if (lower.includes("pro") || lower.includes("premium") || lower.includes("advanced")) planLabel = "PRO";
|
||||||
|
}
|
||||||
|
return { projectId: project ?? "bamboo-precept-lgxtn", planLabel };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Continue next endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { projectId: "bamboo-precept-lgxtn", planLabel: "FREE" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryAccountQuota(
|
||||||
|
accessToken: string,
|
||||||
|
network: PluginContext["network"],
|
||||||
|
): Promise<{ quota: AccountQuota | null; projectId: string }> {
|
||||||
|
const { projectId, planLabel } = await fetchAccountProjectAndTier(accessToken, network);
|
||||||
|
|
||||||
|
for (const endpoint of ANTIGRAVITY_ENDPOINTS) {
|
||||||
|
try {
|
||||||
|
const response = await network.fetch(`${endpoint}/v1internal:fetchAvailableModels`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
accept: "application/json",
|
||||||
|
"user-agent": ANTIGRAVITY_USER_AGENT,
|
||||||
|
...ANTIGRAVITY_CLIENT_HEADERS,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ project: projectId }),
|
||||||
|
});
|
||||||
|
if (response.status < 200 || response.status >= 300) continue;
|
||||||
|
const root = object(JSON.parse(response.body));
|
||||||
|
const models = object(root?.models);
|
||||||
|
if (!models) continue;
|
||||||
|
|
||||||
|
let claudeFraction: number | null = null;
|
||||||
|
let claudeResetAtMs: number | null = null;
|
||||||
|
let geminiFraction: number | null = null;
|
||||||
|
let geminiResetAtMs: number | null = null;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(models)) {
|
||||||
|
const info = object(value);
|
||||||
|
const quota = object(info?.quotaInfo);
|
||||||
|
const fraction = typeof quota?.remainingFraction === "number" ? quota.remainingFraction : null;
|
||||||
|
const resetTime = text(quota?.resetTime);
|
||||||
|
const resetAtMs = resetTime ? Date.parse(resetTime) : null;
|
||||||
|
if (fraction === null) continue;
|
||||||
|
|
||||||
|
const k = key.toLowerCase();
|
||||||
|
if (k.includes("claude") || k.includes("sonnet") || k.includes("opus")) {
|
||||||
|
if (claudeFraction === null || fraction < claudeFraction) {
|
||||||
|
claudeFraction = fraction;
|
||||||
|
claudeResetAtMs = resetAtMs;
|
||||||
|
}
|
||||||
|
} else if (k.includes("gemini") || k.includes("flash") || k.includes("pro")) {
|
||||||
|
if (geminiFraction === null || fraction < geminiFraction) {
|
||||||
|
geminiFraction = fraction;
|
||||||
|
geminiResetAtMs = resetAtMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
quota: {
|
||||||
|
planLabel,
|
||||||
|
limitReached: false,
|
||||||
|
coolingUntilMs: null,
|
||||||
|
updatedAtMs: Date.now(),
|
||||||
|
claude: claudeFraction !== null ? { remainingPercent: Math.round(claudeFraction * 100), resetAtMs: claudeResetAtMs } : null,
|
||||||
|
gemini: geminiFraction !== null ? { remainingPercent: Math.round(geminiFraction * 100), resetAtMs: geminiResetAtMs } : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Continue next endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
quota: {
|
||||||
|
planLabel,
|
||||||
|
limitReached: false,
|
||||||
|
coolingUntilMs: null,
|
||||||
|
updatedAtMs: Date.now(),
|
||||||
|
claude: null,
|
||||||
|
gemini: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function object(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
try {
|
||||||
|
const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||||
|
const bytes = Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
|
||||||
|
return object(JSON.parse(new TextDecoder().decode(bytes)));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function claim(payload: Record<string, unknown> | null, key: string): string | null {
|
||||||
|
return payload ? text(payload[key]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isJwtExpired(token: string, bufferSeconds = 300): boolean {
|
||||||
|
if (token.startsWith("AIza") || !token.includes(".")) return false;
|
||||||
|
const payload = decodeJwtPayload(token);
|
||||||
|
if (!payload) return false;
|
||||||
|
const exp = typeof payload.exp === "number" ? payload.exp : null;
|
||||||
|
if (!exp) return false;
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
return exp <= (nowSeconds + bufferSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTokenExpired(data: AccountData, bufferSeconds = 300): boolean {
|
||||||
|
if (!data.refreshToken) return false;
|
||||||
|
if (typeof data.expiresAtMs === "number" && data.expiresAtMs > 0) {
|
||||||
|
return Date.now() >= data.expiresAtMs - bufferSeconds * 1000;
|
||||||
|
}
|
||||||
|
return isJwtExpired(data.accessToken, bufferSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tokenFingerprint(token: string): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
||||||
|
return Array.from(
|
||||||
|
new Uint8Array(digest).slice(0, 8),
|
||||||
|
(byte) => byte.toString(16).padStart(2, "0"),
|
||||||
|
).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function accountIdentity(
|
||||||
|
token: string,
|
||||||
|
providedDisplayName?: string | null,
|
||||||
|
): Promise<{ key: string; displayName: string }> {
|
||||||
|
const payload = decodeJwtPayload(token);
|
||||||
|
const email = claim(payload, "email");
|
||||||
|
const sub = claim(payload, "sub");
|
||||||
|
const name = claim(payload, "name") ?? claim(payload, "preferred_username");
|
||||||
|
|
||||||
|
const fingerprint = await tokenFingerprint(token);
|
||||||
|
const identity = (providedDisplayName && !providedDisplayName.includes("Antigravity"))
|
||||||
|
? providedDisplayName
|
||||||
|
: (email ?? sub ?? fingerprint);
|
||||||
|
const displayName = providedDisplayName ?? email ?? name ?? (token.startsWith("AIza") ? `API Key (${fingerprint.slice(0, 6)})` : identity);
|
||||||
|
return { key: `antigravity:${identity}`, displayName };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function credentialDraft(credential: CredentialCandidate): Promise<ResourceDraft> {
|
||||||
|
const identity = await accountIdentity(credential.accessToken, credential.displayName);
|
||||||
|
const data: AccountData = {
|
||||||
|
accessToken: credential.accessToken,
|
||||||
|
refreshToken: credential.refreshToken,
|
||||||
|
displayName: credential.displayName ?? identity.displayName,
|
||||||
|
projectId: credential.projectId ?? "bamboo-precept-lgxtn",
|
||||||
|
expiresAtMs: credential.expiresAtMs ?? (credential.refreshToken ? Date.now() + 3500 * 1000 : null),
|
||||||
|
quota: credential.quota ?? null,
|
||||||
|
};
|
||||||
|
return { key: identity.key, privateData: data as unknown as JsonValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accountData(resource: ResourceSnapshot): AccountData {
|
||||||
|
const data = object(resource.privateData);
|
||||||
|
const accessToken = text(data?.accessToken);
|
||||||
|
if (!accessToken) throw new Error("Antigravity account resource is missing its access token");
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken: text(data?.refreshToken),
|
||||||
|
displayName: text(data?.displayName) ?? "Antigravity account",
|
||||||
|
projectId: text(data?.projectId) ?? "bamboo-precept-lgxtn",
|
||||||
|
expiresAtMs: typeof data?.expiresAtMs === "number" ? data.expiresAtMs : null,
|
||||||
|
quota: (data?.quota ?? null) as AccountQuota | null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accountHeaders(data: AccountData): Record<string, string> {
|
||||||
|
return {
|
||||||
|
authorization: `Bearer ${data.accessToken}`,
|
||||||
|
accept: "application/json",
|
||||||
|
"user-agent": ANTIGRAVITY_USER_AGENT,
|
||||||
|
...ANTIGRAVITY_CLIENT_HEADERS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quotaState(quota: AccountQuota | null, nowMs = Date.now()): ResourceState {
|
||||||
|
if (!quota || !quota.limitReached) return { status: "ready" };
|
||||||
|
const coolingUntil = quota.coolingUntilMs;
|
||||||
|
if (coolingUntil !== null && coolingUntil > nowMs) {
|
||||||
|
return {
|
||||||
|
status: "cooling",
|
||||||
|
retryAtMs: coolingUntil,
|
||||||
|
message: "Antigravity rate limit reached; cooling down",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: "ready" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quotaExhaustedPatch(
|
||||||
|
data: AccountData,
|
||||||
|
error?: string,
|
||||||
|
nowMs = Date.now(),
|
||||||
|
): ResourcePatch {
|
||||||
|
let retryAfterMs = 60 * 1000;
|
||||||
|
if (error) {
|
||||||
|
const match = error.match(/retry(?:_after|\s+after)?\s*[:=]?\s*(\d+)/i);
|
||||||
|
if (match?.[1]) {
|
||||||
|
const parsed = Number(match[1]);
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) {
|
||||||
|
retryAfterMs = parsed > 10_000_000 ? parsed - nowMs : parsed * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const coolingUntilMs = nowMs + Math.max(5000, retryAfterMs);
|
||||||
|
const quota: AccountQuota = {
|
||||||
|
planLabel: data.quota?.planLabel ?? "Antigravity / Gemini",
|
||||||
|
limitReached: true,
|
||||||
|
coolingUntilMs,
|
||||||
|
updatedAtMs: nowMs,
|
||||||
|
claude: data.quota?.claude ?? null,
|
||||||
|
gemini: data.quota?.gemini ?? null,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
privateData: { ...data, quota } as unknown as JsonValue,
|
||||||
|
state: quotaState(quota, nowMs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function presentAccount(resource: ResourceSnapshot): ResourceView {
|
||||||
|
const data = accountData(resource);
|
||||||
|
const metrics: ResourceMetric[] = [];
|
||||||
|
if (data.quota?.claude) {
|
||||||
|
metrics.push({
|
||||||
|
id: "claude",
|
||||||
|
label: { "en-US": "Claude", "zh-CN": "Claude" },
|
||||||
|
unit: "percent",
|
||||||
|
value: data.quota.claude.remainingPercent,
|
||||||
|
...(data.quota.claude.resetAtMs ? { resetAtMs: data.quota.claude.resetAtMs } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.quota?.gemini) {
|
||||||
|
metrics.push({
|
||||||
|
id: "gemini",
|
||||||
|
label: { "en-US": "Gemini", "zh-CN": "Gemini" },
|
||||||
|
unit: "percent",
|
||||||
|
value: data.quota.gemini.remainingPercent,
|
||||||
|
...(data.quota.gemini.resetAtMs ? { resetAtMs: data.quota.gemini.resetAtMs } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
displayName: data.displayName,
|
||||||
|
...(data.quota?.planLabel ? { description: data.quota.planLabel } : {}),
|
||||||
|
...(metrics.length > 0 ? { metrics } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
import { CLIENT_ID, CLIENT_SECRET } from "./oauth.ts";
|
||||||
|
|
||||||
|
export async function refreshAccount(
|
||||||
|
resource: ResourceSnapshot,
|
||||||
|
context: PluginContext,
|
||||||
|
): Promise<ResourcePatch> {
|
||||||
|
const data = accountData(resource);
|
||||||
|
let accessToken = data.accessToken;
|
||||||
|
let refreshToken = data.refreshToken;
|
||||||
|
let projectId = data.projectId ?? "bamboo-precept-lgxtn";
|
||||||
|
let expiresAtMs = data.expiresAtMs ?? null;
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
const response = await context.network.fetch(REFRESH_TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"content-type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
client_secret: CLIENT_SECRET,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
|
const bodyText = response.body.toLowerCase();
|
||||||
|
// Only mark invalid if token is revoked or client is invalid
|
||||||
|
if (bodyText.includes("invalid_grant") || bodyText.includes("unauthorized_client")) {
|
||||||
|
return {
|
||||||
|
state: { status: "invalid", message: "Google authorization expired or revoked; please sign in again" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// On network glitches or temporary Google server errors, keep ready
|
||||||
|
return {
|
||||||
|
state: { status: "ready" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = object(JSON.parse(response.body));
|
||||||
|
accessToken = text(body?.access_token) ?? accessToken;
|
||||||
|
refreshToken = text(body?.refresh_token) ?? refreshToken;
|
||||||
|
const expiresIn = typeof body?.expires_in === "number" ? body.expires_in : 3600;
|
||||||
|
expiresAtMs = Date.now() + expiresIn * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch real-time quota and project ID
|
||||||
|
const result = await queryAccountQuota(accessToken, context.network);
|
||||||
|
projectId = result.projectId || projectId;
|
||||||
|
|
||||||
|
const updatedData: AccountData = {
|
||||||
|
...data,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
projectId,
|
||||||
|
expiresAtMs,
|
||||||
|
quota: result.quota ?? data.quota,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
privateData: updatedData as unknown as JsonValue,
|
||||||
|
state: { status: "ready" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstText(source: Record<string, unknown>, keys: string[]): string | null {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = text(source[key]);
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectCredentials(value: unknown, output: CredentialCandidate[]): void {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) collectCredentials(item, output);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = object(value);
|
||||||
|
if (!item || item.disabled === true) return;
|
||||||
|
for (const key of ["accounts", "credentials", "items", "keys"]) {
|
||||||
|
if (Array.isArray(item[key])) {
|
||||||
|
collectCredentials(item[key], output);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tokens = object(item.tokens) ?? item;
|
||||||
|
let accessToken = firstText(tokens, [
|
||||||
|
"access",
|
||||||
|
"accessToken",
|
||||||
|
"access_token",
|
||||||
|
"token",
|
||||||
|
"apiKey",
|
||||||
|
"api_key",
|
||||||
|
"key",
|
||||||
|
"GEMINI_API_KEY",
|
||||||
|
"GOOGLE_API_KEY",
|
||||||
|
"ANTIGRAVITY_API_KEY",
|
||||||
|
]) ?? firstText(item, [
|
||||||
|
"access",
|
||||||
|
"accessToken",
|
||||||
|
"access_token",
|
||||||
|
"token",
|
||||||
|
"apiKey",
|
||||||
|
"api_key",
|
||||||
|
"key",
|
||||||
|
"GEMINI_API_KEY",
|
||||||
|
"GOOGLE_API_KEY",
|
||||||
|
"ANTIGRAVITY_API_KEY",
|
||||||
|
]);
|
||||||
|
const refreshToken = firstText(tokens, ["refresh", "refresh_token", "refreshToken"]) ??
|
||||||
|
firstText(item, ["refresh", "refresh_token", "refreshToken"]);
|
||||||
|
const displayName = firstText(item, ["email", "display_name", "displayName", "name"]) ??
|
||||||
|
firstText(tokens, ["email", "display_name", "displayName", "name"]);
|
||||||
|
const projectId = firstText(item, ["project", "projectId", "project_id", "cloudaicompanionProject"]) ??
|
||||||
|
firstText(tokens, ["project", "projectId", "project_id", "cloudaicompanionProject"]);
|
||||||
|
|
||||||
|
if (!accessToken && !refreshToken) return;
|
||||||
|
if (!accessToken && refreshToken) {
|
||||||
|
accessToken = refreshToken;
|
||||||
|
}
|
||||||
|
output.push({ accessToken, refreshToken, displayName, projectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
import { REDIRECT_URI } from "./oauth.ts";
|
||||||
|
|
||||||
|
export async function parseCredentialFiles(
|
||||||
|
files: ResourceImportFile[],
|
||||||
|
network?: PluginContext["network"],
|
||||||
|
): Promise<{
|
||||||
|
credentials: CredentialCandidate[];
|
||||||
|
warnings: string[];
|
||||||
|
}> {
|
||||||
|
const credentials: CredentialCandidate[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const raw = file.content.trim();
|
||||||
|
if (!raw) continue;
|
||||||
|
|
||||||
|
// Check if user pasted the Google OAuth callback URL or raw auth code (4/0ATs...)
|
||||||
|
const authCodeMatch = raw.match(/code=([40][a-zA-Z0-9_\-%]+)/) || (raw.startsWith("4/") ? [null, raw] : null);
|
||||||
|
if (authCodeMatch?.[1]) {
|
||||||
|
const code = decodeURIComponent(authCodeMatch[1]);
|
||||||
|
try {
|
||||||
|
const fetcher = network ? (url: string, init: RequestInit) => network.fetch(url, init) : (url: string, init: RequestInit) => fetch(url, init).then(async (r) => ({ status: r.status, body: await r.text() }));
|
||||||
|
const response = await fetcher(REFRESH_TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"content-type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
client_secret: CLIENT_SECRET,
|
||||||
|
code,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
const tokenBody = object(JSON.parse(response.body));
|
||||||
|
if (response.status >= 200 && response.status < 300 && text(tokenBody?.access_token)) {
|
||||||
|
credentials.push({
|
||||||
|
accessToken: text(tokenBody?.access_token)!,
|
||||||
|
refreshToken: text(tokenBody?.refresh_token),
|
||||||
|
displayName: text(tokenBody?.email) ?? "Google Antigravity Account",
|
||||||
|
expiresAtMs: Date.now() + ((typeof tokenBody?.expires_in === "number" ? tokenBody.expires_in : 3600) * 1000),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback to normal parsing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file is raw API key or JWT token string
|
||||||
|
if (raw.startsWith("AIza") || (raw.split(".").length === 3 && !raw.includes(" "))) {
|
||||||
|
credentials.push({ accessToken: raw, refreshToken: null, displayName: file.name });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parsing as JSON
|
||||||
|
let content: unknown;
|
||||||
|
try {
|
||||||
|
content = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
const envMatch = raw.match(/(?:API_KEY|TOKEN|GEMINI_API_KEY|GOOGLE_API_KEY|ANTIGRAVITY_API_KEY)\s*=\s*["']?([^"'\r\n]+)/i);
|
||||||
|
if (envMatch?.[1]) {
|
||||||
|
credentials.push({ accessToken: envMatch[1].trim(), refreshToken: null, displayName: file.name });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const keyMatch = raw.match(/AIza[0-9A-Za-z-_]{35}/);
|
||||||
|
if (keyMatch?.[0]) {
|
||||||
|
credentials.push({ accessToken: keyMatch[0], refreshToken: null, displayName: file.name });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
warnings.push(`${file.name}: not valid JSON or API key`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof content === "string") {
|
||||||
|
credentials.push({ accessToken: content.trim(), refreshToken: null, displayName: file.name });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const found: CredentialCandidate[] = [];
|
||||||
|
collectCredentials(content, found);
|
||||||
|
if (found.length === 0) {
|
||||||
|
warnings.push(`${file.name}: no Google/Antigravity API key or token found`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const candidate of found) {
|
||||||
|
if (candidate.refreshToken && candidate.accessToken === candidate.refreshToken) {
|
||||||
|
try {
|
||||||
|
const fetcher = network
|
||||||
|
? (url: string, init: RequestInit) => network.fetch(url, init)
|
||||||
|
: (url: string, init: RequestInit) =>
|
||||||
|
fetch(url, init).then(async (r) => ({ status: r.status, body: await r.text() }));
|
||||||
|
const response = await fetcher(REFRESH_TOKEN_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"content-type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
client_secret: CLIENT_SECRET,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: candidate.refreshToken,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
const body = object(JSON.parse(response.body));
|
||||||
|
if (response.status >= 200 && response.status < 300 && text(body?.access_token)) {
|
||||||
|
candidate.accessToken = text(body?.access_token)!;
|
||||||
|
candidate.refreshToken = text(body?.refresh_token) ?? candidate.refreshToken;
|
||||||
|
candidate.expiresAtMs = Date.now() + ((typeof body?.expires_in === "number" ? body.expires_in : 3600) * 1000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
credentials.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { credentials, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const credentialImport: ResourceImportSupport = {
|
||||||
|
displayName: {
|
||||||
|
"en-US": "Import Google / Antigravity Credentials",
|
||||||
|
"zh-CN": "导入 Google / Antigravity 凭证",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
"en-US": "Import a JSON, TXT, or Callback URL containing Antigravity tokens or Google API keys.",
|
||||||
|
"zh-CN": "导入包含 Antigravity Token、Google API Key 或授权回调 URL 的 JSON/TXT 文件。",
|
||||||
|
},
|
||||||
|
accept: [".json", ".txt", ".key", ".env"],
|
||||||
|
multiple: true,
|
||||||
|
parse: async (files: ResourceImportFile[], context: PluginContext): Promise<ResourceImportResult> => {
|
||||||
|
const { credentials, warnings } = await parseCredentialFiles(files, context.network);
|
||||||
|
if (credentials.length === 0) {
|
||||||
|
throw new Error(warnings.join("; ") || "credential file does not contain a valid token, authorization code, or API key");
|
||||||
|
}
|
||||||
|
const drafts = await Promise.all(
|
||||||
|
credentials.map(async (c) => {
|
||||||
|
try {
|
||||||
|
const res = await queryAccountQuota(c.accessToken, context.network);
|
||||||
|
c.quota = res.quota;
|
||||||
|
c.projectId = res.projectId;
|
||||||
|
} catch {
|
||||||
|
// ignore error
|
||||||
|
}
|
||||||
|
return credentialDraft(c);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
resources: drafts,
|
||||||
|
...(warnings.length > 0 ? { warnings } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -277,9 +277,7 @@ Deno.test("invoke streams normalized events from the xAI Chat Completions API",
|
|||||||
);
|
);
|
||||||
assertEquals(result, { status: "completed" });
|
assertEquals(result, { status: "completed" });
|
||||||
const body = JSON.parse(requestBody) as Record<string, unknown>;
|
const body = JSON.parse(requestBody) as Record<string, unknown>;
|
||||||
assertEquals(body.model, "grok-4");
|
assert(!("prompt_cache_key" in body), "standard OpenAI chat completion does not include prompt_cache_key");
|
||||||
assertEquals(body.stream, true);
|
|
||||||
assertEquals(body.prompt_cache_key, "conversation-1");
|
|
||||||
assert(!("reasoning_effort" in body), "xAI endpoint rejects reasoning_effort");
|
assert(!("reasoning_effort" in body), "xAI endpoint rejects reasoning_effort");
|
||||||
assert(!("service_tier" in body), "xAI endpoint rejects service_tier");
|
assert(!("service_tier" in body), "xAI endpoint rejects service_tier");
|
||||||
assertEquals(requestHeaders["authorization"], `Bearer ${token}`);
|
assertEquals(requestHeaders["authorization"], `Bearer ${token}`);
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ impl App {
|
|||||||
self.registry.web_cache().set_service_addr(address);
|
self.registry.web_cache().set_service_addr(address);
|
||||||
self.harness.set_backend_addr(address);
|
self.harness.set_backend_addr(address);
|
||||||
tracing::info!(%address, "cursor server listening");
|
tracing::info!(%address, "cursor server listening");
|
||||||
|
tokio::spawn(crate::plugin::oauth_callback::start_oauth_callback_server(shutdown.clone()));
|
||||||
let registry = self.registry;
|
let registry = self.registry;
|
||||||
let harness = self.harness;
|
let harness = self.harness;
|
||||||
let graceful = shutdown.clone();
|
let graceful = shutdown.clone();
|
||||||
|
|||||||
@@ -138,6 +138,14 @@ pub fn api_router(service: ControlService) -> Router {
|
|||||||
.route("/__byok-api__/api/llm-calls", get(calls::list))
|
.route("/__byok-api__/api/llm-calls", get(calls::list))
|
||||||
.route("/__byok-api__/api/llm-calls/{call_id}", get(calls::detail))
|
.route("/__byok-api__/api/llm-calls/{call_id}", get(calls::detail))
|
||||||
.route("/__byok-api__/api/plugins", get(plugins::list))
|
.route("/__byok-api__/api/plugins", get(plugins::list))
|
||||||
|
.route(
|
||||||
|
"/__byok-api__/api/plugins/disabled-models",
|
||||||
|
get(plugins::get_disabled_models).put(plugins::set_disabled_models),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/__byok-api__/api/plugins/disabled-accounts",
|
||||||
|
get(plugins::get_disabled_accounts).put(plugins::set_disabled_accounts),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/__byok-api__/api/plugins/runtime",
|
"/__byok-api__/api/plugins/runtime",
|
||||||
get(plugins::runtime_status)
|
get(plugins::runtime_status)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
plugin::{
|
plugin::{
|
||||||
@@ -122,3 +123,45 @@ pub async fn cancel_runtime_initialization(
|
|||||||
) -> Result<Json<PluginRuntimeStatus>> {
|
) -> Result<Json<PluginRuntimeStatus>> {
|
||||||
Ok(Json(service.cancel_plugin_runtime_initialization()))
|
Ok(Json(service.cancel_plugin_runtime_initialization()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SetDisabledModelsInput {
|
||||||
|
pub model_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_disabled_models(
|
||||||
|
State(service): State<ControlService>,
|
||||||
|
) -> Result<Json<Vec<String>>> {
|
||||||
|
Ok(Json(service.disabled_plugin_models().await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_disabled_models(
|
||||||
|
State(service): State<ControlService>,
|
||||||
|
Json(input): Json<SetDisabledModelsInput>,
|
||||||
|
) -> Result<Json<Vec<String>>> {
|
||||||
|
service.set_disabled_plugin_models(input.model_ids).await?;
|
||||||
|
Ok(Json(service.disabled_plugin_models().await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SetDisabledAccountsInput {
|
||||||
|
pub account_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_disabled_accounts(
|
||||||
|
State(service): State<ControlService>,
|
||||||
|
) -> Result<Json<Vec<String>>> {
|
||||||
|
Ok(Json(service.disabled_plugin_accounts().await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_disabled_accounts(
|
||||||
|
State(service): State<ControlService>,
|
||||||
|
Json(input): Json<SetDisabledAccountsInput>,
|
||||||
|
) -> Result<Json<Vec<String>>> {
|
||||||
|
service
|
||||||
|
.set_disabled_plugin_accounts(input.account_ids)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(service.disabled_plugin_accounts().await?))
|
||||||
|
}
|
||||||
|
|||||||
@@ -251,6 +251,28 @@ impl ControlService {
|
|||||||
self.plugin_runtime.cancel_initialization()
|
self.plugin_runtime.cancel_initialization()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn disabled_plugin_models(&self) -> Result<Vec<String>> {
|
||||||
|
let mut list: Vec<_> = self.store.disabled_plugin_models().await?.into_iter().collect();
|
||||||
|
list.sort();
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_disabled_plugin_models(&self, model_ids: Vec<String>) -> Result<()> {
|
||||||
|
let set = model_ids.into_iter().collect();
|
||||||
|
self.store.set_disabled_plugin_models(&set).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn disabled_plugin_accounts(&self) -> Result<Vec<String>> {
|
||||||
|
let mut list: Vec<_> = self.store.disabled_plugin_accounts().await?.into_iter().collect();
|
||||||
|
list.sort();
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_disabled_plugin_accounts(&self, account_ids: Vec<String>) -> Result<()> {
|
||||||
|
let set = account_ids.into_iter().collect();
|
||||||
|
self.store.set_disabled_plugin_accounts(&set).await
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn ads(
|
pub(super) async fn ads(
|
||||||
&self,
|
&self,
|
||||||
disabled_ad_ids: Option<&str>,
|
disabled_ad_ids: Option<&str>,
|
||||||
|
|||||||
@@ -109,7 +109,63 @@ const GROK_AUTH: &[(&str, &str)] = &[
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
const PLUGINS: &[(&str, &[(&str, &str)])] = &[("codex-auth", CODEX_AUTH), ("grok-auth", GROK_AUTH)];
|
const ANTIGRAVITY_AUTH: &[(&str, &str)] = &[
|
||||||
|
(
|
||||||
|
"plugin.json",
|
||||||
|
include_str!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/plugins/build-in/antigravity-auth/plugin.json"
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"main.ts",
|
||||||
|
include_str!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/plugins/build-in/antigravity-auth/main.ts"
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"provider.ts",
|
||||||
|
include_str!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/plugins/build-in/antigravity-auth/provider.ts"
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"models.ts",
|
||||||
|
include_str!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/plugins/build-in/antigravity-auth/models.ts"
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"oauth.ts",
|
||||||
|
include_str!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/plugins/build-in/antigravity-auth/oauth.ts"
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"resources.ts",
|
||||||
|
include_str!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/plugins/build-in/antigravity-auth/resources.ts"
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"assets/antigravity.svg",
|
||||||
|
include_str!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/plugins/build-in/antigravity-auth/assets/antigravity.svg"
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLUGINS: &[(&str, &[(&str, &str)])] = &[
|
||||||
|
("antigravity-auth", ANTIGRAVITY_AUTH),
|
||||||
|
("codex-auth", CODEX_AUTH),
|
||||||
|
("grok-auth", GROK_AUTH),
|
||||||
|
];
|
||||||
|
|
||||||
/// 把内置插件预装到 installed 目录。manifest 的 version 是缓存键:
|
/// 把内置插件预装到 installed 目录。manifest 的 version 是缓存键:
|
||||||
/// 版本一致时零写盘;版本变化时整目录同步并清理旧版本残留文件。
|
/// 版本一致时零写盘;版本变化时整目录同步并清理旧版本残留文件。
|
||||||
|
|||||||
@@ -203,20 +203,20 @@ fn validate_component(value: &str, label: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_directory_permissions(path: &Path) -> Result<()> {
|
fn set_directory_permissions(_path: &Path) -> Result<()> {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
|
std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o700))?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_file_permissions(path: &Path) -> Result<()> {
|
fn set_file_permissions(_path: &Path) -> Result<()> {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600))?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod definition;
|
|||||||
mod descriptor;
|
mod descriptor;
|
||||||
mod installation;
|
mod installation;
|
||||||
mod manifest;
|
mod manifest;
|
||||||
|
pub mod oauth_callback;
|
||||||
mod protocol;
|
mod protocol;
|
||||||
mod registry;
|
mod registry;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
//! Lightweight local OAuth callback server for Google / Antigravity OAuth redirect flows.
|
||||||
|
use std::{collections::HashMap, net::SocketAddr, sync::Arc};
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
response::Html,
|
||||||
|
routing::get,
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
pub struct OAuthCallbackState {
|
||||||
|
codes: Arc<RwLock<HashMap<String, String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CallbackQuery {
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub state: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct StatusQuery {
|
||||||
|
pub state: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct StatusResponse {
|
||||||
|
pub code: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_oauth_callback_server(shutdown: tokio_util::sync::CancellationToken) {
|
||||||
|
let port = 51121;
|
||||||
|
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||||
|
let state = OAuthCallbackState::default();
|
||||||
|
|
||||||
|
let router = Router::new()
|
||||||
|
.route("/oauth-callback", get(handle_callback))
|
||||||
|
.route("/auth-status", get(handle_status))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(%addr, %err, "OAuth callback port 51121 unavailable or already bound");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tracing::info!(%addr, "OAuth callback server listening");
|
||||||
|
|
||||||
|
let server = axum::serve(listener, router).with_graceful_shutdown(async move {
|
||||||
|
shutdown.cancelled().await;
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(err) = server.await {
|
||||||
|
tracing::debug!(%err, "OAuth callback server stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_callback(
|
||||||
|
State(state): State<OAuthCallbackState>,
|
||||||
|
Query(query): Query<CallbackQuery>,
|
||||||
|
) -> Html<&'static str> {
|
||||||
|
if let (Some(code), Some(st)) = (query.code, query.state) {
|
||||||
|
state.codes.write().insert(st, code);
|
||||||
|
}
|
||||||
|
Html(r#"<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Antigravity Authorization Successful</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #18181b; color: #f4f4f5; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
||||||
|
.card { background: #27272a; border: 1px solid #3f3f46; padding: 36px; border-radius: 16px; text-align: center; box-shadow: 0 10px 30px rgba(0,0,0,0.5); max-width: 440px; }
|
||||||
|
.icon { font-size: 40px; color: #4285f4; margin-bottom: 16px; }
|
||||||
|
h1 { color: #ffffff; font-size: 20px; margin: 0 0 12px 0; }
|
||||||
|
p { color: #a1a1aa; font-size: 14px; line-height: 1.6; margin: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon">✓</div>
|
||||||
|
<h1>Authorization Successful</h1>
|
||||||
|
<p>Your Google Antigravity account has been authorized. You can close this browser tab and return to <strong>Cursor BYOK</strong>.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_status(
|
||||||
|
State(state): State<OAuthCallbackState>,
|
||||||
|
Query(query): Query<StatusQuery>,
|
||||||
|
) -> Json<StatusResponse> {
|
||||||
|
let code = query.state.and_then(|st| state.codes.read().get(&st).cloned());
|
||||||
|
Json(StatusResponse { code })
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ struct RegistryInner {
|
|||||||
entries: RwLock<Option<Vec<PluginEntry>>>,
|
entries: RwLock<Option<Vec<PluginEntry>>>,
|
||||||
workers: Mutex<HashMap<String, Arc<PluginWorker>>>,
|
workers: Mutex<HashMap<String, Arc<PluginWorker>>>,
|
||||||
oauth_sessions: Mutex<HashMap<String, OAuthSession>>,
|
oauth_sessions: Mutex<HashMap<String, OAuthSession>>,
|
||||||
|
rr_counter: std::sync::atomic::AtomicUsize,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct OAuthSession {
|
struct OAuthSession {
|
||||||
@@ -108,6 +109,7 @@ impl PluginRegistry {
|
|||||||
entries: RwLock::new(None),
|
entries: RwLock::new(None),
|
||||||
workers: Mutex::new(HashMap::new()),
|
workers: Mutex::new(HashMap::new()),
|
||||||
oauth_sessions: Mutex::new(HashMap::new()),
|
oauth_sessions: Mutex::new(HashMap::new()),
|
||||||
|
rr_counter: std::sync::atomic::AtomicUsize::new(0),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -142,6 +144,12 @@ impl PluginRegistry {
|
|||||||
let Some(executable) = self.inner.runtime.executable() else {
|
let Some(executable) = self.inner.runtime.executable() else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
|
let disabled_models = self
|
||||||
|
.inner
|
||||||
|
.store
|
||||||
|
.disabled_plugin_models()
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
let mut models = Vec::new();
|
let mut models = Vec::new();
|
||||||
for entry in self.entries(&executable).await {
|
for entry in self.entries(&executable).await {
|
||||||
for provider in &entry.definition.providers {
|
for provider in &entry.definition.providers {
|
||||||
@@ -154,14 +162,19 @@ impl PluginRegistry {
|
|||||||
.models(&entry.manifest.id, &provider.id)
|
.models(&entry.manifest.id, &provider.id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
models.extend(stored.iter().map(|model| {
|
models.extend(stored.iter().filter_map(|model| {
|
||||||
PluginModelDescriptor::new(
|
let descriptor = PluginModelDescriptor::new(
|
||||||
&entry.manifest.id,
|
&entry.manifest.id,
|
||||||
&entry.manifest.name,
|
&entry.manifest.name,
|
||||||
&entry.icon,
|
&entry.icon,
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
)
|
);
|
||||||
|
if disabled_models.contains(&descriptor.id) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(descriptor)
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,6 +205,15 @@ impl PluginRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn plan_model(&self, model_id: &str) -> Result<PluginInvocationPlan> {
|
pub async fn plan_model(&self, model_id: &str) -> Result<PluginInvocationPlan> {
|
||||||
|
let disabled_models = self
|
||||||
|
.inner
|
||||||
|
.store
|
||||||
|
.disabled_plugin_models()
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
if disabled_models.contains(model_id) {
|
||||||
|
return Err(Error::Provider(format!("plugin model '{model_id}' is disabled")));
|
||||||
|
}
|
||||||
let model = self.model_descriptor(model_id).await?;
|
let model = self.model_descriptor(model_id).await?;
|
||||||
let request_url = format!("plugin://{}/{}", model.plugin_id, model.provider_id);
|
let request_url = format!("plugin://{}/{}", model.plugin_id, model.provider_id);
|
||||||
Ok(PluginInvocationPlan { model, request_url })
|
Ok(PluginInvocationPlan { model, request_url })
|
||||||
@@ -704,13 +726,21 @@ impl PluginRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
match &provider.resource_type {
|
match &provider.resource_type {
|
||||||
Some(resource_type) => !self
|
Some(resource_type) => {
|
||||||
|
let disabled_accounts = self
|
||||||
|
.inner
|
||||||
|
.store
|
||||||
|
.disabled_plugin_accounts()
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let resources = self
|
||||||
.inner
|
.inner
|
||||||
.state
|
.state
|
||||||
.resources(plugin_id, resource_type)
|
.resources(plugin_id, resource_type)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default()
|
.unwrap_or_default();
|
||||||
.is_empty(),
|
resources.iter().any(|r| !disabled_accounts.contains(&r.id))
|
||||||
|
}
|
||||||
None => true,
|
None => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -796,19 +826,54 @@ impl PluginRegistry {
|
|||||||
plugin_id: &str,
|
plugin_id: &str,
|
||||||
resource_type: &str,
|
resource_type: &str,
|
||||||
) -> Result<ResourceRecord> {
|
) -> Result<ResourceRecord> {
|
||||||
|
let disabled_accounts = self
|
||||||
|
.inner
|
||||||
|
.store
|
||||||
|
.disabled_plugin_accounts()
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
let records = self.inner.state.resources(plugin_id, resource_type).await?;
|
let records = self.inner.state.resources(plugin_id, resource_type).await?;
|
||||||
if records.is_empty() {
|
let active_records: Vec<_> = records
|
||||||
|
.into_iter()
|
||||||
|
.filter(|record| !disabled_accounts.contains(&record.id))
|
||||||
|
.collect();
|
||||||
|
if active_records.is_empty() {
|
||||||
return Err(Error::Provider(format!(
|
return Err(Error::Provider(format!(
|
||||||
"plugin '{plugin_id}' has no '{resource_type}' resource; add one first"
|
"plugin '{plugin_id}' has no enabled '{resource_type}' resource; enable or add one first"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
records
|
let mut ready_records: Vec<_> = active_records
|
||||||
.iter()
|
.into_iter()
|
||||||
.find(|record| record.state.is_ready(now))
|
.filter(|record| record.state.is_ready(now))
|
||||||
.or_else(|| records.first())
|
.collect();
|
||||||
.cloned()
|
if ready_records.is_empty() {
|
||||||
.ok_or_else(|| Error::Provider("no plugin resource is available".into()))
|
return Err(Error::Provider(format!(
|
||||||
|
"all enabled accounts for plugin '{plugin_id}' are currently cooling or rate-limited"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let get_priority = |r: &ResourceRecord| -> u8 {
|
||||||
|
let label = r.private_data.get("quota").and_then(|q| q.get("planLabel")).and_then(|l| l.as_str()).unwrap_or("");
|
||||||
|
let lower = label.to_lowercase();
|
||||||
|
if label.contains("🔥") || lower.contains("pro") || lower.contains("ultra") || lower.contains("premium") || lower.contains("advanced") {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ready_records.sort_by_key(|r| get_priority(r));
|
||||||
|
if let Some(best_prio) = ready_records.first().map(|r| get_priority(r)) {
|
||||||
|
ready_records.retain(|r| get_priority(r) == best_prio);
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = self
|
||||||
|
.inner
|
||||||
|
.rr_counter
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||||
|
% ready_records.len();
|
||||||
|
Ok(ready_records[index].clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_record(
|
async fn find_record(
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ export function buildChatBody(call: OpenAiChatCall): Record<string, JsonValue> {
|
|||||||
body.reasoning_effort = call.request.reasoning.effort;
|
body.reasoning_effort = call.request.reasoning.effort;
|
||||||
}
|
}
|
||||||
if (call.request.latency === "fast") body.service_tier = "fast";
|
if (call.request.latency === "fast") body.service_tier = "fast";
|
||||||
if (call.request.cacheKey !== null) body.prompt_cache_key = call.request.cacheKey;
|
|
||||||
return { ...body, ...call.extraBody };
|
return { ...body, ...call.extraBody };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -425,9 +425,16 @@ impl HostContext {
|
|||||||
let raw_url = required_string(params, "url")?;
|
let raw_url = required_string(params, "url")?;
|
||||||
let url = url::Url::parse(raw_url)
|
let url = url::Url::parse(raw_url)
|
||||||
.map_err(|error| Error::Config(format!("invalid plugin network URL: {error}")))?;
|
.map_err(|error| Error::Config(format!("invalid plugin network URL: {error}")))?;
|
||||||
if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
|
let is_loopback = url
|
||||||
|
.host_str()
|
||||||
|
.map(|h| h == "127.0.0.1" || h == "localhost")
|
||||||
|
.unwrap_or(false);
|
||||||
|
if (url.scheme() != "https" && (!is_loopback || url.scheme() != "http"))
|
||||||
|
|| !url.username().is_empty()
|
||||||
|
|| url.password().is_some()
|
||||||
|
{
|
||||||
return Err(Error::Config(
|
return Err(Error::Config(
|
||||||
"plugin network URL must be HTTPS without credentials".into(),
|
"plugin network URL must be HTTPS without credentials (or loopback HTTP)".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let host = url
|
let host = url
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
//! Persists application settings.
|
//! Persists application settings.
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
@@ -10,6 +12,8 @@ const PROXY_SETTINGS_KEY: &str = "outbound_proxy";
|
|||||||
const TAB_SETTINGS_KEY: &str = "cursor_tab";
|
const TAB_SETTINGS_KEY: &str = "cursor_tab";
|
||||||
const INSTALLATION_ID_KEY: &str = "installation_id";
|
const INSTALLATION_ID_KEY: &str = "installation_id";
|
||||||
const DESKTOP_SETTINGS_KEY: &str = "desktop_lifecycle";
|
const DESKTOP_SETTINGS_KEY: &str = "desktop_lifecycle";
|
||||||
|
const DISABLED_PLUGIN_MODELS_KEY: &str = "disabled_plugin_models";
|
||||||
|
const DISABLED_PLUGIN_ACCOUNTS_KEY: &str = "disabled_plugin_accounts";
|
||||||
|
|
||||||
pub const PUBLIC_TAB_SERVICE_URL: &str = "https://tab.leokun.cn";
|
pub const PUBLIC_TAB_SERVICE_URL: &str = "https://tab.leokun.cn";
|
||||||
|
|
||||||
@@ -299,4 +303,56 @@ impl Store {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn disabled_plugin_models(&self) -> Result<HashSet<String>> {
|
||||||
|
let value = sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT value_json FROM service_settings WHERE setting_key = ?",
|
||||||
|
)
|
||||||
|
.bind(DISABLED_PLUGIN_MODELS_KEY)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
value
|
||||||
|
.map(|value| serde_json::from_str(&value).map_err(Into::into))
|
||||||
|
.unwrap_or_else(|| Ok(HashSet::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_disabled_plugin_models(&self, model_ids: &HashSet<String>) -> Result<()> {
|
||||||
|
let value_json = serde_json::to_string(model_ids)?;
|
||||||
|
let _write = self.writes.lock().await;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms",
|
||||||
|
)
|
||||||
|
.bind(DISABLED_PLUGIN_MODELS_KEY)
|
||||||
|
.bind(value_json)
|
||||||
|
.bind(now_ms())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn disabled_plugin_accounts(&self) -> Result<HashSet<String>> {
|
||||||
|
let value = sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT value_json FROM service_settings WHERE setting_key = ?",
|
||||||
|
)
|
||||||
|
.bind(DISABLED_PLUGIN_ACCOUNTS_KEY)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
value
|
||||||
|
.map(|value| serde_json::from_str(&value).map_err(Into::into))
|
||||||
|
.unwrap_or_else(|| Ok(HashSet::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_disabled_plugin_accounts(&self, account_ids: &HashSet<String>) -> Result<()> {
|
||||||
|
let value_json = serde_json::to_string(account_ids)?;
|
||||||
|
let _write = self.writes.lock().await;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO service_settings(setting_key, value_json, updated_at_ms) VALUES (?, ?, ?) ON CONFLICT(setting_key) DO UPDATE SET value_json = excluded.value_json, updated_at_ms = excluded.updated_at_ms",
|
||||||
|
)
|
||||||
|
.bind(DISABLED_PLUGIN_ACCOUNTS_KEY)
|
||||||
|
.bind(value_json)
|
||||||
|
.bind(now_ms())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user