feat: codex auth support (#7537)
This commit is contained in:
@@ -3,6 +3,8 @@ import { Global } from "../global"
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
|
||||||
|
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||||
|
|
||||||
export namespace Auth {
|
export namespace Auth {
|
||||||
export const Oauth = z
|
export const Oauth = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
417
packages/opencode/src/plugin/codex.ts
Normal file
417
packages/opencode/src/plugin/codex.ts
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
|
||||||
|
// Filter models to only allowed Codex models for OAuth
|
||||||
|
const allowedModels = new Set(["gpt-5.1-codex-max", "gpt-5.1-codex-mini", "gpt-5.2", "gpt-5.2-codex"])
|
||||||
|
for (const modelId of Object.keys(provider.models)) {
|
||||||
|
if (!allowedModels.has(modelId)) {
|
||||||
|
delete provider.models[modelId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider.models["gpt-5.2-codex"]) {
|
||||||
|
provider.models["gpt-5.2-codex"] = {
|
||||||
|
id: "gpt-5.2-codex",
|
||||||
|
providerID: "openai",
|
||||||
|
api: {
|
||||||
|
id: "gpt-5.2-codex",
|
||||||
|
url: "https://chatgpt.com/backend-api/codex",
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
},
|
||||||
|
name: "GPT-5.2 Codex",
|
||||||
|
capabilities: {
|
||||||
|
temperature: false,
|
||||||
|
reasoning: true,
|
||||||
|
attachment: true,
|
||||||
|
toolcall: true,
|
||||||
|
input: { text: true, audio: false, image: true, video: false, pdf: false },
|
||||||
|
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||||
|
},
|
||||||
|
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||||
|
limit: { context: 400000, output: 128000 },
|
||||||
|
status: "active",
|
||||||
|
options: {},
|
||||||
|
headers: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 authorization header with access token
|
||||||
|
headers.set("authorization", `Bearer ${currentAuth.access}`)
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,16 @@ import { Server } from "../server/server"
|
|||||||
import { BunProc } from "../bun"
|
import { BunProc } from "../bun"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
|
import { CodexAuthPlugin } from "./codex"
|
||||||
|
|
||||||
export namespace Plugin {
|
export namespace Plugin {
|
||||||
const log = Log.create({ service: "plugin" })
|
const log = Log.create({ service: "plugin" })
|
||||||
|
|
||||||
const BUILTIN = ["opencode-copilot-auth@0.0.11", "opencode-anthropic-auth@0.0.8"]
|
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 state = Instance.state(async () => {
|
||||||
const client = createOpencodeClient({
|
const client = createOpencodeClient({
|
||||||
baseUrl: "http://localhost:4096",
|
baseUrl: "http://localhost:4096",
|
||||||
@@ -20,7 +24,7 @@ export namespace Plugin {
|
|||||||
fetch: async (...args) => Server.App().fetch(...args),
|
fetch: async (...args) => Server.App().fetch(...args),
|
||||||
})
|
})
|
||||||
const config = await Config.get()
|
const config = await Config.get()
|
||||||
const hooks = []
|
const hooks: Hooks[] = []
|
||||||
const input: PluginInput = {
|
const input: PluginInput = {
|
||||||
client,
|
client,
|
||||||
project: Instance.project,
|
project: Instance.project,
|
||||||
@@ -29,11 +33,23 @@ export namespace Plugin {
|
|||||||
serverUrl: Server.url(),
|
serverUrl: Server.url(),
|
||||||
$: Bun.$,
|
$: 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 ?? [])]
|
const plugins = [...(config.plugin ?? [])]
|
||||||
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
|
||||||
plugins.push(...BUILTIN)
|
plugins.push(...BUILTIN)
|
||||||
}
|
}
|
||||||
for (let plugin of plugins) {
|
for (let plugin of plugins) {
|
||||||
|
// ignore old codex plugin since it is supported first party now
|
||||||
|
if (plugin.includes("opencode-openai-codex-auth")) continue
|
||||||
log.info("loading plugin", { path: plugin })
|
log.info("loading plugin", { path: plugin })
|
||||||
if (!plugin.startsWith("file://")) {
|
if (!plugin.startsWith("file://")) {
|
||||||
const lastAtIndex = plugin.lastIndexOf("@")
|
const lastAtIndex = plugin.lastIndexOf("@")
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export namespace Server {
|
|||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
export const App: () => Hono = lazy(
|
export const App: () => Hono = lazy(
|
||||||
() =>
|
() =>
|
||||||
|
// TODO: Break server.ts into smaller route files to fix type inference
|
||||||
app
|
app
|
||||||
.onError((err, c) => {
|
.onError((err, c) => {
|
||||||
log.error("failed", {
|
log.error("failed", {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import os from "os"
|
||||||
|
import { Installation } from "@/installation"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +21,7 @@ import { Plugin } from "@/plugin"
|
|||||||
import { SystemPrompt } from "./system"
|
import { SystemPrompt } from "./system"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { PermissionNext } from "@/permission/next"
|
import { PermissionNext } from "@/permission/next"
|
||||||
|
import { Auth } from "@/auth"
|
||||||
|
|
||||||
export namespace LLM {
|
export namespace LLM {
|
||||||
const log = Log.create({ service: "llm" })
|
const log = Log.create({ service: "llm" })
|
||||||
@@ -82,12 +85,24 @@ export namespace LLM {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const provider = await Provider.getProvider(input.model.providerID)
|
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 =
|
const variant =
|
||||||
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
|
!input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
|
||||||
const base = input.small
|
const base = input.small
|
||||||
? ProviderTransform.smallOptions(input.model)
|
? ProviderTransform.smallOptions(input.model)
|
||||||
: ProviderTransform.options(input.model, input.sessionID, provider.options)
|
: 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()
|
||||||
|
options.store = false
|
||||||
|
}
|
||||||
|
|
||||||
const params = await Plugin.trigger(
|
const params = await Plugin.trigger(
|
||||||
"chat.params",
|
"chat.params",
|
||||||
@@ -108,16 +123,14 @@ export namespace LLM {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
l.info("params", {
|
const maxOutputTokens = isCodex
|
||||||
params,
|
? undefined
|
||||||
})
|
: ProviderTransform.maxOutputTokens(
|
||||||
|
input.model.api.npm,
|
||||||
const maxOutputTokens = ProviderTransform.maxOutputTokens(
|
params.options,
|
||||||
input.model.api.npm,
|
input.model.limit.output,
|
||||||
params.options,
|
OUTPUT_TOKEN_MAX,
|
||||||
input.model.limit.output,
|
)
|
||||||
OUTPUT_TOKEN_MAX,
|
|
||||||
)
|
|
||||||
|
|
||||||
const tools = await resolveTools(input)
|
const tools = await resolveTools(input)
|
||||||
|
|
||||||
@@ -157,6 +170,13 @@ export namespace LLM {
|
|||||||
maxOutputTokens,
|
maxOutputTokens,
|
||||||
abortSignal: input.abort,
|
abortSignal: input.abort,
|
||||||
headers: {
|
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")
|
...(input.model.providerID.startsWith("opencode")
|
||||||
? {
|
? {
|
||||||
"x-opencode-project": Instance.project.id,
|
"x-opencode-project": Instance.project.id,
|
||||||
@@ -169,12 +189,19 @@ export namespace LLM {
|
|||||||
},
|
},
|
||||||
maxRetries: input.retries ?? 0,
|
maxRetries: input.retries ?? 0,
|
||||||
messages: [
|
messages: [
|
||||||
...system.map(
|
...(isCodex
|
||||||
(x): ModelMessage => ({
|
? [
|
||||||
role: "system",
|
{
|
||||||
content: x,
|
role: "user",
|
||||||
}),
|
content: system.join("\n\n"),
|
||||||
),
|
} as ModelMessage,
|
||||||
|
]
|
||||||
|
: system.map(
|
||||||
|
(x): ModelMessage => ({
|
||||||
|
role: "system",
|
||||||
|
content: x,
|
||||||
|
}),
|
||||||
|
)),
|
||||||
...input.messages,
|
...input.messages,
|
||||||
],
|
],
|
||||||
model: wrapLanguageModel({
|
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_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
||||||
|
|
||||||
import PROMPT_CODEX from "./prompt/codex.txt"
|
import PROMPT_CODEX from "./prompt/codex.txt"
|
||||||
|
import PROMPT_CODEX_INSTRUCTIONS from "./prompt/codex_header.txt"
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
|
|
||||||
@@ -23,6 +24,10 @@ export namespace SystemPrompt {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function instructions() {
|
||||||
|
return PROMPT_CODEX_INSTRUCTIONS.trim()
|
||||||
|
}
|
||||||
|
|
||||||
export function provider(model: Provider.Model) {
|
export function provider(model: Provider.Model) {
|
||||||
if (model.api.id.includes("gpt-5")) return [PROMPT_CODEX]
|
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"))
|
if (model.api.id.includes("gpt-") || model.api.id.includes("o1") || model.api.id.includes("o3"))
|
||||||
|
|||||||
Reference in New Issue
Block a user