mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-25 06:35:35 +00:00
wip: codex
This commit is contained in:
parent
75df5040ea
commit
2844086752
9 changed files with 767 additions and 8 deletions
|
|
@ -3,6 +3,8 @@ import { Global } from "../global"
|
|||
import fs from "fs/promises"
|
||||
import z from "zod"
|
||||
|
||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||
|
||||
export namespace Auth {
|
||||
export const Oauth = z
|
||||
.object({
|
||||
|
|
|
|||
109
packages/opencode/src/codex/auth.ts
Normal file
109
packages/opencode/src/codex/auth.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import crypto from "crypto"
|
||||
|
||||
export namespace CodexAuth {
|
||||
const ISSUER = "https://auth.openai.com"
|
||||
const CLIENT_ID = "openai-codex-cli"
|
||||
|
||||
// Pending OAuth sessions: state -> { verifier, redirectUri }
|
||||
const pending = new Map<string, { verifier: string; redirectUri: string }>()
|
||||
|
||||
function generatePkce() {
|
||||
const verifier = crypto.randomBytes(64).toString("base64url")
|
||||
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url")
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function generateState() {
|
||||
return crypto.randomBytes(32).toString("base64url")
|
||||
}
|
||||
|
||||
export function authorize(redirectUri: string) {
|
||||
const pkce = generatePkce()
|
||||
const state = generateState()
|
||||
|
||||
pending.set(state, { verifier: pkce.verifier, redirectUri })
|
||||
|
||||
// Clean up after 15 minutes
|
||||
setTimeout(() => pending.delete(state), 15 * 60 * 1000)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: redirectUri,
|
||||
scope: "openid profile email offline_access",
|
||||
code_challenge: pkce.challenge,
|
||||
code_challenge_method: "S256",
|
||||
id_token_add_organizations: "true",
|
||||
codex_cli_simplified_flow: "true",
|
||||
state,
|
||||
originator: "opencode",
|
||||
})
|
||||
|
||||
return { url: `${ISSUER}/oauth/authorize?${params}`, state }
|
||||
}
|
||||
|
||||
export async function callback(code: string, state: string) {
|
||||
const session = pending.get(state)
|
||||
if (!session) throw new Error("Invalid or expired OAuth state")
|
||||
pending.delete(state)
|
||||
|
||||
const resp = await fetch(`${ISSUER}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: session.redirectUri,
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: session.verifier,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text()
|
||||
throw new Error(`Token exchange failed: ${resp.status} ${text}`)
|
||||
}
|
||||
|
||||
const tokens = (await resp.json()) as {
|
||||
id_token: string
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in?: number
|
||||
}
|
||||
|
||||
return {
|
||||
access: tokens.access_token,
|
||||
refresh: tokens.refresh_token,
|
||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||
}
|
||||
}
|
||||
|
||||
export async function refresh(refreshToken: string) {
|
||||
const resp = await fetch(`${ISSUER}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text()
|
||||
throw new Error(`Token refresh failed: ${resp.status} ${text}`)
|
||||
}
|
||||
|
||||
const tokens = (await resp.json()) as {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
}
|
||||
|
||||
return {
|
||||
access: tokens.access_token,
|
||||
refresh: tokens.refresh_token ?? refreshToken,
|
||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||
}
|
||||
}
|
||||
}
|
||||
416
packages/opencode/src/plugin/codex.ts
Normal file
416
packages/opencode/src/plugin/codex.ts
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { Log } from "../util/log"
|
||||
import { Installation } from "../installation"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
|
||||
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 OAUTH_PORT = 1455
|
||||
|
||||
interface PkceCodes {
|
||||
verifier: string
|
||||
challenge: string
|
||||
}
|
||||
|
||||
async function generatePKCE(): Promise<PkceCodes> {
|
||||
const verifier = generateRandomString(43)
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(verifier)
|
||||
const hash = await crypto.subtle.digest("SHA-256", data)
|
||||
const challenge = base64UrlEncode(hash)
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function generateRandomString(length: number): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(length))
|
||||
return Array.from(bytes)
|
||||
.map((b) => chars[b % chars.length])
|
||||
.join("")
|
||||
}
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
const binary = String.fromCharCode(...bytes)
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
}
|
||||
|
||||
function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: redirectUri,
|
||||
scope: "openid profile email offline_access",
|
||||
code_challenge: pkce.challenge,
|
||||
code_challenge_method: "S256",
|
||||
id_token_add_organizations: "true",
|
||||
codex_cli_simplified_flow: "true",
|
||||
state,
|
||||
originator: "opencode",
|
||||
})
|
||||
return `${ISSUER}/oauth/authorize?${params.toString()}`
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
id_token: string
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in?: number
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
|
||||
const response = await fetch(`${ISSUER}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: pkce.verifier,
|
||||
}).toString(),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token exchange failed: ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
|
||||
const response = await fetch(`${ISSUER}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
}).toString(),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token refresh failed: ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const HTML_SUCCESS = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OpenCode - Codex Authorization Successful</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
||||
.container { text-align: center; padding: 2rem; }
|
||||
h1 { color: #4ade80; margin-bottom: 1rem; }
|
||||
p { color: #aaa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization Successful</h1>
|
||||
<p>You can close this window and return to OpenCode.</p>
|
||||
</div>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OpenCode - Codex Authorization Failed</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
||||
.container { text-align: center; padding: 2rem; }
|
||||
h1 { color: #f87171; margin-bottom: 1rem; }
|
||||
p { color: #aaa; }
|
||||
.error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>An error occurred during authorization.</p>
|
||||
<div class="error">${error}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
interface PendingOAuth {
|
||||
pkce: PkceCodes
|
||||
state: string
|
||||
resolve: (tokens: TokenResponse) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let oauthServer: ReturnType<typeof Bun.serve> | undefined
|
||||
let pendingOAuth: PendingOAuth | undefined
|
||||
|
||||
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
||||
if (oauthServer) {
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
oauthServer = Bun.serve({
|
||||
port: OAUTH_PORT,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (url.pathname === "/auth/callback") {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
pendingOAuth = undefined
|
||||
return new Response("Login cancelled", { status: 200 })
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
log.info("codex oauth server started", { port: OAUTH_PORT })
|
||||
return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` }
|
||||
}
|
||||
|
||||
function stopOAuthServer() {
|
||||
if (oauthServer) {
|
||||
oauthServer.stop()
|
||||
oauthServer = undefined
|
||||
log.info("codex oauth server stopped")
|
||||
}
|
||||
}
|
||||
|
||||
function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
if (pendingOAuth) {
|
||||
pendingOAuth = undefined
|
||||
reject(new Error("OAuth callback timeout - authorization took too long"))
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
) // 5 minute timeout
|
||||
|
||||
pendingOAuth = {
|
||||
pkce,
|
||||
state,
|
||||
resolve: (tokens) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(tokens)
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getUserAgent(): string {
|
||||
const version = Installation.VERSION
|
||||
const platform = process.platform
|
||||
const arch = process.arch
|
||||
return `opencode/${version} (${platform}; ${arch})`
|
||||
}
|
||||
|
||||
export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
return {
|
||||
auth: {
|
||||
provider: "openai",
|
||||
async loader(getAuth, provider) {
|
||||
const auth = await getAuth()
|
||||
if (auth.type !== "oauth") return {}
|
||||
|
||||
// Zero out costs for Codex (included with ChatGPT subscription)
|
||||
for (const model of Object.values(provider.models)) {
|
||||
model.cost = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: OAUTH_DUMMY_KEY,
|
||||
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||
// Remove dummy API key authorization header
|
||||
if (init?.headers) {
|
||||
if (init.headers instanceof Headers) {
|
||||
init.headers.delete("authorization")
|
||||
init.headers.delete("Authorization")
|
||||
} else if (Array.isArray(init.headers)) {
|
||||
init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization")
|
||||
} else {
|
||||
delete init.headers["authorization"]
|
||||
delete init.headers["Authorization"]
|
||||
}
|
||||
}
|
||||
|
||||
const currentAuth = await getAuth()
|
||||
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
|
||||
|
||||
// Check if token needs refresh
|
||||
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
||||
log.info("refreshing codex access token")
|
||||
const tokens = await refreshAccessToken(currentAuth.refresh)
|
||||
await input.client.auth.set({
|
||||
path: { id: "codex" },
|
||||
body: {
|
||||
type: "oauth",
|
||||
refresh: tokens.refresh_token,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||
},
|
||||
})
|
||||
currentAuth.access = tokens.access_token
|
||||
}
|
||||
|
||||
// Build headers
|
||||
const headers = new Headers()
|
||||
if (init?.headers) {
|
||||
if (init.headers instanceof Headers) {
|
||||
init.headers.forEach((value, key) => 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))
|
||||
}
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(init.headers)) {
|
||||
if (value !== undefined) headers.set(key, String(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set required Codex headers
|
||||
headers.set("authorization", `Bearer ${currentAuth.access}`)
|
||||
headers.set("originator", "opencode")
|
||||
headers.set("user-agent", getUserAgent())
|
||||
|
||||
// Extract session_id from request body if present
|
||||
let body = init?.body
|
||||
if (body && typeof body === "string") {
|
||||
const parsed = JSON.parse(body)
|
||||
// The session ID should be passed in the request - we'll extract it from context
|
||||
// For now, generate a UUIDv7-like ID based on timestamp
|
||||
const sessionId = parsed.metadata?.sessionID || generateSessionId()
|
||||
headers.set("session_id", sessionId)
|
||||
}
|
||||
|
||||
// Rewrite URL to Codex endpoint
|
||||
let url: URL
|
||||
if (typeof requestInput === "string") {
|
||||
url = new URL(requestInput)
|
||||
} else if (requestInput instanceof URL) {
|
||||
url = requestInput
|
||||
} else {
|
||||
url = new URL(requestInput.url)
|
||||
}
|
||||
|
||||
// If this is a messages/responses request, redirect to Codex endpoint
|
||||
if (url.pathname.includes("/v1/responses") || url.pathname.includes("/chat/completions")) {
|
||||
url = new URL(CODEX_API_ENDPOINT)
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
...init,
|
||||
body,
|
||||
headers,
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
methods: [
|
||||
{
|
||||
label: "ChatGPT Pro/Plus",
|
||||
type: "oauth",
|
||||
authorize: async () => {
|
||||
const { redirectUri } = await startOAuthServer()
|
||||
const pkce = await generatePKCE()
|
||||
const state = generateState()
|
||||
const authUrl = buildAuthorizeUrl(redirectUri, pkce, state)
|
||||
|
||||
const callbackPromise = waitForOAuthCallback(pkce, state)
|
||||
|
||||
return {
|
||||
url: authUrl,
|
||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||
method: "auto" as const,
|
||||
callback: async () => {
|
||||
const tokens = await callbackPromise
|
||||
stopOAuthServer()
|
||||
return {
|
||||
type: "success" as const,
|
||||
refresh: tokens.refresh_token,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a UUIDv7-like session ID (timestamp-prefixed)
|
||||
function generateSessionId(): string {
|
||||
const timestamp = Date.now()
|
||||
const timestampHex = timestamp.toString(16).padStart(12, "0")
|
||||
const randomHex = Array.from(crypto.getRandomValues(new Uint8Array(10)))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
// UUIDv7 format: xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx
|
||||
// First 48 bits are timestamp, version is 7
|
||||
return `${timestampHex.slice(0, 8)}-${timestampHex.slice(8, 12)}-7${randomHex.slice(0, 3)}-${(0x80 | (parseInt(randomHex.slice(3, 4), 16) & 0x3f)).toString(16)}${randomHex.slice(4, 7)}-${randomHex.slice(7, 19)}`
|
||||
}
|
||||
|
|
@ -7,12 +7,16 @@ import { Server } from "../server/server"
|
|||
import { BunProc } from "../bun"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
|
||||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
|
||||
const BUILTIN = ["opencode-copilot-auth@0.0.11", "opencode-anthropic-auth@0.0.8"]
|
||||
|
||||
// Built-in plugins that are directly imported (not installed from npm)
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin]
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
|
|
@ -20,7 +24,7 @@ export namespace Plugin {
|
|||
fetch: async (...args) => Server.App().fetch(...args),
|
||||
})
|
||||
const config = await Config.get()
|
||||
const hooks = []
|
||||
const hooks: Hooks[] = []
|
||||
const input: PluginInput = {
|
||||
client,
|
||||
project: Instance.project,
|
||||
|
|
@ -29,6 +33,16 @@ export namespace Plugin {
|
|||
serverUrl: Server.url(),
|
||||
$: Bun.$,
|
||||
}
|
||||
|
||||
// Load internal plugins first
|
||||
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
||||
for (const plugin of INTERNAL_PLUGINS) {
|
||||
log.info("loading internal plugin", { name: plugin.name })
|
||||
const init = await plugin(input)
|
||||
hooks.push(init)
|
||||
}
|
||||
}
|
||||
|
||||
const plugins = [...(config.plugin ?? [])]
|
||||
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
||||
plugins.push(...BUILTIN)
|
||||
|
|
|
|||
180
packages/opencode/src/server/codex.ts
Normal file
180
packages/opencode/src/server/codex.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { errors } from "./error"
|
||||
import { Auth } from "@/auth"
|
||||
import { CodexAuth } from "@/codex/auth"
|
||||
|
||||
export const CodexRoute = new Hono()
|
||||
.post(
|
||||
"/auth/authorize",
|
||||
describeRoute({
|
||||
summary: "Start Codex OAuth",
|
||||
description: "Initiate OAuth flow for Codex/ChatGPT authentication. Returns URL to open in browser.",
|
||||
operationId: "codex.auth.authorize",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Authorization URL and state",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
url: z.string(),
|
||||
state: z.string(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
port: z.coerce.number().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
// Get port from request URL since we can't import Server (circular dep)
|
||||
const url = new URL(c.req.url)
|
||||
const port = c.req.valid("query").port ?? url.port ?? 4096
|
||||
const redirectUri = `http://localhost:${port}/codex/auth/callback`
|
||||
const result = CodexAuth.authorize(redirectUri)
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/auth/callback",
|
||||
describeRoute({
|
||||
summary: "Codex OAuth callback",
|
||||
description: "Handle OAuth callback from ChatGPT auth. Called by browser after user authenticates.",
|
||||
operationId: "codex.auth.callback",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Success page",
|
||||
content: {
|
||||
"text/html": {},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
code: z.string(),
|
||||
state: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
const tokens = await CodexAuth.callback(query.code, query.state)
|
||||
|
||||
await Auth.set("codex", {
|
||||
type: "oauth",
|
||||
access: tokens.access,
|
||||
refresh: tokens.refresh,
|
||||
expires: tokens.expires,
|
||||
})
|
||||
|
||||
return c.html(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login Successful</title></head>
|
||||
<body style="font-family: system-ui; text-align: center; padding: 50px; background: #1a1a1a; color: #fff;">
|
||||
<h1>Login Successful</h1>
|
||||
<p>You can close this window and return to OpenCode.</p>
|
||||
</body>
|
||||
</html>`)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/auth/refresh",
|
||||
describeRoute({
|
||||
summary: "Refresh Codex tokens",
|
||||
description: "Refresh the Codex access token using the stored refresh token.",
|
||||
operationId: "codex.auth.refresh",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Tokens refreshed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const existing = await Auth.get("codex")
|
||||
if (!existing || existing.type !== "oauth") {
|
||||
throw new Error("No Codex OAuth credentials found")
|
||||
}
|
||||
|
||||
const tokens = await CodexAuth.refresh(existing.refresh)
|
||||
|
||||
await Auth.set("codex", {
|
||||
type: "oauth",
|
||||
access: tokens.access,
|
||||
refresh: tokens.refresh,
|
||||
expires: tokens.expires,
|
||||
})
|
||||
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/auth/status",
|
||||
describeRoute({
|
||||
summary: "Get Codex auth status",
|
||||
description: "Check if Codex OAuth credentials exist and whether they're expired.",
|
||||
operationId: "codex.auth.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Auth status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
authenticated: z.boolean(),
|
||||
expired: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const existing = await Auth.get("codex")
|
||||
if (!existing || existing.type !== "oauth") {
|
||||
return c.json({ authenticated: false })
|
||||
}
|
||||
return c.json({
|
||||
authenticated: true,
|
||||
expired: existing.expires < Date.now(),
|
||||
})
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/auth",
|
||||
describeRoute({
|
||||
summary: "Remove Codex auth",
|
||||
description: "Remove stored Codex OAuth credentials.",
|
||||
operationId: "codex.auth.remove",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Auth removed",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await Auth.remove("codex")
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
|
|
@ -52,6 +52,7 @@ import { QuestionRoute } from "./question"
|
|||
import { Installation } from "@/installation"
|
||||
import { MDNS } from "./mdns"
|
||||
import { Worktree } from "../worktree"
|
||||
import { CodexRoute } from "./codex"
|
||||
|
||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
|
|
@ -74,6 +75,8 @@ export namespace Server {
|
|||
const app = new Hono()
|
||||
export const App: () => Hono = lazy(
|
||||
() =>
|
||||
// TODO: Break server.ts into smaller route files to fix type inference
|
||||
// @ts-expect-error - Hono route chain is too deep for TypeScript's type inference
|
||||
app
|
||||
.onError((err, c) => {
|
||||
log.error("failed", {
|
||||
|
|
@ -1898,6 +1901,7 @@ export namespace Server {
|
|||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.route("/codex", CodexRoute)
|
||||
.get(
|
||||
"/find",
|
||||
describeRoute({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import os from "os"
|
||||
import { Installation } from "@/installation"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Log } from "@/util/log"
|
||||
import {
|
||||
|
|
@ -19,6 +21,7 @@ import { Plugin } from "@/plugin"
|
|||
import { SystemPrompt } from "./system"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { Auth } from "@/auth"
|
||||
|
||||
export namespace LLM {
|
||||
const log = Log.create({ service: "llm" })
|
||||
|
|
@ -82,12 +85,23 @@ export namespace LLM {
|
|||
}
|
||||
|
||||
const provider = await Provider.getProvider(input.model.providerID)
|
||||
const auth = await Auth.get(input.model.providerID)
|
||||
const isCodex = provider.id === "openai" && auth?.type === "oauth"
|
||||
|
||||
const variant =
|
||||
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
|
||||
const base = input.small
|
||||
? ProviderTransform.smallOptions(input.model)
|
||||
: ProviderTransform.options(input.model, input.sessionID, provider.options)
|
||||
const options = pipe(base, mergeDeep(input.model.options), mergeDeep(input.agent.options), mergeDeep(variant))
|
||||
const options: Record<string, any> = pipe(
|
||||
base,
|
||||
mergeDeep(input.model.options),
|
||||
mergeDeep(input.agent.options),
|
||||
mergeDeep(variant),
|
||||
)
|
||||
if (isCodex) {
|
||||
options.instructions = SystemPrompt.instructions()
|
||||
}
|
||||
|
||||
const params = await Plugin.trigger(
|
||||
"chat.params",
|
||||
|
|
@ -157,6 +171,13 @@ export namespace LLM {
|
|||
maxOutputTokens,
|
||||
abortSignal: input.abort,
|
||||
headers: {
|
||||
...(isCodex
|
||||
? {
|
||||
originator: "opencode",
|
||||
"User-Agent": `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
|
||||
session_id: input.sessionID,
|
||||
}
|
||||
: undefined),
|
||||
...(input.model.providerID.startsWith("opencode")
|
||||
? {
|
||||
"x-opencode-project": Instance.project.id,
|
||||
|
|
@ -169,12 +190,19 @@ export namespace LLM {
|
|||
},
|
||||
maxRetries: input.retries ?? 0,
|
||||
messages: [
|
||||
...system.map(
|
||||
(x): ModelMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
}),
|
||||
),
|
||||
...(isCodex
|
||||
? [
|
||||
{
|
||||
role: "user",
|
||||
content: system.join("\n\n"),
|
||||
} as ModelMessage,
|
||||
]
|
||||
: system.map(
|
||||
(x): ModelMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
}),
|
||||
)),
|
||||
...input.messages,
|
||||
],
|
||||
model: wrapLanguageModel({
|
||||
|
|
|
|||
1
packages/opencode/src/session/prompt/codex_header.txt
Normal file
1
packages/opencode/src/session/prompt/codex_header.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
You are a coding agent running in the opencode, a terminal-based coding assistant. opencode is an open source project. You are expected to be precise, safe, and helpful.
|
||||
|
|
@ -14,6 +14,7 @@ import PROMPT_GEMINI from "./prompt/gemini.txt"
|
|||
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
||||
|
||||
import PROMPT_CODEX from "./prompt/codex.txt"
|
||||
import PROMPT_CODEX_INSTRUCTIONS from "./prompt/codex_header.txt"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { Flag } from "@/flag/flag"
|
||||
|
||||
|
|
@ -23,6 +24,10 @@ export namespace SystemPrompt {
|
|||
return []
|
||||
}
|
||||
|
||||
export function instructions() {
|
||||
return PROMPT_CODEX_INSTRUCTIONS.trim()
|
||||
}
|
||||
|
||||
export function provider(model: Provider.Model) {
|
||||
if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX]
|
||||
if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3"))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue