Skip to content
2 changes: 2 additions & 0 deletions packages/opencode/src/server/routes/instance/httpapi/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { QuestionApi } from "./groups/question"
import { SessionApi } from "./groups/session"
import { SyncApi } from "./groups/sync"
import { TuiApi } from "./groups/tui"
import { VoiceApi } from "./groups/voice"
import { WorkspaceApi } from "./groups/workspace"
import { makeApi } from "@opencode-ai/protocol/api"
import { LocationMiddleware } from "@opencode-ai/server/location"
Expand Down Expand Up @@ -75,6 +76,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(SessionApi)
.addHttpApi(SyncApi)
.addHttpApi(TuiApi)
.addHttpApi(VoiceApi)
.addHttpApi(WorkspaceApi)
.middleware(SchemaErrorMiddleware)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { InvalidRequestError, UpstreamError } from "../errors"
import { described } from "./metadata"

export const TranscribeInput = Schema.Struct({
audio: Schema.String.annotate({ description: "Base64-encoded audio data" }),
mime: Schema.optional(Schema.String),
language: Schema.optional(Schema.String),
}).annotate({ identifier: "VoiceTranscribeInput" })
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const TranscribeResult = Schema.Struct({
text: Schema.String,
}).annotate({ identifier: "VoiceTranscribeResult" })

export const VoiceApi = HttpApi.make("voice").add(
HttpApiGroup.make("voice")
.add(
HttpApiEndpoint.post("transcribe", "/voice/transcribe", {
query: WorkspaceRoutingQuery,
payload: TranscribeInput,
success: described(TranscribeResult, "Transcribed text"),
error: [InvalidRequestError, UpstreamError],
}).annotateMerge(
OpenApi.annotations({
identifier: "voice.transcribe",
summary: "Transcribe audio",
description: "Transcribe recorded audio to text using the configured speech-to-text provider.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "voice",
description: "Voice transcription routes.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { VoiceTranscription } from "@/voice/transcription"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { InvalidRequestError, UpstreamError } from "../errors"
import { TranscribeInput } from "../groups/voice"

const transcribe = Effect.fn("VoiceHttpApi.transcribe")(function* (ctx: {
payload: typeof TranscribeInput.Type
}) {
const audio = decodeAudio(ctx.payload.audio)
if (!audio) return yield* new InvalidRequestError({ message: "audio must be base64-encoded", field: "audio" })
return yield* VoiceTranscription.transcribe({
audio,
mime: ctx.payload.mime ?? "audio/wav",
language: ctx.payload.language,
}).pipe(
Effect.mapError((error) => {
if (error instanceof VoiceTranscription.NoCredentialError)
return new InvalidRequestError({ message: error.message })
return new UpstreamError({ message: error.message, service: "openai", status: error.status })
}),
)
})

export const voiceHandlers = HttpApiBuilder.group(InstanceHttpApi, "voice", (handlers) =>
Effect.sync(() => handlers.handle("transcribe", transcribe)),
)

function decodeAudio(input: string) {
if (input.length === 0) return undefined
const decoded = Buffer.from(input, "base64")
if (decoded.length === 0) return undefined
return new Uint8Array(decoded)
}
Comment on lines +38 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ import { questionHandlers } from "./handlers/question"
import { sessionHandlers } from "./handlers/session"
import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui"
import { voiceHandlers } from "./handlers/voice"
import { handlers } from "@opencode-ai/server/handlers"
import { buildLocationServiceMap, LocationServiceMap } from "@opencode-ai/core/location-services"
import { layer as locationLayer } from "@opencode-ai/server/location"
Expand Down Expand Up @@ -170,6 +171,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
sessionHandlers,
syncHandlers,
tuiHandlers,
voiceHandlers,
workspaceHandlers,
]),
)
Expand Down
86 changes: 86 additions & 0 deletions packages/opencode/src/voice/transcription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Auth } from "@/auth"
import { Env } from "@/env"
import { Effect, Schema } from "effect"
import { HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"

export class NoCredentialError extends Schema.TaggedErrorClass<NoCredentialError>()("VoiceNoCredentialError", {
message: Schema.String,
}) {}

export class TranscribeError extends Schema.TaggedErrorClass<TranscribeError>()("VoiceTranscribeError", {
message: Schema.String,
status: Schema.optional(Schema.Number),
}) {}

const Result = Schema.Struct({ text: Schema.String })

const EXTENSIONS: Record<string, string> = {
"audio/wav": "wav",
"audio/x-wav": "wav",
"audio/mpeg": "mp3",
"audio/mp4": "m4a",
"audio/webm": "webm",
"audio/ogg": "ogg",
"audio/flac": "flac",
}

// Single speech-to-text entrypoint. Alternative transcription providers can be
// added by branching here on the resolved credential/provider instead of
// touching the HTTP surface or the TUI.
export const transcribe = Effect.fn("VoiceTranscription.transcribe")(function* (input: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function has a cyclomatic complexity of 7 with "medium" risk


A function with high cyclomatic complexity can be hard to understand and
maintain. Cyclomatic complexity is a software metric that measures the number of
independent paths through a function. A higher cyclomatic complexity indicates
that the function has more decision points and is more complex.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function is a linear happy path with early-error returns; repo style explicitly prefers keeping logic in one function over extracting single-use helpers, and complexity 7 is a minor threshold nit.

audio: Uint8Array
mime: string
language?: string
}) {
const key = yield* resolveOpenaiKey()
if (!key)
return yield* new NoCredentialError({
message: "Voice input needs an OpenAI credential. Run `opencode auth login` and add an OpenAI API key.",
})
const form = new FormData()
form.append("model", "whisper-1")
if (input.language) form.append("language", input.language)
form.append(
"file",
new File([input.audio as BlobPart], `voice.${EXTENSIONS[input.mime] ?? "wav"}`, { type: input.mime }),
)
const client = yield* HttpClient.HttpClient
const response = yield* client
.execute(
HttpClientRequest.post("https://api.openai.com/v1/audio/transcriptions", {
headers: { authorization: `Bearer ${key}` },
body: HttpBody.formData(form),
}),
)
.pipe(Effect.mapError((error) => new TranscribeError({ message: `Transcription request failed: ${error}` })))
if (response.status < 200 || response.status >= 300) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
return yield* new TranscribeError({
message: `Transcription failed: ${response.status} ${body}`.trim(),
status: response.status,
})
}
const json = yield* response.json.pipe(
Effect.mapError(() => new TranscribeError({ message: "Transcription returned an unreadable response" })),
)
const decoded = Schema.decodeUnknownOption(Result)(json)
if (decoded._tag === "None")
return yield* new TranscribeError({ message: "Transcription returned an unexpected response shape" })
return decoded.value
})

function resolveOpenaiKey() {
return Effect.gen(function* () {
const env = yield* Env.Service
const fromEnv = yield* env.get("OPENAI_API_KEY")
if (fromEnv) return fromEnv
const auth = yield* Auth.Service
const info = yield* auth.get("openai").pipe(Effect.orElseSucceed(() => undefined))
if (info?.type === "api") return info.key
if (info?.type === "oauth") return info.access
if (info?.type === "wellknown") return info.token
return undefined
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unexpected function declaration in the global scope, wrap in an IIFE for a local variable, assign as global property for a global variable


It is considered a best practice to avoid 'polluting' the global scope with variables that are intended to be local to the script. Global variables created from a script can produce name collisions with global variables created from another script, which will usually lead to runtime errors or unexpected behavior. It is mostly useful for browser scripts.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive: resolveOpenaiKey is an ESM module-scope helper, not a browser global; module-level helpers below the main export are this repo's documented pattern.


export * as VoiceTranscription from "./transcription"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Module imports itself


A module should never import itself. This usually happens as a mistake or typo and occurs mostly during refactoring. Self importing might result in unexpected results like wrong functions/variables being used.

4 changes: 4 additions & 0 deletions packages/opencode/test/server/httpapi-exercise/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,10 @@ const scenarios: Scenario[] = [
body: { method: "bad" },
}))
.status(400),
http.protected
.post("/voice/transcribe", "voice.transcribe")
.at((ctx) => ({ path: "/voice/transcribe", headers: ctx.headers(), body: { audio: "" } }))
.status(400),
http.protected.get("/permission", "permission.list").json(200, array),
http.protected
.post("/permission/{requestID}/reply", "permission.reply.invalid")
Expand Down
47 changes: 47 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,9 @@ import type {
VcsGetResponses,
VcsStatusErrors,
VcsStatusResponses,
VoiceTranscribeErrors,
VoiceTranscribeInput,
VoiceTranscribeResponses,
WorktreeCreateErrors,
WorktreeCreateInput,
WorktreeCreateResponses,
Expand Down Expand Up @@ -5503,6 +5506,45 @@ export class Tui extends HeyApiClient {
}
}

export class Voice extends HeyApiClient {
/**
* Transcribe audio
*
* Transcribe recorded audio to text using the configured speech-to-text provider.
*/
public transcribe<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
voiceTranscribeInput?: VoiceTranscribeInput
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ key: "voiceTranscribeInput", map: "body" },
],
},
],
)
return (options?.client ?? this.client).post<VoiceTranscribeResponses, VoiceTranscribeErrors, ThrowOnError>({
url: "/voice/transcribe",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}

export class Health extends HeyApiClient {
/**
* Check server health
Expand Down Expand Up @@ -7699,6 +7741,11 @@ export class OpencodeClient extends HeyApiClient {
return (this._tui ??= new Tui({ client: this.client }))
}

private _voice?: Voice
get voice(): Voice {
return (this._voice ??= new Voice({ client: this.client }))
}

private _v2?: V2
get v2(): V2 {
return (this._v2 ??= new V2({ client: this.client }))
Expand Down
52 changes: 52 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2701,6 +2701,26 @@ export type EventTuiSessionSelect = {
}
}

export type VoiceTranscribeInput = {
/**
* Base64-encoded audio data
*/
audio: string
mime?: string
language?: string
}

export type VoiceTranscribeResult = {
text: string
}

export type UpstreamError = {
_tag: "UpstreamError"
message: string
service?: string
status?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
}

export type Workspace = {
id: string
type: string
Expand Down Expand Up @@ -11790,6 +11810,38 @@ export type TuiControlResponseResponses = {

export type TuiControlResponseResponse = TuiControlResponseResponses[keyof TuiControlResponseResponses]

export type VoiceTranscribeData = {
body?: VoiceTranscribeInput
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/voice/transcribe"
}

export type VoiceTranscribeErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UpstreamError
*/
502: UpstreamError
}

export type VoiceTranscribeError = VoiceTranscribeErrors[keyof VoiceTranscribeErrors]

export type VoiceTranscribeResponses = {
/**
* Transcribed text
*/
200: VoiceTranscribeResult
}

export type VoiceTranscribeResponse = VoiceTranscribeResponses[keyof VoiceTranscribeResponses]

export type ExperimentalWorkspaceAdapterListData = {
body?: never
path?: never
Expand Down
Loading
Loading