mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat(kimchi): add Kimchi OAuth provider support
Add Kimchi as a browser-token OAuth provider routed through its OpenAI-compatible gateway. Discover live models for /v1/models and provider models, normalize Claude-compatible requests, and wire up provider connection tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
decolua
co-authored by
Cursor
parent
2d94fffe3b
commit
8a664d619d
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user