Merge pull request #913 from carmin777/feat/qoder-pat-qodercli

feat(qoder): support PAT via qodercli and remove stale qoder.cn defaults
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-04-02 14:36:25 -03:00
committed by GitHub
22 changed files with 1241 additions and 261 deletions

View File

@@ -30,6 +30,7 @@ import {
isOpenAICompatibleProvider,
isAnthropicCompatibleProvider,
isClaudeCodeCompatibleProvider,
supportsApiKeyOnFreeProvider,
} from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
@@ -872,7 +873,10 @@ export default function ProviderDetailPage() {
: (FREE_PROVIDERS as any)[providerId] ||
(OAUTH_PROVIDERS as any)[providerId] ||
(APIKEY_PROVIDERS as any)[providerId];
const isOAuth = !!(FREE_PROVIDERS as any)[providerId] || !!(OAUTH_PROVIDERS as any)[providerId];
const providerSupportsOAuth =
!!(FREE_PROVIDERS as any)[providerId] || !!(OAUTH_PROVIDERS as any)[providerId];
const providerSupportsPat = supportsApiKeyOnFreeProvider(providerId);
const isOAuth = providerSupportsOAuth && !providerSupportsPat;
const models = getModelsByProviderId(providerId);
const providerAlias = getProviderAlias(providerId);
const isManagedAvailableModelsProvider = isCompatible || providerId === "openrouter";
@@ -1062,6 +1066,14 @@ export default function ProviderDetailPage() {
setShowOAuthModal(false);
}, [fetchConnections]);
const openPrimaryAddFlow = useCallback(() => {
if (isOAuth) {
setShowOAuthModal(true);
return;
}
setShowAddApiKeyModal(true);
}, [isOAuth]);
const handleSaveApiKey = async (formData) => {
try {
const res = await fetch("/api/providers", {
@@ -2216,13 +2228,16 @@ export default function ProviderDetailPage() {
</button>
)}
{!isCompatible ? (
<Button
size="sm"
icon="add"
onClick={() => (isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true))}
>
{t("add")}
</Button>
<div className="flex items-center gap-2">
<Button size="sm" icon="add" onClick={openPrimaryAddFlow}>
{providerSupportsPat ? "Add PAT" : t("add")}
</Button>
{providerId === "qoder" && (
<Button size="sm" variant="secondary" onClick={() => setShowOAuthModal(true)}>
Experimental OAuth
</Button>
)}
</div>
) : (
connections.length === 0 && (
<Button size="sm" icon="add" onClick={() => setShowAddApiKeyModal(true)}>
@@ -2242,12 +2257,16 @@ export default function ProviderDetailPage() {
<p className="text-text-main font-medium mb-1">{t("noConnectionsYet")}</p>
<p className="text-sm text-text-muted mb-4">{t("addFirstConnectionHint")}</p>
{!isCompatible && (
<Button
icon="add"
onClick={() => (isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true))}
>
{t("addConnection")}
</Button>
<div className="flex items-center justify-center gap-2">
<Button icon="add" onClick={openPrimaryAddFlow}>
{providerSupportsPat ? "Add PAT" : t("addConnection")}
</Button>
{providerId === "qoder" && (
<Button variant="secondary" onClick={() => setShowOAuthModal(true)}>
Experimental OAuth
</Button>
)}
</div>
)}
</div>
) : (
@@ -2264,7 +2283,7 @@ export default function ProviderDetailPage() {
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={isOAuth}
isOAuth={conn.authType === "oauth"}
isFirst={index === 0}
isLast={index === sorted.length - 1}
onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])}
@@ -2285,8 +2304,10 @@ export default function ProviderDetailPage() {
setShowEditModal(true);
}}
onDelete={() => handleDelete(conn.id)}
onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
onReauth={conn.authType === "oauth" ? () => setShowOAuthModal(true) : undefined}
onRefreshToken={
conn.authType === "oauth" ? () => handleRefreshToken(conn.id) : undefined
}
isRefreshing={refreshingId === conn.id}
onApplyCodexAuthLocal={
providerId === "codex"
@@ -2361,7 +2382,7 @@ export default function ProviderDetailPage() {
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={isOAuth}
isOAuth={conn.authType === "oauth"}
isFirst={gi === 0 && index === 0}
isLast={gi === groupKeys.length - 1 && index === groupConns.length - 1}
onMoveUp={() =>
@@ -2388,8 +2409,14 @@ export default function ProviderDetailPage() {
setShowEditModal(true);
}}
onDelete={() => handleDelete(conn.id)}
onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
onReauth={
conn.authType === "oauth" ? () => setShowOAuthModal(true) : undefined
}
onRefreshToken={
conn.authType === "oauth"
? () => handleRefreshToken(conn.id)
: undefined
}
isRefreshing={refreshingId === conn.id}
onApplyCodexAuthLocal={
providerId === "codex"
@@ -4376,6 +4403,7 @@ function AddApiKeyModal({
const isVertex = provider === "vertex";
const defaultRegion = "us-central1";
const isGlm = provider === "glm";
const isQoder = provider === "qoder";
const [formData, setFormData] = useState({
name: "",
@@ -4501,16 +4529,27 @@ function AddApiKeyModal({
label={t("nameLabel")}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={t("productionKey")}
placeholder={isQoder ? "Qoder PAT" : t("productionKey")}
/>
<div className="flex gap-2">
<Input
label={t("apiKeyLabel")}
label={isQoder ? "Personal Access Token" : t("apiKeyLabel")}
type="password"
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
className="flex-1"
placeholder={isVertex ? "Cole o Service Account JSON aqui" : undefined}
placeholder={
isVertex
? "Cole o Service Account JSON aqui"
: isQoder
? "Paste your Qoder Personal Access Token"
: undefined
}
hint={
isQoder
? "Supported path: PAT via qodercli. Browser OAuth remains experimental."
: undefined
}
/>
<div className="pt-6">
<Button

View File

@@ -168,9 +168,11 @@ export default function ProvidersPage() {
};
const getProviderStats = (providerId, authType) => {
const providerConnections = connections.filter(
(c) => c.provider === providerId && c.authType === authType
);
const providerConnections = connections.filter((c) => {
if (c.provider !== providerId) return false;
if (authType === "free") return true;
return c.authType === authType;
});
// Helper: check if connection is effectively active (cooldown expired)
const getEffectiveStatus = (conn) => {
@@ -217,13 +219,17 @@ export default function ProvidersPage() {
// Toggle all connections for a provider on/off
const handleToggleProvider = async (providerId: string, authType: string, newActive: boolean) => {
const providerConns = connections.filter(
(c) => c.provider === providerId && c.authType === authType
);
const providerConns = connections.filter((c) => {
if (c.provider !== providerId) return false;
if (authType === "free") return true;
return c.authType === authType;
});
// Optimistically update UI
setConnections((prev) =>
prev.map((c) =>
c.provider === providerId && c.authType === authType ? { ...c, isActive: newActive } : c
c.provider === providerId && (authType === "free" || c.authType === authType)
? { ...c, isActive: newActive }
: c
)
);
// Fire API calls in parallel
@@ -444,9 +450,9 @@ export default function ProvidersPage() {
key={key}
providerId={key}
provider={info}
stats={getProviderStats(key, "oauth")}
stats={getProviderStats(key, "free")}
authType="free"
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
onToggle={(active) => handleToggleProvider(key, "free", active)}
/>
))}
</div>

View File

@@ -60,6 +60,14 @@ export async function GET(
if (action === "authorize") {
const redirectUri = searchParams.get("redirect_uri") || "http://localhost:8080/callback";
const authData = generateAuthData(provider, redirectUri);
if (provider === "qoder" && !authData.authUrl) {
return NextResponse.json({
...authData,
supported: false,
error:
"Qoder browser OAuth is experimental and disabled by default. Configure QODER_OAUTH_* environment variables or use a Personal Access Token.",
});
}
return NextResponse.json(authData);
}

View File

@@ -6,6 +6,7 @@ import {
} from "@/shared/constants/providers";
import { PROVIDER_MODELS } from "@/shared/constants/models";
import { getModelIsHidden } from "@/lib/localDb";
import { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts";
type JsonRecord = Record<string, unknown>;
@@ -99,6 +100,7 @@ const STATIC_MODEL_PROVIDERS: Record<string, () => Array<{ id: string; name: str
{ id: "glm-4.7", name: "GLM 4.7" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
],
qoder: () => getStaticQoderModels(),
};
/**

View File

@@ -92,6 +92,7 @@ const OAUTH_TEST_CONFIG = {
const CLI_RUNTIME_PROVIDER_MAP = {
cline: "cline",
kilocode: "kilo",
qoder: "qoder",
};
/** POST body is optional; when present, only known fields are validated. */
@@ -206,8 +207,12 @@ function classifyFailure({
);
}
async function getProviderRuntimeStatus(provider: string) {
const toolId = CLI_RUNTIME_PROVIDER_MAP[provider];
async function getProviderRuntimeStatus(connection: any) {
const provider = typeof connection?.provider === "string" ? connection.provider : "";
let toolId = CLI_RUNTIME_PROVIDER_MAP[provider];
if (provider === "qoder" && connection?.authType !== "apikey") {
toolId = null;
}
if (!toolId) return null;
try {
@@ -566,7 +571,7 @@ export async function testSingleConnection(connectionId: string, validationModel
let result;
const startTime = Date.now();
const runtime = await getProviderRuntimeStatus(provider);
const runtime = await getProviderRuntimeStatus(connection);
if ((runtime as any)?.diagnosis) {
result = {

View File

@@ -15,6 +15,7 @@ import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { createProviderSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { normalizeQoderPatProviderData } from "@omniroute/open-sse/services/qoderCli.ts";
// GET /api/providers - List all connections
export async function GET() {
@@ -61,6 +62,7 @@ export async function POST(request: Request) {
// Business validation
const isValidProvider =
APIKEY_PROVIDERS[provider] ||
provider === "qoder" ||
isOpenAICompatibleProvider(provider) ||
isAnthropicCompatibleProvider(provider);
@@ -72,6 +74,10 @@ export async function POST(request: Request) {
const allowMultipleCompatibleConnections =
process.env.ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE === "true";
if (provider === "qoder") {
providerSpecificData = normalizeQoderPatProviderData(providerSpecificData || {});
}
if (isOpenAICompatibleProvider(provider)) {
const node: any = await getProviderNodeById(provider);
if (!node) {

View File

@@ -65,12 +65,25 @@ export const QWEN_CONFIG = {
};
// Qoder OAuth Configuration (Authorization Code)
const QODER_OAUTH_AUTHORIZE_URL = process.env.QODER_OAUTH_AUTHORIZE_URL || "";
const QODER_OAUTH_TOKEN_URL = process.env.QODER_OAUTH_TOKEN_URL || "";
const QODER_OAUTH_USERINFO_URL = process.env.QODER_OAUTH_USERINFO_URL || "";
const QODER_OAUTH_CLIENT_ID = process.env.QODER_OAUTH_CLIENT_ID || "";
const QODER_OAUTH_CLIENT_SECRET = process.env.QODER_OAUTH_CLIENT_SECRET || "";
const QODER_OAUTH_ENABLED =
!!QODER_OAUTH_AUTHORIZE_URL &&
!!QODER_OAUTH_TOKEN_URL &&
!!QODER_OAUTH_USERINFO_URL &&
!!QODER_OAUTH_CLIENT_ID &&
!!QODER_OAUTH_CLIENT_SECRET;
export const QODER_CONFIG = {
clientId: process.env.QODER_OAUTH_CLIENT_ID || "10009311001",
clientSecret: process.env.QODER_OAUTH_CLIENT_SECRET || "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW",
authorizeUrl: "https://qoder.cn/oauth",
tokenUrl: "https://qoder.cn/oauth/token",
userInfoUrl: "https://qoder.cn/api/oauth/getUserInfo",
enabled: QODER_OAUTH_ENABLED,
clientId: QODER_OAUTH_CLIENT_ID,
clientSecret: QODER_OAUTH_CLIENT_SECRET,
authorizeUrl: QODER_OAUTH_AUTHORIZE_URL,
tokenUrl: QODER_OAUTH_TOKEN_URL,
userInfoUrl: QODER_OAUTH_USERINFO_URL,
extraParams: {
loginMethod: "phone",
type: "phone",

View File

@@ -4,6 +4,9 @@ export const qoder = {
config: QODER_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state) => {
if (!config?.enabled || !config?.authorizeUrl) {
return null;
}
const params = new URLSearchParams({
loginMethod: config.extraParams.loginMethod,
type: config.extraParams.type,
@@ -14,6 +17,11 @@ export const qoder = {
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
if (!config?.enabled || !config?.tokenUrl) {
throw new Error(
"Qoder browser OAuth is experimental and disabled by default. Configure QODER_OAUTH_* environment variables or use a Personal Access Token."
);
}
const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
@@ -49,6 +57,11 @@ export const qoder = {
return await response.json();
},
postExchange: async (tokens) => {
if (!QODER_CONFIG.enabled || !QODER_CONFIG.userInfoUrl) {
throw new Error(
"Qoder browser OAuth is experimental and disabled by default. Configure QODER_OAUTH_* environment variables or use a Personal Access Token."
);
}
const userInfoRes = await fetch(
`${QODER_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`,
{ headers: { Accept: "application/json" } }

View File

@@ -20,6 +20,12 @@ export class QoderService {
* Build Qoder authorization URL
*/
buildAuthUrl(redirectUri: string, state: string) {
if (!this.config?.enabled || !this.config?.authorizeUrl) {
throw new Error(
"Qoder browser OAuth is experimental and disabled by default. Configure QODER_OAUTH_* environment variables or use a Personal Access Token."
);
}
const params = new URLSearchParams({
loginMethod: this.config.extraParams.loginMethod,
type: this.config.extraParams.type,
@@ -35,6 +41,12 @@ export class QoderService {
* Exchange authorization code for tokens
*/
async exchangeCode(code: string, redirectUri: string) {
if (!this.config?.enabled || !this.config?.tokenUrl) {
throw new Error(
"Qoder browser OAuth is experimental and disabled by default. Configure QODER_OAUTH_* environment variables or use a Personal Access Token."
);
}
// Create Basic Auth header
const basicAuth = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString(
"base64"
@@ -68,6 +80,12 @@ export class QoderService {
* Get user info from Qoder
*/
async getUserInfo(accessToken: string) {
if (!this.config?.enabled || !this.config?.userInfoUrl) {
throw new Error(
"Qoder browser OAuth is experimental and disabled by default. Configure QODER_OAUTH_* environment variables or use a Personal Access Token."
);
}
const response = await fetch(
`${this.config.userInfoUrl}?accessToken=${encodeURIComponent(accessToken)}`,
{

View File

@@ -14,6 +14,7 @@ import {
isAnthropicCompatibleProvider,
isOpenAICompatibleProvider,
} from "@/shared/constants/providers";
import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts";
const OPENAI_LIKE_FORMATS = new Set(["openai", "openai-responses"]);
const GEMINI_LIKE_FORMATS = new Set(["gemini", "gemini-cli"]);
@@ -747,6 +748,8 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
// ── Specialty provider validation ──
const SPECIALTY_VALIDATORS = {
qoder: ({ apiKey, providerSpecificData }: any) =>
validateQoderCliPat({ apiKey, providerSpecificData }),
deepgram: validateDeepgramProvider,
assemblyai: validateAssemblyAIProvider,
nanobanana: validateNanoBananaProvider,

View File

@@ -313,6 +313,13 @@ export default function OAuthModal({
throw new Error(errMsg);
}
if (!data.authUrl) {
throw new Error(
data.error ||
"Browser OAuth is unavailable for this provider in the current environment. Use the supported auth method instead."
);
}
setAuthData({ ...data, redirectUri });
// For non-true-localhost (LAN IPs, remote) or manual fallback: use manual input mode (user pastes callback URL)

View File

@@ -17,6 +17,12 @@ export const FREE_PROVIDERS = {
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
};
export const FREE_APIKEY_PROVIDER_IDS = new Set(["qoder"]);
export function supportsApiKeyOnFreeProvider(providerId) {
return FREE_APIKEY_PROVIDER_IDS.has(providerId);
}
// OAuth Providers
export const OAUTH_PROVIDERS = {
claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757" },

View File

@@ -109,6 +109,16 @@ const CLI_TOOLS: Record<string, any> = {
config: ".config/opencode/opencode.json",
},
},
qoder: {
defaultCommand: "qodercli",
envBinKey: "CLI_QODER_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 12000,
paths: {
config: ".qoder/settings.json",
auth: ".qoder/auth.json",
},
},
};
const isWindows = () => process.platform === "win32";
@@ -390,6 +400,7 @@ const getKnownToolPaths = (toolId: string): string[] => {
cline: [["cline.cmd", "cline"]],
kilo: [["kilocode.cmd", "kilocode"]],
opencode: [["opencode.cmd", "opencode"]],
qoder: [["qodercli.exe", "qodercli"]],
};
const bins = toolBins[toolId] || [];