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
6 changes: 6 additions & 0 deletions packages/core/src/v1/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ 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:
"'info' logs messages and response text; 'debug' adds generation params; 'trace' adds the raw provider-native request body",
}),
primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Tools that should only be available to primary agents.",
}),
Expand Down
22 changes: 20 additions & 2 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 { logEvents as logResponseEvents, logRequest as logOutgoingRequest } 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 @@ -343,11 +345,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* logOutgoingRequest(request, logMessages as LogLevel, body)
}

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

Expand All @@ -368,13 +376,19 @@ 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 events.pipe(
Stream.tap((event) => logResponseEvents(request, [event], logMessages)),
)
}),
)

const generateWith = (stream: Interface["stream"]) =>
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
return new LLMResponse(
const logMessages = request.metadata?.logMessages as LogLevel | undefined
const response = new LLMResponse(
yield* stream(request).pipe(
Stream.runFold(
() => ({ events: [] as LLMEvent[], usage: undefined as LLMResponse["usage"] }),
Expand All @@ -386,6 +400,10 @@ const generateWith = (stream: Interface["stream"]) =>
),
),
)
if (logMessages) {
yield* logResponseEvents(request, response.events, logMessages)
}
return response
})

export const prepare = <Body = unknown>(request: LLMRequest) =>
Expand Down
1 change: 1 addition & 0 deletions packages/llm/src/route/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
Service as LLMClientService,
} from "./client"
export * from "./executor"
export { MessageLogger } from "./message-logger"
export { Auth } from "./auth"
export { AuthOptions } from "./auth-options"
export { Endpoint } from "./endpoint"
Expand Down
65 changes: 65 additions & 0 deletions packages/llm/src/route/message-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Effect } 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 texts: Array<string> = []
for (const event of events) {
if (event.type === "text-delta") texts.push(event.text)
if (event.type === "reasoning-delta") texts.push(`[reasoning]: ${event.text}`)
if (event.type === "tool-call") texts.push(`tool-call(${event.name}): ${JSON.stringify(event.input)}`)
if (event.type === "tool-result") texts.push(`tool-result(${event.name}): ${JSON.stringify(event.result)}`)
if (event.type === "finish" && event.usage) {
texts.push(`usage: ${JSON.stringify(event.usage)}`)
}
}
return texts.join("")
}

const logAtLevel = (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.logDebug(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 logAtLevel(level, "LLM request", payload)
}

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

export * as MessageLogger from "./message-logger"
91 changes: 91 additions & 0 deletions packages/llm/test/message-logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer, Logger, LogLevel } from "effect"
import { LLMClient, MessageLogger } from "../src/route"
import * as OpenAIChat from "../src/protocols/openai-chat"
import { LLM, LLMRequest, Message, Model } from "../src"
import { dynamicResponse } from "./lib/http"
import { deltaChunk, finishChunk } from "./lib/openai-chunks"
import { sseRaw } from "./lib/sse"
import { it } from "./lib/effect"

const chatRoute = OpenAIChat.route.with({ endpoint: { baseURL: "https://api.openai.test/v1" } })
const model = Model.make({ id: "gpt-4o-mini", provider: "openai", route: chatRoute })

