From 5de17b8d6289876395b26ce0e231997669aea31e Mon Sep 17 00:00:00 2001 From: smajumdar Date: Tue, 30 Dec 2025 10:03:03 -0800 Subject: [PATCH 01/10] Initial impl --- packages/opencode/src/cli/cmd/run.ts | 10 + packages/opencode/src/cli/cmd/serve.ts | 12 +- packages/opencode/src/flag/flag.ts | 1 + packages/opencode/src/index.ts | 5 + packages/opencode/src/session/llm.ts | 119 ++++++++- packages/opencode/src/util/trace-logger.ts | 198 +++++++++++++++ .../opencode/test/util/trace-logger.test.ts | 225 ++++++++++++++++++ 7 files changed, 568 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/util/trace-logger.ts create mode 100644 packages/opencode/test/util/trace-logger.test.ts diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0c371b864ce..49272aeafe4 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -11,6 +11,7 @@ import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2" import { Server } from "../../server/server" import { Provider } from "../../provider/provider" import { Agent } from "../../agent/agent" +import { TraceLogger } from "../../util/trace-logger" const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], @@ -87,8 +88,17 @@ export const RunCommand = cmd({ type: "number", describe: "port for the local server (defaults to random port if no value provided)", }) + .option("trace-dir", { + type: "string", + describe: "directory to save request-response trace logs (also configurable via OPENCODE_TRACE_DIR env variable)", + }) }, handler: async (args) => { + // Initialize trace logger if trace-dir is provided + if (args.traceDir) { + TraceLogger.init(args.traceDir) + } + let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 657f9196c96..585909c784e 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,12 +1,22 @@ import { Server } from "../../server/server" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" +import { TraceLogger } from "../../util/trace-logger" export const ServeCommand = cmd({ command: "serve", - builder: (yargs) => withNetworkOptions(yargs), + builder: (yargs) => + withNetworkOptions(yargs).option("trace-dir", { + type: "string", + describe: "directory to save request-response trace logs (also configurable via OPENCODE_TRACE_DIR env variable)", + }), describe: "starts a headless opencode server", handler: async (args) => { + // Initialize trace logger if trace-dir is provided + if (args.traceDir) { + TraceLogger.init(args.traceDir) + } + const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 805da33cc7a..0c39d873523 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -15,6 +15,7 @@ export namespace Flag { export const OPENCODE_DISABLE_MODELS_FETCH = truthy("OPENCODE_DISABLE_MODELS_FETCH") export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"] export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli" + export const OPENCODE_TRACE_DIR = process.env["OPENCODE_TRACE_DIR"] // Experimental export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL") diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 03ccf76042f..f83e6204011 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -27,6 +27,7 @@ import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" +import { TraceLogger } from "./util/trace-logger" process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -71,6 +72,10 @@ const cli = yargs(hideBin(process.argv)) process.env.AGENT = "1" process.env.OPENCODE = "1" + // Initialize trace logger from environment variable if set + // (individual commands can override with --trace-dir option) + TraceLogger.init() + Log.Default.info("opencode", { version: Installation.VERSION, args: process.argv.slice(2), diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a81aa7db224..61abcdabb20 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -11,6 +11,7 @@ import { Plugin } from "@/plugin" import { SystemPrompt } from "./system" import { ToolRegistry } from "@/tool/registry" import { Flag } from "@/flag/flag" +import { TraceLogger } from "@/util/trace-logger" export namespace LLM { const log = Log.create({ service: "llm" }) @@ -113,11 +114,41 @@ export namespace LLM { const tools = await resolveTools(input) - return streamText({ + // Create trace entry if tracing is enabled + const traceEntry = TraceLogger.isEnabled() + ? TraceLogger.createTraceEntry({ + sessionID: input.sessionID, + providerID: input.model.providerID, + modelID: input.model.id, + agent: input.agent.name, + system: system, + messages: input.messages, + tools: tools, + parameters: { + temperature: params.temperature, + topP: params.topP, + topK: params.topK, + maxOutputTokens: maxOutputTokens, + options: params.options, + }, + }) + : undefined + + const startTime = Date.now() + + const streamResult = streamText({ onError(error) { l.error("stream error", { error, }) + // Log trace with error if tracing is enabled + if (traceEntry) { + TraceLogger.updateTraceWithResponse(traceEntry, { + error: error instanceof Error ? error : new Error(String(error)), + duration: Date.now() - startTime, + }) + TraceLogger.logTrace(traceEntry) + } }, async experimental_repairToolCall(failed) { const lower = failed.toolCall.toolName.toLowerCase() @@ -185,6 +216,92 @@ export namespace LLM { }), experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry }, }) + + // Wrap the result to capture trace data if tracing is enabled + if (traceEntry) { + const originalFullStream = streamResult.fullStream + + // Collect response data as stream progresses + const responseData: { + text: string[] + toolCalls: Array<{ id: string; name: string; input: any }> + reasoning: string[] + finishReason?: string + usage?: any + } = { + text: [], + toolCalls: [], + reasoning: [], + } + + // Wrap fullStream to collect data + const wrappedStream = (async function* () { + try { + for await (const chunk of originalFullStream) { + // Collect response data based on chunk type + if ("type" in chunk) { + switch (chunk.type) { + case "text-delta": + if ("textDelta" in chunk && chunk.textDelta && typeof chunk.textDelta === "string") { + if (responseData.text.length === 0) { + responseData.text.push(chunk.textDelta) + } else { + responseData.text[responseData.text.length - 1] += chunk.textDelta + } + } + break + case "tool-call": + if ("toolCallId" in chunk && "toolName" in chunk && "args" in chunk) { + responseData.toolCalls.push({ + id: chunk.toolCallId, + name: chunk.toolName, + input: chunk.args, + }) + } + break + case "finish": + if ("finishReason" in chunk) { + responseData.finishReason = chunk.finishReason + } + if ("usage" in chunk) { + responseData.usage = chunk.usage + } + // Log trace when stream finishes + TraceLogger.updateTraceWithResponse(traceEntry, { + finishReason: responseData.finishReason, + usage: responseData.usage, + content: { + text: responseData.text.length > 0 ? responseData.text : undefined, + toolCalls: responseData.toolCalls.length > 0 ? responseData.toolCalls : undefined, + reasoning: responseData.reasoning.length > 0 ? responseData.reasoning : undefined, + }, + duration: Date.now() - startTime, + }) + await TraceLogger.logTrace(traceEntry) + break + } + } + yield chunk + } + } catch (error) { + // Log trace with error + if (error instanceof Error) { + TraceLogger.updateTraceWithResponse(traceEntry, { + error, + duration: Date.now() - startTime, + }) + await TraceLogger.logTrace(traceEntry) + } + throw error + } + })() + + // Replace the fullStream with wrapped version + // @ts-expect-error - We're wrapping the stream to add logging + streamResult.fullStream = wrappedStream + } + + return streamResult } async function resolveTools(input: Pick) { diff --git a/packages/opencode/src/util/trace-logger.ts b/packages/opencode/src/util/trace-logger.ts new file mode 100644 index 00000000000..f4815c6d0d4 --- /dev/null +++ b/packages/opencode/src/util/trace-logger.ts @@ -0,0 +1,198 @@ +import { Flag } from "@/flag/flag" +import { Log } from "./log" +import path from "path" +import fs from "fs/promises" + +export namespace TraceLogger { + const log = Log.create({ service: "trace-logger" }) + + let traceDir: string | undefined = undefined + let enabled = false + + /** + * Initialize the trace logger with the specified directory. + * Can be called from CLI options or will use environment variable. + */ + export function init(directory?: string) { + traceDir = directory || Flag.OPENCODE_TRACE_DIR + enabled = !!traceDir + + if (enabled) { + log.info("trace logging enabled", { directory: traceDir }) + } + } + + /** + * Check if trace logging is enabled + */ + export function isEnabled(): boolean { + return enabled + } + + /** + * Get the trace directory path + */ + export function getDirectory(): string | undefined { + return traceDir + } + + export type TraceEntry = { + timestamp: string + sessionID: string + requestID: string + providerID: string + modelID: string + agent: string + request: { + system: string[] + messages: any[] + tools: Record + parameters: { + temperature?: number + topP?: number + topK?: number + maxOutputTokens?: number + options?: any + } + } + response?: { + finishReason?: string + usage?: { + inputTokens: number + outputTokens: number + totalTokens: number + cacheReadTokens?: number + cacheWriteTokens?: number + } + content?: { + text?: string[] + toolCalls?: Array<{ + id: string + name: string + input: any + }> + reasoning?: string[] + } + error?: { + name: string + message: string + stack?: string + } + } + duration?: number + } + + /** + * Log a trace entry for an LLM request-response pair + */ + export async function logTrace(entry: TraceEntry): Promise { + if (!enabled || !traceDir) { + return + } + + try { + // Ensure the trace directory exists + await fs.mkdir(traceDir, { recursive: true }) + + // Create a filename with timestamp and request ID + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const filename = `${timestamp}_${entry.sessionID}_${entry.requestID}.json` + const filepath = path.join(traceDir, filename) + + // Write the trace entry as formatted JSON + await fs.writeFile(filepath, JSON.stringify(entry, null, 2), "utf-8") + + log.debug("trace logged", { filepath }) + } catch (error) { + log.error("failed to write trace", { error }) + } + } + + /** + * Create a trace entry from LLM stream input + */ + export function createTraceEntry(input: { + sessionID: string + providerID: string + modelID: string + agent: string + system: string[] + messages: any[] + tools: Record + parameters: { + temperature?: number + topP?: number + topK?: number + maxOutputTokens?: number + options?: any + } + }): TraceEntry { + return { + timestamp: new Date().toISOString(), + sessionID: input.sessionID, + requestID: generateRequestID(), + providerID: input.providerID, + modelID: input.modelID, + agent: input.agent, + request: { + system: input.system, + messages: input.messages, + tools: input.tools, + parameters: input.parameters, + }, + } + } + + /** + * Generate a unique request ID for tracing + */ + function generateRequestID(): string { + const timestamp = Date.now().toString(36) + const random = Math.random().toString(36).substring(2, 10) + return `trace_${timestamp}_${random}` + } + + /** + * Update a trace entry with response data + */ + export function updateTraceWithResponse( + entry: TraceEntry, + response: { + finishReason?: string + usage?: any + content?: { + text?: string[] + toolCalls?: Array<{ + id: string + name: string + input: any + }> + reasoning?: string[] + } + error?: Error + duration: number + }, + ): void { + entry.duration = response.duration + entry.response = { + finishReason: response.finishReason, + usage: response.usage + ? { + inputTokens: response.usage.promptTokens || 0, + outputTokens: response.usage.completionTokens || 0, + totalTokens: response.usage.totalTokens || 0, + cacheReadTokens: response.usage.cacheReadTokens, + cacheWriteTokens: response.usage.cacheCreationTokens, + } + : undefined, + content: response.content, + error: response.error + ? { + name: response.error.name, + message: response.error.message, + stack: response.error.stack, + } + : undefined, + } + } +} diff --git a/packages/opencode/test/util/trace-logger.test.ts b/packages/opencode/test/util/trace-logger.test.ts new file mode 100644 index 00000000000..830a6f70547 --- /dev/null +++ b/packages/opencode/test/util/trace-logger.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test" +import { TraceLogger } from "../../src/util/trace-logger" +import fs from "fs/promises" +import path from "path" +import os from "os" + +describe("TraceLogger", () => { + let testDir: string + + beforeEach(async () => { + // Create a temporary directory for test traces + testDir = path.join(os.tmpdir(), `opencode-trace-test-${Date.now()}`) + await fs.mkdir(testDir, { recursive: true }) + }) + + afterEach(async () => { + // Clean up test directory + try { + await fs.rm(testDir, { recursive: true, force: true }) + } catch (e) { + // Ignore cleanup errors + } + }) + + it("should not be enabled by default", () => { + expect(TraceLogger.isEnabled()).toBe(false) + }) + + it("should enable tracing when initialized with a directory", () => { + TraceLogger.init(testDir) + expect(TraceLogger.isEnabled()).toBe(true) + expect(TraceLogger.getDirectory()).toBe(testDir) + }) + + it("should create a trace entry with correct structure", () => { + const entry = TraceLogger.createTraceEntry({ + sessionID: "test-session", + providerID: "openai", + modelID: "gpt-4", + agent: "default", + system: ["System prompt"], + messages: [{ role: "user", content: "Hello" }], + tools: { bash: { name: "bash" } }, + parameters: { + temperature: 0.7, + topP: 0.9, + maxOutputTokens: 32000, + }, + }) + + expect(entry.sessionID).toBe("test-session") + expect(entry.providerID).toBe("openai") + expect(entry.modelID).toBe("gpt-4") + expect(entry.agent).toBe("default") + expect(entry.request.system).toEqual(["System prompt"]) + expect(entry.request.messages).toEqual([{ role: "user", content: "Hello" }]) + expect(entry.request.tools).toEqual({ bash: { name: "bash" } }) + }) + + it("should update trace entry with response data", () => { + const entry = TraceLogger.createTraceEntry({ + sessionID: "test-session", + providerID: "openai", + modelID: "gpt-4", + agent: "default", + system: ["System prompt"], + messages: [], + tools: {}, + parameters: {}, + }) + + TraceLogger.updateTraceWithResponse(entry, { + finishReason: "stop", + usage: { + promptTokens: 100, + completionTokens: 50, + totalTokens: 150, + }, + content: { + text: ["Hello, world!"], + }, + duration: 1234, + }) + + expect(entry.response).toBeDefined() + expect(entry.response?.finishReason).toBe("stop") + expect(entry.response?.usage?.inputTokens).toBe(100) + expect(entry.response?.usage?.outputTokens).toBe(50) + expect(entry.response?.usage?.totalTokens).toBe(150) + expect(entry.response?.content?.text).toEqual(["Hello, world!"]) + expect(entry.duration).toBe(1234) + }) + + it("should log trace to file when enabled", async () => { + TraceLogger.init(testDir) + + const entry = TraceLogger.createTraceEntry({ + sessionID: "test-session", + providerID: "openai", + modelID: "gpt-4", + agent: "default", + system: ["System prompt"], + messages: [{ role: "user", content: "Test message" }], + tools: {}, + parameters: {}, + }) + + TraceLogger.updateTraceWithResponse(entry, { + finishReason: "stop", + usage: { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + }, + content: { + text: ["Response text"], + }, + duration: 500, + }) + + await TraceLogger.logTrace(entry) + + // Verify file was created + const files = await fs.readdir(testDir) + expect(files.length).toBe(1) + expect(files[0]).toMatch(/\.json$/) + + // Verify file contents + const filePath = path.join(testDir, files[0]) + const content = await fs.readFile(filePath, "utf-8") + const parsed = JSON.parse(content) + + expect(parsed.sessionID).toBe("test-session") + expect(parsed.providerID).toBe("openai") + expect(parsed.modelID).toBe("gpt-4") + expect(parsed.response.content.text).toEqual(["Response text"]) + }) + + it("should not log trace when disabled", async () => { + // Don't initialize - tracing should be disabled + const entry = TraceLogger.createTraceEntry({ + sessionID: "test-session", + providerID: "openai", + modelID: "gpt-4", + agent: "default", + system: [], + messages: [], + tools: {}, + parameters: {}, + }) + + await TraceLogger.logTrace(entry) + + // Verify no files were created in test directory + const exists = await fs + .access(testDir) + .then(() => true) + .catch(() => false) + if (exists) { + const files = await fs.readdir(testDir) + expect(files.length).toBe(0) + } + }) + + it("should handle errors in trace entry", () => { + const entry = TraceLogger.createTraceEntry({ + sessionID: "test-session", + providerID: "openai", + modelID: "gpt-4", + agent: "default", + system: [], + messages: [], + tools: {}, + parameters: {}, + }) + + const error = new Error("Test error") + error.stack = "Error stack trace" + + TraceLogger.updateTraceWithResponse(entry, { + error, + duration: 100, + }) + + expect(entry.response?.error).toBeDefined() + expect(entry.response?.error?.name).toBe("Error") + expect(entry.response?.error?.message).toBe("Test error") + expect(entry.response?.error?.stack).toBe("Error stack trace") + }) + + it("should include tool calls in response", () => { + const entry = TraceLogger.createTraceEntry({ + sessionID: "test-session", + providerID: "openai", + modelID: "gpt-4", + agent: "default", + system: [], + messages: [], + tools: {}, + parameters: {}, + }) + + TraceLogger.updateTraceWithResponse(entry, { + content: { + toolCalls: [ + { + id: "call_1", + name: "bash", + input: { command: "ls -la" }, + }, + { + id: "call_2", + name: "read_file", + input: { path: "test.txt" }, + }, + ], + }, + duration: 500, + }) + + expect(entry.response?.content?.toolCalls).toHaveLength(2) + expect(entry.response?.content?.toolCalls?.[0].name).toBe("bash") + expect(entry.response?.content?.toolCalls?.[1].name).toBe("read_file") + }) +}) From 0f105b13e044e735fda81ca8078bf69e9b7b7343 Mon Sep 17 00:00:00 2001 From: smajumdar Date: Tue, 30 Dec 2025 10:20:03 -0800 Subject: [PATCH 02/10] refactor(trace-logger): merge system messages into request messages array Updated the TraceLogger to combine system prompts into the messages array with the role "system". Adjusted related tests to verify the new structure and ensure proper functionality. --- packages/opencode/src/util/trace-logger.ts | 10 ++++-- .../opencode/test/util/trace-logger.test.ts | 36 +++++++++++++++++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/util/trace-logger.ts b/packages/opencode/src/util/trace-logger.ts index f4815c6d0d4..36aec0d1fdf 100644 --- a/packages/opencode/src/util/trace-logger.ts +++ b/packages/opencode/src/util/trace-logger.ts @@ -44,7 +44,6 @@ export namespace TraceLogger { modelID: string agent: string request: { - system: string[] messages: any[] tools: Record parameters: { @@ -127,6 +126,12 @@ export namespace TraceLogger { options?: any } }): TraceEntry { + // Merge system prompts into messages array as role "system" + const systemMessages = input.system.map((content) => ({ + role: "system" as const, + content, + })) + return { timestamp: new Date().toISOString(), sessionID: input.sessionID, @@ -135,8 +140,7 @@ export namespace TraceLogger { modelID: input.modelID, agent: input.agent, request: { - system: input.system, - messages: input.messages, + messages: [...systemMessages, ...input.messages], tools: input.tools, parameters: input.parameters, }, diff --git a/packages/opencode/test/util/trace-logger.test.ts b/packages/opencode/test/util/trace-logger.test.ts index 830a6f70547..06a1b8821f8 100644 --- a/packages/opencode/test/util/trace-logger.test.ts +++ b/packages/opencode/test/util/trace-logger.test.ts @@ -52,8 +52,10 @@ describe("TraceLogger", () => { expect(entry.providerID).toBe("openai") expect(entry.modelID).toBe("gpt-4") expect(entry.agent).toBe("default") - expect(entry.request.system).toEqual(["System prompt"]) - expect(entry.request.messages).toEqual([{ role: "user", content: "Hello" }]) + expect(entry.request.messages).toEqual([ + { role: "system", content: "System prompt" }, + { role: "user", content: "Hello" }, + ]) expect(entry.request.tools).toEqual({ bash: { name: "bash" } }) }) @@ -133,6 +135,10 @@ describe("TraceLogger", () => { expect(parsed.sessionID).toBe("test-session") expect(parsed.providerID).toBe("openai") expect(parsed.modelID).toBe("gpt-4") + expect(parsed.request.messages).toEqual([ + { role: "system", content: "System prompt" }, + { role: "user", content: "Test message" }, + ]) expect(parsed.response.content.text).toEqual(["Response text"]) }) @@ -222,4 +228,30 @@ describe("TraceLogger", () => { expect(entry.response?.content?.toolCalls?.[0].name).toBe("bash") expect(entry.response?.content?.toolCalls?.[1].name).toBe("read_file") }) + + it("should merge multiple system messages into messages array", () => { + const entry = TraceLogger.createTraceEntry({ + sessionID: "test-session", + providerID: "openai", + modelID: "gpt-4", + agent: "default", + system: ["System prompt 1", "System prompt 2", "System prompt 3"], + messages: [ + { role: "user", content: "User message 1" }, + { role: "assistant", content: "Assistant response" }, + { role: "user", content: "User message 2" }, + ], + tools: {}, + parameters: {}, + }) + + // Verify system messages are at the beginning + expect(entry.request.messages).toHaveLength(6) + expect(entry.request.messages[0]).toEqual({ role: "system", content: "System prompt 1" }) + expect(entry.request.messages[1]).toEqual({ role: "system", content: "System prompt 2" }) + expect(entry.request.messages[2]).toEqual({ role: "system", content: "System prompt 3" }) + expect(entry.request.messages[3]).toEqual({ role: "user", content: "User message 1" }) + expect(entry.request.messages[4]).toEqual({ role: "assistant", content: "Assistant response" }) + expect(entry.request.messages[5]).toEqual({ role: "user", content: "User message 2" }) + }) }) From 7983a48c461f08aa929fe0841f887f5049f77f82 Mon Sep 17 00:00:00 2001 From: smajumdar Date: Tue, 30 Dec 2025 15:09:16 -0800 Subject: [PATCH 03/10] feat(trace-logger): add trace-dir option for logging and enhance response structure Introduced a new CLI option `--trace-dir` to specify the directory for saving request-response trace logs. Updated the TraceLogger to initialize from this option or the corresponding environment variable. Enhanced the response structure to include additional fields and improved error handling, ensuring comprehensive logging of requests and responses. --- packages/opencode/src/cli/cmd/run.ts | 10 -- packages/opencode/src/cli/cmd/serve.ts | 12 +- packages/opencode/src/index.ts | 9 +- packages/opencode/src/session/llm.ts | 54 ++++-- packages/opencode/src/util/trace-logger.ts | 184 +++++++++++++++------ 5 files changed, 175 insertions(+), 94 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 49272aeafe4..0c371b864ce 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -11,7 +11,6 @@ import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2" import { Server } from "../../server/server" import { Provider } from "../../provider/provider" import { Agent } from "../../agent/agent" -import { TraceLogger } from "../../util/trace-logger" const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], @@ -88,17 +87,8 @@ export const RunCommand = cmd({ type: "number", describe: "port for the local server (defaults to random port if no value provided)", }) - .option("trace-dir", { - type: "string", - describe: "directory to save request-response trace logs (also configurable via OPENCODE_TRACE_DIR env variable)", - }) }, handler: async (args) => { - // Initialize trace logger if trace-dir is provided - if (args.traceDir) { - TraceLogger.init(args.traceDir) - } - let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 585909c784e..657f9196c96 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,22 +1,12 @@ import { Server } from "../../server/server" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" -import { TraceLogger } from "../../util/trace-logger" export const ServeCommand = cmd({ command: "serve", - builder: (yargs) => - withNetworkOptions(yargs).option("trace-dir", { - type: "string", - describe: "directory to save request-response trace logs (also configurable via OPENCODE_TRACE_DIR env variable)", - }), + builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", handler: async (args) => { - // Initialize trace logger if trace-dir is provided - if (args.traceDir) { - TraceLogger.init(args.traceDir) - } - const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index f83e6204011..3476785a544 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -58,6 +58,10 @@ const cli = yargs(hideBin(process.argv)) type: "string", choices: ["DEBUG", "INFO", "WARN", "ERROR"], }) + .option("trace-dir", { + describe: "directory to save request-response trace logs (also configurable via OPENCODE_TRACE_DIR env variable)", + type: "string", + }) .middleware(async (opts) => { await Log.init({ print: process.argv.includes("--print-logs"), @@ -72,9 +76,8 @@ const cli = yargs(hideBin(process.argv)) process.env.AGENT = "1" process.env.OPENCODE = "1" - // Initialize trace logger from environment variable if set - // (individual commands can override with --trace-dir option) - TraceLogger.init() + // Initialize trace logger from CLI option or environment variable + TraceLogger.init(opts.traceDir as string | undefined) Log.Default.info("opencode", { version: Installation.VERSION, diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 61abcdabb20..06c535309c7 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -223,17 +223,19 @@ export namespace LLM { // Collect response data as stream progresses const responseData: { - text: string[] + text: string toolCalls: Array<{ id: string; name: string; input: any }> reasoning: string[] finishReason?: string usage?: any } = { - text: [], + text: "", toolCalls: [], reasoning: [], } + let currentReasoning = "" + // Wrap fullStream to collect data const wrappedStream = (async function* () { try { @@ -242,26 +244,36 @@ export namespace LLM { if ("type" in chunk) { switch (chunk.type) { case "text-delta": - if ("textDelta" in chunk && chunk.textDelta && typeof chunk.textDelta === "string") { - if (responseData.text.length === 0) { - responseData.text.push(chunk.textDelta) - } else { - responseData.text[responseData.text.length - 1] += chunk.textDelta - } + if ("text" in chunk && chunk.text && typeof chunk.text === "string") { + responseData.text += chunk.text + } + break + case "reasoning-start": + currentReasoning = "" + break + case "reasoning-delta": + if ("text" in chunk && chunk.text && typeof chunk.text === "string") { + currentReasoning += chunk.text + } + break + case "reasoning-end": + if (currentReasoning) { + responseData.reasoning.push(currentReasoning) + currentReasoning = "" } break case "tool-call": - if ("toolCallId" in chunk && "toolName" in chunk && "args" in chunk) { + if ("toolCallId" in chunk && "toolName" in chunk && "input" in chunk) { responseData.toolCalls.push({ - id: chunk.toolCallId, - name: chunk.toolName, - input: chunk.args, + id: chunk.toolCallId as string, + name: chunk.toolName as string, + input: chunk.input, }) } break - case "finish": + case "finish-step": if ("finishReason" in chunk) { - responseData.finishReason = chunk.finishReason + responseData.finishReason = chunk.finishReason as string } if ("usage" in chunk) { responseData.usage = chunk.usage @@ -271,7 +283,7 @@ export namespace LLM { finishReason: responseData.finishReason, usage: responseData.usage, content: { - text: responseData.text.length > 0 ? responseData.text : undefined, + text: responseData.text ? [responseData.text] : undefined, toolCalls: responseData.toolCalls.length > 0 ? responseData.toolCalls : undefined, reasoning: responseData.reasoning.length > 0 ? responseData.reasoning : undefined, }, @@ -296,9 +308,15 @@ export namespace LLM { } })() - // Replace the fullStream with wrapped version - // @ts-expect-error - We're wrapping the stream to add logging - streamResult.fullStream = wrappedStream + // Create a proxy to wrap the stream result with our traced fullStream + return new Proxy(streamResult, { + get(target, prop) { + if (prop === "fullStream") { + return wrappedStream + } + return Reflect.get(target, prop) + }, + }) } return streamResult diff --git a/packages/opencode/src/util/trace-logger.ts b/packages/opencode/src/util/trace-logger.ts index 36aec0d1fdf..4e8416a318a 100644 --- a/packages/opencode/src/util/trace-logger.ts +++ b/packages/opencode/src/util/trace-logger.ts @@ -41,44 +41,61 @@ export namespace TraceLogger { sessionID: string requestID: string providerID: string - modelID: string agent: string request: { - messages: any[] - tools: Record - parameters: { - temperature?: number - topP?: number - topK?: number - maxOutputTokens?: number - options?: any + model: string + messages: Array<{ + role: string + content: string | any[] + tool_calls?: any[] + tool_call_id?: string + reasoning_content?: string + }> + temperature?: number + top_p?: number + stream?: boolean + stream_options?: { + include_usage?: boolean } + tools?: any[] + max_tokens?: number } response?: { - finishReason?: string + id?: string + object?: string + created?: number + model?: string + choices?: Array<{ + index: number + message: { + role: string + content: string | null + tool_calls?: any[] + reasoning_content?: string + refusal?: string | null + } + finish_reason: string + logprobs?: any + }> usage?: { - inputTokens: number - outputTokens: number - totalTokens: number - cacheReadTokens?: number - cacheWriteTokens?: number - } - content?: { - text?: string[] - toolCalls?: Array<{ - id: string - name: string - input: any - }> - reasoning?: string[] - } - error?: { - name: string - message: string - stack?: string + prompt_tokens: number + total_tokens: number + completion_tokens: number + cache_read_tokens?: number + cache_write_tokens?: number } } - duration?: number + error?: { + name: string + message: string + stack?: string + } + system?: { + hostname?: string + platform?: string + release?: string + nodeVersion?: string + } } /** @@ -93,7 +110,7 @@ export namespace TraceLogger { // Ensure the trace directory exists await fs.mkdir(traceDir, { recursive: true }) - // Create a filename with timestamp and request ID + // Create a filename with timestamp, session ID, and request ID const timestamp = new Date().toISOString().replace(/[:.]/g, "-") const filename = `${timestamp}_${entry.sessionID}_${entry.requestID}.json` const filepath = path.join(traceDir, filename) @@ -117,32 +134,42 @@ export namespace TraceLogger { agent: string system: string[] messages: any[] - tools: Record + tools: any[] parameters: { temperature?: number topP?: number topK?: number maxOutputTokens?: number + stream?: boolean options?: any } }): TraceEntry { // Merge system prompts into messages array as role "system" const systemMessages = input.system.map((content) => ({ - role: "system" as const, + role: "system", content, })) + // Format tools array if provided + const formattedTools = input.tools?.length > 0 ? input.tools : undefined + return { timestamp: new Date().toISOString(), sessionID: input.sessionID, requestID: generateRequestID(), providerID: input.providerID, - modelID: input.modelID, agent: input.agent, request: { + model: input.modelID, messages: [...systemMessages, ...input.messages], - tools: input.tools, - parameters: input.parameters, + temperature: input.parameters.temperature, + top_p: input.parameters.topP, + stream: input.parameters.stream ?? true, + stream_options: { + include_usage: true, + }, + tools: formattedTools, + max_tokens: input.parameters.maxOutputTokens, }, } } @@ -162,6 +189,7 @@ export namespace TraceLogger { export function updateTraceWithResponse( entry: TraceEntry, response: { + id?: string finishReason?: string usage?: any content?: { @@ -174,29 +202,81 @@ export namespace TraceLogger { reasoning?: string[] } error?: Error - duration: number }, ): void { - entry.duration = response.duration + if (response.error) { + entry.error = { + name: response.error.name, + message: response.error.message, + stack: response.error.stack, + } + // Add system information even on error + entry.system = { + hostname: process.env.HOSTNAME, + platform: process.platform, + release: process.release?.name, + nodeVersion: process.version, + } + return + } + + // Build message content + let messageContent: string | null = null + const toolCalls: any[] = [] + + if (response.content?.text && response.content.text.length > 0) { + messageContent = response.content.text.join("") + } + + if (response.content?.toolCalls && response.content.toolCalls.length > 0) { + response.content.toolCalls.forEach((tc) => { + toolCalls.push({ + id: tc.id, + type: "function", + function: { + name: tc.name, + arguments: JSON.stringify(tc.input), + }, + }) + }) + } + entry.response = { - finishReason: response.finishReason, + id: response.id || `chatcmpl-${Date.now().toString(36)}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: entry.request.model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: messageContent, + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + reasoning_content: response.content?.reasoning?.join("") || undefined, + refusal: null, + }, + finish_reason: response.finishReason || "stop", + logprobs: null, + }, + ], usage: response.usage ? { - inputTokens: response.usage.promptTokens || 0, - outputTokens: response.usage.completionTokens || 0, - totalTokens: response.usage.totalTokens || 0, - cacheReadTokens: response.usage.cacheReadTokens, - cacheWriteTokens: response.usage.cacheCreationTokens, - } - : undefined, - content: response.content, - error: response.error - ? { - name: response.error.name, - message: response.error.message, - stack: response.error.stack, + prompt_tokens: response.usage.inputTokens || response.usage.promptTokens || 0, + total_tokens: response.usage.totalTokens || 0, + completion_tokens: response.usage.outputTokens || response.usage.completionTokens || 0, + cache_read_tokens: response.usage.cachedInputTokens || response.usage.cacheReadTokens, + cache_write_tokens: response.usage.cacheCreationTokens, } : undefined, } + + // Add system information + entry.system = { + hostname: process.env.HOSTNAME, + platform: process.platform, + release: process.release?.name, + nodeVersion: process.version, + } } } From 4dd54a78fd526a750b6df3612d4d4f5dd41d2db3 Mon Sep 17 00:00:00 2001 From: smajumdar Date: Tue, 30 Dec 2025 15:13:05 -0800 Subject: [PATCH 04/10] feat(trace-logger): enhance logging structure with modelID and duration fields Added modelID and duration fields to the TraceLogger for improved request tracking. Updated tools handling to support both array and object formats. Adjusted tests to reflect changes in response structure and ensure accurate logging of errors and tool calls. --- packages/opencode/src/util/trace-logger.ts | 27 ++++++++++++++++-- .../opencode/test/util/trace-logger.test.ts | 28 +++++++++---------- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/util/trace-logger.ts b/packages/opencode/src/util/trace-logger.ts index 4e8416a318a..618e2c62488 100644 --- a/packages/opencode/src/util/trace-logger.ts +++ b/packages/opencode/src/util/trace-logger.ts @@ -41,7 +41,9 @@ export namespace TraceLogger { sessionID: string requestID: string providerID: string + modelID: string agent: string + duration?: number request: { model: string messages: Array<{ @@ -84,6 +86,10 @@ export namespace TraceLogger { cache_read_tokens?: number cache_write_tokens?: number } + error?: Error + content?: { + text?: string[] + } } error?: { name: string @@ -134,7 +140,7 @@ export namespace TraceLogger { agent: string system: string[] messages: any[] - tools: any[] + tools: any[] | Record parameters: { temperature?: number topP?: number @@ -150,14 +156,23 @@ export namespace TraceLogger { content, })) - // Format tools array if provided - const formattedTools = input.tools?.length > 0 ? input.tools : undefined + // Format tools - convert Record to array if needed + let formattedTools: any[] | undefined = undefined + if (input.tools) { + if (Array.isArray(input.tools)) { + formattedTools = input.tools.length > 0 ? input.tools : undefined + } else { + const toolsArray = Object.values(input.tools) + formattedTools = toolsArray.length > 0 ? toolsArray : undefined + } + } return { timestamp: new Date().toISOString(), sessionID: input.sessionID, requestID: generateRequestID(), providerID: input.providerID, + modelID: input.modelID, agent: input.agent, request: { model: input.modelID, @@ -202,8 +217,14 @@ export namespace TraceLogger { reasoning?: string[] } error?: Error + duration?: number }, ): void { + // Store duration if provided + if (response.duration !== undefined) { + entry.duration = response.duration + } + if (response.error) { entry.error = { name: response.error.name, diff --git a/packages/opencode/test/util/trace-logger.test.ts b/packages/opencode/test/util/trace-logger.test.ts index 06a1b8821f8..cc0ddd95548 100644 --- a/packages/opencode/test/util/trace-logger.test.ts +++ b/packages/opencode/test/util/trace-logger.test.ts @@ -56,7 +56,7 @@ describe("TraceLogger", () => { { role: "system", content: "System prompt" }, { role: "user", content: "Hello" }, ]) - expect(entry.request.tools).toEqual({ bash: { name: "bash" } }) + expect(entry.request.tools).toEqual([{ name: "bash" }]) }) it("should update trace entry with response data", () => { @@ -85,11 +85,11 @@ describe("TraceLogger", () => { }) expect(entry.response).toBeDefined() - expect(entry.response?.finishReason).toBe("stop") - expect(entry.response?.usage?.inputTokens).toBe(100) - expect(entry.response?.usage?.outputTokens).toBe(50) - expect(entry.response?.usage?.totalTokens).toBe(150) - expect(entry.response?.content?.text).toEqual(["Hello, world!"]) + expect(entry.response?.choices?.[0]?.finish_reason).toBe("stop") + expect(entry.response?.usage?.prompt_tokens).toBe(100) + expect(entry.response?.usage?.completion_tokens).toBe(50) + expect(entry.response?.usage?.total_tokens).toBe(150) + expect(entry.response?.choices?.[0]?.message.content).toBe("Hello, world!") expect(entry.duration).toBe(1234) }) @@ -139,7 +139,7 @@ describe("TraceLogger", () => { { role: "system", content: "System prompt" }, { role: "user", content: "Test message" }, ]) - expect(parsed.response.content.text).toEqual(["Response text"]) + expect(parsed.response.choices[0].message.content).toBe("Response text") }) it("should not log trace when disabled", async () => { @@ -188,10 +188,10 @@ describe("TraceLogger", () => { duration: 100, }) - expect(entry.response?.error).toBeDefined() - expect(entry.response?.error?.name).toBe("Error") - expect(entry.response?.error?.message).toBe("Test error") - expect(entry.response?.error?.stack).toBe("Error stack trace") + expect(entry.error).toBeDefined() + expect(entry.error?.name).toBe("Error") + expect(entry.error?.message).toBe("Test error") + expect(entry.error?.stack).toBe("Error stack trace") }) it("should include tool calls in response", () => { @@ -224,9 +224,9 @@ describe("TraceLogger", () => { duration: 500, }) - expect(entry.response?.content?.toolCalls).toHaveLength(2) - expect(entry.response?.content?.toolCalls?.[0].name).toBe("bash") - expect(entry.response?.content?.toolCalls?.[1].name).toBe("read_file") + expect(entry.response?.choices?.[0]?.message.tool_calls).toHaveLength(2) + expect(entry.response?.choices?.[0]?.message.tool_calls?.[0].function.name).toBe("bash") + expect(entry.response?.choices?.[0]?.message.tool_calls?.[1].function.name).toBe("read_file") }) it("should merge multiple system messages into messages array", () => { From deace83b004a50213baf28cb30a71e4ff8c0589f Mon Sep 17 00:00:00 2001 From: smajumdar Date: Wed, 31 Dec 2025 20:30:54 -0800 Subject: [PATCH 05/10] feat(run-command): add --project-dir option to specify project directory Introduced a new CLI option `--project-dir` to allow users to define the project directory for running commands. Updated the command handler to use the specified directory or default to the current working directory. Adjusted file resolution and SDK initialization to reflect the new directory option. --- packages/opencode/src/cli/cmd/run.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 0c371b864ce..e038c1ea80d 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -87,8 +87,15 @@ export const RunCommand = cmd({ type: "number", describe: "port for the local server (defaults to random port if no value provided)", }) + .option("project-dir", { + type: "string", + describe: "project directory to run in (defaults to current working directory)", + }) }, handler: async (args) => { + // Use --project-dir if provided, otherwise fall back to cwd + const cwd = args.projectDir ? path.resolve(args.projectDir) : process.cwd() + let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") @@ -98,7 +105,7 @@ export const RunCommand = cmd({ const files = Array.isArray(args.file) ? args.file : [args.file] for (const filePath of files) { - const resolvedPath = path.resolve(process.cwd(), filePath) + const resolvedPath = path.resolve(cwd, filePath) const file = Bun.file(resolvedPath) const stats = await file.stat().catch(() => {}) if (!stats) { @@ -311,9 +318,9 @@ export const RunCommand = cmd({ return await execute(sdk, sessionID) } - await bootstrap(process.cwd(), async () => { + await bootstrap(cwd, async () => { const server = Server.listen({ port: args.port ?? 0, hostname: "127.0.0.1" }) - const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}` }) + const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, directory: cwd }) if (args.command) { const exists = await Command.get(args.command) From f5cec75e9224f86bf3c39f4105be60230cc5f9af Mon Sep 17 00:00:00 2001 From: smajumdar Date: Mon, 5 Jan 2026 14:47:32 -0800 Subject: [PATCH 06/10] feat(models): replace macro import with runtime data fetching for models Implemented a new function to fetch models data at runtime, replacing the previous macro import due to compatibility issues with `bun run --conditions=browser`. The function checks for a local environment variable and falls back to fetching from a remote API if not found. --- packages/opencode/src/provider/models.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index c58638d28e8..6070e3250a0 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -2,10 +2,28 @@ import { Global } from "../global" import { Log } from "../util/log" import path from "path" import z from "zod" -import { data } from "./models-macro" with { type: "macro" } import { Installation } from "../installation" import { Flag } from "../flag/flag" +// Inline fallback for fetching models data at runtime +// Previously used macro import: import { data } from "./models-macro" with { type: "macro" } +// Macros don't work correctly with `bun run --conditions=browser` +async function fetchModelsData(): Promise { + const envPath = Bun.env.MODELS_DEV_API_JSON + if (envPath) { + const file = Bun.file(envPath) + if (await file.exists()) { + return await file.text() + } + } + const json = await fetch("https://models.dev/api.json", { + headers: { + "User-Agent": Installation.USER_AGENT, + }, + }).then((x) => x.text()) + return json +} + export namespace ModelsDev { const log = Log.create({ service: "models.dev" }) const filepath = path.join(Global.Path.cache, "models.json") @@ -79,7 +97,7 @@ export namespace ModelsDev { const file = Bun.file(filepath) const result = await file.json().catch(() => {}) if (result) return result as Record - const json = await data() + const json = await fetchModelsData() return JSON.parse(json) as Record } From 35ea7232bfe942b1d837bf89681f0e1c16d496c4 Mon Sep 17 00:00:00 2001 From: smajumdar Date: Fri, 9 Jan 2026 15:12:10 -0800 Subject: [PATCH 07/10] feat: add OPENCODE_DISABLE_PLUGIN_INSTALL flag and update plugin installation logic --- packages/opencode/src/config/config.ts | 2 +- packages/opencode/src/flag/flag.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index be234948424..461d10b1744 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -125,7 +125,7 @@ export namespace Config { const exists = existsSync(path.join(dir, "node_modules")) const installing = installDependencies(dir) - if (!exists) await installing + if (!exists && !Flag.OPENCODE_DISABLE_PLUGIN_INSTALL) await installing result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) result.agent = mergeDeep(result.agent, await loadAgent(dir)) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index ff0d6050410..26fc1685d7a 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -9,6 +9,7 @@ export namespace Flag { export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE") export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"] export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS") + export const OPENCODE_DISABLE_PLUGIN_INSTALL = truthy("OPENCODE_DISABLE_PLUGIN_INSTALL") export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD") export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS") export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT") From ec0b6f0b3dee2aa3f01046736522e290331c115c Mon Sep 17 00:00:00 2001 From: smajumdar Date: Fri, 9 Jan 2026 15:17:11 -0800 Subject: [PATCH 08/10] refactor: improve plugin installation logic by restructuring conditional flow --- packages/opencode/src/config/config.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 461d10b1744..9ce83fe363a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -124,8 +124,9 @@ export namespace Config { } const exists = existsSync(path.join(dir, "node_modules")) - const installing = installDependencies(dir) - if (!exists && !Flag.OPENCODE_DISABLE_PLUGIN_INSTALL) await installing + if (!exists && !Flag.OPENCODE_DISABLE_PLUGIN_INSTALL) { + await installDependencies(dir) + } result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) result.agent = mergeDeep(result.agent, await loadAgent(dir)) From 63ef39ccc7a26e61b557bc9f9d39d0ba16974000 Mon Sep 17 00:00:00 2001 From: smajumdar Date: Sun, 18 Jan 2026 09:31:20 -0800 Subject: [PATCH 09/10] Add OPENCODE_DISABLE_MODELS_DEV flag to bypass models.dev entirely This flag allows OpenCode to work without any models.dev database, relying solely on providers defined in the config file. This is useful for containerized environments where network access may be restricted or when custom providers are needed without external dependencies. --- packages/opencode/src/flag/flag.ts | 1 + packages/opencode/src/provider/models.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index af6eeb973cb..d599dbdd457 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -14,6 +14,7 @@ export namespace Flag { export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS") export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT") export const OPENCODE_DISABLE_MODELS_FETCH = truthy("OPENCODE_DISABLE_MODELS_FETCH") + export const OPENCODE_DISABLE_MODELS_DEV = truthy("OPENCODE_DISABLE_MODELS_DEV") export const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE") export const OPENCODE_DISABLE_CLAUDE_CODE_PROMPT = OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT") diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 4acb36e9456..9c50e7013cc 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -95,6 +95,8 @@ export namespace ModelsDev { export type Provider = z.infer export async function get() { + // If models.dev is completely disabled, return empty object + if (Flag.OPENCODE_DISABLE_MODELS_DEV) return {} refresh() const file = Bun.file(filepath) const result = await file.json().catch(() => {}) From 411fcd3dce4de7109a711277a6ad4496a98967f9 Mon Sep 17 00:00:00 2001 From: smajumdar Date: Sun, 18 Jan 2026 09:43:41 -0800 Subject: [PATCH 10/10] Fix: Initialize config providers without auth requirements Config-based custom providers (like vllm) were being skipped because mergeProvider expected providers to have API keys from env vars. Now we directly initialize config providers into the providers object after adding them to the database, bypassing auth requirements. This allows custom OpenAI-compatible providers to work with just config settings (baseURL, apiKey in options) without needing environment variables or external auth. --- packages/opencode/src/provider/provider.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index bcb115edf41..6841ce1ecfd 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -800,6 +800,16 @@ export namespace Provider { database[providerID] = parsed } + // Initialize config providers directly into providers object + // This ensures custom providers (like vllm) work even without auth/env setup + for (const [providerID, provider] of configProviders) { + const dbProvider = database[providerID] + if (dbProvider && Object.keys(dbProvider.models).length > 0) { + providers[providerID] = dbProvider + log.info("loaded config provider", { providerID }) + } + } + // load env const env = Env.all() for (const [providerID, provider] of Object.entries(database)) {