diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d63c248fb83..77cae5fa870 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -136,6 +136,73 @@ export function Prompt(props: PromptProps) { extmarkToPartIndex: new Map(), interrupt: 0, }) + const [modelInfo, setModelInfo] = createSignal([]) + const [modelLoading, setModelLoading] = createSignal(false) + + onMount(() => { + let timer: ReturnType | 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( @@ -1006,6 +1073,14 @@ export function Prompt(props: PromptProps) { {local.model.parsed().model} {local.model.parsed().provider} + + · + Loading... + + 0}> + · + {modelInfo().join(" · ")} + · diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 7fa7e05c3d2..a8811b478af 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -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 @@ -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 } }, }) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 56931b2ed62..7f51c24c286 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -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 @@ -457,7 +482,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { 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)) @@ -620,5 +645,67 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { 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 + }, } } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index e65d21bfd60..8caffff01ca 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -12,6 +12,7 @@ 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" }) @@ -19,7 +20,7 @@ export namespace 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({ diff --git a/packages/opencode/src/plugin/openrouter.ts b/packages/opencode/src/plugin/openrouter.ts new file mode 100644 index 00000000000..377a588b5da --- /dev/null +++ b/packages/opencode/src/plugin/openrouter.ts @@ -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 { + 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 + }, + } +} diff --git a/packages/opencode/src/server/routes/tui.ts b/packages/opencode/src/server/routes/tui.ts index 8650a0cccf7..9c5c21970b0 100644 --- a/packages/opencode/src/server/routes/tui.ts +++ b/packages/opencode/src/server/routes/tui.ts @@ -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 const request = new AsyncQueue() @@ -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({ diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 76370d1d5a7..f12feab83c9 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -227,6 +227,19 @@ export interface Hooks { input: { sessionID: string; messageID: string; partID: string }, output: { text: string }, ) => Promise + "tui.footer.model"?: ( + input: { + mode: "normal" | "shell" + model?: { + providerID: string + modelID: string + } + }, + output: { + info: string[] + refresh_ms?: number + }, + ) => Promise /** * Modify tool definitions (description and parameters) sent to LLM */ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index ec8ee46857d..2b2bcbda189 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -161,6 +161,7 @@ import type { TuiControlResponseResponses, TuiExecuteCommandErrors, TuiExecuteCommandResponses, + TuiFooterModelResponses, TuiOpenHelpResponses, TuiOpenModelsResponses, TuiOpenSessionsResponses, @@ -2849,6 +2850,46 @@ export class Control extends HeyApiClient { } export class Tui extends HeyApiClient { + /** + * Get TUI footer model info + * + * Get provider/plugin metadata for the composer model footer. + */ + public footerModel( + parameters?: { + directory?: string + mode?: "normal" | "shell" + model?: { + providerID: string + modelID: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "body", key: "mode" }, + { in: "body", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/tui/footer-model", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Append TUI prompt * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 385de2cc85e..6a19a83af93 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4385,6 +4385,33 @@ export type McpDisconnectResponses = { export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] +export type TuiFooterModelData = { + body?: { + mode: "normal" | "shell" + model?: { + providerID: string + modelID: string + } + } + path?: never + query?: { + directory?: string + } + url: "/tui/footer-model" +} + +export type TuiFooterModelResponses = { + /** + * Footer model metadata + */ + 200: { + info: Array + refresh_ms?: number + } +} + +export type TuiFooterModelResponse = TuiFooterModelResponses[keyof TuiFooterModelResponses] + export type TuiAppendPromptData = { body?: { text: string