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
75 changes: 75 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,73 @@ export function Prompt(props: PromptProps) {
extmarkToPartIndex: new Map(),
interrupt: 0,
})
const [modelInfo, setModelInfo] = createSignal<string[]>([])
const [modelLoading, setModelLoading] = createSignal(false)

onMount(() => {
let timer: ReturnType<typeof setTimeout> | undefined
let disposed = false
let key = ""
let run = 0

const wait = (ms: number) => {
if (disposed) return
if (timer) clearTimeout(timer)
timer = setTimeout(() => void tick(run), ms)
}

const tick = async (id: number) => {
const model = local.model.current()
const eligible = store.mode === "normal" && !!model
const response = await sdk
.call("/tui/footer-model", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
mode: store.mode,
model: model ?? undefined,
}),
})
.catch(() => undefined)
if (disposed || id !== run) return
if (!response) {
setModelInfo([])
setModelLoading(false)
wait(30_000)
return
}
if (!response.ok) {
setModelInfo([])
setModelLoading(false)
wait(30_000)
return
}
const output = await response.json().catch(() => ({ info: [], refresh_ms: 30_000 }))
if (disposed || id !== run) return
setModelInfo(output.info)
setModelLoading(false)
wait(output.refresh_ms ?? 30_000)
}

createEffect(() => {
const mode = store.mode
const model = local.model.current()
const next = `${mode}:${model?.providerID ?? ""}:${model?.modelID ?? ""}`
if (next === key) return
key = next
setModelInfo([])
setModelLoading(mode === "normal" && !!model)
run += 1
void tick(run)
})

onCleanup(() => {
disposed = true
if (timer) clearTimeout(timer)
})
})

createEffect(
on(
Expand Down Expand Up @@ -1006,6 +1073,14 @@ export function Prompt(props: PromptProps) {
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={modelLoading() && modelInfo().length === 0}>
<text fg={theme.textMuted}>·</text>
<text fg={theme.textMuted}>Loading...</text>
</Show>
<Show when={modelInfo().length > 0}>
<text fg={theme.textMuted}>·</text>
<text fg={theme.textMuted}>{modelInfo().join(" · ")}</text>
</Show>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
Expand Down
17 changes: 15 additions & 2 deletions packages/opencode/src/cli/cmd/tui/context/sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,26 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
events?: EventSource
}) => {
const abort = new AbortController()
const headers = new Headers(props.headers)
if (props.directory) headers.set("x-opencode-directory", encodeURIComponent(props.directory))
const sdk = createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
directory: props.directory,
fetch: props.fetch,
headers: props.headers,
headers,
})
const call = (path: string, init?: RequestInit) => {
const next = new Headers(headers)
new Headers(init?.headers).forEach((value, key) => {
next.set(key, value)
})
return (props.fetch ?? fetch)(new URL(path, props.url), {
...init,
signal: abort.signal,
headers: next,
})
}

const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }>
Expand Down Expand Up @@ -96,6 +109,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
if (timer) clearTimeout(timer)
})

return { client: sdk, event: emitter, url: props.url }
return { client: sdk, event: emitter, url: props.url, call }
},
})
89 changes: 88 additions & 1 deletion packages/opencode/src/plugin/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,34 @@ const log = Log.create({ service: "plugin.codex" })
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
const ISSUER = "https://auth.openai.com"
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
const CODEX_USAGE_ENDPOINT = "https://chatgpt.com/backend-api/codex/usage"
const OAUTH_PORT = 1455
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000

type CodexWindow = {
used_percent?: number
reset_after_seconds?: number
}

type CodexUsage = {
plan_type?: string
rate_limit?: {
primary_window?: CodexWindow
secondary_window?: CodexWindow
}
}

function formatTimeLeft(seconds?: number) {
if (!seconds || seconds <= 0) return "-"
const total = Math.ceil(seconds)
const days = Math.floor(total / 86400)
const hours = Math.floor((total % 86400) / 3600)
const minutes = Math.max(1, Math.floor((total % 3600) / 60))
if (days > 0) return `${days}d ${hours}h`
if (hours > 0) return `${hours}h ${minutes}m`
return `${minutes}m`
}

