Skip to content

Instantly share code, notes, and snippets.

@mrchrisadams
Created January 25, 2026 23:00
Show Gist options
  • Select an option

  • Save mrchrisadams/549aa3804ea112f04a615900fe595166 to your computer and use it in GitHub Desktop.

Select an option

Save mrchrisadams/549aa3804ea112f04a615900fe595166 to your computer and use it in GitHub Desktop.
RFC: Energy Tracking as First-Class Citizen in OpenCode - Proposal for native energy/carbon tracking with plugin hooks
/**
* PATCH 01: SDK Energy Capture
*
* File: packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-language-model.ts
*
* This patch modifies the OpenAI-compatible SDK to capture energy data from:
* 1. GreenPT-style: `impact` field in response.completed chunks
* 2. Neuralwatt-style: SSE comments (`: energy {...}`)
*
* The energy data is passed through providerMetadata.energy to downstream consumers.
*/
// =============================================================================
// CHANGE 1: Add energy schema definitions (add after line ~1320, before usageSchema)
// =============================================================================
// Energy data schema - supports multiple provider formats
const energyDataSchema = z.object({
// Common fields (normalized)
wh: z.number().optional(), // Watt-hours
kwh: z.number().optional(), // Kilowatt-hours
joules: z.number().optional(), // Joules
// Carbon/emissions
gCO2e: z.number().optional(), // Grams CO2 equivalent
// Source tracking
source: z.enum(['measured', 'estimated']).optional(),
provider: z.string().optional(), // 'greenpt', 'neuralwatt', etc.
// Raw provider data (preserved for debugging/analysis)
raw: z.record(z.unknown()).optional(),
}).optional()
// GreenPT impact schema
const greenptImpactSchema = z.object({
version: z.string().optional(),
inferenceTime: z.object({
total: z.number(),
unit: z.string(),
}).optional(),
energy: z.object({
total: z.number(),
unit: z.string(), // "Wms" = watt-milliseconds
}).optional(),
emissions: z.object({
total: z.number(),
unit: z.string(), // "ugCO2e" = micrograms CO2 equivalent
}).optional(),
}).optional()
// Neuralwatt energy schema
const neuralwattEnergySchema = z.object({
energy_joules: z.number().optional(),
energy_kwh: z.number().optional(),
avg_power_watts: z.number().optional(),
duration_seconds: z.number().optional(),
attribution_method: z.string().optional(),
attribution_ratio: z.number().optional(),
}).optional()
// =============================================================================
// CHANGE 2: Extend responseFinishedChunkSchema (modify existing, ~line 1345)
// =============================================================================
const responseFinishedChunkSchema = z.object({
type: z.enum(["response.completed", "response.incomplete"]),
response: z.object({
incomplete_details: z.object({ reason: z.string() }).nullish(),
usage: usageSchema,
service_tier: z.string().nullish(),
// ADD: GreenPT-style impact data
impact: greenptImpactSchema,
}),
})
// =============================================================================
// CHANGE 3: Add energy comment chunk schema (add to union, ~line 1380)
// =============================================================================
// SSE comment-based energy (Neuralwatt style)
// Note: This requires modifying createEventSourceResponseHandler to capture comments
const energyCommentChunkSchema = z.object({
type: z.literal("energy.comment"),
data: neuralwattEnergySchema,
})
const openaiResponsesChunkSchema = z.union([
textDeltaChunkSchema,
responseFinishedChunkSchema,
responseCreatedChunkSchema,
responseOutputItemAddedSchema,
responseOutputItemDoneSchema,
responseFunctionCallArgumentsDeltaSchema,
responseImageGenerationCallPartialImageSchema,
responseCodeInterpreterCallCodeDeltaSchema,
responseCodeInterpreterCallCodeDoneSchema,
responseAnnotationAddedSchema,
responseReasoningSummaryPartAddedSchema,
responseReasoningSummaryTextDeltaSchema,
errorChunkSchema,
energyCommentChunkSchema, // ADD: Energy comment support
z.object({ type: z.string() }).loose(),
])
// =============================================================================
// CHANGE 4: Add energy tracking variables (in doStream method, ~line 796)
// =============================================================================
// After: let serviceTier: string | undefined
// Add:
let capturedEnergy: z.infer<typeof energyDataSchema> = undefined
// =============================================================================
// CHANGE 5: Capture energy in transform function (~line 1250)
// =============================================================================
// Inside: if (isResponseFinishedChunk(value)) { ... }
// After usage extraction, add:
if (isResponseFinishedChunk(value)) {
finishReason = mapOpenAIResponseFinishReason({
finishReason: value.response.incomplete_details?.reason,
hasFunctionCall,
})
usage.inputTokens = value.response.usage.input_tokens
usage.outputTokens = value.response.usage.output_tokens
usage.totalTokens = value.response.usage.input_tokens + value.response.usage.output_tokens
usage.reasoningTokens = value.response.usage.output_tokens_details?.reasoning_tokens ?? undefined
usage.cachedInputTokens = value.response.usage.input_tokens_details?.cached_tokens ?? undefined
if (typeof value.response.service_tier === "string") {
serviceTier = value.response.service_tier
}
// ADD: Capture GreenPT-style impact data
if (value.response.impact) {
const impact = value.response.impact
capturedEnergy = {
source: 'measured',
provider: 'greenpt',
raw: impact,
}
// Convert GreenPT units to standard
if (impact.energy) {
// GreenPT uses Wms (watt-milliseconds)
if (impact.energy.unit === 'Wms') {
const wms = impact.energy.total
capturedEnergy.wh = wms / 3_600_000 // Wms to Wh
capturedEnergy.kwh = wms / 3_600_000_000 // Wms to kWh
capturedEnergy.joules = wms / 1000 // Wms to J
}
}
if (impact.emissions) {
// GreenPT uses ugCO2e (micrograms CO2 equivalent)
if (impact.emissions.unit === 'ugCO2e') {
capturedEnergy.gCO2e = impact.emissions.total / 1_000_000 // ug to g
}
}
}
}
// ADD: Handle Neuralwatt-style energy comments
if (isEnergyCommentChunk(value)) {
const data = value.data
capturedEnergy = {
source: 'measured',
provider: 'neuralwatt',
raw: data,
joules: data.energy_joules,
kwh: data.energy_kwh,
wh: data.energy_kwh ? data.energy_kwh * 1000 : undefined,
}
}
// =============================================================================
// CHANGE 6: Include energy in providerMetadata (in flush function, ~line 1290)
// =============================================================================
flush(controller) {
// Close any dangling text part
if (currentTextId) {
controller.enqueue({ type: "text-end", id: currentTextId })
currentTextId = null
}
const providerMetadata: SharedV2ProviderMetadata = {
openai: {
responseId,
},
}
if (logprobs.length > 0) {
providerMetadata.openai.logprobs = logprobs
}
if (serviceTier !== undefined) {
providerMetadata.openai.serviceTier = serviceTier
}
// ADD: Include captured energy data
if (capturedEnergy) {
providerMetadata.energy = capturedEnergy
}
controller.enqueue({
type: "finish",
finishReason,
usage,
providerMetadata,
})
}
// =============================================================================
// CHANGE 7: Add type guard for energy comment chunks
// =============================================================================
function isEnergyCommentChunk(
chunk: z.infer<typeof openaiResponsesChunkSchema>,
): chunk is z.infer<typeof energyCommentChunkSchema> {
return chunk.type === "energy.comment"
}
// =============================================================================
// CHANGE 8: Modify createEventSourceResponseHandler to capture SSE comments
//
// This requires changes to @ai-sdk/provider-utils or a local wrapper.
// The standard SSE spec says lines starting with ":" are comments and ignored.
// We need to capture ": energy {...}" comments.
//
// Option A: Fork/patch @ai-sdk/provider-utils
// Option B: Create a wrapper that pre-processes the stream
// =============================================================================
// Wrapper approach (add to this file or a separate utility):
import { createEventSourceResponseHandler as originalHandler } from "@ai-sdk/provider-utils"
function createEnergyAwareEventSourceResponseHandler<T>(schema: z.ZodType<T>) {
// Create a transform stream that captures energy comments
// before passing to the standard handler
return async function* (response: Response) {
const reader = response.body?.getReader()
if (!reader) throw new Error('No response body')
const decoder = new TextDecoder()
let buffer = ''
let capturedEnergyComment: any = null
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
// Check for energy comment (Neuralwatt style)
if (line.startsWith(': energy ')) {
try {
capturedEnergyComment = JSON.parse(line.slice(9))
// Emit as a synthetic chunk
yield {
success: true,
value: {
type: 'energy.comment',
data: capturedEnergyComment,
},
rawValue: line,
}
} catch (e) {
// Ignore parse errors
}
continue
}
// Standard SSE handling
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') continue
try {
const parsed = JSON.parse(data)
const result = schema.safeParse(parsed)
yield {
success: result.success,
value: result.success ? result.data : undefined,
error: result.success ? undefined : result.error,
rawValue: data,
}
} catch (e) {
yield {
success: false,
error: e,
rawValue: data,
}
}
}
}
}
}
}
/**
* Example Plugin: Energy Tracking with Custom Grid Intensity
*
* This plugin demonstrates how to use the new energy hooks to:
* 1. Enhance energy data with real-time grid carbon intensity
* 2. Log energy data to an external service
* 3. Provide custom energy estimates for specific providers
*/
import type { Plugin, Energy } from "@opencode-ai/plugin"
// Simulated grid intensity API (in reality, use ElectricityMaps or similar)
async function fetchGridIntensity(region: string): Promise<{ intensity: number; source: string }> {
const intensities: Record<string, number> = {
'us-west': 210,
'us-east': 380,
'eu-west': 230,
'uk': 180,
'france': 50,
'germany': 350,
'global': 436,
}
return {
intensity: intensities[region] || intensities['global'],
source: 'static-lookup',
}
}
// Custom energy coefficients based on published research
const CUSTOM_COEFFICIENTS: Record<string, { input: number; output: number }> = {
'claude-3-5-sonnet': { input: 0.00018, output: 0.00036 },
'claude-3-opus': { input: 0.00042, output: 0.00084 },
'gpt-4o': { input: 0.00028, output: 0.00056 },
}
export const EnergyEnhancerPlugin: Plugin = async (ctx) => {
// Track session totals for logging
const sessionTotals: Record<string, { wh: number; gCO2e: number; count: number }> = {}
await ctx.client.app.log({
service: 'energy-enhancer',
level: 'info',
message: 'Energy enhancer plugin initialized',
})
return {
/**
* Provide custom energy estimates for providers that don't measure
*/
"message.energy.estimate": async (input, output) => {
// Check if we have custom coefficients for this model
const modelKey = Object.keys(CUSTOM_COEFFICIENTS).find(k =>
input.modelID.toLowerCase().includes(k)
)
if (!modelKey) return // Use OpenCode's default estimation
const coef = CUSTOM_COEFFICIENTS[modelKey]
const inputKwh = (input.tokens.input / 1_000_000) * coef.input
const outputKwh = ((input.tokens.output + input.tokens.reasoning) / 1_000_000) * coef.output
const cacheKwh = (input.tokens.cache.read / 1_000_000) * coef.input * 0.1
const totalKwh = (inputKwh + outputKwh + cacheKwh) * 1.1 // PUE
output.energy = {
kwh: totalKwh,
wh: totalKwh * 1000,
joules: totalKwh * 3_600_000,
source: 'estimated',
provider: 'energy-enhancer-plugin',
method: 'custom_coefficients_v1',
}
await ctx.client.app.log({
service: 'energy-enhancer',
level: 'debug',
message: `Custom estimate for ${input.modelID}: ${totalKwh.toExponential(3)} kWh`,
})
},
/**
* Enhance energy data with real-time grid intensity
*/
"message.energy": async (input, output) => {
// Fetch real-time grid intensity
const region = input.region || 'global'
const grid = await fetchGridIntensity(region)
// Recalculate carbon with real grid intensity
if (output.energy.kwh) {
output.energy.gCO2e = output.energy.kwh * grid.intensity
output.energy.gridIntensity = {
value: grid.intensity,
region,
source: grid.source,
}
}
// Track session totals
if (!sessionTotals[input.sessionID]) {
sessionTotals[input.sessionID] = { wh: 0, gCO2e: 0, count: 0 }
}
sessionTotals[input.sessionID].wh += output.energy.wh || 0
sessionTotals[input.sessionID].gCO2e += output.energy.gCO2e || 0
sessionTotals[input.sessionID].count += 1
// Log to external service (example)
await ctx.client.app.log({
service: 'energy-enhancer',
level: 'info',
message: `Energy: ${(output.energy.wh || 0).toFixed(4)} Wh, ${(output.energy.gCO2e || 0).toFixed(4)} gCO2e`,
extra: {
sessionID: input.sessionID,
messageID: input.messageID,
model: input.modelID,
provider: input.providerID,
energy: output.energy,
sessionTotals: sessionTotals[input.sessionID],
},
})
},
/**
* Log session totals when session becomes idle
*/
event: async ({ event }) => {
if (event.type === 'session.idle') {
const sessionID = event.properties.sessionID
const totals = sessionTotals[sessionID]
if (totals) {
await ctx.client.app.log({
service: 'energy-enhancer',
level: 'info',
message: `Session ${sessionID} complete`,
extra: {
totalWh: totals.wh,
totalGCO2e: totals.gCO2e,
messageCount: totals.count,
avgWhPerMessage: totals.wh / totals.count,
},
})
// Clean up
delete sessionTotals[sessionID]
}
}
},
}
}
export default EnergyEnhancerPlugin

Proposal: Energy Tracking as a First-Class Citizen in OpenCode

Summary

This proposal adds native energy and carbon tracking to OpenCode by:

  1. Capturing measured energy data from providers that supply it (GreenPT, Neuralwatt)
  2. Estimating energy for providers that don't measure it
  3. Storing energy data alongside existing token/cost data
  4. Providing plugin hooks for customization and external integration

Motivation

AI inference has significant energy and carbon implications. Tools like llm-greenpt and llm-neuralwatt have shown that this data can be captured and made visible to users. OpenCode should make this a first-class feature.

Design Goals

  1. Non-breaking - All changes are additive; existing code continues to work
  2. Provider-agnostic - Works with any provider, measured or estimated
  3. Plugin-extensible - Plugins can enhance, modify, or replace energy calculations
  4. Visible - Energy data appears in UI alongside cost

Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│                              Provider API                                    │
│                                                                             │
│  Standard providers:        Energy-aware providers:                         │
│  - OpenAI                   - GreenPT (impact in response)                  │
│  - Anthropic                - Neuralwatt (energy in SSE comment)            │
│  - Google                   - Future providers...                           │
└─────────────────────────────────────────────────────────────────────────────┘
                                      │
                                      ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                         SDK Layer (Modified)                                 │
│                                                                             │
│  openai-compatible/openai-responses-language-model.ts                       │
│                                                                             │
│  Changes:                                                                   │
│  • Extended chunk schema to capture `impact` field                          │
│  • Custom SSE handler to capture `: energy` comments                        │
│  • Energy data passed via providerMetadata.energy                           │
└─────────────────────────────────────────────────────────────────────────────┘
                                      │
                                      │ providerMetadata.energy
                                      ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                      Session Processor (Modified)                            │
│                                                                             │
│  processor.ts                                                               │
│                                                                             │
│  Changes:                                                                   │
│  • Check for measured energy in providerMetadata                            │
│  • Call plugin hook for custom estimates if not measured                    │
│  • Fall back to built-in estimation                                         │
│  • Call plugin hook to allow enhancement                                    │
│  • Store energy in StepFinishPart and AssistantMessage                      │
│  • Publish energy event                                                     │
└─────────────────────────────────────────────────────────────────────────────┘
                                      │
                    ┌─────────────────┼─────────────────┐
                    │                 │                 │
                    ▼                 ▼                 ▼
           ┌───────────────┐  ┌───────────────┐  ┌───────────────┐
           │   Storage     │  │  Plugin Hooks │  │      UI       │
           │               │  │               │  │               │
           │ message.json  │  │ energy.estimate│ │ Context usage │
           │ + energy {}   │  │ energy        │  │ + energy      │
           │               │  │               │  │               │
           │ part.json     │  │ (custom calc, │  │ Tooltips      │
           │ + energy {}   │  │  logging,     │  │ + energy      │
           │               │  │  external svc)│  │               │
           └───────────────┘  └───────────────┘  └───────────────┘

Data Flow

Measured Energy (GreenPT/Neuralwatt)

1. Provider sends energy in SSE stream
2. SDK captures and normalizes to providerMetadata.energy
3. Processor stores as-is with source: 'measured'
4. Plugin hooks can enhance (e.g., add grid intensity)

Estimated Energy (Standard Providers)

1. Provider sends standard response (no energy)
2. Processor checks providerMetadata.energy → not present
3. Processor calls plugin hook: message.energy.estimate
4. If plugin provides estimate → use it
5. Else → use built-in estimation (model coefficients × tokens × PUE)
6. Store with source: 'estimated'
7. Plugin hooks can enhance (e.g., add real-time grid intensity)

Schema Changes

Energy Type

type Energy = {
  // Normalized energy values
  wh?: number           // Watt-hours
  kwh?: number          // Kilowatt-hours
  joules?: number       // Joules
  
  // Carbon emissions
  gCO2e?: number        // Grams CO2 equivalent
  
  // Metadata
  source: 'measured' | 'estimated'
  provider?: string     // 'greenpt', 'neuralwatt', 'opencode', plugin name
  method?: string       // Calculation method
  
  // Grid intensity (if carbon was calculated)
  gridIntensity?: {
    value: number       // gCO2e/kWh
    region: string      // 'global', 'us-west', etc.
    source?: string     // 'electricitymaps', 'static'
  }
  
  // Raw provider data
  raw?: Record<string, unknown>
}

StepFinishPart (Extended)

type StepFinishPart = {
  // ... existing fields ...
  type: 'step-finish'
  tokens: { input, output, reasoning, cache }
  cost: number
  
  // NEW
  energy?: Energy
}

AssistantMessage (Extended)

type AssistantMessage = {
  // ... existing fields ...
  tokens: { input, output, reasoning, cache }
  cost: number
  
  // NEW: Aggregated from all steps
  energy?: Energy
}

Plugin Hooks

message.energy.estimate

Called when provider doesn't supply measured energy. Plugins can provide custom estimates.

"message.energy.estimate": async (input, output) => {
  // input: { sessionID, messageID, modelID, providerID, tokens }
  // output: { energy?: Energy }
  
  // Plugin sets output.energy to provide custom estimate
  output.energy = {
    kwh: myCustomCalculation(input.tokens, input.modelID),
    source: 'estimated',
    provider: 'my-plugin',
  }
}

message.energy

Called after energy is determined (measured or estimated). Plugins can enhance or log.

"message.energy": async (input, output) => {
  // input: { sessionID, messageID, modelID, providerID, tokens, region? }
  // output: { energy: Energy }
  
  // Enhance with real-time grid intensity
  const grid = await fetchGridIntensity(input.region)
  output.energy.gCO2e = output.energy.kwh * grid.intensity
  output.energy.gridIntensity = { value: grid.intensity, ... }
  
  // Log to external service
  await logToHelicone(input.sessionID, output.energy)
}

Events

message.energy.updated

Published when energy data is stored. Plugins subscribed to event can react.

{
  type: 'message.energy.updated',
  properties: {
    sessionID: string
    messageID: string
    partID?: string
    energy: Energy
    context: {
      modelID: string
      providerID: string
      tokens: { input, output, reasoning }
    }
  }
}

UI Changes

Context Usage Tooltip

Extend existing tooltip to show energy:

┌────────────────────────────┐
│  15,234 tokens             │
│  42% context usage         │
│  $0.0234 cost              │
│  ─────────────────         │  ← NEW
│  0.0047 Wh energy          │  ← NEW
│  0.0021 gCO₂e carbon       │  ← NEW
│  ─────────────────         │
│  Click to view details     │
└────────────────────────────┘

Session Summary

Add energy totals to session summary:

Session Summary
├── Messages: 12
├── Tokens: 45,000 in / 8,000 out
├── Cost: $0.089
├── Energy: 0.024 Wh (estimated)     ← NEW
└── Carbon: 0.010 gCO₂e              ← NEW

Implementation Plan

Phase 1: Core Infrastructure

  1. Add Energy type to message-v2.ts
  2. Extend StepFinishPart and AssistantMessage schemas
  3. Update processor to capture/estimate energy
  4. Regenerate SDK types

Phase 2: SDK Changes

  1. Extend chunk schema for GreenPT impact field
  2. Add custom SSE handler for Neuralwatt comments
  3. Pass energy through providerMetadata

Phase 3: Plugin Hooks

  1. Add message.energy.estimate hook
  2. Add message.energy hook
  3. Add message.energy.updated event
  4. Update Plugin.trigger calls in processor

Phase 4: UI

  1. Update session-context-usage.tsx
  2. Add energy to message tooltips
  3. Add to session summary view

