mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
feat(kimchi): add Kimchi OAuth provider support
Add Kimchi as a browser-token OAuth provider routed through its OpenAI-compatible gateway. Discover live models for /v1/models and provider models, normalize Claude-compatible requests, and wire up provider connection tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
decolua
co-authored by
Cursor
parent
2d94fffe3b
commit
8a664d619d
@@ -232,8 +232,8 @@ export async function POST(request, { params }) {
|
||||
});
|
||||
}
|
||||
|
||||
// Cline uses authorization_code without PKCE
|
||||
const noPkceExchangeProviders = ["cline"];
|
||||
// Cline uses authorization_code without PKCE. Kimchi returns a browser token.
|
||||
const noPkceExchangeProviders = ["cline", "kimchi"];
|
||||
if (!code || !redirectUri || (!codeVerifier && !noPkceExchangeProviders.includes(provider))) {
|
||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/sha
|
||||
import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth";
|
||||
import { refreshGoogleToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
||||
import { resolveOllamaLocalHost } from "open-sse/config/providers.js";
|
||||
import { getModelsByProviderId } from "open-sse/config/providerModels.js";
|
||||
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
|
||||
import { resolveKimchiModels } from "open-sse/services/kimchiModels.js";
|
||||
import { resolveQoderModels } from "open-sse/services/qoderModels.js";
|
||||
|
||||
const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
|
||||
@@ -79,6 +81,13 @@ const resolveQwenModelsUrl = (connection) => {
|
||||
return `https://${value.replace(/\/$/, "")}/v1/models`;
|
||||
};
|
||||
|
||||
const getStaticProviderModels = (providerId) =>
|
||||
getModelsByProviderId(providerId).map((model) => ({
|
||||
...model,
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
}));
|
||||
|
||||
// Generic custom resolver for OAuth providers that need refresh-on-401 + token persist.
|
||||
// Receives a `fetchFn(token)` and returns parsed models or throws.
|
||||
const buildOAuthResolver = ({ refreshFn, fetchFn, parseFn, errorLabel }) => async (connection) => {
|
||||
@@ -241,6 +250,22 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
nvidia: createOpenAIModelsConfig("https://integrate.api.nvidia.com/v1/models"),
|
||||
assemblyai: createOpenAIModelsConfig("https://api.assemblyai.com/v1/models"),
|
||||
"vercel-ai-gateway": createOpenAIModelsConfig("https://ai-gateway.vercel.sh/v1/models"),
|
||||
kimchi: {
|
||||
customResolver: async (connection) => {
|
||||
const result = await resolveKimchiModels({
|
||||
accessToken: connection.accessToken,
|
||||
apiKey: connection.apiKey,
|
||||
providerSpecificData: connection.providerSpecificData || {},
|
||||
}, { forceRefresh: true, log: console });
|
||||
if (result?.models?.length) {
|
||||
return { models: result.models };
|
||||
}
|
||||
return {
|
||||
models: getStaticProviderModels("kimchi"),
|
||||
warning: "Kimchi returned no live models; falling back to static catalog.",
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// Custom resolvers (non-OpenAI-shaped APIs / token-refresh flows)
|
||||
kiro: {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
CLAUDE_CONFIG,
|
||||
CLINE_CONFIG,
|
||||
KILOCODE_CONFIG,
|
||||
KIMCHI_CONFIG,
|
||||
} from "@/lib/oauth/constants/oauth";
|
||||
import { buildClineHeaders } from "@/shared/utils/clineAuth";
|
||||
|
||||
@@ -91,6 +92,17 @@ const OAUTH_TEST_CONFIG = {
|
||||
authPrefix: "Bearer ",
|
||||
},
|
||||
"codebuddy-cn": { tokenExists: true },
|
||||
kimchi: {
|
||||
url: KIMCHI_CONFIG.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers",
|
||||
method: "GET",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
extraHeaders: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "kimchi/0.0.0",
|
||||
},
|
||||
refreshable: false,
|
||||
},
|
||||
};
|
||||
|
||||
async function probeClineAccessToken(accessToken) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb";
|
||||
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
|
||||
import { resolveKimchiModels } from "open-sse/services/kimchiModels.js";
|
||||
import { resolveQoderModels } from "open-sse/services/qoderModels.js";
|
||||
import { resolveCopilotModels } from "open-sse/services/copilotModels.js";
|
||||
import { updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
||||
@@ -38,6 +39,14 @@ const LIVE_MODEL_RESOLVERS = {
|
||||
models: result.models.map((m) => ({ id: m.id, name: m.name })),
|
||||
};
|
||||
},
|
||||
kimchi: async (conn) => {
|
||||
const result = await resolveKimchiModels({
|
||||
accessToken: conn.accessToken,
|
||||
apiKey: conn.apiKey,
|
||||
providerSpecificData: conn.providerSpecificData || {}
|
||||
}, { log: console });
|
||||
return result?.models?.length ? { models: result.models } : null;
|
||||
},
|
||||
github: async (conn) => {
|
||||
const result = await resolveCopilotModels({
|
||||
accessToken: conn.accessToken,
|
||||
@@ -289,6 +298,8 @@ export async function buildModelsList(kindFilter) {
|
||||
const staticModelKindById = new Map(
|
||||
providerModels.map((m) => [m.id, modelKind(m)])
|
||||
);
|
||||
let liveModelKindById = new Map();
|
||||
let liveCapabilitiesById = new Map();
|
||||
|
||||
let rawModelIds = hasExplicitEnabledModels
|
||||
? Array.from(
|
||||
@@ -313,6 +324,16 @@ export async function buildModelsList(kindFilter) {
|
||||
const live = await liveResolver(conn);
|
||||
if (live?.models?.length) {
|
||||
rawModelIds = live.models.map((m) => m.id);
|
||||
liveModelKindById = new Map(
|
||||
live.models
|
||||
.filter((m) => m?.id)
|
||||
.map((m) => [m.id, modelKind(m)])
|
||||
);
|
||||
liveCapabilitiesById = new Map(
|
||||
live.models
|
||||
.filter((m) => m?.id && m.capabilities)
|
||||
.map((m) => [m.id, m.capabilities])
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`Live model fetch failed for ${providerId}: ${err?.message || err}`);
|
||||
@@ -378,9 +399,10 @@ export async function buildModelsList(kindFilter) {
|
||||
const mergedModelIds = Array.from(new Set([...modelIds, ...customModelIds, ...aliasModelIds]));
|
||||
|
||||
for (const modelId of mergedModelIds) {
|
||||
// Resolve kind: prefer static/custom metadata, otherwise infer from ID heuristics
|
||||
// Resolve kind: prefer custom/live metadata, then static, then ID heuristics.
|
||||
const customKind = customModelKindById.get(modelId);
|
||||
const kind = staticModelKindById.get(modelId) || customKind || inferKindFromUnknownModelId(modelId);
|
||||
const liveKind = liveModelKindById.get(modelId);
|
||||
const kind = customKind || liveKind || staticModelKindById.get(modelId) || inferKindFromUnknownModelId(modelId);
|
||||
// imageToText custom models stay in the LLM list (vision-capable chat models)
|
||||
const allowAsLlm = kind === "imageToText" && kindFilter.includes(LLM_KIND);
|
||||
if (!kindFilter.includes(kind) && !allowAsLlm) continue;
|
||||
@@ -391,7 +413,7 @@ export async function buildModelsList(kindFilter) {
|
||||
object: "model",
|
||||
owned_by: outputAlias,
|
||||
};
|
||||
const caps = capabilitiesFromServiceKind(customKind);
|
||||
const caps = liveCapabilitiesById.get(modelId) || capabilitiesFromServiceKind(customKind || liveKind);
|
||||
if (caps) model.capabilities = caps;
|
||||
models.push(model);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,14 @@ function CallbackContent() {
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code");
|
||||
const token = searchParams.get("token");
|
||||
const state = searchParams.get("state");
|
||||
const error = searchParams.get("error");
|
||||
const errorDescription = searchParams.get("error_description");
|
||||
|
||||
const callbackData = {
|
||||
code,
|
||||
token,
|
||||
state,
|
||||
error,
|
||||
errorDescription,
|
||||
@@ -70,7 +72,7 @@ function CallbackContent() {
|
||||
console.log("localStorage failed:", e);
|
||||
}
|
||||
|
||||
if (!(code || error)) {
|
||||
if (!(code || token || error)) {
|
||||
setTimeout(() => setStatus("manual"), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,6 +108,9 @@ export const GITLAB_CONFIG = { ...PROVIDER_OAUTH["gitlab"] };
|
||||
// CodeBuddy (Tencent) OAuth Configuration (Browser OAuth Polling Flow)
|
||||
export const CODEBUDDY_CONFIG = { ...PROVIDER_OAUTH["codebuddy-cn"] };
|
||||
|
||||
// Kimchi OAuth Configuration (Browser token callback flow)
|
||||
export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] };
|
||||
|
||||
// OAuth timeout (5 minutes)
|
||||
export const OAUTH_TIMEOUT = 300000;
|
||||
|
||||
@@ -129,4 +132,5 @@ export const PROVIDERS = {
|
||||
CLINE: "cline",
|
||||
GITLAB: "gitlab",
|
||||
CODEBUDDY: "codebuddy-cn",
|
||||
KIMCHI: "kimchi",
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
CLINE_CONFIG,
|
||||
GITLAB_CONFIG,
|
||||
CODEBUDDY_CONFIG,
|
||||
KIMCHI_CONFIG,
|
||||
getOAuthClientMetadata,
|
||||
} from "./constants/oauth";
|
||||
import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai";
|
||||
@@ -1252,6 +1253,78 @@ const PROVIDERS = {
|
||||
providerSpecificData: {},
|
||||
}),
|
||||
},
|
||||
|
||||
kimchi: {
|
||||
config: KIMCHI_CONFIG,
|
||||
flowType: "browser_token",
|
||||
buildAuthUrl: (config, redirectUri, state) => {
|
||||
const baseUrl = (config.webAppUrl || "https://app.kimchi.dev").replace(/\/+$/, "");
|
||||
const params = new URLSearchParams({
|
||||
callback: redirectUri,
|
||||
state,
|
||||
});
|
||||
return `${baseUrl}/cli-auth?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, token) => {
|
||||
const accessToken = String(token || "").trim();
|
||||
if (!accessToken) {
|
||||
throw new Error("Missing Kimchi token");
|
||||
}
|
||||
|
||||
const validationUrl = config.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers";
|
||||
const validationRes = await fetch(validationUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
if (!validationRes.ok) {
|
||||
throw new Error(`Kimchi token validation failed: ${validationRes.status}`);
|
||||
}
|
||||
|
||||
let userInfo = {};
|
||||
if (config.userInfoUrl) {
|
||||
try {
|
||||
const userRes = await fetch(config.userInfoUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
if (userRes.ok) {
|
||||
userInfo = await userRes.json();
|
||||
}
|
||||
} catch {
|
||||
userInfo = {};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
_kimchiUser: userInfo,
|
||||
};
|
||||
},
|
||||
mapTokens: (tokens) => {
|
||||
const user = tokens._kimchiUser || {};
|
||||
const userId = user.id ? String(user.id) : "";
|
||||
const username = user.username || "";
|
||||
const email = user.email || (userId ? `kimchi-user-${userId}` : null);
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: null,
|
||||
email,
|
||||
displayName: user.name || username || null,
|
||||
providerSpecificData: {
|
||||
authMethod: "browser_token",
|
||||
userId,
|
||||
username,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
setError(err.message);
|
||||
setStep("error");
|
||||
}
|
||||
}, [authData, provider, onSuccess]);
|
||||
}, [authData, provider, onSuccess, oauthMeta]);
|
||||
|
||||
const completeXaiManualCode = useCallback(async (code) => {
|
||||
if (!authData?.state) return;
|
||||
@@ -387,7 +387,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
const handleCallback = async (data) => {
|
||||
if (callbackProcessedRef.current) return; // Already processed
|
||||
|
||||
const { code, state, error: callbackError, errorDescription } = data;
|
||||
const { code, token, state, error: callbackError, errorDescription } = data;
|
||||
|
||||
if (callbackError) {
|
||||
callbackProcessedRef.current = true;
|
||||
@@ -396,9 +396,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
return;
|
||||
}
|
||||
|
||||
if (code) {
|
||||
if (token || code) {
|
||||
callbackProcessedRef.current = true;
|
||||
await exchangeTokens(code, state);
|
||||
await exchangeTokens(token || code, state);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -477,8 +477,14 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === "kimchi" && input && !input.includes("://") && !input.includes("?")) {
|
||||
await exchangeTokens(input, null);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(input);
|
||||
const code = url.searchParams.get("code");
|
||||
const token = url.searchParams.get("token");
|
||||
const state = url.searchParams.get("state");
|
||||
const errorParam = url.searchParams.get("error");
|
||||
|
||||
@@ -486,11 +492,17 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
throw new Error(url.searchParams.get("error_description") || errorParam);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error(provider === "xai" ? "Paste the callback URL or copied xAI code" : "No authorization code found in URL");
|
||||
if (!code && !token) {
|
||||
throw new Error(
|
||||
provider === "xai"
|
||||
? "Paste the callback URL or copied xAI code"
|
||||
: provider === "kimchi"
|
||||
? "No Kimchi token found in URL"
|
||||
: "No authorization code found in URL"
|
||||
);
|
||||
}
|
||||
|
||||
await exchangeTokens(code, state);
|
||||
await exchangeTokens(token || code, state);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setStep("error");
|
||||
@@ -509,11 +521,14 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
|
||||
if (!provider || !providerInfo) return null;
|
||||
const isXaiProvider = provider === "xai";
|
||||
const isKimchiProvider = provider === "kimchi";
|
||||
const deviceLoginUrl = deviceData?.verification_uri_complete || deviceData?.verification_uri || "";
|
||||
const modalTitle = isXaiProvider ? "Connect Grok Build OAuth" : `Connect ${providerInfo.name}`;
|
||||
const manualPlaceholder = isXaiProvider
|
||||
? "http://127.0.0.1:56121/callback?code=... or copied code"
|
||||
: placeholderUrl;
|
||||
: isKimchiProvider
|
||||
? `${placeholderUrl.replace("code=...", "token=...")} or copied token`
|
||||
: placeholderUrl;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} title={modalTitle} onClose={handleClose} size="lg">
|
||||
@@ -554,11 +569,13 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Step 2: Paste the {provider === "xai" ? "callback URL or copied code" : "callback URL"} here
|
||||
Step 2: Paste the {provider === "xai" ? "callback URL or copied code" : isKimchiProvider ? "callback URL or copied token" : "callback URL"} here
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
{provider === "xai"
|
||||
? "If xAI shows a code instead of redirecting, paste that code here."
|
||||
: isKimchiProvider
|
||||
? "After authorization, copy the full callback URL or token from your browser."
|
||||
: "After authorization, copy the full URL from your browser."}
|
||||
</p>
|
||||
<Input
|
||||
|
||||
Reference in New Issue
Block a user