Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
340 changes: 208 additions & 132 deletions apps/supercode-cli/server/src/cli/ai/chat/chat.ts

Large diffs are not rendered by default.

14 changes: 10 additions & 4 deletions apps/supercode-cli/server/src/cli/ai/google-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@ import chalk from "chalk";

export class AIService {
model: ReturnType<ReturnType<typeof createGoogleGenerativeAI>>
readonly modelName: string

constructor() {
constructor(modelName?: string) {
if (!config.googleApiKey) {
throw new Error("Google Gemini is not configured.\n\n Set GOOGLE_GENERATIVE_AI_API_KEY in your environment:\n export GOOGLE_GENERATIVE_AI_API_KEY=<your-key>\n\n Get a key at: https://aistudio.google.com/apikey");
}

this.modelName = modelName || config.model

const google = createGoogleGenerativeAI({
apiKey: config.googleApiKey,
})

this.model = google(config.model)
this.model = google(this.modelName)
}

async generateStructured(
Expand Down Expand Up @@ -43,6 +46,7 @@ export class AIService {
onChunk?: (chunk: string) => void,
tools?: any,
onToolCall?: any,
signal?: AbortSignal,
) {
try {
const systemMessages = messages.filter(m => m.role === "system")
Expand All @@ -52,6 +56,7 @@ export class AIService {
const streamOptions: any = {
model: this.model,
messages: nonSystemMessages,
abortSignal: signal,
}

if (system) {
Expand All @@ -60,7 +65,7 @@ export class AIService {

if (tools && Object.keys(tools).length > 0) {
streamOptions.tools = tools
streamOptions.maxSteps = 5 // allow limit tool calling
streamOptions.maxSteps = 25
if (onToolCall) {
streamOptions.experimental_onToolCallStart = (event: any) => {
const tc = event.toolCall
Expand Down Expand Up @@ -110,7 +115,8 @@ export class AIService {
toolResults,
step: fullResult.steps
}
} catch (error) {
} catch (error: any) {
if (error?.name === "AbortError") throw error
console.error(chalk.red("AI Service Error:"), error instanceof Error ? error.message : String(error))
throw error
}
Expand Down
15 changes: 13 additions & 2 deletions apps/supercode-cli/server/src/cli/ai/minimax-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class MinimaxService {
const streamOptions: any = {
model: this.model,
messages: nonSystemMessages,
maxTokens: Number(process.env.MINIMAX_MAX_TOKENS) || 4096,
}

if (system) {
Expand All @@ -40,7 +41,7 @@ export class MinimaxService {

if (tools && Object.keys(tools).length > 0) {
streamOptions.tools = tools
streamOptions.maxSteps = 5
streamOptions.maxSteps = 25
if (onToolCall) {
streamOptions.experimental_onToolCallStart = (event: any) => {
const tc = event.toolCall
Expand All @@ -64,7 +65,17 @@ export class MinimaxService {
usage: result.usage,
}
} catch (error) {
console.error(chalk.red("MiniMax Service Error:"), error instanceof Error ? error.message : String(error))
const message = error instanceof Error ? error.message : String(error)
if (message.includes("insufficient balance") || message.includes("402") || message.includes("1008")) {
console.error(chalk.red("MiniMax API Error:"), "Insufficient balance. Top up at https://platform.minimax.ai")
throw new Error(
"MiniMax API: insufficient balance (402).\n\n" +
" Your MiniMax account has insufficient credits.\n" +
" Top up at: https://platform.minimax.ai\n" +
" Or switch to a different provider."
)
}
console.error(chalk.red("MiniMax Service Error:"), message)
throw error
}
}
Expand Down
5 changes: 4 additions & 1 deletion apps/supercode-cli/server/src/cli/ai/nvidia-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export class NvidiaService {
onChunk?: (chunk: string) => void,
tools?: any,
onToolCall?: any,
signal?: AbortSignal,
) {
try {
const bodyObj: any = {
Expand All @@ -46,6 +47,7 @@ export class NvidiaService {
"Content-Type": "application/json",
},
body: JSON.stringify(bodyObj),
signal,
})

if (!response.ok) {
Expand Down Expand Up @@ -139,7 +141,8 @@ export class NvidiaService {
finishResponse: Promise.resolve(finishReason),
usage: Promise.resolve(usage),
}
} catch (error) {
} catch (error: any) {
if (error?.name === "AbortError") throw error
console.error(chalk.red("NVIDIA Service Error:"), error instanceof Error ? error.message : String(error))
throw error
}
Expand Down
220 changes: 173 additions & 47 deletions apps/supercode-cli/server/src/cli/ai/openrouter-service.ts
Original file line number Diff line number Diff line change
@@ -1,89 +1,215 @@
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { streamText, type ModelMessage } from "ai"
import { type ModelMessage } from "ai"
import { openRouterConfig } from "../../config/openrouter.config.ts"
import chalk from "chalk"

const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"

function isServerTool(name: string): boolean {
return name === "web_search" || name === "url_fetch"
}

function serverTool(name: string): any {
if (name === "web_search") return { type: "openrouter:web_search" }
if (name === "url_fetch") return { type: "openrouter:web_fetch" }
return null
}

function zodToJsonSchema(schema: any): any {
return typeof schema === "object" && "toJSON" in (schema as any)
? (schema as any).toJSON()
: schema
}

export class OpenRouterService {
readonly modelName: string
model: any

constructor(model?: string) {
if (!openRouterConfig.apiKey) {
throw new Error("OpenRouter is not configured.\n\n Set OPENROUTER_API_KEY in your environment:\n export OPENROUTER_API_KEY=<your-key>\n\n Get a key at: https://openrouter.ai/keys")
}

this.modelName = model || openRouterConfig.model

const openrouter = createOpenRouter({
apiKey: openRouterConfig.apiKey,
})

this.model = openrouter(this.modelName)
}

async sendMessage(
messages: ModelMessage[],
onChunk?: (chunk: string) => void,
tools?: any,
onToolCall?: any,
signal?: AbortSignal,
) {
try {
const systemMessages = messages.filter(m => m.role === "system")
const nonSystemMessages = messages.filter(m => m.role !== "system")
const system = systemMessages.map(m => m.content).join("\n")

const streamOptions: any = {
model: this.model,
messages: nonSystemMessages,
const systemMessages = messages.filter(m => m.role === "system")
const nonSystemMessages = messages.filter(m => m.role !== "system")
const system = systemMessages.map(m => m.content).join("\n")

const apiMessages: any[] = []
if (system) apiMessages.push({ role: "system", content: system })
for (const m of nonSystemMessages) {
if (m.role === "assistant" && (m as any).tool_calls) {
const msg: any = { role: "assistant", content: m.content }
msg.tool_calls = (m as any).tool_calls
apiMessages.push(msg)
} else {
apiMessages.push({ role: m.role, content: m.content as string })
}
}

const apiTools: any[] = []
const functionTools: Record<string, any> = {}
if (tools) {
for (const [name, def] of Object.entries(tools as Record<string, any>)) {
if (isServerTool(name)) {
apiTools.push(serverTool(name))
} else {
apiTools.push({
type: "function",
function: {
name,
description: def.description || "",
parameters: zodToJsonSchema(def.parameters),
},
})
functionTools[name] = def
}
}
}

const allMessages = [...apiMessages]
const maxToolIterations = 25
let fullResponse = ""

for (let iter = 0; iter < maxToolIterations; iter++) {
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
if (fullResponse) onChunk?.("\n\n")

const body: any = {
model: this.modelName,
messages: allMessages,
stream: true,
}
if (apiTools.length > 0) body.tools = apiTools
if (this.modelName.includes("minimax-m3") || this.modelName.includes("glm-5.1")) {
streamOptions.maxOutputTokens = 8192
body.max_tokens = 8192
}

if (system) {
streamOptions.system = system
const res = await fetch(OPENROUTER_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${openRouterConfig.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal,
})

if (!res.ok) {
const errText = await res.text().catch(() => "unknown error")
throw new Error(`OpenRouter API ${res.status}: ${errText.slice(0, 500)}`)
}

if (tools && Object.keys(tools).length > 0) {
streamOptions.tools = tools
streamOptions.maxSteps = 5
if (onToolCall) {
streamOptions.experimental_onToolCallStart = (event: any) => {
const tc = event.toolCall
onToolCall({ toolName: tc.toolName, args: tc.input as Record<string, unknown> })
}
const reader = res.body?.getReader()
if (!reader) throw new Error("No response body")

const decoder = new TextDecoder()
let buffer = ""
let toolCalls: Array<{ id: string; type: string; function: { name: string; arguments: string } }> = []
let finishReason = ""

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]") continue

try {
const data = JSON.parse(jsonStr)
const delta = data.choices?.[0]?.delta
const finish = data.choices?.[0]?.finish_reason

if (finish) finishReason = finish

if (delta?.content) {
fullResponse += delta.content
onChunk?.(delta.content)
}

if (delta?.tool_calls) {
for (const tc of delta.tool_calls) {
const existing = toolCalls.find(t => t.id === tc.id)
if (existing) {
if (tc.function?.arguments) existing.function.arguments += tc.function.arguments
} else {
toolCalls.push({
id: tc.id,
type: tc.type || "function",
function: {
name: tc.function?.name || "",
arguments: tc.function?.arguments || "",
},
})
}
}
}
Comment on lines +144 to +160

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Tool call argument accumulation uses wrong identifier.

OpenRouter/OpenAI streaming sends tool calls with an index field for delta accumulation, not repeated id values. After the first chunk, subsequent deltas have index but may omit id, causing this logic to create duplicate entries instead of appending arguments.

🐛 Proposed fix using index-based accumulation
             if (delta?.tool_calls) {
               for (const tc of delta.tool_calls) {
-                const existing = toolCalls.find(t => t.id === tc.id)
+                const idx = tc.index ?? toolCalls.length
+                const existing = toolCalls[idx]
                 if (existing) {
                   if (tc.function?.arguments) existing.function.arguments += tc.function.arguments
+                  if (tc.function?.name) existing.function.name = tc.function.name
+                  if (tc.id) existing.id = tc.id
                 } else {
-                  toolCalls.push({
-                    id: tc.id,
+                  toolCalls[idx] = {
+                    id: tc.id || "",
                     type: tc.type || "function",
                     function: {
                       name: tc.function?.name || "",
                       arguments: tc.function?.arguments || "",
                     },
-                  })
+                  }
                 }
               }
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (delta?.tool_calls) {
for (const tc of delta.tool_calls) {
const existing = toolCalls.find(t => t.id === tc.id)
if (existing) {
if (tc.function?.arguments) existing.function.arguments += tc.function.arguments
} else {
toolCalls.push({
id: tc.id,
type: tc.type || "function",
function: {
name: tc.function?.name || "",
arguments: tc.function?.arguments || "",
},
})
}
}
}
if (delta?.tool_calls) {
for (const tc of delta.tool_calls) {
const idx = tc.index ?? toolCalls.length
const existing = toolCalls[idx]
if (existing) {
if (tc.function?.arguments) existing.function.arguments += tc.function.arguments
if (tc.function?.name) existing.function.name = tc.function.name
if (tc.id) existing.id = tc.id
} else {
toolCalls[idx] = {
id: tc.id || "",
type: tc.type || "function",
function: {
name: tc.function?.name || "",
arguments: tc.function?.arguments || "",
},
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/supercode-cli/server/src/cli/ai/openrouter-service.ts` around lines 144
- 160, The accumulation logic for streaming tool call deltas uses tc.id to find
existing entries but OpenRouter uses tc.index for subsequent chunks; update the
loop in openrouter-service.ts to prefer tc.index when locating/updating entries:
if tc.index is defined, use toolCalls[tc.index] (or assign into that slot) to
append function.arguments; otherwise fall back to finding by tc.id. When
creating a new entry, place it at toolCalls[tc.index] if index is provided (and
set id from tc.id if present), or push normally if no index; ensure
concatenation uses tc.function.arguments when appending.

} catch { /* skip malformed */ }
}
}

const result = streamText(streamOptions)
if (finishReason === "tool_calls" && toolCalls.length > 0) {
const assistantMsg: any = { role: "assistant", content: null }
assistantMsg.tool_calls = toolCalls.map(tc => ({
id: tc.id,
type: tc.type,
function: { name: tc.function.name, arguments: tc.function.arguments },
}))
allMessages.push(assistantMsg)

let fullResponse = ""
for (const tc of toolCalls) {
const toolName = tc.function.name
const toolDef = functionTools[toolName]
let toolResult: string

for await (const chunk of result.textStream) {
fullResponse += chunk
onChunk?.(chunk)
}
if (toolDef?.execute) {
let args: any = {}
try { args = JSON.parse(tc.function.arguments || "{}") } catch { /* */ }
onToolCall?.({ toolName, args })
try { toolResult = await toolDef.execute(args) } catch (err: any) { toolResult = `Error: ${err.message || String(err)}` }
} else {
toolResult = `Tool "${toolName}" is not available locally`
}

return {
content: fullResponse,
finishResponse: result.finishReason,
usage: result.usage,
}
} catch (error) {
console.error(chalk.red("OpenRouter Service Error:"), error instanceof Error ? error.message : String(error))
if (error instanceof Error && "cause" in error) {
console.error(chalk.red(" Cause:"), String((error as any).cause))
allMessages.push({
role: "tool",
tool_call_id: tc.id,
content: typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult),
})
}

toolCalls = []
finishReason = ""
continue
}
throw error

break
}

return {
content: fullResponse,
finishResponse: Promise.resolve("stop" as any),
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0, totalTokens: 0 } as any),
}
}

async getMessage(messages: ModelMessage[], tools?: any) {
let fullResponse = ""
await this.sendMessage(messages, (chunk) => {
fullResponse += chunk
})
await this.sendMessage(messages, (chunk) => { fullResponse += chunk }, tools)
return fullResponse
}
}
Loading