Phase 5: Documentation

  1. Update plugin documentation
  2. Add energy tracking guide
  3. Document provider support

Backwards Compatibility

  • All energy fields are optional
  • Existing messages without energy continue to work
  • Plugins that don't implement energy hooks are unaffected
  • UI gracefully handles missing energy data

Future Considerations

  1. ElectricityMaps integration - Real-time grid carbon intensity
  2. Regional configuration - User can set their region for better carbon estimates
  3. Energy budgets - Warn when session exceeds energy threshold
  4. Aggregated reporting - Daily/weekly energy usage reports
  5. Provider adoption - Encourage more providers to send measured energy

Files Changed

packages/opencode/src/provider/sdk/openai-compatible/src/responses/
  └── openai-responses-language-model.ts   # SDK energy capture

packages/opencode/src/session/
  ├── message-v2.ts                        # Schema changes
  ├── processor.ts                         # Energy capture/estimation
  └── index.ts                             # getUsage updates

packages/plugin/src/
  └── index.ts                             # New hooks

packages/sdk/js/src/gen/
  └── types.gen.ts                         # Regenerated types

packages/app/src/components/
  └── session-context-usage.tsx            # UI display

Questions for Discussion

  1. Should energy be opt-in or always calculated?
  2. Default grid intensity: global average or configurable?
  3. Should we show energy in the main UI or only in details?
  4. How to handle energy for tool calls vs. main response?
  5. Should session viewer be updated in core or as separate project?

SSE Parsing Analysis: Where Energy Data Could Be Captured

The Data Flow

Provider API (e.g., GreenPT, Neuralwatt)
        │
        │ SSE Stream (Server-Sent Events)
        │ Contains: text deltas, usage, AND energy data
        ▼
┌─────────────────────────────────────────────────────────┐
│  @ai-sdk/provider-utils                              │
│  createEventSourceResponseHandler()                 │
│                                                      │
│  Parses SSE lines into JSON chunks                   │
│  ⚠️  Filters to schema-defined types only            │
└─────────────────────────┬──────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────┐
│  OpenAIResponsesLanguageModel                        │
│  (opencode/src/provider/sdk/openai-compatible/...)   │
│                                                      │
│  openaiResponsesChunkSchema = z.union([              │
│    textDeltaChunkSchema,                             │
│    responseFinishedChunkSchema,  ◀── usage here      │
│    ...other known types...                           │
│    z.object({ type: z.string() }).loose()  ◀── FALLBACK
│  ])                                                  │
└─────────────────────────┬──────────────────────────────┘
                          │
                          │ TransformStream processes each chunk
                          │ Known types → handled
                          │ Unknown types → silently dropped!
                          ▼
┌─────────────────────────────────────────────────────────┐
│  LanguageModelV2StreamPart events                    │
│                                                      │
│  - text-delta                                        │
│  - reasoning-start/delta                             │
│  - tool-call                                         │
│  - finish (includes usage + providerMetadata)        │
│                                                      │
│  ❌ No energy/impact data preserved                   │
└─────────────────────────┬──────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────┐
│  LLM.stream() / Processor                            │
│                                                      │
│  Handles finish-step event:                          │
│    usage: { inputTokens, outputTokens, ... }         │
│    providerMetadata: { openai: { ... } }             │
│                                                      │
│  ❌ Energy data already lost at this point            │
└─────────────────────────┬──────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────┐
│  Session.getUsage() / message.updated event          │
│                                                      │
│  Only sees: tokens, cost (calculated from tokens)    │
│  Plugin hooks can access this                        │
└─────────────────────────────────────────────────────────┘

Key Code Locations

1. SSE Parsing (AI SDK Provider Utils)

@ai-sdk/provider-utils/createEventSourceResponseHandler()

This is imported from the AI SDK and parses raw SSE lines into JSON.

2. Chunk Schema Definition

// opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-language-model.ts
// Lines ~1380-1400

const openaiResponsesChunkSchema = z.union([
  textDeltaChunkSchema,
  responseFinishedChunkSchema,   // <-- Has usage: { input_tokens, output_tokens, ... }
  responseCreatedChunkSchema,
  // ... other known types ...
  z.object({ type: z.string() }).loose(), // fallback - catches unknown but doesn't use them
])

