Skip to content
Merged
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
57 changes: 29 additions & 28 deletions documentor/FORK.md

Large diffs are not rendered by default.

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)",
}),
openTelemetryChatContent: Schema.optional(Schema.Boolean).annotate({
description:
"Record the prompt and the response text on the AI SDK spans. Unless true, the spans carry model, usage, timing and ids only",
}),
primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Tools that should only be available to primary agents.",
}),
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,9 @@ const layer = Layer.effect(
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
// Same switch as session.llm: the generated agent's prompt stays off the span unless asked for.
recordInputs: cfg.experimental?.openTelemetryChatContent === true,
recordOutputs: cfg.experimental?.openTelemetryChatContent === true,
tracer,
metadata: {
userId: cfg.username ?? "unknown",
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,10 @@ const live: Layer.Layer<
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
// The AI SDK records the prompt and the reply unless each flag is
// exactly false, so an absent openTelemetryChatContent records nothing.
recordInputs: cfg.experimental?.openTelemetryChatContent === true,
recordOutputs: cfg.experimental?.openTelemetryChatContent === true,
functionId: "session.llm",
tracer: telemetryTracer,
metadata: {
Expand Down
121 changes: 121 additions & 0 deletions packages/opencode/test/session/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { ProviderError } from "@/provider/error"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"

type ConfigModel = NonNullable<NonNullable<ConfigV1.Info["provider"]>[string]["models"]>[string]

Expand Down Expand Up @@ -1393,6 +1395,125 @@ describe("session.llm.stream", () => {
{ config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, `${state.server!.url.origin}/v1`) },
)

// The AI SDK writes the prompt and the reply onto its spans unless told not
// to. experimental.openTelemetryChatContent is that switch, and absent means
// off, so a deployment that forgets it loses chat content from its traces
// rather than leaking it. Everything else on the span stays either way.
for (const chatContent of [false, true]) {
it.instance(
`documentor: AI SDK spans carry chat content only when openTelemetryChatContent is true (${chatContent})`,
() =>
Effect.gen(function* () {
const model = loadFixture("openai", "gpt-5.2").model
waitRequest(
"/responses",
createEventResponse(
[
{
type: "response.created",
response: {
id: "resp-otel",
created_at: Math.floor(Date.now() / 1000),
model: model.id,
service_tier: null,
},
},
{
type: "response.output_item.added",
output_index: 0,
item: { type: "message", id: "item-otel", status: "in_progress", role: "assistant", content: [] },
},
{
type: "response.content_part.added",
item_id: "item-otel",
output_index: 0,
content_index: 0,
part: { type: "output_text", text: "", annotations: [] },
},
{ type: "response.output_text.delta", item_id: "item-otel", delta: "Secret reply", logprobs: null },
{
type: "response.completed",
response: {
incomplete_details: null,
usage: {
input_tokens: 1,
input_tokens_details: null,
output_tokens: 1,
output_tokens_details: null,
},
service_tier: null,
},
},
],
true,
),
)

const exporter = new InMemorySpanExporter()
const tracing = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] })

const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id))
const sessionID = SessionID.make("session-test-otel")
const agent = {
name: "test",
mode: "primary",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
} satisfies Agent.Info
const user = {
id: MessageID.make("msg_user-otel"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: agent.name,
model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id, variant: "high" },
} satisfies SessionV1.User

yield* drain({
user,
sessionID,
model: resolved,
agent,
system: ["You are a helpful assistant."],
messages: [{ role: "user", content: "Secret prompt" }],
tools: {},
}).pipe(Effect.provideService(OtelTracer.OtelTracer, tracing.getTracer("test")))
yield* Effect.promise(() => tracing.forceFlush())

const spans = exporter.getFinishedSpans()
const outer = spans.find((span) => span.name === "ai.streamText")
const inner = spans.find((span) => span.name === "ai.streamText.doStream")
expect(outer).toBeDefined()
expect(inner).toBeDefined()

const content = (keys: string[]) =>
keys.filter((key) => key.startsWith("ai.prompt") || key === "ai.response.text")
const serialized = JSON.stringify(spans.map((span) => span.attributes))
if (chatContent) {
expect(outer!.attributes["ai.response.text"]).toBe("Secret reply")
expect(content(Object.keys(inner!.attributes))).toContain("ai.prompt.messages")
expect(serialized).toContain("Secret prompt")
} else {
expect(content(Object.keys(outer!.attributes))).toEqual([])
expect(content(Object.keys(inner!.attributes))).toEqual([])
expect(serialized).not.toContain("Secret prompt")
expect(serialized).not.toContain("Secret reply")
}
// What the spans exist for survives the switch in both positions.
expect(outer!.attributes["ai.telemetry.metadata.sessionId"]).toBe(sessionID)
expect(outer!.attributes["session.id"]).toBe(sessionID)
expect(inner!.attributes["ai.usage.outputTokens"]).toBe(1)
expect(inner!.attributes["gen_ai.request.model"]).toBe(resolved.api.id)
}),
{
config: () => ({
...openAIConfig(loadFixture("openai", "gpt-5.2").model, `${state.server!.url.origin}/v1`),
experimental: chatContent ? { openTelemetry: true, openTelemetryChatContent: true } : { openTelemetry: true },
}),
},
)
}

it.instance(
"keeps supported OpenAI models on AI SDK path when native flag is off",
() =>
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2032,6 +2032,7 @@ export type Config = {
disable_paste_summary?: boolean
batch_tool?: boolean
openTelemetry?: boolean
openTelemetryChatContent?: boolean
primary_tools?: Array<string>
continue_loop_on_deny?: boolean
mcp_timeout?: number
Expand Down
Loading