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/core/src/observability/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const stderrLogger = Logger.make((options) => process.stderr.write(formatter().l
export function minimumLogLevel() {
const value = process.env.OPENCODE_LOG_LEVEL?.toUpperCase()
const levels = {
TRACE: "Trace",
DEBUG: "Debug",
INFO: "Info",
WARN: "Warn",
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/v1/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ export const Info = Schema.Struct({
openTelemetry: Schema.optional(Schema.Boolean).annotate({
description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)",
}),
log_messages: Schema.optional(Schema.Literals(["info", "debug", "trace"])).annotate({
description:
"Verbosity for LLM request/response logging: 'info' logs messages and response text; 'debug' adds generation params at Effect debug level; 'trace' adds the raw provider-native request body at Effect trace level (native runtime only; requires OPENCODE_LOG_LEVEL=DEBUG or TRACE). Logs can contain full transcripts, including tool results — treat log destinations as sensitive.",
}),
primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Tools that should only be available to primary agents.",
}),
Expand Down
27 changes: 21 additions & 6 deletions packages/llm/src/route/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { HttpTransport } from "./transport"
import type { Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { logRequest, responseStream } from "./message-logger"
import type { LogLevel } from "./message-logger"
import { applyCachePolicy } from "../cache-policy"
import * as ProviderShared from "../protocols/shared"
import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
Expand Down Expand Up @@ -350,11 +352,17 @@ const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
const prepared = yield* route.prepareTransport(body, resolved)

const logMessages = request.metadata?.logMessages
if (logMessages) {
yield* logRequest(request, logMessages as LogLevel, body)
}

return {
request: resolved,
route,
body,
prepared,
logMessages,
}
})

Expand All @@ -375,19 +383,26 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =
Stream.unwrap(
Effect.gen(function* () {
const compiled = yield* compile(request)
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
const events = compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
const logMessages = request.metadata?.logMessages as LogLevel | undefined
if (!logMessages) return events
return responseStream(`${request.model.provider}/${request.model.id}`, logMessages)(events)
}),
)

const generateWith = (stream: Interface["stream"]) =>
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
// The stream pipeline emits the single coalesced "LLM response" log at the
// terminal event, which runs before this fold completes.
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
const response = LLMResponse.complete(state)
if (response) return response
return yield* ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
"Provider stream ended without a terminal finish event",
)
if (!response) {
return yield* ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
"Provider stream ended without a terminal finish event",
)
}
return response
})

export const prepare = <Body = unknown>(request: LLMRequest) =>
Expand Down
2 changes: 2 additions & 0 deletions packages/llm/src/route/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export type {
Service as LLMClientService,
} from "./client"
export * from "./executor"
export { MessageLogger } from "./message-logger"
export type { LogLevel } from "./message-logger"
export { Auth } from "./auth"
export { AuthOptions } from "./auth-options"
export { Endpoint } from "./endpoint"
Expand Down
124 changes: 124 additions & 0 deletions packages/llm/src/route/message-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Effect, Stream } from "effect"
import type { LLMEvent, LLMRequest } from "../schema"

export type LogLevel = "info" | "debug" | "trace"

export const formatMessages = (request: LLMRequest): string => {
const parts: Array<string> = []
for (const part of request.system) {
if (part.type === "text") parts.push(`system: ${part.text}`)
}
for (const message of request.messages) {
const texts: Array<string> = []
for (const part of message.content) {
if (part.type === "text") texts.push(part.text)
if (part.type === "tool-call") texts.push(`tool-call(${part.name}): ${JSON.stringify(part.input)}`)
if (part.type === "tool-result") texts.push(`tool-result(${part.name}): ${JSON.stringify(part.result)}`)
}
parts.push(`${message.role}: ${texts.join("\n")}`)
}
return parts.join("\n")
}

export const formatEvents = (events: ReadonlyArray<LLMEvent>): string => {
const segments: Array<string> = []
let pending = ""
let kind: "text" | "reasoning" = "text"
const flush = () => {
if (!pending) return
segments.push(kind === "reasoning" ? `[reasoning]: ${pending}` : pending)
pending = ""
}
for (const event of events) {
if (event.type === "text-delta") {
if (kind !== "text") {
flush()
kind = "text"
}
pending += event.text
continue
}
if (event.type === "reasoning-delta") {
if (kind !== "reasoning") {
flush()
kind = "reasoning"
}
pending += event.text
continue
}
if (event.type === "tool-call" || event.type === "tool-result") {
flush()
segments.push(
event.type === "tool-call"
? `tool-call(${event.name}): ${JSON.stringify(event.input)}`
: `tool-result(${event.name}): ${JSON.stringify(event.result)}`,
)
continue
}
if (event.type === "provider-error") {
flush()
segments.push(`error: ${event.message}`)
continue
}
if (event.type === "finish" && event.usage) {
flush()
segments.push(`usage: ${JSON.stringify(event.usage)}`)
}
}
flush()
return segments.join("\n")
}

// Trace severity sits above Debug, so runtimes configured at Debug still pass
// trace entries through while keeping the three tiers distinguishable.
export const log = (level: LogLevel, label: string, data: Record<string, unknown>): Effect.Effect<void> => {
switch (level) {
case "info":
return Effect.logInfo(label, data)
case "debug":
return Effect.logDebug(label, data)
case "trace":
return Effect.logTrace(label, data)
}
}

export const logRequest = (request: LLMRequest, level: LogLevel, body?: unknown): Effect.Effect<void> => {
const model = `${request.model.provider}/${request.model.id}`
const payload: Record<string, unknown> = { model, messages: formatMessages(request) }
if (level !== "info" && request.generation) {
payload.generation = Object.fromEntries(
Object.entries(request.generation).filter(([, value]) => value !== undefined),
)
}
if (level === "trace" && body !== undefined) {
payload.body = JSON.stringify(body)
}
return log(level, "LLM request", payload)
}

export const logEvents = (request: LLMRequest, events: ReadonlyArray<LLMEvent>, level: LogLevel): Effect.Effect<void> =>
log(level, "LLM response", {
model: `${request.model.provider}/${request.model.id}`,
response: formatEvents(events),
})

// Accumulates the response in the stream itself so a single "LLM response"
// entry is emitted once, when the terminal event (finish or provider-error)
// passes through, instead of one entry per streamed delta.
export const responseStream = (model: string, level: LogLevel) => {
const collected: Array<LLMEvent> = []
return <E>(events: Stream.Stream<LLMEvent, E>): Stream.Stream<LLMEvent, E> =>
events.pipe(
Stream.mapEffect((event) =>
Effect.gen(function* () {
collected.push(event)
if (event.type === "finish" || event.type === "provider-error") {
yield* log(level, "LLM response", { model, response: formatEvents(collected) })
}
return event
}),
),
)
}

export * as MessageLogger from "./message-logger"
Loading
Loading