3. Stream Transform (Where Energy Gets Lost)

// Lines ~850-1300 in same file

return {
  stream: response.pipeThrough(
    new TransformStream<ParseResult<...>, LanguageModelV2StreamPart>({
      transform(chunk, controller) {
        const value = chunk.value
        
        if (isResponseFinishedChunk(value)) {
          // Usage is extracted here
          usage.inputTokens = value.response.usage.input_tokens
          usage.outputTokens = value.response.usage.output_tokens
          // ...
          
          // BUT: No handling for energy/impact fields!
          // Even if they're in the raw response, they're not captured
        }
        // Unknown chunk types are silently ignored
      },
      
      flush(controller) {
        controller.enqueue({
          type: "finish",
          finishReason,
          usage,  // Only standard usage data
          providerMetadata,  // Only openai-specific metadata
        })
      },
    }),
  ),
}

4. Usage Consumption (OpenCode Session)

// opencode/src/session/processor.ts ~Line 237

case "finish-step":
  const usage = Session.getUsage({
    model: input.model,
    usage: value.usage,           // LanguageModelV2Usage - no energy
    metadata: value.providerMetadata,  // Only has openai.responseId, logprobs, etc.
  })

Where Energy Data Is Sent by Providers

GreenPT Format (in data: field)

data: {"choices":[],"impact":{"version":"20250922","inferenceTime":{"total":156,"unit":"ms"},"energy":{"total":40433,"unit":"Wms"},"emissions":{"total":1,"unit":"ugCO2e"}}}
data: [DONE]

Neuralwatt Format (SSE comment)

: energy {"energy_joules":0.5,"energy_kwh":0.000000139,"avg_power_watts":150}
data: [DONE]

Why Energy Data Is Lost

  1. GreenPT: The impact field is in the final data: chunk, but it's not part of any recognized chunk type in openaiResponsesChunkSchema. It falls through to the loose fallback schema but is never processed.

  2. Neuralwatt: The : energy line is an SSE comment. The createEventSourceResponseHandler likely ignores comments entirely (per SSE spec, comments start with :).

Options to Capture Energy Data

Option 1: Modify openaiResponsesChunkSchema (OpenCode Core Change)

Add energy/impact to the responseFinishedChunkSchema:

const responseFinishedChunkSchema = z.object({
  type: z.enum(["response.completed", "response.incomplete"]),
  response: z.object({
    incomplete_details: z.object({ reason: z.string() }).nullish(),
    usage: usageSchema,
    service_tier: z.string().nullish(),
    // ADD: GreenPT-style impact
    impact: z.object({
      energy: z.object({ total: z.number(), unit: z.string() }).optional(),
      emissions: z.object({ total: z.number(), unit: z.string() }).optional(),
    }).optional(),
  }),
})

Then propagate through providerMetadata:

flush(controller) {
  const providerMetadata: SharedV2ProviderMetadata = {
    openai: { responseId },
    // ADD:
    energy: responseImpact ? {
      wh: responseImpact.energy?.total,
      gCO2e: responseImpact.emissions?.total,
      source: 'greenpt',
    } : undefined,
  }
  controller.enqueue({ type: "finish", finishReason, usage, providerMetadata })
}

Option 2: Custom SSE Decoder (OpenCode Core Change)

Override createEventSourceResponseHandler with a custom version that:

  1. Captures SSE comments (for Neuralwatt)
  2. Preserves unknown fields in data chunks (for GreenPT)

Option 3: Proxy/Gateway (External)

Route requests through a proxy that:

  1. Captures energy data from SSE stream
  2. Stores it separately
  3. Adds correlation headers for later matching

Option 4: Provider-Specific SDK (Most Work)

Create custom provider SDKs for GreenPT/Neuralwatt that properly parse energy data, similar to how llm-greenpt and llm-neuralwatt subclass the OpenAI Python client.

Recommendation

For measured energy data to flow through OpenCode, changes are needed at the SDK level:

  1. Short term: Contribute to @ai-sdk/openai-compatible to add optional energy/impact fields to response schemas

  2. Medium term: Add providerMetadata.energy convention to Vercel AI SDK spec

  3. OpenCode-specific: Modify opencode/src/provider/sdk/openai-compatible/ to capture energy data and expose via providerMetadata

Without these changes, plugins can only estimate energy from token counts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment