This commit is contained in:
roomote-v0[bot] 2026-04-09 10:15:18 -06:00 committed by GitHub
commit 318090bcf0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 742 additions and 1 deletions

View file

@ -1,3 +1,6 @@
export * from "./login.js"
export * from "./logout.js"
export * from "./status.js"
export * from "./openai-codex-login.js"
export * from "./openai-codex-logout.js"
export * from "./openai-codex-status.js"

View file

@ -0,0 +1,324 @@
import * as crypto from "crypto"
import * as http from "http"
import { URL } from "url"
import { exec } from "child_process"
import { saveOpenAiCodexCredentials, OpenAiCodexCredentials } from "@/lib/storage/openai-codex-credentials.js"
/**
* OpenAI Codex OAuth Configuration
* Matches the config in src/integrations/openai-codex/oauth.ts
*/
const OPENAI_CODEX_OAUTH_CONFIG = {
authorizationEndpoint: "https://auth.openai.com/oauth/authorize",
tokenEndpoint: "https://auth.openai.com/oauth/token",
clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
redirectUri: "http://localhost:1455/auth/callback",
scopes: "openid profile email offline_access",
callbackPort: 1455,
} as const
export interface OpenAiCodexLoginOptions {
timeout?: number
verbose?: boolean
}
export type OpenAiCodexLoginResult = { success: true; email?: string } | { success: false; error: string }
/**
* JWT claims structure for extracting ChatGPT account ID
*/
interface IdTokenClaims {
chatgpt_account_id?: string
organizations?: Array<{ id: string }>
email?: string
"https://api.openai.com/auth"?: {
chatgpt_account_id?: string
}
}
function parseJwtClaims(token: string): IdTokenClaims | undefined {
const parts = token.split(".")
if (parts.length !== 3 || !parts[1]) return undefined
try {
const payload = Buffer.from(parts[1], "base64url").toString("utf-8")
return JSON.parse(payload) as IdTokenClaims
} catch {
return undefined
}
}
function extractAccountIdFromClaims(claims: IdTokenClaims): string | undefined {
return (
claims.chatgpt_account_id ||
claims["https://api.openai.com/auth"]?.chatgpt_account_id ||
claims.organizations?.[0]?.id
)
}
function extractAccountId(tokens: { id_token?: string; access_token: string }): string | undefined {
if (tokens.id_token) {
const claims = parseJwtClaims(tokens.id_token)
const accountId = claims && extractAccountIdFromClaims(claims)
if (accountId) return accountId
}
if (tokens.access_token) {
const claims = parseJwtClaims(tokens.access_token)
return claims ? extractAccountIdFromClaims(claims) : undefined
}
return undefined
}
function generateCodeVerifier(): string {
return crypto.randomBytes(32).toString("base64url")
}
function generateCodeChallenge(verifier: string): string {
return crypto.createHash("sha256").update(verifier).digest().toString("base64url")
}
function generateState(): string {
return crypto.randomBytes(16).toString("hex")
}
function buildAuthorizationUrl(codeChallenge: string, state: string): string {
const params = new URLSearchParams({
client_id: OPENAI_CODEX_OAUTH_CONFIG.clientId,
redirect_uri: OPENAI_CODEX_OAUTH_CONFIG.redirectUri,
scope: OPENAI_CODEX_OAUTH_CONFIG.scopes,
code_challenge: codeChallenge,
code_challenge_method: "S256",
response_type: "code",
state,
codex_cli_simplified_flow: "true",
originator: "roo-code",
})
return `${OPENAI_CODEX_OAUTH_CONFIG.authorizationEndpoint}?${params.toString()}`
}
async function exchangeCodeForTokens(code: string, codeVerifier: string): Promise<OpenAiCodexCredentials> {
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: OPENAI_CODEX_OAUTH_CONFIG.clientId,
code,
redirect_uri: OPENAI_CODEX_OAUTH_CONFIG.redirectUri,
code_verifier: codeVerifier,
})
const response = await fetch(OPENAI_CODEX_OAUTH_CONFIG.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
signal: AbortSignal.timeout(30000),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`)
}
const data = await response.json()
if (!data.access_token || !data.refresh_token) {
throw new Error("Token exchange did not return required tokens")
}
const expiresAt = Date.now() + (data.expires_in ?? 3600) * 1000
const accountId = extractAccountId({
id_token: data.id_token,
access_token: data.access_token,
})
return {
type: "openai-codex",
access_token: data.access_token,
refresh_token: data.refresh_token,
expires: expiresAt,
email: data.email,
accountId,
}
}
function openBrowser(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const platform = process.platform
let command: string
switch (platform) {
case "darwin":
command = `open "${url}"`
break
case "win32":
command = `start "" "${url}"`
break
default:
command = `xdg-open "${url}"`
break
}
exec(command, (error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}
export async function openaiCodexLogin({
timeout = 5 * 60 * 1000,
verbose = false,
}: OpenAiCodexLoginOptions = {}): Promise<OpenAiCodexLoginResult> {
const codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(codeVerifier)
const state = generateState()
if (verbose) {
console.log(`[Auth] Starting OpenAI Codex OAuth flow on port ${OPENAI_CODEX_OAUTH_CONFIG.callbackPort}`)
}
const credentialsPromise = new Promise<OpenAiCodexCredentials>((resolve, reject) => {
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url || "", `http://localhost:${OPENAI_CODEX_OAUTH_CONFIG.callbackPort}`)
if (url.pathname !== "/auth/callback") {
res.writeHead(404)
res.end("Not Found")
return
}
const code = url.searchParams.get("code")
const receivedState = url.searchParams.get("state")
const error = url.searchParams.get("error")
if (error) {
res.writeHead(400)
res.end(`Authentication failed: ${error}`)
reject(new Error(`OAuth error: ${error}`))
server.close()
return
}
if (!code || !receivedState) {
res.writeHead(400)
res.end("Missing code or state parameter")
reject(new Error("Missing code or state parameter"))
server.close()
return
}
if (receivedState !== state) {
res.writeHead(400)
res.end("State mismatch - possible CSRF attack")
reject(new Error("State mismatch"))
server.close()
return
}
try {
const credentials = await exchangeCodeForTokens(code, codeVerifier)
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Authentication Successful</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #10a37f 0%, #0d8f6f 100%);
color: white;
}
.container { text-align: center; padding: 2rem; }
h1 { font-size: 2rem; margin-bottom: 1rem; }
p { opacity: 0.9; }
</style>
</head>
<body>
<div class="container">
<h1>&#10003; Authentication Successful</h1>
<p>You can close this window and return to the terminal.</p>
</div>
<script>setTimeout(() => window.close(), 3000);</script>
</body>
</html>`)
server.close()
resolve(credentials)
} catch (exchangeError) {
res.writeHead(500)
res.end(`Token exchange failed: ${exchangeError}`)
reject(exchangeError)
server.close()
}
} catch (err) {
res.writeHead(500)
res.end("Internal server error")
reject(err)
server.close()
}
})
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
reject(
new Error(
`Port ${OPENAI_CODEX_OAUTH_CONFIG.callbackPort} is already in use. ` +
`Please close any other applications using this port and try again.`,
),
)
} else {
reject(err)
}
})
const timeoutId = setTimeout(() => {
server.close()
reject(new Error("Authentication timed out"))
}, timeout)
server.listen(OPENAI_CODEX_OAUTH_CONFIG.callbackPort, () => {
if (verbose) {
console.log(`[Auth] Callback server listening on port ${OPENAI_CODEX_OAUTH_CONFIG.callbackPort}`)
}
})
server.on("close", () => {
clearTimeout(timeoutId)
})
})
const authUrl = buildAuthorizationUrl(codeChallenge, state)
console.log("Opening browser for OpenAI authentication...")
console.log(`If the browser doesn't open, visit: ${authUrl}`)
try {
await openBrowser(authUrl)
} catch (error) {
if (verbose) {
console.warn("[Auth] Failed to open browser automatically:", error)
}
console.log("Please open the URL above in your browser manually.")
}
try {
const credentials = await credentialsPromise
await saveOpenAiCodexCredentials(credentials)
const emailInfo = credentials.email ? ` (${credentials.email})` : ""
console.log(`✓ Successfully authenticated with OpenAI${emailInfo}`)
return { success: true, email: credentials.email }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`✗ OpenAI authentication failed: ${message}`)
return { success: false, error: message }
}
}

