diff --git a/apps/supercode-cli/server/package.json b/apps/supercode-cli/server/package.json index 5ce2ab7..b14c291 100644 --- a/apps/supercode-cli/server/package.json +++ b/apps/supercode-cli/server/package.json @@ -1,6 +1,6 @@ { "name": "supercode-cli", - "version": "0.1.79", + "version": "0.1.80", "description": "AI-powered coding agent CLI", "main": "dist/main.js", "bin": { diff --git a/apps/supercode-cli/server/src/cli/ai/provider.ts b/apps/supercode-cli/server/src/cli/ai/provider.ts index 5184f65..03feb87 100644 --- a/apps/supercode-cli/server/src/cli/ai/provider.ts +++ b/apps/supercode-cli/server/src/cli/ai/provider.ts @@ -44,7 +44,7 @@ export interface AIProvider { export const providerMeta: Record = { supercode: { env: "", label: "Supercode Cloud", defaultModel: "deepseek-v4-flash" }, google: { env: "GOOGLE_BYOK_PROD_KEY / GOOGLE_BYOK_DEV_KEY", label: "Google Gemini", defaultModel: "gemini-2.5-flash", link: "https://aistudio.google.com/apikey" }, - minimax: { env: "MINIMAX_API_KEY", label: "MiniMax", defaultModel: "MiniMax-M2" }, + minimax: { env: "MINIMAX_BYOK_PROD_KEY / MINIMAX_BYOK_DEV_KEY", label: "MiniMax", defaultModel: "MiniMax-M2", link: "https://platform.minimax.ai" }, openrouter: { env: "OPENROUTER_BYOK_PROD_KEY / OPENROUTER_BYOK_DEV_KEY", label: "OpenRouter", defaultModel: "openai/gpt-oss-120b:free", link: "https://openrouter.ai/keys" }, nvidia: { env: "NVIDIA_BYOK_PROD_KEY / NVIDIA_BYOK_DEV_KEY", label: "NVIDIA NIM", defaultModel: "minimaxai/minimax-m3" }, concentrateai: { env: "CONCENTRATE_BYOK_PROD_KEY / CONCENTRATE_BYOK_DEV_KEY", label: "ConcentrateAI", defaultModel: "deepseek-v4-flash", link: "https://concentrate.ai" }, @@ -55,7 +55,7 @@ export const providerMeta: Record string> = { supercode: () => "", google: () => process.env.GOOGLE_BYOK_PROD_KEY || process.env.GOOGLE_BYOK_DEV_KEY || config.googleApiKey, - minimax: () => minimaxConfig.apiKey, + minimax: () => process.env.MINIMAX_BYOK_PROD_KEY || process.env.MINIMAX_BYOK_DEV_KEY || minimaxConfig.apiKey, openrouter: () => process.env.OPENROUTER_BYOK_PROD_KEY || process.env.OPENROUTER_BYOK_DEV_KEY || openRouterConfig.apiKey, nvidia: () => process.env.NVIDIA_BYOK_PROD_KEY || process.env.NVIDIA_BYOK_DEV_KEY || nvidiaConfig.apiKey, concentrateai: () => process.env.CONCENTRATE_BYOK_PROD_KEY || process.env.CONCENTRATE_BYOK_DEV_KEY || process.env.CONCENTRATEAI_API_KEY || "", @@ -84,6 +84,19 @@ export function createProvider(provider: ModelProvider, model?: string): AIProvi generateObject: (schema, prompt) => svc.generateObject(schema, prompt), } } + if (provider === "orcarouter") { + // Fall back to Supercode Cloud proxy when no BYOK key is configured. + // The server uses its own ORCAROUTER_API_KEY to handle the request. + const svc = new ServerProxyService("orcarouter", model || meta.defaultModel) + return { + name: provider, + modelName: model || meta.defaultModel, + connectionType: "proxy", + sendMessage: (messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult, onStepFinish) => + svc.sendMessage(messages, onChunk, tools, onToolCall, signal, onReasoning, onToolResult, onStepFinish), + generateObject: (schema, prompt) => svc.generateObject(schema, prompt), + } + } throw new Error( `No API key configured for ${meta.label}. ` + `Run \`/connect\` and select "${meta.label}" to save your key, ` + diff --git a/apps/supercode-cli/server/src/cli/commands/slashCommands/connect.ts b/apps/supercode-cli/server/src/cli/commands/slashCommands/connect.ts index a58651e..fdad241 100644 --- a/apps/supercode-cli/server/src/cli/commands/slashCommands/connect.ts +++ b/apps/supercode-cli/server/src/cli/commands/slashCommands/connect.ts @@ -16,6 +16,7 @@ const PROVIDERS: Array<{ value: ModelProvider; label: string; hint: string; link { value: "openrouter", label: "OpenRouter", hint: "multi-provider access", link: "https://openrouter.ai/keys" }, { value: "nvidia", label: "NVIDIA NIM", hint: "free NVIDIA hosted models", link: "https://build.nvidia.com/explore/discover" }, { value: "orcarouter", label: "OrcaRouter", hint: "multi-provider router", link: "https://orcarouter.ai" }, + { value: "minimax", label: "MiniMax", hint: "fast & smart models", link: "https://platform.minimax.ai" }, ] export async function connectProvider(): Promise<{ type: "connect"; provider?: ModelProvider; model?: string }> { diff --git a/apps/supercode-cli/server/src/config/orcarouter.config.ts b/apps/supercode-cli/server/src/config/orcarouter.config.ts index 7b8c3d8..738160b 100644 --- a/apps/supercode-cli/server/src/config/orcarouter.config.ts +++ b/apps/supercode-cli/server/src/config/orcarouter.config.ts @@ -1,5 +1,12 @@ +export function getOrcaRouterApiKey(): string { + return process.env.ORCAROUTER_BYOK_PROD_KEY + || process.env.ORCAROUTER_BYOK_DEV_KEY + || process.env.ORCAROUTER_API_KEY + || "" +} + export const orcarouterConfig = { - get apiKey() { return process.env.ORCAROUTER_API_KEY || "" }, + get apiKey() { return getOrcaRouterApiKey() }, get model() { return process.env.ORCAROUTER_MODEL || "openai/gpt-4o-mini" }, baseUrl: "https://api.orcarouter.ai/v1", get maxTokens() { return Number(process.env.ORCAROUTER_MAX_TOKENS) || 128000 }, diff --git a/apps/supercode-cli/server/src/index.ts b/apps/supercode-cli/server/src/index.ts index ddbc7bd..24e6cc9 100644 --- a/apps/supercode-cli/server/src/index.ts +++ b/apps/supercode-cli/server/src/index.ts @@ -660,6 +660,113 @@ app.post("/api/ai/chat", async (req, res) => { res.end() break } + case "orcarouter": { + const apiKey = process.env.ORCAROUTER_API_KEY + if (!apiKey) { res.status(500).json({ error: "OrcaRouter not configured on server" }); return } + const modelName = modelParam || "openai/gpt-4o-mini" + const orStart = Date.now() + const bodyObj: any = { + model: modelName, + messages: nonSystemMessages.map((m: any) => { + const msg: any = { + role: m.role, + content: m.content !== null && m.content !== undefined ? String(m.content) : "", + } + if (m.tool_calls) msg.tool_calls = m.tool_calls + if (m.tool_call_id) msg.tool_call_id = m.tool_call_id + return msg + }), + max_tokens: getModelMaxTokens(modelName), + temperature: 0.7, + stream: true, + } + if (system && nonSystemMessages.length > 0) { + bodyObj.messages = [{ role: "system", content: system }, ...bodyObj.messages] + } + if (tools) { + bodyObj.tools = Object.entries(tools).map(([name, fn]: [string, any]) => ({ + type: "function", + function: { name, description: fn.description || "", parameters: toolParams(fn) }, + })) + } + const response = await fetch("https://api.orcarouter.ai/v1/chat/completions", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify(bodyObj), + }) + if (!response.ok) { + const errText = await response.text().catch(() => "unknown error") + res.status(response.status).json({ error: `OrcaRouter API ${response.status}: ${errText}` }) + return + } + const reader = response.body?.getReader() + if (!reader) { res.status(500).json({ error: "No response body" }); return } + const decoder = new TextDecoder() + let buffer = "" + let inputTokens = 0 + let outputTokens = 0 + let pendingToolCalls: Record = {} + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop() || "" + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || !trimmed.startsWith("data: ")) continue + const jsonStr = trimmed.slice(6) + if (jsonStr === "[DONE]") break + try { + const data = JSON.parse(jsonStr) + const delta = data.choices?.[0]?.delta + if (delta?.content) { + res.write(JSON.stringify({ type: "text", content: delta.content }) + "\n") + } + if (delta?.tool_calls) { + for (const tc of delta.tool_calls) { + const index = tc.index ?? 0 + if (!pendingToolCalls[index]) { + pendingToolCalls[index] = { id: "", name: "", args: "" } + } + if (tc.id) pendingToolCalls[index].id = tc.id + if (tc.function?.name) pendingToolCalls[index].name = tc.function.name + if (tc.function?.arguments) pendingToolCalls[index].args += tc.function.arguments + } + } + const finishReason = data.choices?.[0]?.finish_reason + if (finishReason === "tool_calls") { + for (const [, call] of Object.entries(pendingToolCalls)) { + if (call.name && call.args) { + try { + const parsed = JSON.parse(call.args) + res.write(JSON.stringify({ type: "tool-call", toolName: call.name, args: parsed, toolCallId: call.id }) + "\n") + } catch { /* skip malformed args */ } + } + } + pendingToolCalls = {} + } + if (data.usage) { + inputTokens = data.usage.prompt_tokens ?? 0 + outputTokens = data.usage.completion_tokens ?? 0 + } + } catch { /* skip malformed */ } + } + } + recordUsage({ + provider: "orcarouter", model: modelName, + inputTokens, outputTokens, cachedInputTokens: 0, + totalTokens: inputTokens + outputTokens, + costUsd: computeCost(modelName, inputTokens, outputTokens, 0), + durationMs: Date.now() - orStart, + }) + res.write(JSON.stringify({ + type: "finish", reason: "stop", + usage: { inputTokens, outputTokenDetails: { textTokens: outputTokens, reasoningTokens: 0 }, outputTokens, inputTokenDetails: { noCacheTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, totalTokens: inputTokens + outputTokens } + }) + "\n") + res.end() + break + } case "concentrateai": { const { concentrateAiKey: forwardedKey } = req.body const apiKey = forwardedKey || process.env.CONCENTRATEAI_API_KEY @@ -1056,6 +1163,29 @@ app.post("/api/ai/generate-object", async (req, res) => { res.json({ object: { content: data.choices?.[0]?.message?.content || "" } }) break } + case "orcarouter": { + const apiKey = process.env.ORCAROUTER_API_KEY + if (!apiKey) { res.status(500).json({ error: "OrcaRouter not configured on server" }); return } + const modelName = modelParam || "openai/gpt-4o-mini" + const response = await fetch("https://api.orcarouter.ai/v1/chat/completions", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + model: modelName, + messages: [{ role: "user", content: prompt }], + max_tokens: getModelMaxTokens(modelName), + stream: false, + }), + }) + if (!response.ok) { + const errText = await response.text().catch(() => "unknown error") + res.status(response.status).json({ error: `OrcaRouter API ${response.status}: ${errText}` }) + return + } + const data: any = await response.json() + res.json({ object: { content: data.choices?.[0]?.message?.content || "" } }) + break + } case "concentrateai": { const { concentrateAiKey: forwardedKey } = req.body const apiKey = forwardedKey || process.env.CONCENTRATEAI_API_KEY diff --git a/apps/supercode-cli/server/src/lib/cli-config.ts b/apps/supercode-cli/server/src/lib/cli-config.ts index 2cc2985..21c0fb2 100644 --- a/apps/supercode-cli/server/src/lib/cli-config.ts +++ b/apps/supercode-cli/server/src/lib/cli-config.ts @@ -103,6 +103,7 @@ const BYOK_ENV_OVERRIDES: Partial