Documentation: Web Interface Client Interaction Architecture #8222

Closed
opened 2026-02-16 18:09:27 -05:00 by yindo · 3 comments
Owner

Originally created by @BenceBertalan on GitHub (Feb 1, 2026).

Originally assigned to: @jayair on GitHub.

Overview

This issue documents the architecture and implementation details of how the OpenCode web interface handles client interaction, including messaging, event handling, and real-time updates.

Architecture

The opencode web command starts a local Hono-based HTTP server that serves as the backend API for the web interface. The architecture follows a Local Server + Remote UI Proxy model:

  • Backend: Local Hono server running on the user's machine (default port 4096)
  • Frontend: Static UI assets proxied from https://app.opencode.ai
  • Communication: RESTful API + Server-Sent Events (SSE) for real-time updates

Key Files

  • packages/opencode/src/cli/cmd/web.ts - Web command entry point
  • packages/opencode/src/server/server.ts - Main server configuration and routing
  • packages/opencode/src/server/routes/session.ts - Session and message handling
  • packages/opencode/src/server/event.ts - Server event definitions
  • packages/opencode/src/bus/bus-event.ts - Event bus system
  • packages/opencode/src/server/routes/question.ts - Question handling
  • packages/opencode/src/server/routes/permission.ts - Permission handling

Message Handling

Sending Messages

The web client sends messages to sessions via HTTP POST requests:

Endpoint: POST /session/:sessionID/message

Request Body:

{
  text: string,
  files?: FilePart[],
  agent?: string,
  model?: { providerID: string, modelID: string },
  // ... other optional fields
}

Response: Streams the assistant's response as JSON

Implementation: packages/opencode/src/server/routes/session.ts:697-736

Related Endpoints:

  • POST /session/:sessionID/prompt_async - Asynchronous message sending (returns immediately)
  • POST /session/:sessionID/command - Send slash commands
  • POST /session/:sessionID/shell - Execute shell commands

Receiving Messages

Messages are received in two ways:

  1. Initial Fetch: GET /session/:sessionID/message

    • Returns all messages in a session with their parts
    • Implementation: packages/opencode/src/server/routes/session.ts:546-583
  2. Real-time Updates: Via SSE stream (see Event Handling below)

Message Structure

Messages follow a structured format:

type Message = {
  info: UserMessage | AssistantMessage,
  parts: Part[]
}

type Part = 
  | TextPart 
  | ToolPart 
  | FilePart 
  | ReasoningPart 
  | SnapshotPart
  | PatchPart
  | AgentPart
  | RetryPart
  | CompactionPart
  | SubtaskPart
  | StepStartPart
  | StepFinishPart

Reference: packages/opencode/src/session/message-v2.ts

Event Handling (Real-time Updates)

Event Stream Connection

Endpoint: GET /event

Protocol: Server-Sent Events (SSE)

Implementation: packages/opencode/src/server/server.ts:474-529

The client establishes a persistent SSE connection to receive real-time updates. The server:

  1. Immediately sends a server.connected event upon connection
  2. Subscribes to all bus events and forwards them to the client
  3. Sends heartbeat events every 30 seconds to prevent connection timeouts
  4. Automatically closes the stream when the instance is disposed

Event Bus System

OpenCode uses a centralized event bus (BusEvent) to broadcast updates across the system:

// Event definition pattern
BusEvent.define("event.type", zodSchema)

Core Implementation: packages/opencode/src/bus/bus-event.ts

All events follow a discriminated union pattern:

type Event = {
  type: string,
  properties: { /* event-specific data */ }
}

Message Events

Event Types (from packages/opencode/src/session/message-v2.ts:401-430):

  1. message.updated

    {
      type: "message.updated",
      properties: {
        info: Message
      }
    }
    
    • Fired when a message is created or modified
    • Contains the full message info
  2. message.removed

    {
      type: "message.removed",
      properties: {
        sessionID: string,
        messageID: string
      }
    }
    
    • Fired when a message is deleted
  3. message.part.updated

    {
      type: "message.part.updated",
      properties: {
        part: Part,
        delta?: string
      }
    }
    
    • Fired when a message part is created or updated
    • Used for streaming responses (text parts updated incrementally)
    • The delta field contains incremental text updates
  4. message.part.removed

    {
      type: "message.part.removed",
      properties: {
        sessionID: string,
        messageID: string,
        partID: string
      }
    }
    
    • Fired when a message part is deleted

Non-Message Event Handling

Todo Events

Event Type: todo.updated (from packages/opencode/src/session/todo.ts:18)

{
  type: "todo.updated",
  properties: {
    sessionID: string,
    todos: Todo[]
  }
}

REST Endpoints:

  • GET /session/:sessionID/todo - Fetch current todos

Implementation: packages/opencode/src/server/routes/session.ts:155-183

Question Events

Event Type: question.asked (from packages/opencode/src/question/index.ts:64)

{
  type: "question.asked",
  properties: {
    requestID: string,
    sessionID: string,
    questions: Question[]
  }
}

REST Endpoints:

  • GET /question/ - List all pending questions
  • POST /question/:requestID/reply - Answer a question
  • POST /question/:requestID/reject - Reject a question

Implementation: packages/opencode/src/server/routes/question.ts

When the AI needs user input, it:

  1. Creates a question request with a unique requestID
  2. Broadcasts question.asked event via SSE
  3. Waits for client response via POST
  4. Broadcasts question.replied or question.rejected event

Permission Events

Event Type: permission.asked (from packages/opencode/src/permission/next.ts:97)

{
  type: "permission.asked",
  properties: {
    requestID: string,
    sessionID: string,
    // ... permission details
  }
}

REST Endpoints:

  • GET /permission/ - List all pending permissions
  • POST /permission/:requestID/reply - Approve/deny permission

Implementation: packages/opencode/src/server/routes/permission.ts

The permission flow:

  1. AI requests permission (e.g., to execute a command)
  2. Server broadcasts permission.asked event
  3. Client displays permission UI
  4. User approves/denies via POST request
  5. Server broadcasts permission.replied event

Session Events

Additional session-level events (from packages/opencode/src/session/index.ts:106-131):

  • session.created - New session created
  • session.updated - Session metadata updated
  • session.deleted - Session removed
  • session.diff - File changes summary
  • session.error - Session error occurred

Status Events

Event Type: session.status (from packages/opencode/src/session/status.ts:28)

{
  type: "session.status",
  properties: {
    sessionID: string,
    status: "idle" | "active" | "error"
  }
}

Indicates the current execution state of a session.

Authentication

If OPENCODE_SERVER_PASSWORD is set, the server uses HTTP Basic Auth:

  • Username: OPENCODE_SERVER_USERNAME (default: "opencode")
  • Password: OPENCODE_SERVER_PASSWORD

Implementation: packages/opencode/src/server/server.ts:80-85

CORS Configuration

The server allows connections from:

  • localhost on any port
  • 127.0.0.1 on any port
  • tauri://localhost (for desktop app)
  • *.opencode.ai (HTTPS only)
  • Custom origins via the --cors flag

Implementation: packages/opencode/src/server/server.ts:103-123

Client Implementation Pattern

A typical web client would:

  1. Initialize:

    • Connect to local server (detect via mDNS or user config)
    • Establish SSE connection to /event
  2. Handle Events:

    const eventSource = new EventSource('/event');
    eventSource.onmessage = (event) => {
      const { type, properties } = JSON.parse(event.data);
      switch(type) {
        case 'message.part.updated':
          // Update UI with new message part
          break;
        case 'question.asked':
          // Show question modal
          break;
        case 'permission.asked':
          // Show permission request
          break;
        // ... handle other events
      }
    };
    
  3. Send Messages:

    await fetch(`/session/${sessionID}/message`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: userInput })
    });
    
  4. Respond to Questions/Permissions:

    await fetch(`/question/${requestID}/reply`, {
      method: 'POST',
      body: JSON.stringify({ answers: [...] })
    });
    

Key Observations

  1. Separation of Concerns: The backend logic runs locally while UI assets are served remotely, allowing UI updates without CLI updates

  2. Event-Driven Architecture: All real-time updates flow through a centralized event bus with strong typing via Zod schemas

  3. Streaming Support: Message parts can be streamed incrementally using the delta field in message.part.updated events

  4. Type Safety: All API contracts are defined using Zod schemas and exposed via OpenAPI documentation at /doc

  5. Flexible Deployment: Supports network access (0.0.0.0), mDNS discovery, and custom CORS origins for various deployment scenarios

