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
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export const TuiThreadCommand = cmd({
env: Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
),
argv: process.argv,
})
worker.onerror = (e) => {
Log.Default.error(e)
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/tui/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ await Log.init({
if (Installation.isLocal()) return "DEBUG"
return "INFO"
})(),
requestLog: process.argv.includes("--request-log"),
})

process.on("unhandledRejection", (e) => {
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ const cli = yargs(hideBin(process.argv))
type: "string",
choices: ["DEBUG", "INFO", "WARN", "ERROR"],
})
.option("request-log", {
describe: "log provider requests and responses to a separate file",
type: "boolean",
})
.middleware(async (opts) => {
await Log.init({
print: process.argv.includes("--print-logs"),
Expand All @@ -65,6 +69,7 @@ const cli = yargs(hideBin(process.argv))
if (Installation.isLocal()) return "DEBUG"
return "INFO"
})(),
requestLog: process.argv.includes("--request-log"),
})

process.env.AGENT = "1"
Expand Down Expand Up @@ -151,6 +156,8 @@ try {
}
process.exitCode = 1
} finally {
// Flush any pending request logs before exiting
await Log.flushRequestLog()
// Some subprocesses don't react properly to SIGTERM and similar signals.
// Most notably, some docker-container-based MCP servers don't handle such signals unless
// run using `docker run --init`.
Expand Down
227 changes: 222 additions & 5 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,7 @@ export namespace Provider {
// Preserve custom fetch if it exists, wrap it with timeout logic
const fetchFn = customFetch ?? fetch
const opts = init ?? {}
const startTime = Date.now()

if (options["timeout"] !== undefined && options["timeout"] !== null) {
const signals: AbortSignal[] = []
Expand All @@ -1002,6 +1003,10 @@ export namespace Provider {
opts.signal = combined
}

const url = typeof input === "string" ? input : input.url

const requestId = Math.random().toString(36).substring(2, 15)

// Strip openai itemId metadata following what codex does
// Codex uses #[serde(skip_serializing)] on id fields for all item types:
// Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall
Expand All @@ -1020,11 +1025,223 @@ export namespace Provider {
}
}

return fetchFn(input, {
...opts,
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
timeout: false,
})
// Log request if enabled
if (Log.isRequestLoggingEnabled()) {
const requestData: any = {
type: "REQUEST",
requestId,
provider: model.providerID,
model: model.id,
url,
method: opts.method ?? "GET",
}

const requestRaw: any = {
type: "REQUEST",
requestId,
provider: model.providerID,
model: model.id,
url,
method: opts.method ?? "GET",
headers: opts.headers,
body: opts.body,
}

// Parse and filter the request body
if (opts.body) {
try {
const body = typeof opts.body === "string" ? JSON.parse(opts.body) : opts.body
const filteredBody: any = {}

// Extract messages (OpenCode format uses 'input', OpenAI uses 'messages')
const messages = body.input || body.messages
if (messages && Array.isArray(messages)) {
filteredBody.messages = messages.map((msg: any) => {
const role = msg.role
let content: string

// Handle different content formats
if (typeof msg.content === "string") {
content = msg.content
} else if (Array.isArray(msg.content)) {
const textPart = msg.content.find((p: any) => p.type === "text" || p.text)
content = textPart?.text ?? textPart?.content ?? "[complex content]"
} else {
content = "[complex content]"
}

return { role, content }
})
}

if (body.tools && Array.isArray(body.tools)) {
filteredBody.tools_count = body.tools.length
filteredBody.tools_summary = body.tools
.map((t: any) => t.function?.name || t.name)
.filter((name: any): name is string => typeof name === "string" && name.length > 0)
.join(", ")
}

// Keep other useful fields
if (body.model) filteredBody.model = body.model
if (body.max_tokens) filteredBody.max_tokens = body.max_tokens
if (body.max_output_tokens) filteredBody.max_output_tokens = body.max_output_tokens
if (body.temperature !== undefined) filteredBody.temperature = body.temperature
if (body.stream !== undefined) filteredBody.stream = body.stream

requestData.body = filteredBody
} catch {
requestData.body = "[parse error]"
}
}

await Log.logRequest(requestData)
await Log.logRequestRaw(requestRaw)
}

try {
const response = await fetchFn(input, {
...opts,
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
timeout: false,
})

// Log response if enabled
if (Log.isRequestLoggingEnabled()) {
const clonedResponse = response.clone()
const responseText = await clonedResponse.text().catch(() => "[stream or unreadable]")
const responseData: any = {
type: "RESPONSE",
requestId,
provider: model.providerID,
model: model.id,
url,
status: response.status,
duration: Date.now() - startTime,
}

const responseRaw: any = {
type: "RESPONSE",
requestId,
provider: model.providerID,
model: model.id,
url,
status: response.status,
duration: Date.now() - startTime,
headers: Object.fromEntries(response.headers.entries()),
body: responseText,
}

// Parse and extract useful information from response
let completion = ""
let tokenUsage: any = null

// Try to parse as JSON first (non-streaming response)
try {
const jsonResponse = JSON.parse(responseText)

// Extract completion text
if (jsonResponse.choices && Array.isArray(jsonResponse.choices)) {
const firstChoice = jsonResponse.choices[0]
completion = firstChoice?.message?.content || firstChoice?.text || ""
} else if (jsonResponse.content) {
completion = jsonResponse.content
} else if (jsonResponse.text) {
completion = jsonResponse.text
}

// Extract token usage
if (jsonResponse.usage) {
tokenUsage = jsonResponse.usage
}
} catch {
// If not JSON, try parsing as SSE stream
if (responseText.includes("data:")) {
const lines = responseText.split("\n")

for (const line of lines) {
if (!line.startsWith("data:")) continue

const dataStr = line.substring(5).trim()
if (dataStr === "[DONE]") continue

try {
const data = JSON.parse(dataStr)

// OpenAI format: extract delta content
if (data.choices?.[0]?.delta?.content) {
completion += data.choices[0].delta.content
}

// OpenAI format: final message
if (data.choices?.[0]?.message?.content) {
completion = data.choices[0].message.content
}

// OpenCode format: extract text from various event types
if (data.type === "response.output_text.done" && data.text) {
completion = data.text
}
if (data.type === "content.delta" && data.delta?.text) {
completion += data.delta.text
}
if (data.type === "response.done" && data.response?.output?.[0]?.content?.[0]?.text) {
completion = data.response.output[0].content[0].text
}

// Extract token usage from various formats
if (data.usage) {
tokenUsage = data.usage
}
if (data.response?.usage) {
tokenUsage = data.response.usage
}
} catch {
// Skip lines that can't be parsed
}
}
}
}

// Add completion text (no truncation)
if (completion) {
responseData.completion = completion
} else {
responseData.completion = "[no text extracted]"
}

// Add token usage if found
if (tokenUsage) {
responseData.input_tokens = tokenUsage.input_tokens || tokenUsage.prompt_tokens
responseData.output_tokens = tokenUsage.output_tokens || tokenUsage.completion_tokens
responseData.total_tokens =
tokenUsage.total_tokens ||
(tokenUsage.input_tokens || tokenUsage.prompt_tokens || 0) +
(tokenUsage.output_tokens || tokenUsage.completion_tokens || 0)
}

await Log.logRequest(responseData)
await Log.logRequestRaw(responseRaw)
}

return response
} catch (error) {
// Log error if enabled
if (Log.isRequestLoggingEnabled()) {
const errorData = {
type: "ERROR",
requestId,
provider: model.providerID,
model: model.id,
url,
error: error instanceof Error ? error.message : String(error),
duration: Date.now() - startTime,
}
await Log.logRequest(errorData)
await Log.logRequestRaw(errorData)
}
throw error
}
}

const bundledFn = BUNDLED_PROVIDERS[model.api.npm]
Expand Down
Loading