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
+4
View File
@@ -284,6 +284,10 @@ Default URLs:
<img src="./public/providers/cursor.png" width="60" alt="Cursor"/><br/> <img src="./public/providers/cursor.png" width="60" alt="Cursor"/><br/>
<b>Cursor</b> <b>Cursor</b>
</td> </td>
<td align="center" width="120">
<img src="./public/providers/kimchi.png" width="60" alt="Kimchi"/><br/>
<b>Kimchi</b>
</td>
</tr> </tr>
</table> </table>
</div> </div>
+3
View File
@@ -5,6 +5,7 @@ import { GithubExecutor } from "./github.js";
import { IFlowExecutor } from "./iflow.js"; import { IFlowExecutor } from "./iflow.js";
import { QoderExecutor } from "./qoder.js"; import { QoderExecutor } from "./qoder.js";
import { KiroExecutor } from "./kiro.js"; import { KiroExecutor } from "./kiro.js";
import { KimchiExecutor } from "./kimchi.js";
import { CodexExecutor } from "./codex.js"; import { CodexExecutor } from "./codex.js";
import { CursorExecutor } from "./cursor.js"; import { CursorExecutor } from "./cursor.js";
import { VertexExecutor } from "./vertex.js"; import { VertexExecutor } from "./vertex.js";
@@ -28,6 +29,7 @@ const executors = {
iflow: new IFlowExecutor(), iflow: new IFlowExecutor(),
qoder: new QoderExecutor(), qoder: new QoderExecutor(),
kiro: new KiroExecutor(), kiro: new KiroExecutor(),
kimchi: new KimchiExecutor(),
codex: new CodexExecutor(), codex: new CodexExecutor(),
cursor: new CursorExecutor(), cursor: new CursorExecutor(),
cu: new CursorExecutor(), // Alias for cursor cu: new CursorExecutor(), // Alias for cursor
@@ -66,6 +68,7 @@ export { GithubExecutor } from "./github.js";
export { IFlowExecutor } from "./iflow.js"; export { IFlowExecutor } from "./iflow.js";
export { QoderExecutor } from "./qoder.js"; export { QoderExecutor } from "./qoder.js";
export { KiroExecutor } from "./kiro.js"; export { KiroExecutor } from "./kiro.js";
export { KimchiExecutor } from "./kimchi.js";
export { CodexExecutor } from "./codex.js"; export { CodexExecutor } from "./codex.js";
export { CursorExecutor } from "./cursor.js"; export { CursorExecutor } from "./cursor.js";
export { VertexExecutor } from "./vertex.js"; export { VertexExecutor } from "./vertex.js";
+103
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;
@@ -1,5 +1,6 @@
import { FORMATS } from "../../translator/formats.js"; import { FORMATS } from "../../translator/formats.js";
import { needsTranslation } from "../../translator/index.js"; import { needsTranslation } from "../../translator/index.js";
import { fromOpenAIFinish } from "../../translator/concerns/finishReason.js";
import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js"; import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js";
import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js"; import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js";
import { createErrorResult } from "../../utils/error.js"; import { createErrorResult } from "../../utils/error.js";
@@ -9,11 +10,65 @@ import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, sav
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
import { decloakToolNames } from "../../utils/claudeCloaking.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. * Translate non-streaming response body from provider format → OpenAI format.
*/ */
export function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) { 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 // Gemini / Antigravity
if (targetFormat === FORMATS.GEMINI || targetFormat === FORMATS.ANTIGRAVITY || targetFormat === FORMATS.GEMINI_CLI || targetFormat === FORMATS.VERTEX) { 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) const translatedResponse = needsTranslation(targetFormat, sourceFormat)
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) ? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
: responseBody; : responseBody;
const isClaudeMessageResponse = sourceFormat === FORMATS.CLAUDE && translatedResponse?.type === "message";
// Fix finish_reason for tool_calls: some providers return non-standard values (e.g. "other") // Fix finish_reason for tool_calls: some providers return non-standard values (e.g. "other")
if (translatedResponse?.choices?.[0]) { if (translatedResponse?.choices?.[0]) {
@@ -197,13 +253,17 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
} }
// Ensure OpenAI-required fields // Ensure OpenAI-required fields
if (!translatedResponse.object) translatedResponse.object = "chat.completion"; if (!isClaudeMessageResponse) {
if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000); if (!translatedResponse.object) translatedResponse.object = "chat.completion";
if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000);
}
// Strip Azure-specific fields // Strip Azure-specific fields
delete translatedResponse.prompt_filter_results; if (!isClaudeMessageResponse) {
if (translatedResponse?.choices) { delete translatedResponse.prompt_filter_results;
for (const choice of translatedResponse.choices) delete choice.content_filter_results; if (translatedResponse?.choices) {
for (const choice of translatedResponse.choices) delete choice.content_filter_results;
}
} }
if (translatedResponse?.usage) { if (translatedResponse?.usage) {
@@ -213,7 +273,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
// Strip reasoning_content only when content is non-empty. // Strip reasoning_content only when content is non-empty.
// When content is empty (e.g. thinking models that used all tokens for reasoning), // 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. // reasoning_content is the only useful output and must be preserved.
if (translatedResponse?.choices) { if (!isClaudeMessageResponse && translatedResponse?.choices) {
for (const choice of translatedResponse.choices) { for (const choice of translatedResponse.choices) {
if (choice?.message?.reasoning_content && choice.message.content) { if (choice?.message?.reasoning_content && choice.message.content) {
delete choice.message.reasoning_content; delete choice.message.reasoning_content;
+49 -47
View File
@@ -48,52 +48,53 @@ import p45 from "./inworld.js";
import p46 from "./jina-ai.js"; import p46 from "./jina-ai.js";
import p47 from "./jina-reader.js"; import p47 from "./jina-reader.js";
import p48 from "./kilocode.js"; import p48 from "./kilocode.js";
import p49 from "./kimi-coding.js"; import p49 from "./kimchi.js";
import p50 from "./kimi.js"; import p50 from "./kimi-coding.js";
import p51 from "./kiro.js"; import p51 from "./kimi.js";
import p52 from "./linkup.js"; import p52 from "./kiro.js";
import p53 from "./local-device.js"; import p53 from "./linkup.js";
import p54 from "./mimo-free.js"; import p54 from "./local-device.js";
import p55 from "./minimax-cn.js"; import p55 from "./mimo-free.js";
import p56 from "./minimax.js"; import p56 from "./minimax-cn.js";
import p57 from "./mistral.js"; import p57 from "./minimax.js";
import p58 from "./mmf.js"; import p58 from "./mistral.js";
import p59 from "./nanobanana.js"; import p59 from "./mmf.js";
import p60 from "./nebius.js"; import p60 from "./nanobanana.js";
import p61 from "./nvidia.js"; import p61 from "./nebius.js";
import p62 from "./ollama-local.js"; import p62 from "./nvidia.js";
import p63 from "./ollama.js"; import p63 from "./ollama-local.js";
import p64 from "./openai.js"; import p64 from "./ollama.js";
import p65 from "./opencode-go.js"; import p65 from "./openai.js";
import p66 from "./opencode.js"; import p66 from "./opencode-go.js";
import p67 from "./openrouter.js"; import p67 from "./opencode.js";
import p68 from "./perplexity-web.js"; import p68 from "./openrouter.js";
import p69 from "./perplexity.js"; import p69 from "./perplexity-web.js";
import p70 from "./playht.js"; import p70 from "./perplexity.js";
import p71 from "./qoder.js"; import p71 from "./playht.js";
import p72 from "./qwen.js"; import p72 from "./qoder.js";
import p73 from "./recraft.js"; import p73 from "./qwen.js";
import p74 from "./runwayml.js"; import p74 from "./recraft.js";
import p75 from "./sdwebui.js"; import p75 from "./runwayml.js";
import p76 from "./searchapi.js"; import p76 from "./sdwebui.js";
import p77 from "./searxng.js"; import p77 from "./searchapi.js";
import p78 from "./serper.js"; import p78 from "./searxng.js";
import p79 from "./siliconflow.js"; import p79 from "./serper.js";
import p80 from "./stability-ai.js"; import p80 from "./siliconflow.js";
import p81 from "./tavily.js"; import p81 from "./stability-ai.js";
import p82 from "./together.js"; import p82 from "./tavily.js";
import p83 from "./topaz.js"; import p83 from "./together.js";
import p84 from "./tortoise.js"; import p84 from "./topaz.js";
import p85 from "./venice.js"; import p85 from "./tortoise.js";
import p86 from "./vercel-ai-gateway.js"; import p86 from "./venice.js";
import p87 from "./vertex-partner.js"; import p87 from "./vercel-ai-gateway.js";
import p88 from "./vertex.js"; import p88 from "./vertex-partner.js";
import p89 from "./volcengine-ark.js"; import p89 from "./vertex.js";
import p90 from "./voyage-ai.js"; import p90 from "./volcengine-ark.js";
import p91 from "./xai.js"; import p91 from "./voyage-ai.js";
import p92 from "./xiaomi-mimo.js"; import p92 from "./xai.js";
import p93 from "./xiaomi-tokenplan.js"; import p93 from "./xiaomi-mimo.js";
import p94 from "./youcom.js"; import p94 from "./xiaomi-tokenplan.js";
import p95 from "./youcom.js";
export default [ export default [
p0, p0,
@@ -190,5 +191,6 @@ export default [
p91, p91,
p92, p92,
p93, p93,
p94 p94,
p95
]; ];
+49
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,
};
+176
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();
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -232,8 +232,8 @@ export async function POST(request, { params }) {
}); });
} }
// Cline uses authorization_code without PKCE // Cline uses authorization_code without PKCE. Kimchi returns a browser token.
const noPkceExchangeProviders = ["cline"]; const noPkceExchangeProviders = ["cline", "kimchi"];
if (!code || !redirectUri || (!codeVerifier && !noPkceExchangeProviders.includes(provider))) { if (!code || !redirectUri || (!codeVerifier && !noPkceExchangeProviders.includes(provider))) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); 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 { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth";
import { refreshGoogleToken, updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { refreshGoogleToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
import { resolveOllamaLocalHost } from "open-sse/config/providers.js"; 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 { resolveKiroModels } from "open-sse/services/kiroModels.js";
import { resolveKimchiModels } from "open-sse/services/kimchiModels.js";
import { resolveQoderModels } from "open-sse/services/qoderModels.js"; import { resolveQoderModels } from "open-sse/services/qoderModels.js";
const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; 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`; 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. // Generic custom resolver for OAuth providers that need refresh-on-401 + token persist.
// Receives a `fetchFn(token)` and returns parsed models or throws. // Receives a `fetchFn(token)` and returns parsed models or throws.
const buildOAuthResolver = ({ refreshFn, fetchFn, parseFn, errorLabel }) => async (connection) => { 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"), nvidia: createOpenAIModelsConfig("https://integrate.api.nvidia.com/v1/models"),
assemblyai: createOpenAIModelsConfig("https://api.assemblyai.com/v1/models"), assemblyai: createOpenAIModelsConfig("https://api.assemblyai.com/v1/models"),
"vercel-ai-gateway": createOpenAIModelsConfig("https://ai-gateway.vercel.sh/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) // Custom resolvers (non-OpenAI-shaped APIs / token-refresh flows)
kiro: { kiro: {
@@ -16,6 +16,7 @@ import {
CLAUDE_CONFIG, CLAUDE_CONFIG,
CLINE_CONFIG, CLINE_CONFIG,
KILOCODE_CONFIG, KILOCODE_CONFIG,
KIMCHI_CONFIG,
} from "@/lib/oauth/constants/oauth"; } from "@/lib/oauth/constants/oauth";
import { buildClineHeaders } from "@/shared/utils/clineAuth"; import { buildClineHeaders } from "@/shared/utils/clineAuth";
@@ -91,6 +92,17 @@ const OAUTH_TEST_CONFIG = {
authPrefix: "Bearer ", authPrefix: "Bearer ",
}, },
"codebuddy-cn": { tokenExists: true }, "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) { async function probeClineAccessToken(accessToken) {
+25 -3
View File
@@ -8,6 +8,7 @@ import {
import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb"; import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb";
import { getDisabledModels } from "@/lib/disabledModelsDb"; import { getDisabledModels } from "@/lib/disabledModelsDb";
import { resolveKiroModels } from "open-sse/services/kiroModels.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"; import { resolveQoderModels } from "open-sse/services/qoderModels.js";
import { resolveCopilotModels } from "open-sse/services/copilotModels.js"; import { resolveCopilotModels } from "open-sse/services/copilotModels.js";
import { updateProviderCredentials } from "@/sse/services/tokenRefresh"; 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 })), 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) => { github: async (conn) => {
const result = await resolveCopilotModels({ const result = await resolveCopilotModels({
accessToken: conn.accessToken, accessToken: conn.accessToken,
@@ -289,6 +298,8 @@ export async function buildModelsList(kindFilter) {
const staticModelKindById = new Map( const staticModelKindById = new Map(
providerModels.map((m) => [m.id, modelKind(m)]) providerModels.map((m) => [m.id, modelKind(m)])
); );
let liveModelKindById = new Map();
let liveCapabilitiesById = new Map();
let rawModelIds = hasExplicitEnabledModels let rawModelIds = hasExplicitEnabledModels
? Array.from( ? Array.from(
@@ -313,6 +324,16 @@ export async function buildModelsList(kindFilter) {
const live = await liveResolver(conn); const live = await liveResolver(conn);
if (live?.models?.length) { if (live?.models?.length) {
rawModelIds = live.models.map((m) => m.id); 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) { } catch (err) {
console.log(`Live model fetch failed for ${providerId}: ${err?.message || 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])); const mergedModelIds = Array.from(new Set([...modelIds, ...customModelIds, ...aliasModelIds]));
for (const modelId of mergedModelIds) { 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 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) // imageToText custom models stay in the LLM list (vision-capable chat models)
const allowAsLlm = kind === "imageToText" && kindFilter.includes(LLM_KIND); const allowAsLlm = kind === "imageToText" && kindFilter.includes(LLM_KIND);
if (!kindFilter.includes(kind) && !allowAsLlm) continue; if (!kindFilter.includes(kind) && !allowAsLlm) continue;
@@ -391,7 +413,7 @@ export async function buildModelsList(kindFilter) {
object: "model", object: "model",
owned_by: outputAlias, owned_by: outputAlias,
}; };
const caps = capabilitiesFromServiceKind(customKind); const caps = liveCapabilitiesById.get(modelId) || capabilitiesFromServiceKind(customKind || liveKind);
if (caps) model.capabilities = caps; if (caps) model.capabilities = caps;
models.push(model); models.push(model);
} }
+3 -1
View File
@@ -12,12 +12,14 @@ function CallbackContent() {
useEffect(() => { useEffect(() => {
const code = searchParams.get("code"); const code = searchParams.get("code");
const token = searchParams.get("token");
const state = searchParams.get("state"); const state = searchParams.get("state");
const error = searchParams.get("error"); const error = searchParams.get("error");
const errorDescription = searchParams.get("error_description"); const errorDescription = searchParams.get("error_description");
const callbackData = { const callbackData = {
code, code,
token,
state, state,
error, error,
errorDescription, errorDescription,
@@ -70,7 +72,7 @@ function CallbackContent() {
console.log("localStorage failed:", e); console.log("localStorage failed:", e);
} }
if (!(code || error)) { if (!(code || token || error)) {
setTimeout(() => setStatus("manual"), 0); setTimeout(() => setStatus("manual"), 0);
return; return;
} }
+4
View File
@@ -108,6 +108,9 @@ export const GITLAB_CONFIG = { ...PROVIDER_OAUTH["gitlab"] };
// CodeBuddy (Tencent) OAuth Configuration (Browser OAuth Polling Flow) // CodeBuddy (Tencent) OAuth Configuration (Browser OAuth Polling Flow)
export const CODEBUDDY_CONFIG = { ...PROVIDER_OAUTH["codebuddy-cn"] }; 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) // OAuth timeout (5 minutes)
export const OAUTH_TIMEOUT = 300000; export const OAUTH_TIMEOUT = 300000;
@@ -129,4 +132,5 @@ export const PROVIDERS = {
CLINE: "cline", CLINE: "cline",
GITLAB: "gitlab", GITLAB: "gitlab",
CODEBUDDY: "codebuddy-cn", CODEBUDDY: "codebuddy-cn",
KIMCHI: "kimchi",
}; };
+73
View File
@@ -25,6 +25,7 @@ import {
CLINE_CONFIG, CLINE_CONFIG,
GITLAB_CONFIG, GITLAB_CONFIG,
CODEBUDDY_CONFIG, CODEBUDDY_CONFIG,
KIMCHI_CONFIG,
getOAuthClientMetadata, getOAuthClientMetadata,
} from "./constants/oauth"; } from "./constants/oauth";
import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai"; import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai";
@@ -1252,6 +1253,78 @@ const PROVIDERS = {
providerSpecificData: {}, 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,
},
};
},
},
}; };
/** /**
+26 -9
View File
@@ -65,7 +65,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setError(err.message); setError(err.message);
setStep("error"); setStep("error");
} }
}, [authData, provider, onSuccess]); }, [authData, provider, onSuccess, oauthMeta]);
const completeXaiManualCode = useCallback(async (code) => { const completeXaiManualCode = useCallback(async (code) => {
if (!authData?.state) return; if (!authData?.state) return;
@@ -387,7 +387,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
const handleCallback = async (data) => { const handleCallback = async (data) => {
if (callbackProcessedRef.current) return; // Already processed if (callbackProcessedRef.current) return; // Already processed
const { code, state, error: callbackError, errorDescription } = data; const { code, token, state, error: callbackError, errorDescription } = data;
if (callbackError) { if (callbackError) {
callbackProcessedRef.current = true; callbackProcessedRef.current = true;
@@ -396,9 +396,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
return; return;
} }
if (code) { if (token || code) {
callbackProcessedRef.current = true; 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; return;
} }
if (provider === "kimchi" && input && !input.includes("://") && !input.includes("?")) {
await exchangeTokens(input, null);
return;
}
const url = new URL(input); const url = new URL(input);
const code = url.searchParams.get("code"); const code = url.searchParams.get("code");
const token = url.searchParams.get("token");
const state = url.searchParams.get("state"); const state = url.searchParams.get("state");
const errorParam = url.searchParams.get("error"); 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); throw new Error(url.searchParams.get("error_description") || errorParam);
} }
if (!code) { if (!code && !token) {
throw new Error(provider === "xai" ? "Paste the callback URL or copied xAI code" : "No authorization code found in URL"); 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) { } catch (err) {
setError(err.message); setError(err.message);
setStep("error"); setStep("error");
@@ -509,11 +521,14 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
if (!provider || !providerInfo) return null; if (!provider || !providerInfo) return null;
const isXaiProvider = provider === "xai"; const isXaiProvider = provider === "xai";
const isKimchiProvider = provider === "kimchi";
const deviceLoginUrl = deviceData?.verification_uri_complete || deviceData?.verification_uri || ""; const deviceLoginUrl = deviceData?.verification_uri_complete || deviceData?.verification_uri || "";
const modalTitle = isXaiProvider ? "Connect Grok Build OAuth" : `Connect ${providerInfo.name}`; const modalTitle = isXaiProvider ? "Connect Grok Build OAuth" : `Connect ${providerInfo.name}`;
const manualPlaceholder = isXaiProvider const manualPlaceholder = isXaiProvider
? "http://127.0.0.1:56121/callback?code=... or copied code" ? "http://127.0.0.1:56121/callback?code=... or copied code"
: placeholderUrl; : isKimchiProvider
? `${placeholderUrl.replace("code=...", "token=...")} or copied token`
: placeholderUrl;
return ( return (
<Modal isOpen={isOpen} title={modalTitle} onClose={handleClose} size="lg"> <Modal isOpen={isOpen} title={modalTitle} onClose={handleClose} size="lg">
@@ -554,11 +569,13 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
<div> <div>
<p className="text-sm font-medium mb-2"> <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>
<p className="text-xs text-text-muted mb-2"> <p className="text-xs text-text-muted mb-2">
{provider === "xai" {provider === "xai"
? "If xAI shows a code instead of redirecting, paste that code here." ? "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."} : "After authorization, copy the full URL from your browser."}
</p> </p>
<Input <Input