Related Documentation

  • OpenAPI spec available at: GET /doc
  • Full event type definitions: Search codebase for BusEvent.define
  • Message schemas: packages/opencode/src/session/message-v2.ts
Originally created by @BenceBertalan on GitHub (Feb 1, 2026). Originally assigned to: @jayair on GitHub. ## Overview This issue documents the architecture and implementation details of how the OpenCode web interface handles client interaction, including messaging, event handling, and real-time updates. ## Architecture The `opencode web` command starts a local Hono-based HTTP server that serves as the backend API for the web interface. The architecture follows a **Local Server + Remote UI Proxy** model: - **Backend**: Local Hono server running on the user's machine (default port 4096) - **Frontend**: Static UI assets proxied from `https://app.opencode.ai` - **Communication**: RESTful API + Server-Sent Events (SSE) for real-time updates ### Key Files - `packages/opencode/src/cli/cmd/web.ts` - Web command entry point - `packages/opencode/src/server/server.ts` - Main server configuration and routing - `packages/opencode/src/server/routes/session.ts` - Session and message handling - `packages/opencode/src/server/event.ts` - Server event definitions - `packages/opencode/src/bus/bus-event.ts` - Event bus system - `packages/opencode/src/server/routes/question.ts` - Question handling - `packages/opencode/src/server/routes/permission.ts` - Permission handling ## Message Handling ### Sending Messages The web client sends messages to sessions via HTTP POST requests: **Endpoint**: `POST /session/:sessionID/message` **Request Body**: ```typescript { text: string, files?: FilePart[], agent?: string, model?: { providerID: string, modelID: string }, // ... other optional fields } ``` **Response**: Streams the assistant's response as JSON **Implementation**: `packages/opencode/src/server/routes/session.ts:697-736` **Related Endpoints**: - `POST /session/:sessionID/prompt_async` - Asynchronous message sending (returns immediately) - `POST /session/:sessionID/command` - Send slash commands - `POST /session/:sessionID/shell` - Execute shell commands ### Receiving Messages Messages are received in two ways: 1. **Initial Fetch**: `GET /session/:sessionID/message` - Returns all messages in a session with their parts - Implementation: `packages/opencode/src/server/routes/session.ts:546-583` 2. **Real-time Updates**: Via SSE stream (see Event Handling below) ### Message Structure Messages follow a structured format: ```typescript type Message = { info: UserMessage | AssistantMessage, parts: Part[] } type Part = | TextPart | ToolPart | FilePart | ReasoningPart | SnapshotPart | PatchPart | AgentPart | RetryPart | CompactionPart | SubtaskPart | StepStartPart | StepFinishPart ``` **Reference**: `packages/opencode/src/session/message-v2.ts` ## Event Handling (Real-time Updates) ### Event Stream Connection **Endpoint**: `GET /event` **Protocol**: Server-Sent Events (SSE) **Implementation**: `packages/opencode/src/server/server.ts:474-529` The client establishes a persistent SSE connection to receive real-time updates. The server: 1. Immediately sends a `server.connected` event upon connection 2. Subscribes to all bus events and forwards them to the client 3. Sends heartbeat events every 30 seconds to prevent connection timeouts 4. Automatically closes the stream when the instance is disposed ### Event Bus System OpenCode uses a centralized event bus (`BusEvent`) to broadcast updates across the system: ```typescript // Event definition pattern BusEvent.define("event.type", zodSchema) ``` **Core Implementation**: `packages/opencode/src/bus/bus-event.ts` All events follow a discriminated union pattern: ```typescript type Event = { type: string, properties: { /* event-specific data */ } } ``` ### Message Events **Event Types** (from `packages/opencode/src/session/message-v2.ts:401-430`): 1. **`message.updated`** ```typescript { type: "message.updated", properties: { info: Message } } ``` - Fired when a message is created or modified - Contains the full message info 2. **`message.removed`** ```typescript { type: "message.removed", properties: { sessionID: string, messageID: string } } ``` - Fired when a message is deleted 3. **`message.part.updated`** ```typescript { type: "message.part.updated", properties: { part: Part, delta?: string } } ``` - Fired when a message part is created or updated - Used for streaming responses (text parts updated incrementally) - The `delta` field contains incremental text updates 4. **`message.part.removed`** ```typescript { type: "message.part.removed", properties: { sessionID: string, messageID: string, partID: string } } ``` - Fired when a message part is deleted ## Non-Message Event Handling ### Todo Events **Event Type**: `todo.updated` (from `packages/opencode/src/session/todo.ts:18`) ```typescript { type: "todo.updated", properties: { sessionID: string, todos: Todo[] } } ``` **REST Endpoints**: - `GET /session/:sessionID/todo` - Fetch current todos **Implementation**: `packages/opencode/src/server/routes/session.ts:155-183` ### Question Events **Event Type**: `question.asked` (from `packages/opencode/src/question/index.ts:64`) ```typescript { type: "question.asked", properties: { requestID: string, sessionID: string, questions: Question[] } } ``` **REST Endpoints**: - `GET /question/` - List all pending questions - `POST /question/:requestID/reply` - Answer a question - `POST /question/:requestID/reject` - Reject a question **Implementation**: `packages/opencode/src/server/routes/question.ts` When the AI needs user input, it: 1. Creates a question request with a unique `requestID` 2. Broadcasts `question.asked` event via SSE 3. Waits for client response via POST 4. Broadcasts `question.replied` or `question.rejected` event ### Permission Events **Event Type**: `permission.asked` (from `packages/opencode/src/permission/next.ts:97`) ```typescript { type: "permission.asked", properties: { requestID: string, sessionID: string, // ... permission details } } ``` **REST Endpoints**: - `GET /permission/` - List all pending permissions - `POST /permission/:requestID/reply` - Approve/deny permission **Implementation**: `packages/opencode/src/server/routes/permission.ts` The permission flow: 1. AI requests permission (e.g., to execute a command) 2. Server broadcasts `permission.asked` event 3. Client displays permission UI 4. User approves/denies via POST request 5. Server broadcasts `permission.replied` event ## Session Events Additional session-level events (from `packages/opencode/src/session/index.ts:106-131`): - **`session.created`** - New session created - **`session.updated`** - Session metadata updated - **`session.deleted`** - Session removed - **`session.diff`** - File changes summary - **`session.error`** - Session error occurred ## Status Events **Event Type**: `session.status` (from `packages/opencode/src/session/status.ts:28`) ```typescript { type: "session.status", properties: { sessionID: string, status: "idle" | "active" | "error" } } ``` Indicates the current execution state of a session. ## Authentication If `OPENCODE_SERVER_PASSWORD` is set, the server uses HTTP Basic Auth: - Username: `OPENCODE_SERVER_USERNAME` (default: "opencode") - Password: `OPENCODE_SERVER_PASSWORD` **Implementation**: `packages/opencode/src/server/server.ts:80-85` ## CORS Configuration The server allows connections from: - `localhost` on any port - `127.0.0.1` on any port - `tauri://localhost` (for desktop app) - `*.opencode.ai` (HTTPS only) - Custom origins via the `--cors` flag **Implementation**: `packages/opencode/src/server/server.ts:103-123` ## Client Implementation Pattern A typical web client would: 1. **Initialize**: - Connect to local server (detect via mDNS or user config) - Establish SSE connection to `/event` 2. **Handle Events**: ```javascript const eventSource = new EventSource('/event'); eventSource.onmessage = (event) => { const { type, properties } = JSON.parse(event.data); switch(type) { case 'message.part.updated': // Update UI with new message part break; case 'question.asked': // Show question modal break; case 'permission.asked': // Show permission request break; // ... handle other events } }; ``` 3. **Send Messages**: ```javascript await fetch(`/session/${sessionID}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: userInput }) }); ``` 4. **Respond to Questions/Permissions**: ```javascript await fetch(`/question/${requestID}/reply`, { method: 'POST', body: JSON.stringify({ answers: [...] }) }); ``` ## Key Observations 1. **Separation of Concerns**: The backend logic runs locally while UI assets are served remotely, allowing UI updates without CLI updates 2. **Event-Driven Architecture**: All real-time updates flow through a centralized event bus with strong typing via Zod schemas 3. **Streaming Support**: Message parts can be streamed incrementally using the `delta` field in `message.part.updated` events 4. **Type Safety**: All API contracts are defined using Zod schemas and exposed via OpenAPI documentation at `/doc` 5. **Flexible Deployment**: Supports network access (0.0.0.0), mDNS discovery, and custom CORS origins for various deployment scenarios ## Related Documentation - OpenAPI spec available at: `GET /doc` - Full event type definitions: Search codebase for `BusEvent.define` - Message schemas: `packages/opencode/src/session/message-v2.ts`
yindo added the docs label 2026-02-16 18:09:27 -05:00
yindo closed this issue 2026-02-16 18:09:27 -05:00
Author
Owner

@github-actions[bot] commented on GitHub (Feb 1, 2026):

This issue documents the web interface architecture and might be related to the following operational/implementation issues that touch similar components:

  • #11522: SSE events not emitted when using opencode attach --dir - Related to the event handling system documented here
  • #11360: `YO WTF` printed in the `opencode web` console - Related to the web command implementation

These aren't exact duplicates, but they address real bugs in the systems documented in this architecture guide. You may want to reference this documentation issue when investigating those bugs.

@github-actions[bot] commented on GitHub (Feb 1, 2026): This issue documents the web interface architecture and might be related to the following operational/implementation issues that touch similar components: - #11522: SSE events not emitted when using `opencode attach --dir` - Related to the event handling system documented here - #11360: \`YO WTF\` printed in the \`opencode web\` console - Related to the web command implementation These aren't exact duplicates, but they address real bugs in the systems documented in this architecture guide. You may want to reference this documentation issue when investigating those bugs.
Author
Owner

@BenceBertalan commented on GitHub (Feb 1, 2026):

Update: Quota and Rate Limit Detection

How OpenCode Detects Quota/Rate Limit Errors

OpenCode has sophisticated error detection and retry logic that identifies quota and rate limit issues from AI provider API responses. Here's how it works:

Detection Mechanism

File: packages/opencode/src/session/retry.ts:61-96

The retryable() function analyzes API errors to determine if they're quota/rate limit related:

export function retryable(error: ReturnType<NamedError["toObject"]>) {
  if (MessageV2.APIError.isInstance(error)) {
    if (!error.data.isRetryable) return undefined
    return error.data.message.includes("Overloaded") 
      ? "Provider is overloaded" 
      : error.data.message
  }

  // Parse error response body as JSON
  const json = JSON.parse(error.data.message)
  
  // Check for specific error patterns:
  
  // 1. Anthropic/Claude rate limit format
  if (json.type === "error" && json.error?.type === "too_many_requests") {
    return "Too Many Requests"
  }
  
  // 2. Generic quota exhaustion
  if (code.includes("exhausted") || code.includes("unavailable")) {
    return "Provider is overloaded"
  }
  
  // 3. Rate limit error codes
  if (json.type === "error" && json.error?.code?.includes("rate_limit")) {
    return "Rate Limited"
  }
}

Error Sources

File: packages/opencode/src/session/message-v2.ts:668-748

Errors are captured from the AI SDK's APICallError class and transformed into structured MessageV2.APIError objects:

{
  name: "APIError",
  data: {
    message: string,           // Human-readable error message
    statusCode?: number,       // HTTP status code (e.g., 429)
    isRetryable: boolean,      // Whether the error can be retried
    responseHeaders?: object,  // Raw response headers
    responseBody?: string,     // Raw response body (JSON)
    metadata?: object          // Additional metadata (e.g., URL)
  }
}

The isRetryable flag comes from the AI SDK and is provider-specific. OpenCode relies on:

  1. The AI SDK's built-in retry detection (e.isRetryable)
  2. HTTP status codes (some providers return 429, others use different codes)
  3. Parsing the response body for known error patterns

Retry Logic with Exponential Backoff

File: packages/opencode/src/session/retry.ts:28-59

When a retryable error is detected, OpenCode calculates a delay before retrying:

