feat(copilot): add GitHub Copilot reverse proxy support (#930)

* refactor(toolsearch): replace binary patch with ENABLE_TOOL_SEARCH env var toggle

- Remove toolsearch_patch.rs binary patching mechanism (~590 lines)
  - Delete `toolsearch_patch.rs` and `commands/toolsearch.rs`
  - Remove auto-patch startup logic and command registration from lib.rs
  - Remove `tool_search_bypass` field from settings.rs
  - Remove frontend settings ToggleRow, useSettings hook sync logic, and API methods
  - Clean up zh/en/ja i18n keys (notifications + settings)

- Add ENABLE_TOOL_SEARCH toggle to Claude provider form
  - Add checkbox in CommonConfigEditor.tsx (alongside teammates toggle)
  - When enabled, writes `"env": { "ENABLE_TOOL_SEARCH": "true" }`
  - When disabled, removes the key; takes effect on provider switch
  - Add zh/en/ja i18n key: `claudeConfig.enableToolSearch`

Claude Code 2.1.76+ natively supports this env var, eliminating the need for binary patching.

* feat(claude): add effortLevel high toggle to provider form

- Add "high-effort thinking" checkbox to Claude provider config form
- When checked, writes `"effortLevel": "high"`; when unchecked, removes the field
- Add zh/en/ja i18n translations

* refactor(claude): remove deprecated alwaysThinking toggle

- Claude Code now enables extended thinking by default; alwaysThinkingEnabled is a no-op
- Thinking control is now handled via effortLevel (added in prior commit)
- Remove state, switch case, and checkbox UI from CommonConfigEditor
- Clean up alwaysThinking i18n keys across zh/en/ja locales

* feat(opencode): add setCacheKey: true to all provider presets

- Add setCacheKey: true to options in all 33 regular presets
- Add setCacheKey: true to OPENCODE_DEFAULT_CONFIG for custom providers
- Exclude 2 OMO presets (Oh My OpenCode / Slim) which have their own config mechanism

Closes #1523

* fix(codex): resolve 1M context window toggle causing MCP editor flicker

- Add localValueRef to short-circuit duplicate CodeMirror updateListener callbacks,
  breaking the React state → CodeMirror → stale onChange → React state feedback loop
- Use localValueRef.current in handleContextWindowToggle and handleCompactLimitChange
  to avoid stale closure reads
- Change compact limit input from type="number" to type="text" with inputMode="numeric"
  to remove unnecessary spinner buttons

* feat(codex): add 1M context window toggle utilities and i18n keys

- Add extractCodexTopLevelInt, setCodexTopLevelInt, removeCodexTopLevelField
  TOML helpers in providerConfigUtils.ts
- Add i18n keys for contextWindow1M, autoCompactLimit in zh/en/ja locales

* feat(claude): collapse model mapping fields by default

- Wrap 5 model mapping inputs in a Collapsible, collapsed by default
- Auto-expand when any model value is present (including preset-filled)
- Show hint text when collapsed explaining most users need no config
- Add zh/en/ja i18n keys for toggle label and collapsed hint
- Use variant={null} to avoid ghost button hover style clash in dark mode

* feat(claude): merge advanced fields into single collapsible section

- Merge API format, auth field, and model mapping into a unified "Advanced Options" collapsible
- Extend smart-expand logic to detect non-default values across all advanced fields
- Preserve model mapping sub-header and hint with a separator line
- Update zh/en/ja i18n keys (advancedOptionsToggle, advancedOptionsHint, modelMappingLabel, modelMappingHint)

* feat(copilot): add GitHub Copilot reverse proxy support

Add GitHub Copilot as a Claude provider variant with OAuth device code
authentication and Anthropic ↔ OpenAI format transformation.

Backend:
- Add CopilotAuthManager for GitHub OAuth device code flow
- Implement Copilot token auto-refresh (60s before expiry)
- Persist GitHub token to ~/.cc-switch/copilot_auth.json
- Add ProviderType::GitHubCopilot and AuthStrategy::GitHubCopilot
- Modify forwarder to use /chat/completions for Copilot
- Add Copilot-specific headers (Editor-Version, Editor-Plugin-Version)

Frontend:
- Add CopilotAuthSection component for OAuth UI
- Add useCopilotAuth hook for OAuth state management
- Auto-copy user code to clipboard and open browser
- Use 8-second polling interval to avoid GitHub rate limits
- Skip API Key validation for Copilot providers
- Add GitHub Copilot preset with claude-sonnet-4 model

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix(copilot): remove is_expired() calls from tests

Remove references to deleted is_expired() method in test code.
Only is_expiring_soon() is needed for token refresh logic.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* feat(copilot): add real-time model listing from Copilot API

- Add fetch_models() to CopilotAuthManager calling GET /models endpoint
- Add copilot_get_models Tauri command
- Add copilotGetModels() frontend API wrapper
- Modify ClaudeFormFields to show model dropdown for Copilot providers
  - Fetches available models on component mount when isCopilotPreset
  - Groups models by vendor (Anthropic, OpenAI, Google, etc.)
  - Input + dropdown button combo allows both manual entry and selection
  - Non-Copilot providers keep original plain Input behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(copilot): add usage query integration

- Add Copilot usage API integration (fetch_usage method)
- Add copilot_get_usage Tauri command
- Add GitHub Copilot template in usage query modal
- Unify naming: copilot → github_copilot
- Add constants management (TEMPLATE_TYPES, PROVIDER_TYPES)
- Improve error handling with detailed error messages
- Add database migration (v5 → v6) for template type update
- Add i18n translations (zh, en, ja)
- Improve type safety with TemplateType
- Apply code formatting (cargo fmt, prettier)

* 修复github 登录和注销问题 ,模型选择问题

* feat(copilot): add multi-account support for GitHub Copilot

- Add multi-account storage structure with v1 to v2 migration
- Add per-account token caching and auto-refresh
- Add new Tauri commands for account management
- Integrate account selection in Proxy forwarder
- Add account selection UI in CopilotAuthSection
- Save githubAccountId to ProviderMeta
- Add i18n translations for multi-account features (zh/en/ja)

* 修复用量查询Reset字段出现多余字符

* refactor(auth-binding): introduce generic provider auth binding primitives

- add shared authBinding types in Rust and TypeScript while keeping githubAccountId as a compatibility field\n- resolve Copilot token, models, and usage through provider-bound account lookup instead of only the implicit default account\n- fix the Unix build regression in settings.rs by restoring std::io::Write for write_all()\n- remove the accidental .github ignore entry and drop leftover Copilot form debug logs\n- keep the first migration step non-breaking by writing both authBinding and the legacy githubAccountId field from the form

* refactor(auth-service): add managed auth command surface and explicit default account state

- introduce generic managed auth commands and frontend auth API wrappers for provider-scoped login, status, account listing, removal, logout, and default-account selection\n- store an explicit Copilot default_account_id instead of relying on HashMap iteration order, and use it consistently for fallback token/model/usage resolution\n- sort managed accounts deterministically and surface default-account state to the UI\n- refactor the Copilot form hook to wrap a generic useManagedAuth implementation while preserving the existing component contract\n- add default-account controls to the Copilot auth section and extend Copilot auth status serialization/tests for the new state

* feat(auth-center): add a dedicated settings entrypoint for managed OAuth accounts

- add an Auth Center tab to Settings so managed OAuth accounts are no longer hidden inside individual provider forms\n- introduce a first AuthCenterPanel that hosts GitHub Copilot account management as the initial managed auth provider\n- keep the provider form experience intact while establishing a global account-management surface for future providers such as OpenAI\n- validate that the new settings tab works cleanly with the generic managed auth hook and existing Copilot account controls

* feat(add-provider): expose managed OAuth sources alongside universal providers

- add an OAuth tab to the Add Provider flow so managed auth sources sit beside app-specific and universal providers\n- reuse the new Auth Center panel inside the dialog, keeping account management discoverable during provider creation\n- make the dialog footer adapt to the OAuth tab so account setup does not pretend to create a provider directly\n- align the add-provider UX with the new architecture where OAuth accounts are global assets and providers bind to them later

* fix(auth-reliability): harden managed auth persistence and refresh behavior

- replace direct Copilot auth store writes with private temp-file writes and atomic rename semantics, and document the local token storage limitation\n- add per-account refresh locks plus a double-check path so concurrent requests do not stampede GitHub token refresh\n- surface legacy migration failures through auth status, expose them in the UI, and add translated copy for the new account-state labels\n- stop writing the legacy githubAccountId field from the provider form while keeping compatibility reads in place\n- add logout error recovery and Copilot model-load toasts so auth failures are no longer silently swallowed

* refactor(copilot-detection): prefer provider type before URL fallbacks

- update forwarder endpoint rewriting to treat providerType as the primary GitHub Copilot signal\n- keep githubcopilot.com string matching only as a compatibility fallback for older provider records without providerType\n- reduce one more path where Copilot behavior depended purely on URL heuristics

* fix(copilot-auth): add cancel button to error state in CopilotAuthSection

- 错误状态下仅有"重试"按钮,用户无法退出(如不可恢复的 403 未订阅错误)
- 新增"取消"按钮,复用已有的 cancelAuth 逻辑重置为 idle 状态

* 修复打包后github账号头像显示异常

* 修复github copilot 来源的模型测试报错

* feat(copilot-preset): add default model presets for GitHub Copilot

- 补充 Copilot 预设的默认模型配置,用户选完预设即可直接使用
- ANTHROPIC_MODEL: claude-opus-4.6
- ANTHROPIC_DEFAULT_HAIKU_MODEL: claude-haiku-4.5
- ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4.6
- ANTHROPIC_DEFAULT_OPUS_MODEL: claude-opus-4.6

---------

Co-authored-by: Jason <farion1231@gmail.com>
Co-authored-by: 周梦泽 <mengze.zhou@dafeng-tech.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Zhou Mengze
2026-03-17 23:57:58 +08:00
committed by GitHub
parent 36bbdc36f5
commit 8ccfbd36d6
50 changed files with 4555 additions and 1062 deletions
+23 -5
View File
@@ -14,6 +14,7 @@ import {
} from "@/components/providers/forms/ProviderForm";
import { UniversalProviderFormModal } from "@/components/universal/UniversalProviderFormModal";
import { UniversalProviderPanel } from "@/components/universal";
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
@@ -42,9 +43,9 @@ export function AddProviderDialog({
const { t } = useTranslation();
// OpenCode and OpenClaw don't support universal providers
const showUniversalTab = appId !== "opencode" && appId !== "openclaw";
const [activeTab, setActiveTab] = useState<"app-specific" | "universal">(
"app-specific",
);
const [activeTab, setActiveTab] = useState<
"app-specific" | "universal" | "oauth"
>("app-specific");
const [universalFormOpen, setUniversalFormOpen] = useState(false);
const [selectedUniversalPreset, setSelectedUniversalPreset] =
useState<UniversalProviderPreset | null>(null);
@@ -255,6 +256,14 @@ export function AddProviderDialog({
{t("common.add")}
</Button>
</>
) : activeTab === "oauth" ? (
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.close", { defaultValue: "关闭" })}
</Button>
) : (
<>
<Button
@@ -284,15 +293,20 @@ export function AddProviderDialog({
{showUniversalTab ? (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
onValueChange={(v) =>
setActiveTab(v as "app-specific" | "universal" | "oauth")
}
>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsList className="grid w-full grid-cols-3 mb-6">
<TabsTrigger value="app-specific">
{t(`apps.${appId}`)} {t("provider.tabProvider")}
</TabsTrigger>
<TabsTrigger value="universal">
{t("provider.tabUniversal")}
</TabsTrigger>
<TabsTrigger value="oauth">
{t("provider.tabOAuth", { defaultValue: "OAuth 认证源" })}
</TabsTrigger>
</TabsList>
<TabsContent value="app-specific" className="mt-0">
@@ -309,6 +323,10 @@ export function AddProviderDialog({
<TabsContent value="universal" className="mt-0">
<UniversalProviderPanel />
</TabsContent>
<TabsContent value="oauth" className="mt-0">
<AuthCenterPanel />
</TabsContent>
</Tabs>
) : (
// OpenCode/OpenClaw: directly show form without tabs
+8 -8
View File
@@ -199,14 +199,14 @@ export function ProviderCard({
// - 故障转移模式:代理实际使用的供应商(activeProviderId
// - 普通模式:isCurrent
const isActiveProvider = isAnyOmo
? isCurrent
: appId === "openclaw"
? Boolean(isDefaultModel)
: appId === "opencode"
? false
: isAutoFailoverEnabled
? activeProviderId === provider.id
: isCurrent;
? isCurrent
: appId === "openclaw"
? Boolean(isDefaultModel)
: appId === "opencode"
? false
: isAutoFailoverEnabled
? activeProviderId === provider.id
: isCurrent;
const shouldUseGreen = !isAnyOmo && isProxyTakeover && isActiveProvider;
const shouldUseBlue =
@@ -1,4 +1,12 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { toast } from "sonner";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
@@ -8,8 +16,23 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField } from "./shared";
import { CopilotAuthSection } from "./CopilotAuthSection";
import {
copilotGetModels,
copilotGetModelsForAccount,
} from "@/lib/api/copilot";
import type { CopilotModel } from "@/lib/api/copilot";
import type {
ProviderCategory,
ClaudeApiFormat,
@@ -33,6 +56,15 @@ interface ClaudeFormFieldsProps {
isPartner?: boolean;
partnerPromotionKey?: string;
// GitHub Copilot OAuth
isCopilotPreset?: boolean;
usesOAuth?: boolean;
isCopilotAuthenticated?: boolean;
/** 当前选中的 GitHub 账号 ID(多账号支持) */
selectedGitHubAccountId?: string | null;
/** GitHub 账号选择回调(多账号支持) */
onGitHubAccountSelect?: (accountId: string | null) => void;
// Template Values
templateValueEntries: Array<[string, TemplateValueConfig]>;
templateValues: Record<string, TemplateValueConfig>;
@@ -88,6 +120,11 @@ export function ClaudeFormFields({
websiteUrl,
isPartner,
partnerPromotionKey,
isCopilotPreset,
usesOAuth,
isCopilotAuthenticated,
selectedGitHubAccountId,
onGitHubAccountSelect,
templateValueEntries,
templateValues,
templatePresetName,
@@ -114,11 +151,172 @@ export function ClaudeFormFields({
onApiKeyFieldChange,
}: ClaudeFormFieldsProps) {
const { t } = useTranslation();
const hasAnyAdvancedValue = !!(
claudeModel ||
reasoningModel ||
defaultHaikuModel ||
defaultSonnetModel ||
defaultOpusModel ||
apiFormat !== "anthropic" ||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN"
);
const [advancedExpanded, setAdvancedExpanded] =
useState(hasAnyAdvancedValue);
// 预设填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
useEffect(() => {
if (hasAnyAdvancedValue) {
setAdvancedExpanded(true);
}
}, [hasAnyAdvancedValue]);
// Copilot 可用模型列表
const [copilotModels, setCopilotModels] = useState<CopilotModel[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
// 当 Copilot 预设且已认证时,加载可用模型
useEffect(() => {
// 如果不是 Copilot 预设或未认证,清空模型列表
if (!isCopilotPreset || !isCopilotAuthenticated) {
setCopilotModels([]);
setModelsLoading(false);
return;
}
let cancelled = false;
setModelsLoading(true);
const fetchModels = selectedGitHubAccountId
? copilotGetModelsForAccount(selectedGitHubAccountId)
: copilotGetModels();
fetchModels
.then((models) => {
if (!cancelled) setCopilotModels(models);
})
.catch((err) => {
console.warn("[Copilot] Failed to fetch models:", err);
if (!cancelled) {
toast.error(
t("copilot.loadModelsFailed", {
defaultValue: "加载 Copilot 模型列表失败",
}),
);
}
})
.finally(() => {
if (!cancelled) setModelsLoading(false);
});
return () => {
cancelled = true;
};
}, [isCopilotPreset, isCopilotAuthenticated, selectedGitHubAccountId]);
// 模型输入框:支持手动输入 + 下拉选择
const renderModelInput = (
id: string,
value: string,
field: ClaudeFormFieldsProps["onModelChange"] extends (
f: infer F,
v: string,
) => void
? F
: never,
placeholder?: string,
) => {
if (isCopilotPreset && copilotModels.length > 0) {
// 按 vendor 分组
const grouped: Record<string, CopilotModel[]> = {};
for (const model of copilotModels) {
const vendor = model.vendor || "Other";
if (!grouped[vendor]) grouped[vendor] = [];
grouped[vendor].push(model);
}
const vendors = Object.keys(grouped).sort();
return (
<div className="flex gap-1">
<Input
id={id}
type="text"
value={value}
onChange={(e) => onModelChange(field, e.target.value)}
placeholder={placeholder}
autoComplete="off"
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0">
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-64 overflow-y-auto z-[200]"
>
{vendors.map((vendor, vi) => (
<div key={vendor}>
{vi > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel>{vendor}</DropdownMenuLabel>
{grouped[vendor].map((model) => (
<DropdownMenuItem
key={model.id}
onSelect={() => onModelChange(field, model.id)}
>
{model.id}
</DropdownMenuItem>
))}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
if (isCopilotPreset && modelsLoading) {
return (
<div className="flex gap-1">
<Input
id={id}
type="text"
value={value}
onChange={(e) => onModelChange(field, e.target.value)}
placeholder={placeholder}
autoComplete="off"
className="flex-1"
/>
<Button variant="outline" size="icon" className="shrink-0" disabled>
<Loader2 className="h-4 w-4 animate-spin" />
</Button>
</div>
);
}
return (
<Input
id={id}
type="text"
value={value}
onChange={(e) => onModelChange(field, e.target.value)}
placeholder={placeholder}
autoComplete="off"
/>
);
};
return (
<>
{/* API Key 输入框 */}
{shouldShowApiKey && (
{/* GitHub Copilot OAuth 认证 */}
{isCopilotPreset && (
<CopilotAuthSection
selectedAccountId={selectedGitHubAccountId}
onAccountSelect={onGitHubAccountSelect}
/>
)}
{/* API Key 输入框(非 OAuth 预设时显示) */}
{shouldShowApiKey && !usesOAuth && (
<ApiKeySection
value={apiKey}
onChange={onApiKeyChange}
@@ -200,188 +398,185 @@ export function ClaudeFormFields({
/>
)}
{/* API 格式选择(仅非官方、非云服务商显示 */}
{shouldShowModelSelector && category !== "cloud_provider" && (
<div className="space-y-2">
<FormLabel htmlFor="apiFormat">
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
</FormLabel>
<Select value={apiFormat} onValueChange={onApiFormatChange}>
<SelectTrigger id="apiFormat" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="anthropic">
{t("providerForm.apiFormatAnthropic", {
defaultValue: "Anthropic Messages (原生)",
})}
</SelectItem>
<SelectItem value="openai_chat">
{t("providerForm.apiFormatOpenAIChat", {
defaultValue: "OpenAI Chat Completions (需转换)",
})}
</SelectItem>
<SelectItem value="openai_responses">
{t("providerForm.apiFormatOpenAIResponses", {
defaultValue: "OpenAI Responses API (需转换)",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("providerForm.apiFormatHint", {
defaultValue: "选择供应商 API 的输入格式",
})}
</p>
</div>
)}
{/* 认证字段选择器 */}
{/* 高级选项(API 格式 + 认证字段 + 模型映射 */}
{shouldShowModelSelector && (
<div className="space-y-2">
<FormLabel>
{t("providerForm.authField", { defaultValue: "认证字段" })}
</FormLabel>
<Select
value={apiKeyField}
onValueChange={(v) => onApiKeyFieldChange(v as ClaudeApiKeyField)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
{t("providerForm.authFieldAuthToken", {
defaultValue: "ANTHROPIC_AUTH_TOKEN(默认)",
})}
</SelectItem>
<SelectItem value="ANTHROPIC_API_KEY">
{t("providerForm.authFieldApiKey", {
defaultValue: "ANTHROPIC_API_KEY",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("providerForm.authFieldHint", {
defaultValue: "选择写入配置的认证环境变量名",
})}
</p>
</div>
)}
<Collapsible
open={advancedExpanded}
onOpenChange={setAdvancedExpanded}
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant={null}
size="sm"
className="h-8 gap-1.5 px-0 text-sm font-medium text-foreground hover:opacity-70"
>
{advancedExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{t("providerForm.advancedOptionsToggle")}
</Button>
</CollapsibleTrigger>
{!advancedExpanded && (
<p className="text-xs text-muted-foreground mt-1 ml-1">
{t("providerForm.advancedOptionsHint")}
</p>
)}
<CollapsibleContent className="space-y-4 pt-2">
{/* API 格式选择(仅非云服务商显示) */}
{category !== "cloud_provider" && (
<div className="space-y-2">
<FormLabel htmlFor="apiFormat">
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
</FormLabel>
<Select value={apiFormat} onValueChange={onApiFormatChange}>
<SelectTrigger id="apiFormat" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="anthropic">
{t("providerForm.apiFormatAnthropic", {
defaultValue: "Anthropic Messages (原生)",
})}
</SelectItem>
<SelectItem value="openai_chat">
{t("providerForm.apiFormatOpenAIChat", {
defaultValue: "OpenAI Chat Completions (需转换)",
})}
</SelectItem>
<SelectItem value="openai_responses">
{t("providerForm.apiFormatOpenAIResponses", {
defaultValue: "OpenAI Responses API (需转换)",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("providerForm.apiFormatHint", {
defaultValue: "选择供应商 API 的输入格式",
})}
</p>
</div>
)}
{/* 模型选择器 */}
{shouldShowModelSelector && (
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 主模型 */}
{/* 认证字段选择器 */}
<div className="space-y-2">
<FormLabel htmlFor="claudeModel">
{t("providerForm.anthropicModel", { defaultValue: "主模型" })}
<FormLabel>
{t("providerForm.authField", { defaultValue: "认证字段" })}
</FormLabel>
<Input
id="claudeModel"
type="text"
value={claudeModel}
onChange={(e) =>
onModelChange("ANTHROPIC_MODEL", e.target.value)
<Select
value={apiKeyField}
onValueChange={(v) =>
onApiKeyFieldChange(v as ClaudeApiKeyField)
}
placeholder={t("providerForm.modelPlaceholder", {
defaultValue: "",
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
{t("providerForm.authFieldAuthToken", {
defaultValue: "ANTHROPIC_AUTH_TOKEN(默认)",
})}
</SelectItem>
<SelectItem value="ANTHROPIC_API_KEY">
{t("providerForm.authFieldApiKey", {
defaultValue: "ANTHROPIC_API_KEY",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("providerForm.authFieldHint", {
defaultValue: "选择写入配置的认证环境变量名",
})}
autoComplete="off"
/>
</p>
</div>
{/* 推理模型 */}
<div className="space-y-2">
<FormLabel htmlFor="reasoningModel">
{t("providerForm.anthropicReasoningModel")}
</FormLabel>
<Input
id="reasoningModel"
type="text"
value={reasoningModel}
onChange={(e) =>
onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value)
}
autoComplete="off"
/>
{/* 模型映射 */}
<div className="space-y-1 pt-2 border-t">
<FormLabel>{t("providerForm.modelMappingLabel")}</FormLabel>
<p className="text-xs text-muted-foreground">
{t("providerForm.modelMappingHint")}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 主模型 */}
<div className="space-y-2">
<FormLabel htmlFor="claudeModel">
{t("providerForm.anthropicModel", {
defaultValue: "主模型",
})}
</FormLabel>
{renderModelInput(
"claudeModel",
claudeModel,
"ANTHROPIC_MODEL",
t("providerForm.modelPlaceholder", { defaultValue: "" }),
)}
</div>
{/* 默认 Haiku */}
<div className="space-y-2">
<FormLabel htmlFor="claudeDefaultHaikuModel">
{t("providerForm.anthropicDefaultHaikuModel", {
defaultValue: "Haiku 默认模型",
})}
</FormLabel>
<Input
id="claudeDefaultHaikuModel"
type="text"
value={defaultHaikuModel}
onChange={(e) =>
onModelChange("ANTHROPIC_DEFAULT_HAIKU_MODEL", e.target.value)
}
placeholder={t("providerForm.haikuModelPlaceholder", {
defaultValue: "",
})}
autoComplete="off"
/>
</div>
{/* 推理模型 */}
<div className="space-y-2">
<FormLabel htmlFor="reasoningModel">
{t("providerForm.anthropicReasoningModel")}
</FormLabel>
{renderModelInput(
"reasoningModel",
reasoningModel,
"ANTHROPIC_REASONING_MODEL",
)}
</div>
{/* 默认 Sonnet */}
<div className="space-y-2">
<FormLabel htmlFor="claudeDefaultSonnetModel">
{t("providerForm.anthropicDefaultSonnetModel", {
defaultValue: "Sonnet 默认模型",
})}
</FormLabel>
<Input
id="claudeDefaultSonnetModel"
type="text"
value={defaultSonnetModel}
onChange={(e) =>
onModelChange(
"ANTHROPIC_DEFAULT_SONNET_MODEL",
e.target.value,
)
}
placeholder={t("providerForm.modelPlaceholder", {
defaultValue: "",
})}
autoComplete="off"
/>
</div>
{/* 默认 Haiku */}
<div className="space-y-2">
<FormLabel htmlFor="claudeDefaultHaikuModel">
{t("providerForm.anthropicDefaultHaikuModel", {
defaultValue: "Haiku 默认模型",
})}
</FormLabel>
{renderModelInput(
"claudeDefaultHaikuModel",
defaultHaikuModel,
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
t("providerForm.haikuModelPlaceholder", { defaultValue: "" }),
)}
</div>
{/* 默认 Opus */}
<div className="space-y-2">
<FormLabel htmlFor="claudeDefaultOpusModel">
{t("providerForm.anthropicDefaultOpusModel", {
defaultValue: "Opus 默认模型",
})}
</FormLabel>
<Input
id="claudeDefaultOpusModel"
type="text"
value={defaultOpusModel}
onChange={(e) =>
onModelChange("ANTHROPIC_DEFAULT_OPUS_MODEL", e.target.value)
}
placeholder={t("providerForm.modelPlaceholder", {
defaultValue: "",
})}
autoComplete="off"
/>
{/* 默认 Sonnet */}
<div className="space-y-2">
<FormLabel htmlFor="claudeDefaultSonnetModel">
{t("providerForm.anthropicDefaultSonnetModel", {
defaultValue: "Sonnet 默认模型",
})}
</FormLabel>
{renderModelInput(
"claudeDefaultSonnetModel",
defaultSonnetModel,
"ANTHROPIC_DEFAULT_SONNET_MODEL",
t("providerForm.modelPlaceholder", { defaultValue: "" }),
)}
</div>
{/* 默认 Opus */}
<div className="space-y-2">
<FormLabel htmlFor="claudeDefaultOpusModel">
{t("providerForm.anthropicDefaultOpusModel", {
defaultValue: "Opus 默认模型",
})}
</FormLabel>
{renderModelInput(
"claudeDefaultOpusModel",
defaultOpusModel,
"ANTHROPIC_DEFAULT_OPUS_MODEL",
t("providerForm.modelPlaceholder", { defaultValue: "" }),
)}
</div>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t("providerForm.modelHelper", {
defaultValue:
"可选:指定默认使用的 Claude 模型,留空则使用系统默认。",
})}
</p>
</div>
</CollapsibleContent>
</Collapsible>
)}
</>
);
@@ -1,6 +1,11 @@
import React, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import JsonEditor from "@/components/JsonEditor";
import {
extractCodexTopLevelInt,
setCodexTopLevelInt,
removeCodexTopLevelField,
} from "@/utils/providerConfigUtils";
interface CodexAuthSectionProps {
value: string;
@@ -115,6 +120,95 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
return () => observer.disconnect();
}, []);
// Mirror value prop to local state (same pattern as CommonConfigEditor)
const [localValue, setLocalValue] = useState(value);
const localValueRef = useRef(value);
useEffect(() => {
setLocalValue(value);
localValueRef.current = value;
}, [value]);
const handleLocalChange = useCallback(
(newValue: string) => {
if (newValue === localValueRef.current) return;
localValueRef.current = newValue;
setLocalValue(newValue);
onChange(newValue);
},
[onChange],
);
// Parse toggle states from TOML text
const toggleStates = useMemo(() => {
const contextWindow = extractCodexTopLevelInt(
localValue,
"model_context_window",
);
const compactLimit = extractCodexTopLevelInt(
localValue,
"model_auto_compact_token_limit",
);
return {
contextWindow1M: contextWindow === 1000000,
compactLimit: compactLimit ?? 900000,
};
}, [localValue]);
// Debounce timer for compact limit input
const compactTimerRef = useRef<ReturnType<typeof setTimeout>>();
const handleContextWindowToggle = useCallback(
(checked: boolean) => {
let toml = localValueRef.current || "";
if (checked) {
toml = setCodexTopLevelInt(toml, "model_context_window", 1000000);
// Auto-set compact limit if not already present
if (
extractCodexTopLevelInt(toml, "model_auto_compact_token_limit") ===
undefined
) {
toml = setCodexTopLevelInt(
toml,
"model_auto_compact_token_limit",
900000,
);
}
} else {
toml = removeCodexTopLevelField(toml, "model_context_window");
toml = removeCodexTopLevelField(
toml,
"model_auto_compact_token_limit",
);
}
handleLocalChange(toml);
},
[handleLocalChange],
);
const handleCompactLimitChange = useCallback(
(inputValue: string) => {
clearTimeout(compactTimerRef.current);
compactTimerRef.current = setTimeout(() => {
const num = parseInt(inputValue, 10);
if (!Number.isNaN(num) && num > 0) {
handleLocalChange(
setCodexTopLevelInt(
localValueRef.current || "",
"model_auto_compact_token_limit",
num,
),
);
}
}, 500);
},
[handleLocalChange],
);
// Cleanup debounce timer
useEffect(() => {
return () => clearTimeout(compactTimerRef.current);
}, []);
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -152,9 +246,34 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
</p>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.contextWindow1M}
onChange={(e) => handleContextWindowToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>{t("codexConfig.contextWindow1M")}</span>
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<span>{t("codexConfig.autoCompactLimit")}:</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
key={toggleStates.compactLimit}
defaultValue={toggleStates.compactLimit}
disabled={!toggleStates.contextWindow1M}
onChange={(e) => handleCompactLimitChange(e.target.value)}
className="w-28 h-7 px-2 text-sm rounded border border-border bg-background text-foreground disabled:opacity-50 disabled:cursor-not-allowed"
/>
</label>
</div>
<JsonEditor
value={value}
onChange={onChange}
value={localValue}
onChange={handleLocalChange}
placeholder=""
darkMode={isDarkMode}
rows={8}
@@ -75,16 +75,20 @@ export function CommonConfigEditor({
return {
hideAttribution:
config?.attribution?.commit === "" && config?.attribution?.pr === "",
alwaysThinking: config?.alwaysThinkingEnabled === true,
teammates:
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1" ||
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === 1,
enableToolSearch:
config?.env?.ENABLE_TOOL_SEARCH === "true" ||
config?.env?.ENABLE_TOOL_SEARCH === "1",
effortHigh: config?.effortLevel === "high",
};
} catch {
return {
hideAttribution: false,
alwaysThinking: false,
teammates: false,
enableToolSearch: false,
effortHigh: false,
};
}
}, [localValue]);
@@ -102,13 +106,6 @@ export function CommonConfigEditor({
delete config.attribution;
}
break;
case "alwaysThinking":
if (checked) {
config.alwaysThinkingEnabled = true;
} else {
delete config.alwaysThinkingEnabled;
}
break;
case "teammates":
if (!config.env) config.env = {};
if (checked) {
@@ -118,6 +115,22 @@ export function CommonConfigEditor({
if (Object.keys(config.env).length === 0) delete config.env;
}
break;
case "enableToolSearch":
if (!config.env) config.env = {};
if (checked) {
config.env.ENABLE_TOOL_SEARCH = "true";
} else {
delete config.env.ENABLE_TOOL_SEARCH;
if (Object.keys(config.env).length === 0) delete config.env;
}
break;
case "effortHigh":
if (checked) {
config.effortLevel = "high";
} else {
delete config.effortLevel;
}
break;
}
handleLocalChange(JSON.stringify(config, null, 2));
@@ -178,15 +191,6 @@ export function CommonConfigEditor({
/>
<span>{t("claudeConfig.hideAttribution")}</span>
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.alwaysThinking}
onChange={(e) => handleToggle("alwaysThinking", e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>{t("claudeConfig.alwaysThinking")}</span>
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
@@ -196,6 +200,26 @@ export function CommonConfigEditor({
/>
<span>{t("claudeConfig.enableTeammates")}</span>
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.enableToolSearch}
onChange={(e) =>
handleToggle("enableToolSearch", e.target.checked)
}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>{t("claudeConfig.enableToolSearch")}</span>
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.effortHigh}
onChange={(e) => handleToggle("effortHigh", e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>{t("claudeConfig.effortHigh")}</span>
</label>
</div>
<JsonEditor
value={localValue}
@@ -0,0 +1,367 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Loader2,
Github,
LogOut,
Copy,
Check,
ExternalLink,
Plus,
X,
User,
} from "lucide-react";
import { useCopilotAuth } from "./hooks/useCopilotAuth";
import type { GitHubAccount } from "@/lib/api";
interface CopilotAuthSectionProps {
className?: string;
/** 当前选中的 GitHub 账号 ID */
selectedAccountId?: string | null;
/** 账号选择回调 */
onAccountSelect?: (accountId: string | null) => void;
}
/**
* Copilot OAuth 认证区块
*
* 显示 GitHub Copilot 的认证状态,支持多账号管理和选择。
*/
export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
className,
selectedAccountId,
onAccountSelect,
}) => {
const { t } = useTranslation();
const [copied, setCopied] = React.useState(false);
const {
accounts,
defaultAccountId,
migrationError,
hasAnyAccount,
pollingState,
deviceCode,
error,
isPolling,
isAddingAccount,
isRemovingAccount,
isSettingDefaultAccount,
addAccount,
removeAccount,
setDefaultAccount,
cancelAuth,
logout,
} = useCopilotAuth();
// 复制用户码
const copyUserCode = async () => {
if (deviceCode?.user_code) {
await navigator.clipboard.writeText(deviceCode.user_code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
// 处理账号选择
const handleAccountSelect = (value: string) => {
onAccountSelect?.(value === "none" ? null : value);
};
// 处理移除账号
const handleRemoveAccount = (accountId: string, e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
removeAccount(accountId);
// 如果移除的是当前选中的账号,清除选择
if (selectedAccountId === accountId) {
onAccountSelect?.(null);
}
};
// 渲染账号头像
const renderAvatar = (account: GitHubAccount) => {
return <CopilotAccountAvatar account={account} />;
};
return (
<div className={`space-y-4 ${className || ""}`}>
{/* 认证状态标题 */}
<div className="flex items-center justify-between">
<Label>{t("copilot.authStatus", "GitHub Copilot 认证")}</Label>
<Badge
variant={hasAnyAccount ? "default" : "secondary"}
className={hasAnyAccount ? "bg-green-500 hover:bg-green-600" : ""}
>
{hasAnyAccount
? t("copilot.accountCount", {
count: accounts.length,
defaultValue: `${accounts.length} 个账号`,
})
: t("copilot.notAuthenticated", "未认证")}
</Badge>
</div>
{migrationError && (
<p className="text-sm text-amber-600 dark:text-amber-400">
{t("copilot.migrationFailed", {
error: migrationError,
defaultValue: `旧认证数据迁移失败:${migrationError}`,
})}
</p>
)}
{/* 账号选择器(有账号时显示) */}
{hasAnyAccount && onAccountSelect && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={handleAccountSelect}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"copilot.selectAccountPlaceholder",
"选择一个 GitHub 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{t("copilot.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
{renderAvatar(account)}
<span>{account.login}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 已登录账号列表 */}
{hasAnyAccount && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.loggedInAccounts", "已登录账号")}
</Label>
<div className="space-y-1">
{accounts.map((account) => (
<div
key={account.id}
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
>
<div className="flex items-center gap-2">
{renderAvatar(account)}
<span className="text-sm font-medium">{account.login}</span>
{defaultAccountId === account.id && (
<Badge variant="secondary" className="text-xs">
{t("copilot.defaultAccount", "默认")}
</Badge>
)}
{selectedAccountId === account.id && (
<Badge variant="outline" className="text-xs">
{t("copilot.selected", "已选中")}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{defaultAccountId !== account.id && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setDefaultAccount(account.id)}
disabled={isSettingDefaultAccount}
>
{t("copilot.setAsDefault", "设为默认")}
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-500"
onClick={(e) => handleRemoveAccount(account.id, e)}
disabled={isRemovingAccount}
title={t("copilot.removeAccount", "移除账号")}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* 未认证状态 - 登录按钮 */}
{!hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
>
<Github className="mr-2 h-4 w-4" />
{t("copilot.loginWithGitHub", "使用 GitHub 登录")}
</Button>
)}
{/* 已有账号 - 添加更多账号按钮 */}
{hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
disabled={isAddingAccount}
>
<Plus className="mr-2 h-4 w-4" />
{t("copilot.addAnotherAccount", "添加其他账号")}
</Button>
)}
{/* 轮询中状态 */}
{isPolling && deviceCode && (
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/50">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("copilot.waitingForAuth", "等待授权中...")}
</div>
{/* 用户码 */}
<div className="text-center">
<p className="text-xs text-muted-foreground mb-1">
{t("copilot.enterCode", "在浏览器中输入以下代码:")}
</p>
<div className="flex items-center justify-center gap-2">
<code className="text-2xl font-mono font-bold tracking-wider bg-background px-4 py-2 rounded border">
{deviceCode.user_code}
</code>
<Button
type="button"
size="icon"
variant="ghost"
onClick={copyUserCode}
title={t("copilot.copyCode", "复制代码")}
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* 验证链接 */}
<div className="text-center">
<a
href={deviceCode.verification_uri}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-500 hover:underline"
>
{deviceCode.verification_uri}
<ExternalLink className="h-3 w-3" />
</a>
</div>
{/* 取消按钮 */}
<div className="text-center">
<Button
type="button"
variant="ghost"
size="sm"
onClick={cancelAuth}
>
{t("common.cancel", "取消")}
</Button>
</div>
</div>
)}
{/* 错误状态 */}
{pollingState === "error" && error && (
<div className="space-y-2">
<p className="text-sm text-red-500">{error}</p>
<div className="flex gap-2">
<Button
type="button"
onClick={addAccount}
variant="outline"
size="sm"
>
{t("copilot.retry", "重试")}
</Button>
<Button
type="button"
onClick={cancelAuth}
variant="ghost"
size="sm"
>
{t("common.cancel", "取消")}
</Button>
</div>
</div>
)}
{/* 注销所有账号按钮 */}
{hasAnyAccount && accounts.length > 1 && (
<Button
type="button"
variant="outline"
onClick={logout}
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950"
>
<LogOut className="mr-2 h-4 w-4" />
{t("copilot.logoutAll", "注销所有账号")}
</Button>
)}
</div>
);
};
const CopilotAccountAvatar: React.FC<{ account: GitHubAccount }> = ({
account,
}) => {
const [failed, setFailed] = React.useState(false);
if (!account.avatar_url || failed) {
return <User className="h-5 w-5 text-muted-foreground" />;
}
return (
<img
src={account.avatar_url}
alt={account.login}
className="h-5 w-5 rounded-full"
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
/>
);
};
export default CopilotAuthSection;
@@ -80,6 +80,7 @@ import {
useOpencodeFormState,
useOmoDraftState,
useOpenclawFormState,
useCopilotAuth,
} from "./hooks";
import {
CLAUDE_DEFAULT_CONFIG,
@@ -89,6 +90,7 @@ import {
OPENCLAW_DEFAULT_CONFIG,
normalizePricingSource,
} from "./helpers/opencodeFormUtils";
import { resolveManagedAccountId } from "@/lib/authBinding";
type PresetEntry = {
id: string;
@@ -331,6 +333,14 @@ export function ProviderForm({
[localApiKeyField, form, handleSettingsConfigChange],
);
// Copilot OAuth 认证状态(仅 Claude 应用需要)
const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth();
// 选中的 GitHub 账号 ID(多账号支持)
const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState<
string | null
>(() => resolveManagedAccountId(initialData?.meta, "github_copilot"));
const {
codexAuth,
codexConfig,
@@ -660,6 +670,21 @@ export function ProviderForm({
// 非官方供应商必填校验:端点和 API Key
// cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验
// GitHub Copilot 使用 OAuth 认证,不需要 API Key
const isCopilotProvider =
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com");
// GitHub Copilot 必须先登录才能添加
if (isCopilotProvider && !isCopilotAuthenticated) {
toast.error(
t("copilot.loginRequired", {
defaultValue: "请先登录 GitHub Copilot",
}),
);
return;
}
if (category !== "official" && category !== "cloud_provider") {
if (appId === "claude") {
if (!baseUrl.trim()) {
@@ -670,7 +695,7 @@ export function ProviderForm({
);
return;
}
if (!apiKey.trim()) {
if (!isCopilotProvider && !apiKey.trim()) {
toast.error(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
@@ -867,6 +892,11 @@ export function ProviderForm({
const baseMeta: ProviderMeta | undefined =
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
// 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
const providerType =
templatePreset?.providerType || initialData?.meta?.providerType;
payload.meta = {
...(baseMeta ?? {}),
commonConfigEnabled:
@@ -878,6 +908,20 @@ export function ProviderForm({
? useGeminiCommonConfigFlag
: undefined,
endpointAutoSelect,
// 保存 providerType(用于识别 Copilot 等特殊供应商)
providerType,
authBinding: isCopilotProvider
? {
source: "managed_account",
authProvider: "github_copilot",
accountId: selectedGitHubAccountId ?? undefined,
}
: undefined,
// GitHub Copilot 多账号:保存关联的账号 ID
githubAccountId:
isCopilotProvider && selectedGitHubAccountId
? selectedGitHubAccountId
: undefined,
testConfig: testConfig.enabled ? testConfig : undefined,
proxyConfig: proxyConfig.enabled ? proxyConfig : undefined,
costMultiplier: pricingConfig.enabled
@@ -1318,6 +1362,20 @@ export function ProviderForm({
websiteUrl={claudeWebsiteUrl}
isPartner={isClaudePartner}
partnerPromotionKey={claudePartnerPromotionKey}
isCopilotPreset={
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com")
}
usesOAuth={
templatePreset?.requiresOAuth === true ||
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com")
}
isCopilotAuthenticated={isCopilotAuthenticated}
selectedGitHubAccountId={selectedGitHubAccountId}
onGitHubAccountSelect={setSelectedGitHubAccountId}
templateValueEntries={templateValueEntries}
templateValues={templateValues}
templatePresetName={templatePreset?.name || ""}
@@ -1607,7 +1665,9 @@ export function ProviderForm({
<Button variant="outline" type="button" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>{submitLabel}</Button>
<Button type="submit" disabled={isSubmitting}>
{submitLabel}
</Button>
</div>
)}
</form>
@@ -28,6 +28,7 @@ export const OPENCODE_DEFAULT_CONFIG = JSON.stringify(
options: {
baseURL: "",
apiKey: "",
setCacheKey: true,
},
models: {},
},
@@ -11,8 +11,10 @@ export { useCodexCommonConfig } from "./useCodexCommonConfig";
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
export { useCodexTomlValidation } from "./useCodexTomlValidation";
export { useGeminiConfigState } from "./useGeminiConfigState";
export { useManagedAuth } from "./useManagedAuth";
export { useGeminiCommonConfig } from "./useGeminiCommonConfig";
export { useOmoModelSource } from "./useOmoModelSource";
export { useOpencodeFormState } from "./useOpencodeFormState";
export { useOmoDraftState } from "./useOmoDraftState";
export { useOpenclawFormState } from "./useOpenclawFormState";
export { useCopilotAuth } from "./useCopilotAuth";
@@ -0,0 +1,27 @@
import type { GitHubAccount } from "@/lib/api";
import { useManagedAuth } from "./useManagedAuth";
export function useCopilotAuth() {
const managedAuth = useManagedAuth("github_copilot");
const defaultAccount =
managedAuth.accounts.find(
(account) => account.id === managedAuth.defaultAccountId,
) ?? managedAuth.accounts[0];
return {
...managedAuth,
authStatus: managedAuth.authStatus
? {
authenticated: managedAuth.authStatus.authenticated,
username: defaultAccount?.login ?? null,
// Managed auth status does not expose a single provider-wide token expiry.
expires_at: null,
default_account_id: managedAuth.defaultAccountId,
migration_error: managedAuth.migrationError,
accounts: managedAuth.accounts as GitHubAccount[],
}
: undefined,
// Managed auth status no longer exposes a single default token expiry.
username: defaultAccount?.login ?? null,
};
}
@@ -0,0 +1,233 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { authApi, settingsApi } from "@/lib/api";
import type {
ManagedAuthProvider,
ManagedAuthStatus,
ManagedAuthDeviceCodeResponse,
} from "@/lib/api";
type PollingState = "idle" | "polling" | "success" | "error";
export function useManagedAuth(authProvider: ManagedAuthProvider) {
const queryClient = useQueryClient();
const queryKey = ["managed-auth-status", authProvider];
const [pollingState, setPollingState] = useState<PollingState>("idle");
const [deviceCode, setDeviceCode] =
useState<ManagedAuthDeviceCodeResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
null,
);
const pollingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const {
data: authStatus,
isLoading: isLoadingStatus,
refetch: refetchStatus,
} = useQuery<ManagedAuthStatus>({
queryKey,
queryFn: () => authApi.authGetStatus(authProvider),
staleTime: 30000,
});
const stopPolling = useCallback(() => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
if (pollingTimeoutRef.current) {
clearTimeout(pollingTimeoutRef.current);
pollingTimeoutRef.current = null;
}
}, []);
useEffect(() => {
return () => {
stopPolling();
};
}, [stopPolling]);
const startLoginMutation = useMutation({
mutationFn: () => authApi.authStartLogin(authProvider),
onSuccess: async (response) => {
setDeviceCode(response);
setPollingState("polling");
setError(null);
try {
await navigator.clipboard.writeText(response.user_code);
} catch (e) {
console.debug("[ManagedAuth] Failed to copy user code:", e);
}
try {
await settingsApi.openExternal(response.verification_uri);
} catch (e) {
console.debug("[ManagedAuth] Failed to open browser:", e);
}
// Add a small buffer on top of GitHub's suggested interval to avoid
// hitting slow_down responses too aggressively during device polling.
const interval = Math.max((response.interval || 5) + 3, 8) * 1000;
const expiresAt = Date.now() + response.expires_in * 1000;
const pollOnce = async () => {
if (Date.now() > expiresAt) {
stopPolling();
setPollingState("error");
setError("Device code expired. Please try again.");
return;
}
try {
const newAccount = await authApi.authPollForAccount(
authProvider,
response.device_code,
);
if (newAccount) {
stopPolling();
setPollingState("success");
await refetchStatus();
await queryClient.invalidateQueries({ queryKey });
setPollingState("idle");
setDeviceCode(null);
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
if (
!errorMessage.includes("pending") &&
!errorMessage.includes("slow_down")
) {
stopPolling();
setPollingState("error");
setError(errorMessage);
}
}
};
void pollOnce();
pollingIntervalRef.current = setInterval(pollOnce, interval);
pollingTimeoutRef.current = setTimeout(() => {
stopPolling();
setPollingState("error");
setError("Device code expired. Please try again.");
}, response.expires_in * 1000);
},
onError: (e) => {
setPollingState("error");
setError(e instanceof Error ? e.message : String(e));
},
});
const logoutMutation = useMutation({
mutationFn: () => authApi.authLogout(authProvider),
onSuccess: async () => {
setPollingState("idle");
setDeviceCode(null);
setError(null);
queryClient.setQueryData(queryKey, {
provider: authProvider,
authenticated: false,
default_account_id: null,
accounts: [],
});
await queryClient.invalidateQueries({ queryKey });
},
onError: async (e) => {
console.error("[ManagedAuth] Failed to logout:", e);
setError(e instanceof Error ? e.message : String(e));
await refetchStatus();
},
});
const removeAccountMutation = useMutation({
mutationFn: (accountId: string) =>
authApi.authRemoveAccount(authProvider, accountId),
onSuccess: async () => {
setPollingState("idle");
setDeviceCode(null);
setError(null);
await refetchStatus();
await queryClient.invalidateQueries({ queryKey });
},
onError: (e) => {
console.error("[ManagedAuth] Failed to remove account:", e);
setError(e instanceof Error ? e.message : String(e));
},
});
const setDefaultAccountMutation = useMutation({
mutationFn: (accountId: string) =>
authApi.authSetDefaultAccount(authProvider, accountId),
onSuccess: async () => {
await refetchStatus();
await queryClient.invalidateQueries({ queryKey });
},
onError: (e) => {
console.error("[ManagedAuth] Failed to set default account:", e);
setError(e instanceof Error ? e.message : String(e));
},
});
const startAuth = useCallback(() => {
setPollingState("idle");
setDeviceCode(null);
setError(null);
stopPolling();
startLoginMutation.mutate();
}, [startLoginMutation, stopPolling]);
const cancelAuth = useCallback(() => {
stopPolling();
setPollingState("idle");
setDeviceCode(null);
setError(null);
}, [stopPolling]);
const logout = useCallback(() => {
logoutMutation.mutate();
}, [logoutMutation]);
const removeAccount = useCallback(
(accountId: string) => {
removeAccountMutation.mutate(accountId);
},
[removeAccountMutation],
);
const setDefaultAccount = useCallback(
(accountId: string) => {
setDefaultAccountMutation.mutate(accountId);
},
[setDefaultAccountMutation],
);
const accounts = authStatus?.accounts ?? [];
return {
authStatus,
isLoadingStatus,
accounts,
hasAnyAccount: accounts.length > 0,
isAuthenticated: authStatus?.authenticated ?? false,
defaultAccountId: authStatus?.default_account_id ?? null,
migrationError: authStatus?.migration_error ?? null,
pollingState,
deviceCode,
error,
isPolling: pollingState === "polling",
isAddingAccount: startLoginMutation.isPending || pollingState === "polling",
isRemovingAccount: removeAccountMutation.isPending,
isSettingDefaultAccount: setDefaultAccountMutation.isPending,
startAuth,
addAccount: startAuth,
cancelAuth,
logout,
removeAccount,
setDefaultAccount,
refetchStatus,
};
}