From 8a664d619dc124f710993fa7a0a75c2963b93d64 Mon Sep 17 00:00:00 2001 From: Nant361 Date: Mon, 29 Jun 2026 15:24:39 +0700 Subject: [PATCH] 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 --- README.md | 4 + open-sse/executors/index.js | 3 + open-sse/executors/kimchi.js | 103 ++++++++++ .../handlers/chatCore/nonStreamingHandler.js | 74 +++++++- open-sse/providers/registry/index.js | 96 +++++----- open-sse/providers/registry/kimchi.js | 49 +++++ open-sse/services/kimchiModels.js | 176 ++++++++++++++++++ public/providers/kimchi.png | Bin 0 -> 13548 bytes .../api/oauth/[provider]/[action]/route.js | 4 +- src/app/api/providers/[id]/models/route.js | 25 +++ src/app/api/providers/[id]/test/testUtils.js | 12 ++ src/app/api/v1/models/route.js | 28 ++- src/app/callback/page.js | 4 +- src/lib/oauth/constants/oauth.js | 4 + src/lib/oauth/providers.js | 73 ++++++++ src/shared/components/OAuthModal.js | 35 +++- 16 files changed, 621 insertions(+), 69 deletions(-) create mode 100644 open-sse/executors/kimchi.js create mode 100644 open-sse/providers/registry/kimchi.js create mode 100644 open-sse/services/kimchiModels.js create mode 100644 public/providers/kimchi.png diff --git a/README.md b/README.md index 7f4b0552..a9e169a9 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,10 @@ Default URLs: Cursor
Cursor + + Kimchi
+ Kimchi + diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index 77d12fb3..52ae29bb 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -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"; diff --git a/open-sse/executors/kimchi.js b/open-sse/executors/kimchi.js new file mode 100644 index 00000000..8c6f7687 --- /dev/null +++ b/open-sse/executors/kimchi.js @@ -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; diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 0053e9b1..3996f94f 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -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; diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index d0a2fe57..64edcf32 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -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 ]; diff --git a/open-sse/providers/registry/kimchi.js b/open-sse/providers/registry/kimchi.js new file mode 100644 index 00000000..b89ee85b --- /dev/null +++ b/open-sse/providers/registry/kimchi.js @@ -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, +}; diff --git a/open-sse/services/kimchiModels.js b/open-sse/services/kimchiModels.js new file mode 100644 index 00000000..ae6f2a95 --- /dev/null +++ b/open-sse/services/kimchiModels.js @@ -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} */ +const catalogCache = new Map(); +/** @type {Map} */ +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(); +} diff --git a/public/providers/kimchi.png b/public/providers/kimchi.png new file mode 100644 index 0000000000000000000000000000000000000000..ff116adf3c2f4a56fc323d81758e01ad094a93c9 GIT binary patch literal 13548 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 { 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: { diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index cebd50a4..82035bea 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -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) { diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 0c5dffbc..8b1c720b 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -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); } diff --git a/src/app/callback/page.js b/src/app/callback/page.js index e55370d9..df7bbbc3 100644 --- a/src/app/callback/page.js +++ b/src/app/callback/page.js @@ -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; } diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index 4ce0eb7d..1354d5d9 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -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", }; diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index c66d25bb..bb543b85 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -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, + }, + }; + }, + }, }; /** diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index 424e23a1..6cb6f4f8 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -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 ( @@ -554,11 +569,13 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,

- 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

{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."}