export function delay(attempt: number, error?: MessageV2.APIError) {
  // 1. Check for Retry-After headers (preferred)
  if (error?.data.responseHeaders) {
    // Retry-After-Ms: milliseconds
    const retryAfterMs = headers["retry-after-ms"]
    if (retryAfterMs) return parseFloat(retryAfterMs)
    
    // Retry-After: seconds or HTTP date
    const retryAfter = headers["retry-after"]
    if (retryAfter) {
      // Try parsing as seconds
      const seconds = parseFloat(retryAfter)
      if (!isNaN(seconds)) return seconds * 1000
      
      // Try parsing as HTTP date
      const date = Date.parse(retryAfter) - Date.now()
      if (!isNaN(date) && date > 0) return date
    }
  }
  
  // 2. Fallback to exponential backoff
  return RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1)
  // RETRY_INITIAL_DELAY = 2000ms
  // RETRY_BACKOFF_FACTOR = 2
  // Max delay = 30 seconds (without headers) or ~24 days (with headers)
}

Retry schedule: 2s → 4s → 8s → 16s → 30s (capped)

Communication to Web Client

When a retry occurs, the web client is notified via two mechanisms:

1. Session Status Event

Event: session.status (via SSE /event stream)

File: packages/opencode/src/session/status.ts:28-34

{
  type: "session.status",
  properties: {
    sessionID: string,
    status: {
      type: "retry",
      attempt: number,        // Which retry attempt (1, 2, 3, ...)
      message: string,        // User-friendly error message
      next: number           // Timestamp when next retry will occur
    }
  }
}

Trigger: packages/opencode/src/session/processor.ts:349-354

When the processor catches a retryable error, it immediately broadcasts a status update:

const retry = SessionRetry.retryable(error)
if (retry !== undefined) {
  attempt++
  const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
  SessionStatus.set(input.sessionID, {
    type: "retry",
    attempt,
    message: retry,              // "Rate Limited", "Too Many Requests", etc.
    next: Date.now() + delay,    // When the retry will happen
  })
  await SessionRetry.sleep(delay, input.abort)
  continue  // Retry the request
}

2. Retry Part (Message History)

File: packages/opencode/src/session/message-v2.ts:184-194

If a retry occurs, a RetryPart is not automatically added to the message (based on code review). However, the error information is preserved in the assistant message:

type RetryPart = {
  type: "retry",
  id: string,
  sessionID: string,
  messageID: string,
  attempt: number,
  error: APIError,
  time: {
    created: number
  }
}

Note: The current implementation broadcasts retry status via session.status events but does not persist retry attempts as message parts. If the retry succeeds, the user never sees the error. If all retries fail, the final error is stored in the assistant message's error field.

Client UI Handling Example

From the TUI implementation (packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx:1044-1084):

const retry = createMemo(() => {
  const s = status()
  if (s.type !== "retry") return
  return s
})

const message = createMemo(() => {
  const r = retry()
  if (!r) return
  
  // Special case for Gemini quota errors
  if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
    return "gemini is way too hot right now"
    
  // Truncate long messages
  if (r.message.length > 80) return r.message.slice(0, 80) + "..."
  return r.message
})

// Calculate countdown timer
const seconds = Math.round((retry.next - Date.now()) / 1000)

// Display: "Rate Limited [retrying in 8s attempt #2]"

Web Client Implementation Pattern

To handle quota/rate limit errors, a web client should:

  1. Listen for session.status events:
eventSource.onmessage = (event) => {
  const { type, properties } = JSON.parse(event.data);
  
  if (type === 'session.status' && properties.status.type === 'retry') {
    const { attempt, message, next } = properties.status;
    
    // Show retry notification
    showRetryBanner({
      message,                           // "Rate Limited", "Too Many Requests"
      attempt,                           // 1, 2, 3...
      retryIn: next - Date.now()        // Milliseconds until retry
    });
  }
  
  if (type === 'session.status' && properties.status.type === 'idle') {
    // Retry succeeded or session completed
    hideRetryBanner();
  }
};
  1. Check for final errors in assistant messages:
if (message.role === 'assistant' && message.error) {
  if (message.error.name === 'APIError') {
    const { message: errorMsg, statusCode, isRetryable } = message.error.data;
    
    if (statusCode === 429 || errorMsg.includes('quota') || errorMsg.includes('rate limit')) {
      showQuotaError(errorMsg);
    }
  }
}

Known Provider Error Patterns

Based on the code, OpenCode detects these patterns:

Provider Error Pattern Detection Method
Anthropic/Claude {"type":"error","error":{"type":"too_many_requests"}} JSON parsing
OpenAI Status 429, rate_limit_exceeded error code Status code + AI SDK
Google Gemini "exceeded your current quota" in message String matching
Generic exhausted, unavailable in error code String matching
Any Overloaded in error message String matching

REST API for Status Checking

Clients can also poll session status via:

Endpoint: GET /session/status

Response:

{
  "session_abc123": {
    "type": "retry",
    "attempt": 2,
    "message": "Rate Limited",
    "next": 1738435200000
  },
  "session_xyz789": {
    "type": "idle"
  }
}

Implementation: packages/opencode/src/server/routes/session.ts:70-90

This allows clients to check retry status without maintaining a persistent SSE connection.

@BenceBertalan commented on GitHub (Feb 1, 2026): ## Update: Quota and Rate Limit Detection ### How OpenCode Detects Quota/Rate Limit Errors OpenCode has sophisticated error detection and retry logic that identifies quota and rate limit issues from AI provider API responses. Here's how it works: ### Detection Mechanism **File**: `packages/opencode/src/session/retry.ts:61-96` The `retryable()` function analyzes API errors to determine if they're quota/rate limit related: ```typescript export function retryable(error: ReturnType<NamedError["toObject"]>) { if (MessageV2.APIError.isInstance(error)) { if (!error.data.isRetryable) return undefined return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message } // Parse error response body as JSON const json = JSON.parse(error.data.message) // Check for specific error patterns: // 1. Anthropic/Claude rate limit format if (json.type === "error" && json.error?.type === "too_many_requests") { return "Too Many Requests" } // 2. Generic quota exhaustion if (code.includes("exhausted") || code.includes("unavailable")) { return "Provider is overloaded" } // 3. Rate limit error codes if (json.type === "error" && json.error?.code?.includes("rate_limit")) { return "Rate Limited" } } ``` ### Error Sources **File**: `packages/opencode/src/session/message-v2.ts:668-748` Errors are captured from the AI SDK's `APICallError` class and transformed into structured `MessageV2.APIError` objects: ```typescript { name: "APIError", data: { message: string, // Human-readable error message statusCode?: number, // HTTP status code (e.g., 429) isRetryable: boolean, // Whether the error can be retried responseHeaders?: object, // Raw response headers responseBody?: string, // Raw response body (JSON) metadata?: object // Additional metadata (e.g., URL) } } ``` The `isRetryable` flag comes from the AI SDK and is provider-specific. OpenCode relies on: 1. The AI SDK's built-in retry detection (`e.isRetryable`) 2. HTTP status codes (some providers return 429, others use different codes) 3. Parsing the response body for known error patterns ### Retry Logic with Exponential Backoff **File**: `packages/opencode/src/session/retry.ts:28-59` When a retryable error is detected, OpenCode calculates a delay before retrying: ```typescript export function delay(attempt: number, error?: MessageV2.APIError) { // 1. Check for Retry-After headers (preferred) if (error?.data.responseHeaders) { // Retry-After-Ms: milliseconds const retryAfterMs = headers["retry-after-ms"] if (retryAfterMs) return parseFloat(retryAfterMs) // Retry-After: seconds or HTTP date const retryAfter = headers["retry-after"] if (retryAfter) { // Try parsing as seconds const seconds = parseFloat(retryAfter) if (!isNaN(seconds)) return seconds * 1000 // Try parsing as HTTP date const date = Date.parse(retryAfter) - Date.now() if (!isNaN(date) && date > 0) return date } } // 2. Fallback to exponential backoff return RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1) // RETRY_INITIAL_DELAY = 2000ms // RETRY_BACKOFF_FACTOR = 2 // Max delay = 30 seconds (without headers) or ~24 days (with headers) } ``` **Retry schedule**: 2s → 4s → 8s → 16s → 30s (capped) ### Communication to Web Client When a retry occurs, the web client is notified via **two mechanisms**: #### 1. Session Status Event **Event**: `session.status` (via SSE `/event` stream) **File**: `packages/opencode/src/session/status.ts:28-34` ```typescript { type: "session.status", properties: { sessionID: string, status: { type: "retry", attempt: number, // Which retry attempt (1, 2, 3, ...) message: string, // User-friendly error message next: number // Timestamp when next retry will occur } } } ``` **Trigger**: `packages/opencode/src/session/processor.ts:349-354` When the processor catches a retryable error, it immediately broadcasts a status update: ```typescript const retry = SessionRetry.retryable(error) if (retry !== undefined) { attempt++ const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined) SessionStatus.set(input.sessionID, { type: "retry", attempt, message: retry, // "Rate Limited", "Too Many Requests", etc. next: Date.now() + delay, // When the retry will happen }) await SessionRetry.sleep(delay, input.abort) continue // Retry the request } ``` #### 2. Retry Part (Message History) **File**: `packages/opencode/src/session/message-v2.ts:184-194` If a retry occurs, a `RetryPart` is **not** automatically added to the message (based on code review). However, the error information is preserved in the assistant message: ```typescript type RetryPart = { type: "retry", id: string, sessionID: string, messageID: string, attempt: number, error: APIError, time: { created: number } } ``` **Note**: The current implementation broadcasts retry status via `session.status` events but does not persist retry attempts as message parts. If the retry succeeds, the user never sees the error. If all retries fail, the final error is stored in the assistant message's `error` field. ### Client UI Handling Example From the TUI implementation (`packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx:1044-1084`): ```typescript const retry = createMemo(() => { const s = status() if (s.type !== "retry") return return s }) const message = createMemo(() => { const r = retry() if (!r) return // Special case for Gemini quota errors if (r.message.includes("exceeded your current quota") && r.message.includes("gemini")) return "gemini is way too hot right now" // Truncate long messages if (r.message.length > 80) return r.message.slice(0, 80) + "..." return r.message }) // Calculate countdown timer const seconds = Math.round((retry.next - Date.now()) / 1000) // Display: "Rate Limited [retrying in 8s attempt #2]" ``` ### Web Client Implementation Pattern To handle quota/rate limit errors, a web client should: 1. **Listen for `session.status` events**: ```javascript eventSource.onmessage = (event) => { const { type, properties } = JSON.parse(event.data); if (type === 'session.status' && properties.status.type === 'retry') { const { attempt, message, next } = properties.status; // Show retry notification showRetryBanner({ message, // "Rate Limited", "Too Many Requests" attempt, // 1, 2, 3... retryIn: next - Date.now() // Milliseconds until retry }); } if (type === 'session.status' && properties.status.type === 'idle') { // Retry succeeded or session completed hideRetryBanner(); } }; ``` 2. **Check for final errors** in assistant messages: ```javascript if (message.role === 'assistant' && message.error) { if (message.error.name === 'APIError') { const { message: errorMsg, statusCode, isRetryable } = message.error.data; if (statusCode === 429 || errorMsg.includes('quota') || errorMsg.includes('rate limit')) { showQuotaError(errorMsg); } } } ``` ### Known Provider Error Patterns Based on the code, OpenCode detects these patterns: | Provider | Error Pattern | Detection Method | |----------|--------------|------------------| | **Anthropic/Claude** | `{"type":"error","error":{"type":"too_many_requests"}}` | JSON parsing | | **OpenAI** | Status 429, `rate_limit_exceeded` error code | Status code + AI SDK | | **Google Gemini** | "exceeded your current quota" in message | String matching | | **Generic** | `exhausted`, `unavailable` in error code | String matching | | **Any** | `Overloaded` in error message | String matching | ### REST API for Status Checking Clients can also poll session status via: **Endpoint**: `GET /session/status` **Response**: ```json { "session_abc123": { "type": "retry", "attempt": 2, "message": "Rate Limited", "next": 1738435200000 }, "session_xyz789": { "type": "idle" } } ``` **Implementation**: `packages/opencode/src/server/routes/session.ts:70-90` This allows clients to check retry status without maintaining a persistent SSE connection.
Author
Owner

@BenceBertalan commented on GitHub (Feb 1, 2026):

This issue has been moved to BenceBertalan/octools#1 where it will be maintained as part of the OpenCode documentation repository.

@BenceBertalan commented on GitHub (Feb 1, 2026): This issue has been moved to BenceBertalan/octools#1 where it will be maintained as part of the OpenCode documentation repository.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: anomalyco/opencode#8222