describe("MessageLogger", () => {
describe("formatMessages", () => {
test("formats system and user messages", () => {
const request = LLM.request({
model,
system: "You are helpful.",
prompt: "Say hello.",
})
const formatted = MessageLogger.formatMessages(request)
expect(formatted).toContain("system: You are helpful.")
expect(formatted).toContain("user: Say hello.")
})

test("formats messages with tool calls and results", () => {
const request = LLM.request({
model,
messages: [
Message.user("Check weather"),
Message.assistant([
{ type: "tool-call", id: "call_1", name: "get_weather", input: { city: "Tokyo" } },
]),
Message.tool({ id: "call_1", name: "get_weather", result: { temperature: 72 } }),
],
})
const formatted = MessageLogger.formatMessages(request)
expect(formatted).toContain('tool-call(get_weather): {"city":"Tokyo"}')
expect(formatted).toContain('tool-result(get_weather): {"type":"json","value":{"temperature":72}}')
})
})

describe("formatEvents", () => {
test("formats text deltas and usage", () => {
const formatted = MessageLogger.formatEvents([
{ type: "text-delta", id: "text-0", text: "Hello" },
{ type: "text-delta", id: "text-0", text: " world" },
{ type: "finish", reason: "stop", usage: { inputTokens: 10, outputTokens: 5, visibleOutputTokens: 3 } },
])
expect(formatted).toBe("Hello worldusage: {\"inputTokens\":10,\"outputTokens\":5,\"visibleOutputTokens\":3}")
})

test("formats reasoning deltas", () => {
const formatted = MessageLogger.formatEvents([
{ type: "reasoning-delta", id: "reason-0", text: "thinking step" },
])
expect(formatted).toContain("[reasoning]: thinking step")
})

test("formats tool call events", () => {
const formatted = MessageLogger.formatEvents([
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
])
expect(formatted).toContain('tool-call(lookup): {"query":"weather"}')
})
})

describe("LLMClient integration", () => {
const helloResponse = sseRaw(
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}`,
`data: ${JSON.stringify(finishChunk("stop"))}`,
)

it.effect("does not log when metadata.logMessages is not set", () =>
Effect.gen(function* () {
const result = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." }))
expect(result.text).toBe("Hello")
}).pipe(Effect.provide(dynamicResponse((input) => Effect.succeed(input.respond(helloResponse))))),
)

it.effect("logs request when metadata.logMessages is set to info", () =>
Effect.gen(function* () {
const result = yield* LLMClient.generate(
LLM.request({ model, prompt: "Say hello.", metadata: { logMessages: "info" as const } }),
)
expect(result.text).toBe("Hello")
}).pipe(Effect.provide(dynamicResponse((input) => Effect.succeed(input.respond(helloResponse))))),
)
})
})
38 changes: 36 additions & 2 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Context, Effect, Layer } from "effect"
import * as Stream from "effect/Stream"
import { streamText, wrapLanguageModel, type ModelMessage, type Tool } from "ai"
import type { LLMEvent } from "@opencode-ai/llm"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
import { LLMClient, MessageLogger, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
import type { LLMClientService } from "@opencode-ai/llm/route"
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
import { ProviderTransform } from "@/provider/transform"
Expand Down Expand Up @@ -239,6 +239,7 @@ const live: Layer.Layer<
providerOptions: prepared.params.options,
headers: prepared.headers,
abort: input.abort,
logMessages: cfg.experimental?.log_messages,
})
if (native.type === "supported") {
yield* Effect.logInfo("llm runtime selected", {
Expand Down Expand Up @@ -268,6 +269,26 @@ const live: Layer.Layer<
})
}

const logMessages = cfg.experimental?.log_messages as MessageLogger.LogLevel | undefined
if (logMessages) {
const model = `${input.model.providerID}/${input.model.id}`
const texts: Array<string> = []
for (const s of prepared.system) texts.push(`system: ${s}`)
for (const m of prepared.messages) {
const content = typeof m.content === "string"
? m.content
: m.content?.map((p) => (p.type === "text" ? p.text : `[${p.type}]`)).join("\n") ?? ""
texts.push(`${m.role}: ${content}`)
}
const payload: Record<string, unknown> = { model, messages: texts.join("\n") }
if (logMessages !== "info") {
payload.generation = Object.fromEntries(
Object.entries(prepared.params).filter(([, v]) => v !== undefined) as Array<[string, unknown]>,
)
}
yield* Effect.logInfo("LLM request", payload)
}

yield* Effect.logInfo("llm runtime selected", {
"llm.runtime": "ai-sdk",
"llm.provider": input.model.providerID,
Expand All @@ -277,6 +298,7 @@ const live: Layer.Layer<
// LLMAISDK.toLLMEvents below normalizes fullStream parts for the processor.
return {
type: "ai-sdk" as const,
logMessages,
result: streamText({
onError(error) {
bridge.fork(
Expand Down Expand Up @@ -370,12 +392,24 @@ const live: Layer.Layer<
// Adapter seam: both runtimes expose the same LLMEvent stream. Native
// already returns one; AI SDK streams are converted here.
const state = LLMAISDK.adapterState()
return Stream.fromAsyncIterable(result.result.fullStream, (e) =>
const model = `${input.model.providerID}/${input.model.id}`
let events = Stream.fromAsyncIterable(result.result.fullStream, (e) =>
e instanceof Error ? e : new Error(String(e)),
).pipe(
Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)),
Stream.flatMap((events) => Stream.fromIterable(events)),
)
if (result.logMessages) {
events = events.pipe(
Stream.tap((event) =>
Effect.logInfo("LLM response", {
model,
response: MessageLogger.formatEvents([event]),
}),
),
)
}
return events
}),
),
)
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/session/llm/native-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type StreamInput = {
readonly providerOptions?: Record<string, any>
readonly headers: Record<string, string>
readonly abort: AbortSignal
readonly logMessages?: string
}

export function status(input: Pick<StreamInput, "model" | "provider" | "auth">): RuntimeStatus {
Expand Down Expand Up @@ -109,6 +110,7 @@ export function stream(input: StreamInput): StreamResult {
.stream(
LLMRequest.update(request, {
tools: [...request.tools, ...toDefinitions(tools)],
metadata: input.logMessages ? { ...request.metadata, logMessages: input.logMessages } : request.metadata,
}),
)
.pipe(
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
}}
>
<ClipboardProvider>
<ClipboardProvider linuxClipboardSelection={input.config.linux_clipboard_selection}>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args}>
<KVProvider>
Expand Down
Loading
Loading