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:
Nant361
2026-06-29 15:29:17 +07:00
committed by decolua
co-authored by Cursor
parent 2d94fffe3b
commit 8a664d619d
16 changed files with 621 additions and 69 deletions
@@ -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) {
+25 -3
View File
@@ -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);
}
+3 -1
View File
@@ -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;
}