View file

@ -0,0 +1,29 @@
import { clearOpenAiCodexCredentials, hasOpenAiCodexCredentials } from "@/lib/storage/openai-codex-credentials.js"
export interface OpenAiCodexLogoutOptions {
verbose?: boolean
}
export interface OpenAiCodexLogoutResult {
success: boolean
wasLoggedIn: boolean
}
export async function openaiCodexLogout({
verbose = false,
}: OpenAiCodexLogoutOptions = {}): Promise<OpenAiCodexLogoutResult> {
const wasLoggedIn = await hasOpenAiCodexCredentials()
if (!wasLoggedIn) {
console.log("You are not currently logged in to OpenAI Codex.")
return { success: true, wasLoggedIn: false }
}
if (verbose) {
console.log("[Auth] Removing OpenAI Codex OAuth credentials")
}
await clearOpenAiCodexCredentials()
console.log("✓ Successfully logged out from OpenAI Codex")
return { success: true, wasLoggedIn: true }
}

View file

@ -0,0 +1,74 @@
import { loadOpenAiCodexCredentials, isCredentialsExpired } from "@/lib/storage/openai-codex-credentials.js"
export interface OpenAiCodexStatusOptions {
verbose?: boolean
}
export interface OpenAiCodexStatusResult {
authenticated: boolean
expired?: boolean
email?: string
expiresAt?: Date
}
export async function openaiCodexStatus({
verbose = false,
}: OpenAiCodexStatusOptions = {}): Promise<OpenAiCodexStatusResult> {
const credentials = await loadOpenAiCodexCredentials()
if (!credentials) {
console.log("✗ Not authenticated with OpenAI Codex")
console.log("")
console.log("Run: roo auth login-openai")
return { authenticated: false }
}
const expired = isCredentialsExpired(credentials)
const expiresAt = new Date(credentials.expires)
if (expired) {
console.log("⚠ OpenAI Codex access token expired (will auto-refresh on next use)")
} else {
console.log("✓ Authenticated with OpenAI Codex")
}
if (credentials.email) {
console.log(` Email: ${credentials.email}`)
}
const remaining = getTimeRemaining(expiresAt)
console.log(` Token: ${expired ? "expired" : `expires ${formatDate(expiresAt)} (${remaining})`}`)
if (verbose && credentials.accountId) {
console.log(` Account ID: ${credentials.accountId}`)
}
return {
authenticated: true,
expired,
email: credentials.email,
expiresAt,
}
}
function formatDate(date: Date): string {
return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })
}
function getTimeRemaining(date: Date): string {
const now = new Date()
const diff = date.getTime() - now.getTime()
if (diff <= 0) {
return "expired"
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
if (days > 0) {
return `${days} day${days === 1 ? "" : "s"}`
}
return `${hours} hour${hours === 1 ? "" : "s"}`
}

View file

@ -22,6 +22,7 @@ import { JsonEventEmitter } from "@/agent/json-event-emitter.js"
import { createClient } from "@/lib/sdk/index.js"
import { loadToken, loadSettings } from "@/lib/storage/index.js"
import { hasOpenAiCodexCredentials } from "@/lib/storage/openai-codex-credentials.js"
import { readWorkspaceTaskSessions, resolveWorkspaceResumeSessionId } from "@/lib/task-history/index.js"
import { isRecord } from "@/lib/utils/guards.js"
import { getEnvVarName, getApiKeyFromEnv } from "@/lib/utils/provider.js"
@ -272,6 +273,20 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
// which will check flagOptions.apiKey and ROO_API_KEY env var.
}
// OpenAI Codex OAuth Authentication
if (extensionHostOptions.provider === "openai-codex") {
const hasCodexCreds = await hasOpenAiCodexCredentials()
if (!hasCodexCreds) {
console.error("[CLI] Error: Not authenticated with OpenAI Codex.")
console.error("[CLI] Please run: roo auth login-openai")
process.exit(1)
}
// OpenAI Codex uses OAuth tokens managed by the extension's openAiCodexOAuthManager.
// Credentials are stored in the vscode-shim secret storage and loaded automatically.
// Set a placeholder so the API key check below doesn't fail.
extensionHostOptions.apiKey = "openai-codex-oauth"
}
// Validations
// TODO: Validate the API key for the chosen provider.
// TODO: Validate the model for the chosen provider.

