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:24:39 +07:00
committed by decolua
parent 2d94fffe3b
commit 8a664d619d
16 changed files with 621 additions and 69 deletions

View File

@@ -284,6 +284,10 @@ Default URLs:
<img src="./public/providers/cursor.png" width="60" alt="Cursor"/><br/>
<b>Cursor</b>
</td>
<td align="center" width="120">
<img src="./public/providers/kimchi.png" width="60" alt="Kimchi"/><br/>
<b>Kimchi</b>
</td>
</tr>
</table>
</div>

View File

@@ -5,6 +5,7 @@ import { GithubExecutor } from "./github.js";
import { IFlowExecutor } from "./iflow.js";
import { QoderExecutor } from "./qoder.js";
import { KiroExecutor } from "./kiro.js";
import { KimchiExecutor } from "./kimchi.js";
import { CodexExecutor } from "./codex.js";
import { CursorExecutor } from "./cursor.js";
import { VertexExecutor } from "./vertex.js";
@@ -28,6 +29,7 @@ const executors = {
iflow: new IFlowExecutor(),
qoder: new QoderExecutor(),
kiro: new KiroExecutor(),
kimchi: new KimchiExecutor(),
codex: new CodexExecutor(),
cursor: new CursorExecutor(),
cu: new CursorExecutor(), // Alias for cursor
@@ -66,6 +68,7 @@ export { GithubExecutor } from "./github.js";
export { IFlowExecutor } from "./iflow.js";
export { QoderExecutor } from "./qoder.js";
export { KiroExecutor } from "./kiro.js";
export { KimchiExecutor } from "./kimchi.js";
export { CodexExecutor } from "./codex.js";
export { CursorExecutor } from "./cursor.js";
export { VertexExecutor } from "./vertex.js";

View File

@@ -0,0 +1,103 @@
import { DefaultExecutor } from "./default.js";
import { getCachedKimchiModelMetadata } from "../services/kimchiModels.js";
const TOP_LEVEL_OPENAI_GATEWAY_DROPS = [
"anthropic_version",
"anthropic_beta",
"client_metadata",
"mcp_servers",
"stop_sequences",
"thinking",
"top_k",
];
function systemToText(system) {
if (typeof system === "string") return system;
if (Array.isArray(system)) {
return system
.map((part) => {
if (typeof part === "string") return part;
if (typeof part?.text === "string") return part.text;
return "";
})
.filter(Boolean)
.join("\n");
}
return "";
}
function mergeTopLevelSystem(body) {
if (!body?.system || !Array.isArray(body.messages)) return;
const text = systemToText(body.system).trim();
if (!text) return;
const existing = body.messages.find((msg) => msg?.role === "system");
if (!existing) {
body.messages.unshift({ role: "system", content: text });
return;
}
if (typeof existing.content === "string") {
existing.content = `${text}\n\n${existing.content}`;
} else if (Array.isArray(existing.content)) {
existing.content.unshift({ type: "text", text });
}
}
function stripMessageArtifacts(body) {
if (!Array.isArray(body?.messages)) return;
for (const msg of body.messages) {
if (!msg || typeof msg !== "object") continue;
delete msg.cache_control;
if (!Array.isArray(msg.content)) continue;
msg.content = msg.content.map((part) => {
if (!part || typeof part !== "object") return part;
const { cache_control, signature, ...clean } = part;
return clean;
});
}
}
function stripToolArtifacts(body) {
if (!Array.isArray(body?.tools)) return;
body.tools = body.tools.map((tool) => {
if (!tool || typeof tool !== "object") return tool;
const { cache_control, ...clean } = tool;
return clean;
});
}
function isAnthropicBackedKimchiModel(model) {
const meta = getCachedKimchiModelMetadata(model);
if (meta?.provider === "anthropic" || meta?.upstreamProvider === "anthropic") return true;
return /(^|[-_/])(?:claude|anthropic)(?:[-_/]|$)/i.test(String(model || ""));
}
export class KimchiExecutor extends DefaultExecutor {
constructor() {
super("kimchi");
}
transformRequest(model, body, stream, credentials) {
const transformed = super.transformRequest(model, body, stream, credentials);
if (!transformed || typeof transformed !== "object") return transformed;
mergeTopLevelSystem(transformed);
for (const key of TOP_LEVEL_OPENAI_GATEWAY_DROPS) {
if (transformed[key] !== undefined) delete transformed[key];
}
delete transformed.system;
if (isAnthropicBackedKimchiModel(model)) {
delete transformed.reasoning_effort;
delete transformed.reasoning;
delete transformed.thinking;
}
stripMessageArtifacts(transformed);
stripToolArtifacts(transformed);
return transformed;
}
}
export default KimchiExecutor;

View File

@@ -1,5 +1,6 @@
import { FORMATS } from "../../translator/formats.js";
import { needsTranslation } from "../../translator/index.js";
import { fromOpenAIFinish } from "../../translator/concerns/finishReason.js";
import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js";
import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js";
import { createErrorResult } from "../../utils/error.js";
@@ -9,11 +10,65 @@ import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, sav
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
import { decloakToolNames } from "../../utils/claudeCloaking.js";
function parseToolArguments(value) {
if (!value) return {};
if (typeof value === "object") return value;
try {
return JSON.parse(value);
} catch {
return {};
}
}
function openAICompletionToClaudeMessage(responseBody) {
if (!responseBody?.choices?.[0]) return responseBody;
const choice = responseBody.choices[0];
const message = choice.message || {};
const content = [];
const reasoning = message.reasoning_content || message.provider_specific_fields?.reasoning_content || "";
if (reasoning) {
content.push({ type: "thinking", thinking: reasoning });
}
if (typeof message.content === "string" && message.content.length > 0) {
content.push({ type: "text", text: message.content });
}
for (const toolCall of message.tool_calls || []) {
const fn = toolCall.function || {};
content.push({
type: "tool_use",
id: toolCall.id || `toolu_${Date.now()}_${content.length}`,
name: fn.name || toolCall.name || "",
input: parseToolArguments(fn.arguments || toolCall.arguments),
});
}
if (content.length === 0) content.push({ type: "text", text: "" });
const usage = responseBody.usage || {};
return {
id: String(responseBody.id || `msg_${Date.now()}`).replace(/^chatcmpl-/, ""),
type: "message",
role: "assistant",
model: responseBody.model || "unknown",
content,
stop_reason: fromOpenAIFinish(choice.finish_reason, FORMATS.CLAUDE),
stop_sequence: null,
usage: {
input_tokens: usage.prompt_tokens || usage.input_tokens || 0,
output_tokens: usage.completion_tokens || usage.output_tokens || 0,
},
};
}
/**
* Translate non-streaming response body from provider format → OpenAI format.
*/
export function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) {
if (targetFormat === sourceFormat || targetFormat === FORMATS.OPENAI) return responseBody;
if (targetFormat === sourceFormat) return responseBody;
if (targetFormat === FORMATS.OPENAI && sourceFormat === FORMATS.CLAUDE) {
return openAICompletionToClaudeMessage(responseBody);
}
if (targetFormat === FORMATS.OPENAI) return responseBody;
// Gemini / Antigravity
if (targetFormat === FORMATS.GEMINI || targetFormat === FORMATS.ANTIGRAVITY || targetFormat === FORMATS.GEMINI_CLI || targetFormat === FORMATS.VERTEX) {
@@ -185,6 +240,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
const translatedResponse = needsTranslation(targetFormat, sourceFormat)
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
: responseBody;
const isClaudeMessageResponse = sourceFormat === FORMATS.CLAUDE && translatedResponse?.type === "message";
// Fix finish_reason for tool_calls: some providers return non-standard values (e.g. "other")
if (translatedResponse?.choices?.[0]) {
@@ -197,13 +253,17 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
}
// Ensure OpenAI-required fields
if (!translatedResponse.object) translatedResponse.object = "chat.completion";
if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000);
if (!isClaudeMessageResponse) {
if (!translatedResponse.object) translatedResponse.object = "chat.completion";
if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000);
}
// Strip Azure-specific fields
delete translatedResponse.prompt_filter_results;
if (translatedResponse?.choices) {
for (const choice of translatedResponse.choices) delete choice.content_filter_results;
if (!isClaudeMessageResponse) {
delete translatedResponse.prompt_filter_results;
if (translatedResponse?.choices) {
for (const choice of translatedResponse.choices) delete choice.content_filter_results;
}
}
if (translatedResponse?.usage) {
@@ -213,7 +273,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
// Strip reasoning_content only when content is non-empty.
// When content is empty (e.g. thinking models that used all tokens for reasoning),
// reasoning_content is the only useful output and must be preserved.
if (translatedResponse?.choices) {
if (!isClaudeMessageResponse && translatedResponse?.choices) {
for (const choice of translatedResponse.choices) {
if (choice?.message?.reasoning_content && choice.message.content) {
delete choice.message.reasoning_content;

View File

@@ -48,52 +48,53 @@ import p45 from "./inworld.js";
import p46 from "./jina-ai.js";
import p47 from "./jina-reader.js";
import p48 from "./kilocode.js";
import p49 from "./kimi-coding.js";
import p50 from "./kimi.js";
import p51 from "./kiro.js";
import p52 from "./linkup.js";
import p53 from "./local-device.js";
import p54 from "./mimo-free.js";
import p55 from "./minimax-cn.js";
import p56 from "./minimax.js";
import p57 from "./mistral.js";
import p58 from "./mmf.js";
import p59 from "./nanobanana.js";
import p60 from "./nebius.js";
import p61 from "./nvidia.js";
import p62 from "./ollama-local.js";
import p63 from "./ollama.js";
import p64 from "./openai.js";
import p65 from "./opencode-go.js";
import p66 from "./opencode.js";
import p67 from "./openrouter.js";
import p68 from "./perplexity-web.js";
import p69 from "./perplexity.js";
import p70 from "./playht.js";
import p71 from "./qoder.js";
import p72 from "./qwen.js";
import p73 from "./recraft.js";
import p74 from "./runwayml.js";
import p75 from "./sdwebui.js";
import p76 from "./searchapi.js";
import p77 from "./searxng.js";
import p78 from "./serper.js";
import p79 from "./siliconflow.js";
import p80 from "./stability-ai.js";
import p81 from "./tavily.js";
import p82 from "./together.js";
import p83 from "./topaz.js";
import p84 from "./tortoise.js";
import p85 from "./venice.js";
import p86 from "./vercel-ai-gateway.js";
import p87 from "./vertex-partner.js";
import p88 from "./vertex.js";
import p89 from "./volcengine-ark.js";
import p90 from "./voyage-ai.js";
import p91 from "./xai.js";
import p92 from "./xiaomi-mimo.js";
import p93 from "./xiaomi-tokenplan.js";
import p94 from "./youcom.js";
import p49 from "./kimchi.js";
import p50 from "./kimi-coding.js";
import p51 from "./kimi.js";
import p52 from "./kiro.js";
import p53 from "./linkup.js";
import p54 from "./local-device.js";
import p55 from "./mimo-free.js";
import p56 from "./minimax-cn.js";
import p57 from "./minimax.js";
import p58 from "./mistral.js";
import p59 from "./mmf.js";
import p60 from "./nanobanana.js";
import p61 from "./nebius.js";
import p62 from "./nvidia.js";
import p63 from "./ollama-local.js";
import p64 from "./ollama.js";
import p65 from "./openai.js";
import p66 from "./opencode-go.js";
import p67 from "./opencode.js";
import p68 from "./openrouter.js";
import p69 from "./perplexity-web.js";
import p70 from "./perplexity.js";
import p71 from "./playht.js";
import p72 from "./qoder.js";
import p73 from "./qwen.js";
import p74 from "./recraft.js";
import p75 from "./runwayml.js";
import p76 from "./sdwebui.js";
import p77 from "./searchapi.js";
import p78 from "./searxng.js";
import p79 from "./serper.js";
import p80 from "./siliconflow.js";
import p81 from "./stability-ai.js";
import p82 from "./tavily.js";
import p83 from "./together.js";
import p84 from "./topaz.js";
import p85 from "./tortoise.js";
import p86 from "./venice.js";
import p87 from "./vercel-ai-gateway.js";
import p88 from "./vertex-partner.js";
import p89 from "./vertex.js";
import p90 from "./volcengine-ark.js";
import p91 from "./voyage-ai.js";
import p92 from "./xai.js";
import p93 from "./xiaomi-mimo.js";
import p94 from "./xiaomi-tokenplan.js";
import p95 from "./youcom.js";
export default [
p0,
@@ -190,5 +191,6 @@ export default [
p91,
p92,
p93,
p94
p94,
p95
];

View File

@@ -0,0 +1,49 @@
export default {
id: "kimchi",
priority: 95,
alias: "kimchi",
uiAlias: "kimchi",
display: {
name: "Kimchi",
icon: "restaurant",
color: "#FF521D",
textIcon: "KC",
website: "https://kimchi.dev",
notice: {
signupUrl: "https://app.kimchi.dev",
},
},
category: "oauth",
authModes: ["oauth"],
hasOAuth: true,
transport: {
baseUrl: "https://llm.kimchi.dev/openai/v1/chat/completions",
format: "openai",
headers: {
"User-Agent": "kimchi/0.0.0",
},
auth: {
combined: true,
header: "Authorization",
scheme: "bearer",
},
},
models: [
{ id: "minimax-m3", name: "MiniMax-M3" },
{ id: "kimi-k2.7", name: "Kimi-K2.7" },
{ id: "kimi-k2.6", name: "Kimi-K2.6" },
{ id: "kimi-k2.5", name: "Kimi-K2.5" },
{ id: "nemotron-3-ultra-fp4", name: "Nemotron 3 Ultra FP4" },
{ id: "minimax-m2.7", name: "MiniMax-M2.7" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
],
serviceKinds: ["llm", "imageToText"],
oauth: {
webAppUrl: "https://app.kimchi.dev",
validationUrl: "https://api.cast.ai/v1/llm/openai/supported-providers",
userInfoUrl: "https://app.kimchi.dev/api/v1/me",
modelsUrl: "https://llm.kimchi.dev/v1/models/metadata?include_in_cli=true",
},
passthroughModels: true,
};

View File

@@ -0,0 +1,176 @@
import { createHash } from "crypto";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
export const KIMCHI_API = "https://llm.kimchi.dev";
export const KIMCHI_USER_AGENT = "kimchi/0.0.0";
const FETCH_TIMEOUT_MS = 20_000;
const CACHE_TTL_MS = 5 * 60 * 1000;
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
/** @type {Map<string, { expiresAt: number, models: object[], rawModels: object[] }>} */
const catalogCache = new Map();
/** @type {Map<string, object>} */
const metadataByModelId = new Map();
function normalizeKimchiEndpoint(endpoint) {
const raw = typeof endpoint === "string" ? endpoint.trim() : "";
return (raw || KIMCHI_API).replace(/\/+$/, "");
}
export function buildKimchiModelsUrl(endpoint) {
return `${normalizeKimchiEndpoint(endpoint)}/v1/models/metadata?include_in_cli=true`;
}
function readToken(credentials) {
return (
credentials?.accessToken
|| credentials?.apiKey
|| credentials?.providerSpecificData?.apiKey
|| null
);
}
function cacheKey(credentials, endpoint) {
const psd = credentials?.providerSpecificData || {};
const seed = psd.userId || psd.username || credentials?.refreshToken || readToken(credentials) || "anonymous";
return createHash("sha256")
.update(`kimchi:${normalizeKimchiEndpoint(endpoint)}:${seed}`)
.digest("hex");
}
function toModelKind(inputModalities) {
return Array.isArray(inputModalities) && inputModalities.includes("image")
? "imageToText"
: "llm";
}
export function normalizeKimchiModel(item) {
if (!item || typeof item !== "object") return null;
const id = item.slug || item.id || item.model || item.name;
if (typeof id !== "string" || id.trim() === "") return null;
const inputModalities = Array.isArray(item.input_modalities)
? item.input_modalities.filter((value) => value === "text" || value === "image")
: [];
const limits = item.limits && typeof item.limits === "object" ? item.limits : {};
const contextLength = Number(limits.context_window || item.contextLength || item.context_length) || undefined;
const maxOutputTokens = Number(limits.max_output_tokens || item.maxOutputTokens || item.max_output_tokens) || undefined;
const upstreamProvider = typeof item.provider === "string" ? item.provider : "";
const reasoning = item.reasoning === true;
const kind = toModelKind(inputModalities);
const model = {
...item,
id: id.trim(),
name: String(item.display_name || item.displayName || item.name || id).trim(),
provider: upstreamProvider,
upstreamProvider,
reasoning,
inputModalities,
kind,
type: kind,
capabilities: {
vision: inputModalities.includes("image"),
reasoning,
...(contextLength ? { contextWindow: contextLength } : {}),
...(maxOutputTokens ? { maxOutput: maxOutputTokens } : {}),
...(upstreamProvider ? { upstreamProvider } : {}),
},
...(contextLength ? { contextLength } : {}),
...(maxOutputTokens ? { maxOutputTokens } : {}),
};
if (upstreamProvider === "anthropic") {
model.compat = { supportsReasoningEffort: false, cacheControlFormat: "anthropic" };
}
return model;
}
function rememberModels(models) {
for (const model of models || []) {
if (!model?.id) continue;
metadataByModelId.set(model.id, model);
metadataByModelId.set(model.id.toLowerCase(), model);
}
}
export function getCachedKimchiModelMetadata(modelId) {
if (typeof modelId !== "string" || modelId.trim() === "") return null;
const raw = modelId.includes("/") ? modelId.split("/").pop() : modelId;
return metadataByModelId.get(raw) || metadataByModelId.get(raw.toLowerCase()) || null;
}
async function fetchKimchiCatalogRaw(token, endpoint, options = {}) {
const url = buildKimchiModelsUrl(endpoint);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error("Kimchi models fetch timeout")), FETCH_TIMEOUT_MS);
const signal = options.signal
? AbortSignal.any([options.signal, controller.signal])
: controller.signal;
try {
const response = await proxyAwareFetch(url, {
method: "GET",
headers: {
"Accept": "application/json",
"Authorization": `Bearer ${token}`,
"User-Agent": KIMCHI_USER_AGENT,
},
cache: "no-store",
signal,
}, options.proxyOptions || null);
if (!response.ok) {
const error = new Error(`Kimchi models ${response.status}: ${response.statusText}`);
error.status = response.status;
error.retryable = RETRYABLE_STATUSES.has(response.status);
throw error;
}
const data = await response.json();
return Array.isArray(data?.models) ? data.models : [];
} finally {
clearTimeout(timeout);
}
}
export async function resolveKimchiModels(credentials, options = {}) {
const token = readToken(credentials);
if (!token) return null;
const endpoint = credentials?.providerSpecificData?.kimchiEndpoint || options.endpoint || KIMCHI_API;
const key = cacheKey(credentials, endpoint);
const now = Date.now();
if (!options.forceRefresh) {
const cached = catalogCache.get(key);
if (cached && cached.expiresAt > now) return cached;
}
let rawModels;
try {
rawModels = await fetchKimchiCatalogRaw(token, endpoint, options);
} catch (error) {
options.log?.warn?.("KIMCHI_MODELS", error.message);
return null;
}
const models = rawModels.map(normalizeKimchiModel).filter(Boolean);
if (models.length === 0) return null;
rememberModels(models);
const entry = {
expiresAt: Date.now() + CACHE_TTL_MS,
models,
rawModels,
};
catalogCache.set(key, entry);
return entry;
}
export function clearKimchiCatalog() {
catalogCache.clear();
metadataByModelId.clear();
}

BIN
public/providers/kimchi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -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 });
}

View File

@@ -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: {

View File

@@ -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) {

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);
}

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;
}

View File

@@ -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",
};

View File

@@ -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,
},
};
},
},
};
/**

View File

@@ -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