interface PkceCodes {
verifier: string
challenge: string
Expand Down Expand Up @@ -457,7 +482,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
const headers = new Headers()
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.forEach((value, key) => headers.set(key, value))
init.headers.forEach((value, key) => void headers.set(key, value))
} else if (Array.isArray(init.headers)) {
for (const [key, value] of init.headers) {
if (value !== undefined) headers.set(key, String(value))
Expand Down Expand Up @@ -620,5 +645,67 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
output.headers["User-Agent"] = `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`
output.headers.session_id = input.sessionID
},
"tui.footer.model": async (input, output) => {
if (input.mode !== "normal") return
if (!input.model) return
if (input.model.providerID !== "openai") return

const auth = await Auth.get("openai")
if (auth?.type !== "oauth") return

let oauth = auth
if (!oauth.access || oauth.expires < Date.now()) {
const tokens = await refreshAccessToken(oauth.refresh).catch(() => undefined)
if (!tokens?.access_token) return
const accountId = extractAccountId(tokens) || oauth.accountId
oauth = {
type: "oauth",
refresh: tokens.refresh_token || oauth.refresh,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
...(accountId && { accountId }),
}
await Auth.set("openai", oauth)
}

const headers = new Headers({
authorization: `Bearer ${oauth.access}`,
originator: "opencode",
})
if (oauth.accountId) headers.set("ChatGPT-Account-Id", oauth.accountId)

const response = await fetch(CODEX_USAGE_ENDPOINT, {
method: "GET",
headers,
}).catch(() => undefined)
if (!response?.ok) {
output.info.push("rate unavailable")
output.refresh_ms = 30_000
return
}

const data = (await response.json().catch(() => undefined)) as CodexUsage | undefined
const primary = data?.rate_limit?.primary_window
const secondary = data?.rate_limit?.secondary_window
if (typeof primary?.used_percent !== "number") {
output.info.push("rate unavailable")
output.refresh_ms = 30_000
return
}

const primary_percentage = `${Math.round(primary.used_percent)}%`
const secondary_percentage =
typeof secondary?.used_percent === "number" ? `${Math.round(secondary.used_percent)}%` : "-"
const primary_time_left = formatTimeLeft(primary.reset_after_seconds)
const secondary_time_left = formatTimeLeft(secondary?.reset_after_seconds)

output.info.push(
`${primary_percentage} (${primary_time_left}) · ${secondary_percentage} (${secondary_time_left})`,
)
output.refresh_ms =
typeof primary.reset_after_seconds === "number" && primary.reset_after_seconds > 0
? Math.min(Math.max(primary.reset_after_seconds * 1000, 10_000), 60_000)
: 30_000
},
}
}
3 changes: 2 additions & 1 deletion packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
import { OpenRouterPlugin } from "./openrouter"

export namespace Plugin {
const log = Log.create({ service: "plugin" })

const BUILTIN = ["opencode-anthropic-auth@0.0.13"]

// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, OpenRouterPlugin]

const state = Instance.state(async () => {
const client = createOpencodeClient({
Expand Down
52 changes: 52 additions & 0 deletions packages/opencode/src/plugin/openrouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { Auth } from "@/auth"

const CREDITS_ENDPOINT = "https://openrouter.ai/api/v1/credits"

type Credits = {
data?: {
total_credits?: number
total_usage?: number
remaining_credits?: number
limit_remaining?: number
}
}

function remaining(data: Credits["data"]) {
if (!data) return
if (typeof data.remaining_credits === "number") return data.remaining_credits
if (typeof data.limit_remaining === "number") return data.limit_remaining
if (typeof data.total_credits === "number" && typeof data.total_usage === "number") {
return data.total_credits - data.total_usage
}
}

export async function OpenRouterPlugin(_input: PluginInput): Promise<Hooks> {
return {
"tui.footer.model": async (input, output) => {
if (input.mode !== "normal") return
if (!input.model) return
if (input.model.providerID !== "openrouter") return

const auth = await Auth.get("openrouter")
if (auth?.type !== "api") return

const response = await fetch(CREDITS_ENDPOINT, {
method: "GET",
headers: {
authorization: `Bearer ${auth.key}`,
},
}).catch(() => undefined)
if (!response?.ok) return

const json = (await response.json().catch(() => undefined)) as Credits | undefined
const credit = remaining(json?.data)
if (typeof credit !== "number") return

output.info.push(
`${new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 2 }).format(Math.max(0, credit))} left`,
)
output.refresh_ms = 60_000
},
}
}
40 changes: 40 additions & 0 deletions packages/opencode/src/server/routes/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,23 @@ import { TuiEvent } from "@/cli/cmd/tui/event"
import { AsyncQueue } from "../../util/queue"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Plugin } from "@/plugin"

const TuiRequest = z.object({
path: z.string(),
body: z.any(),
})

const TuiFooterModel = z.object({
mode: z.enum(["normal", "shell"]),
model: z
.object({
providerID: z.string(),
modelID: z.string(),
})
.optional(),
})

type TuiRequest = z.infer<typeof TuiRequest>

const request = new AsyncQueue<TuiRequest>()
Expand Down Expand Up @@ -77,6 +88,35 @@ const TuiControlRoutes = new Hono()

export const TuiRoutes = lazy(() =>
new Hono()
.post(
"/footer-model",
describeRoute({
summary: "Get TUI footer model info",
description: "Get provider/plugin metadata for the composer model footer.",
operationId: "tui.footerModel",
responses: {
200: {
description: "Footer model metadata",
content: {
"application/json": {
schema: resolver(
z.object({
info: z.array(z.string()),
refresh_ms: z.number().optional(),
}),
),
},
},
},
},
}),
validator("json", TuiFooterModel),
async (c) => {
const body = c.req.valid("json")
const output = await Plugin.trigger("tui.footer.model", body, { info: [], refresh_ms: 30_000 })
return c.json(output)
},
)
.post(
"/append-prompt",
describeRoute({
Expand Down
13 changes: 13 additions & 0 deletions packages/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,19 @@ export interface Hooks {
input: { sessionID: string; messageID: string; partID: string },
output: { text: string },
) => Promise<void>
"tui.footer.model"?: (
input: {
mode: "normal" | "shell"
model?: {
providerID: string
modelID: string
}
},
output: {
info: string[]
refresh_ms?: number
},
) => Promise<void>
/**
* Modify tool definitions (description and parameters) sent to LLM
*/
Expand Down
Loading
Loading