View file

@ -7,6 +7,9 @@ import {
login,
logout,
status,
openaiCodexLogin,
openaiCodexLogout,
openaiCodexStatus,
listCommands,
listModes,
listModels,
@ -136,7 +139,7 @@ program
await runUpgradeAction(() => upgrade())
})
const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud")
const authCommand = program.command("auth").description("Manage authentication")
authCommand
.command("login")
@ -165,4 +168,31 @@ authCommand
process.exit(result.authenticated ? 0 : 1)
})
authCommand
.command("login-openai")
.description("Authenticate with OpenAI (ChatGPT Plus/Pro subscription via OAuth)")
.option("-v, --verbose", "Enable verbose output", false)
.action(async (options: { verbose: boolean }) => {
const result = await openaiCodexLogin({ verbose: options.verbose })
process.exit(result.success ? 0 : 1)
})
authCommand
.command("logout-openai")
.description("Log out from OpenAI Codex")
.option("-v, --verbose", "Enable verbose output", false)
.action(async (options: { verbose: boolean }) => {
const result = await openaiCodexLogout({ verbose: options.verbose })
process.exit(result.success ? 0 : 1)
})
authCommand
.command("status-openai")
.description("Show OpenAI Codex authentication status")
.option("-v, --verbose", "Enable verbose output", false)
.action(async (options: { verbose: boolean }) => {
const result = await openaiCodexStatus({ verbose: options.verbose })
process.exit(result.authenticated ? 0 : 1)
})
program.parse()

View file

@ -0,0 +1,150 @@
import fs from "fs/promises"
import path from "path"
import os from "os"
import {
saveOpenAiCodexCredentials,
loadOpenAiCodexCredentials,
clearOpenAiCodexCredentials,
hasOpenAiCodexCredentials,
isCredentialsExpired,
type OpenAiCodexCredentials,
} from "../openai-codex-credentials.js"
// Override the HOME env to use a temp directory for tests
const testDir = path.join(os.tmpdir(), `roo-cli-codex-test-${Date.now()}`)
const originalHome = process.env.HOME
beforeAll(() => {
process.env.HOME = testDir
})
afterAll(async () => {
process.env.HOME = originalHome
await fs.rm(testDir, { recursive: true, force: true })
})
beforeEach(async () => {
// Clean secrets between tests
const secretsPath = path.join(testDir, ".vscode-mock", "global-storage", "secrets.json")
try {
await fs.unlink(secretsPath)
} catch {
// file may not exist
}
})
const validCredentials: OpenAiCodexCredentials = {
type: "openai-codex",
access_token: "test-access-token",
refresh_token: "test-refresh-token",
expires: Date.now() + 3600 * 1000,
email: "test@example.com",
accountId: "acct_123",
}
describe("OpenAI Codex Credentials Storage", () => {
describe("saveOpenAiCodexCredentials", () => {
it("should save credentials to secrets file", async () => {
await saveOpenAiCodexCredentials(validCredentials)
const secretsPath = path.join(testDir, ".vscode-mock", "global-storage", "secrets.json")
const raw = await fs.readFile(secretsPath, "utf-8")
const secrets = JSON.parse(raw)
expect(secrets["openai-codex-oauth-credentials"]).toBeDefined()
const stored = JSON.parse(secrets["openai-codex-oauth-credentials"])
expect(stored.type).toBe("openai-codex")
expect(stored.access_token).toBe("test-access-token")
expect(stored.refresh_token).toBe("test-refresh-token")
})
it("should create directory structure if it doesn't exist", async () => {
await saveOpenAiCodexCredentials(validCredentials)
const dirPath = path.join(testDir, ".vscode-mock", "global-storage")
const stats = await fs.stat(dirPath)
expect(stats.isDirectory()).toBe(true)
})
})
describe("loadOpenAiCodexCredentials", () => {
it("should load saved credentials", async () => {
await saveOpenAiCodexCredentials(validCredentials)
const loaded = await loadOpenAiCodexCredentials()
expect(loaded).not.toBeNull()
expect(loaded!.type).toBe("openai-codex")
expect(loaded!.access_token).toBe("test-access-token")
expect(loaded!.refresh_token).toBe("test-refresh-token")
expect(loaded!.email).toBe("test@example.com")
expect(loaded!.accountId).toBe("acct_123")
})
it("should return null when no credentials exist", async () => {
const loaded = await loadOpenAiCodexCredentials()
expect(loaded).toBeNull()
})
it("should return null for invalid credentials data", async () => {
const secretsPath = path.join(testDir, ".vscode-mock", "global-storage", "secrets.json")
await fs.mkdir(path.dirname(secretsPath), { recursive: true })
await fs.writeFile(secretsPath, JSON.stringify({ "openai-codex-oauth-credentials": "invalid-json" }))
const loaded = await loadOpenAiCodexCredentials()
expect(loaded).toBeNull()
})
})
describe("clearOpenAiCodexCredentials", () => {
it("should remove credentials", async () => {
await saveOpenAiCodexCredentials(validCredentials)
expect(await hasOpenAiCodexCredentials()).toBe(true)
await clearOpenAiCodexCredentials()
expect(await hasOpenAiCodexCredentials()).toBe(false)
})
it("should not throw when no credentials exist", async () => {
await expect(clearOpenAiCodexCredentials()).resolves.not.toThrow()
})
})
describe("hasOpenAiCodexCredentials", () => {
it("should return false when no credentials exist", async () => {
expect(await hasOpenAiCodexCredentials()).toBe(false)
})
it("should return true when credentials exist", async () => {
await saveOpenAiCodexCredentials(validCredentials)
expect(await hasOpenAiCodexCredentials()).toBe(true)
})
})
describe("isCredentialsExpired", () => {
it("should return false for unexpired credentials", () => {
const creds: OpenAiCodexCredentials = {
...validCredentials,
expires: Date.now() + 60 * 60 * 1000, // 1 hour from now
}
expect(isCredentialsExpired(creds)).toBe(false)
})
it("should return true for expired credentials", () => {
const creds: OpenAiCodexCredentials = {
...validCredentials,
expires: Date.now() - 1000, // already expired
}
expect(isCredentialsExpired(creds)).toBe(true)
})
it("should return true when within 5-minute buffer", () => {
const creds: OpenAiCodexCredentials = {
...validCredentials,
expires: Date.now() + 2 * 60 * 1000, // 2 minutes from now (within 5 min buffer)
}
expect(isCredentialsExpired(creds)).toBe(true)
})
})
})

View file

@ -2,3 +2,4 @@ export * from "./config-dir.js"
export * from "./settings.js"
export * from "./credentials.js"
export * from "./ephemeral.js"
export * from "./openai-codex-credentials.js"

View file

@ -0,0 +1,108 @@
import fs from "fs/promises"
import path from "path"
/**
* OpenAI Codex OAuth credentials storage for CLI.
*
* Stores credentials in the same location as the vscode-shim's FileSecretStorage
* (~/.vscode-mock/global-storage/secrets.json) so the existing OpenAiCodexOAuthManager
* can find them transparently when the extension loads.
*/
const STORAGE_BASE_DIR = ".vscode-mock"
const SECRETS_KEY = "openai-codex-oauth-credentials"
// Credentials type (matches src/integrations/openai-codex/oauth.ts)
export interface OpenAiCodexCredentials {
type: "openai-codex"
access_token: string
refresh_token: string
expires: number
email?: string
accountId?: string
}
function isValidCredentials(obj: unknown): obj is OpenAiCodexCredentials {
if (!obj || typeof obj !== "object") return false
const o = obj as Record<string, unknown>
return (
o.type === "openai-codex" &&
typeof o.access_token === "string" &&
o.access_token.length > 0 &&
typeof o.refresh_token === "string" &&
o.refresh_token.length > 0 &&
typeof o.expires === "number"
)
}
function getSecretsFilePath(): string {
const homeDir = process.env.HOME || process.env.USERPROFILE || "."
return path.join(homeDir, STORAGE_BASE_DIR, "global-storage", "secrets.json")
}
async function readSecretsFile(): Promise<Record<string, string>> {
try {
const data = await fs.readFile(getSecretsFilePath(), "utf-8")
return JSON.parse(data)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return {}
}
throw error
}
}
async function writeSecretsFile(secrets: Record<string, string>): Promise<void> {
const filePath = getSecretsFilePath()
const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(filePath, JSON.stringify(secrets, null, 2), { mode: 0o600 })
}
export async function saveOpenAiCodexCredentials(credentials: OpenAiCodexCredentials): Promise<void> {
const secrets = await readSecretsFile()
secrets[SECRETS_KEY] = JSON.stringify(credentials)
await writeSecretsFile(secrets)
}
export async function loadOpenAiCodexCredentials(): Promise<OpenAiCodexCredentials | null> {
try {
const secrets = await readSecretsFile()
const raw = secrets[SECRETS_KEY]
if (!raw) {
return null
}
const parsed = JSON.parse(raw)
if (!isValidCredentials(parsed)) {
return null
}
return parsed
} catch {
return null
}
}
export async function clearOpenAiCodexCredentials(): Promise<void> {
try {
const secrets = await readSecretsFile()
delete secrets[SECRETS_KEY]
await writeSecretsFile(secrets)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error
}
}
}
export async function hasOpenAiCodexCredentials(): Promise<boolean> {
const creds = await loadOpenAiCodexCredentials()
return creds !== null
}
/**
* Check if the stored credentials have an expired access token (with 5 min buffer).
*/
export function isCredentialsExpired(credentials: OpenAiCodexCredentials): boolean {
const bufferMs = 5 * 60 * 1000
return Date.now() >= credentials.expires - bufferMs
}

View file

@ -5,6 +5,7 @@ import type { SupportedProvider } from "@/types/index.js"
const envVarMap: Record<SupportedProvider, string> = {
anthropic: "ANTHROPIC_API_KEY",
"openai-native": "OPENAI_API_KEY",
"openai-codex": "", // No API key - uses OAuth
gemini: "GOOGLE_API_KEY",
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "VERCEL_AI_GATEWAY_API_KEY",
@ -36,6 +37,11 @@ export function getProviderSettings(
if (apiKey) config.openAiNativeApiKey = apiKey
if (model) config.apiModelId = model
break
case "openai-codex":
// OpenAI Codex uses OAuth, not API keys.
// The OpenAiCodexHandler gets its token from openAiCodexOAuthManager.
if (model) config.apiModelId = model
break
case "gemini":
if (apiKey) config.geminiApiKey = apiKey
if (model) config.apiModelId = model

View file

@ -4,6 +4,7 @@ import type { OutputFormat } from "./json-events.js"
export const supportedProviders = [
"anthropic",
"openai-native",
"openai-codex",
"gemini",
"openrouter",
"vercel-ai-gateway",