mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(core): add ChatGPT token-sharing sign-in alongside Codex
This commit is contained in:
@@ -377,6 +377,7 @@
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jose": "6.0.11",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { onMount } from "solid-js"
|
||||
import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome"
|
||||
|
||||
function WelcomeStory() {
|
||||
const dialog = useDialog()
|
||||
const open = () => dialog.show(() => <DialogChatGPTPlanWelcome />)
|
||||
onMount(open)
|
||||
|
||||
return (
|
||||
<Button variant="neutral" onClick={open}>
|
||||
Open ChatGPT plan welcome
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/Dialogs/Connect Provider",
|
||||
id: "app-dialog-chatgpt-welcome",
|
||||
}
|
||||
|
||||
export const ChatGPTPlanWelcome = { render: () => <WelcomeStory /> }
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Dialog, DialogTitle } from "@opencode/ui/dialog"
|
||||
import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
|
||||
export function DialogChatGPTPlanWelcome() {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<Dialog fit containerClass="!w-[min(calc(100vw_-_32px),390px)] !rounded-xl">
|
||||
<div class="flex w-full flex-col items-center px-8 pb-8 pt-9 text-center [font-family:var(--v2-font-family-sans)]">
|
||||
<ProviderIcon id="openai" class="!size-12 text-v2-icon-icon-base" aria-hidden="true" />
|
||||
<div class="mt-6 max-w-[270px] text-[20px] font-[530] leading-7 tracking-[-0.3px] text-v2-text-text-base">
|
||||
<DialogTitle>
|
||||
<bdi dir="auto">{language.t("provider.connect.chatgptWelcome.title")}</bdi>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<p class="mt-3 text-[13px] leading-5 text-v2-text-text-muted">
|
||||
<bdi dir="auto">{language.t("provider.connect.chatgptWelcome.description")}</bdi>
|
||||
</p>
|
||||
<ExternalLink
|
||||
href="https://chatgpt.com/#settings/Usage"
|
||||
dir="auto"
|
||||
class="mt-1 rounded-sm text-[13px] leading-5 text-v2-text-text-muted underline-offset-2 hover:text-v2-text-text-base focus-visible:outline focus-visible:outline-2"
|
||||
>
|
||||
{language.t("provider.connect.chatgptWelcome.usage")}
|
||||
</ExternalLink>
|
||||
<Button variant="contrast" size="large" class="mt-8 w-full" autofocus onClick={() => dialog.close()}>
|
||||
{language.t("provider.connect.chatgptWelcome.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -71,3 +71,12 @@ export const LoginMethods = {
|
||||
{ type: "key", label: "API key" },
|
||||
]),
|
||||
}
|
||||
|
||||
export const ChatGPTSignIn = {
|
||||
render: renderConnection("openai", [
|
||||
{ type: "oauth", id: "chatgpt-token-sharing", label: "Sign in with ChatGPT" },
|
||||
{ type: "oauth", id: "chatgpt-browser", label: "ChatGPT Pro/Plus (browser)" },
|
||||
{ type: "oauth", id: "chatgpt-headless", label: "ChatGPT Pro/Plus (headless)" },
|
||||
{ type: "key", label: "API key" },
|
||||
]),
|
||||
}
|
||||
|
||||
@@ -37,8 +37,11 @@ import { ProviderModelGroup, ProviderModelIcon } from "@/providers/models/provid
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import { OpenCodeLogo } from "@/providers/opencode-logo"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { Schema } from "effect"
|
||||
import {
|
||||
CONSOLE_INTEGRATION,
|
||||
CONSOLE_PROVIDERS,
|
||||
@@ -48,6 +51,7 @@ import {
|
||||
type ProviderConnectMethod,
|
||||
} from "./controller"
|
||||
import { ConsoleAuthorization } from "./console"
|
||||
import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome"
|
||||
import { authServerName, RemoteAuthNotice } from "./remote"
|
||||
import "./models.css"
|
||||
|
||||
@@ -81,8 +85,16 @@ export const DialogConnectProvider: Component<{
|
||||
completed: false,
|
||||
modelProvider: undefined as { id: string; name: string } | undefined,
|
||||
authorization: false,
|
||||
chatgptWelcome: false,
|
||||
})
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const [welcome, setWelcome, , welcomeReady] = persisted(
|
||||
Persist.global("chatgpt-plan-welcome.v1"),
|
||||
Persistence.struct({ seen: Schema.Boolean }),
|
||||
{ seen: false },
|
||||
)
|
||||
const reset = controller.reset
|
||||
const back = { current: reset }
|
||||
const consoleSelected = () => CONSOLE_PROVIDERS.has(controller.selected() ?? "")
|
||||
@@ -112,7 +124,11 @@ export const DialogConnectProvider: Component<{
|
||||
setBack={(handler) => (back.current = handler)}
|
||||
selection={props.selection}
|
||||
onDone={props.onDone ? () => setState("completed", true) : undefined}
|
||||
onConnected={() => props.onConnected?.(provider)}
|
||||
onConnected={(methodID) => {
|
||||
props.onConnected?.(provider)
|
||||
if (platform.platform === "desktop" && provider === "openai" && methodID === "chatgpt-token-sharing")
|
||||
setState("chatgptWelcome", true)
|
||||
}}
|
||||
onFirstConnection={(provider) => setState("modelProvider", provider)}
|
||||
onAuthorization={(authorization) => setState("authorization", authorization)}
|
||||
/>
|
||||
@@ -136,13 +152,21 @@ export const DialogConnectProvider: Component<{
|
||||
: "!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
|
||||
}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!state.completed || !props.onDone) return
|
||||
event.preventDefault()
|
||||
props.onDone()
|
||||
if (state.completed && props.onDone) {
|
||||
event.preventDefault()
|
||||
props.onDone()
|
||||
}
|
||||
if (!state.chatgptWelcome) return
|
||||
void Promise.resolve(welcomeReady.promise).then(() => {
|
||||
if (welcome.seen) return
|
||||
setWelcome("seen", true)
|
||||
void dialog.show(() => <DialogChatGPTPlanWelcome />)
|
||||
})
|
||||
}}
|
||||
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
||||
classList={{
|
||||
"[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3": consoleSelected() && !state.modelProvider,
|
||||
"[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3":
|
||||
consoleSelected() && !state.modelProvider,
|
||||
"[&_[data-slot=dialog-header]]:!pt-5": !!state.modelProvider,
|
||||
}}
|
||||
>
|
||||
@@ -354,7 +378,7 @@ function ProviderConnection(props: {
|
||||
setBack: (handler: () => void) => void
|
||||
selection?: ModelSelection
|
||||
onDone?: () => void
|
||||
onConnected?: () => void
|
||||
onConnected?: (methodID?: string) => void
|
||||
onFirstConnection: (provider: { id: string; name: string }) => void
|
||||
onAuthorization: (authorization: boolean) => void
|
||||
}) {
|
||||
@@ -401,12 +425,11 @@ function ProviderConnection(props: {
|
||||
prepare: isConsole ? prepareConsoleCatalog : undefined,
|
||||
pollInterval: isConsole ? 500 : undefined,
|
||||
onComplete: () => {
|
||||
props.onConnected?.()
|
||||
const method = controller.currentMethod()
|
||||
props.onConnected?.(method?.type === "oauth" ? method.id : undefined)
|
||||
// The picker only lists the newest model per family by default, which hides most of
|
||||
// what a new connection just unlocked. Show everything the connected integration offers.
|
||||
global.models.show(
|
||||
connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id })),
|
||||
)
|
||||
global.models.show(connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id })))
|
||||
if (state.catalogPending) {
|
||||
setState("noModels", true)
|
||||
return
|
||||
@@ -500,7 +523,9 @@ function ProviderConnection(props: {
|
||||
})
|
||||
createEffect(() => {
|
||||
const current = controller.auth.state()
|
||||
props.onAuthorization(controller.authorization() !== undefined && (current === "waiting" || current === "refreshing"))
|
||||
props.onAuthorization(
|
||||
controller.authorization() !== undefined && (current === "waiting" || current === "refreshing"),
|
||||
)
|
||||
})
|
||||
const provider = createMemo(() => ({
|
||||
id: props.provider,
|
||||
@@ -541,6 +566,10 @@ function ProviderConnection(props: {
|
||||
})
|
||||
const keyIndex = () => controller.methods().findIndex((method) => method.type === "key")
|
||||
const oauthIndex = () => controller.methods().findIndex((method) => method.type === "oauth")
|
||||
const chatgptIndex = () =>
|
||||
props.provider === "openai"
|
||||
? controller.methods().findIndex((method) => method.type === "oauth" && method.id === "chatgpt-token-sharing")
|
||||
: -1
|
||||
// The Console device flow owns the dialog from the first frame until the catalogs are loaded.
|
||||
const consoleSignIn = () =>
|
||||
isConsole &&
|
||||
@@ -691,33 +720,96 @@ function ProviderConnection(props: {
|
||||
props.setBack(goBack)
|
||||
|
||||
function MethodSelection() {
|
||||
const [selection, setSelection] = createStore({ codex: false })
|
||||
const codex = () =>
|
||||
controller
|
||||
.methods()
|
||||
.flatMap((method, index) =>
|
||||
method.type === "oauth" && (method.id === "chatgpt-browser" || method.id === "chatgpt-headless")
|
||||
? [{ method, index }]
|
||||
: [],
|
||||
)
|
||||
const other = () =>
|
||||
controller
|
||||
.methods()
|
||||
.flatMap((method, index) =>
|
||||
chatgptIndex() === -1 ||
|
||||
(method.type === "oauth" &&
|
||||
!["chatgpt-token-sharing", "chatgpt-browser", "chatgpt-headless"].includes(method.id))
|
||||
? [{ method, index }]
|
||||
: [],
|
||||
)
|
||||
return (
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<For each={controller.methods()}>
|
||||
{(item, index) => {
|
||||
const details = () => methodDetails(item)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={() => void controller.auth.select(index())}
|
||||
>
|
||||
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
|
||||
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
|
||||
</span>
|
||||
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
|
||||
<Show when={details().hint}>
|
||||
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={chatgptIndex() !== -1}>
|
||||
<div class="flex flex-col items-start gap-3 px-3 pb-3">
|
||||
<Button variant="contrast" size="large" onClick={() => void controller.auth.select(chatgptIndex())}>
|
||||
{language.t("provider.connect.chatgpt.signIn")}
|
||||
</Button>
|
||||
<Show when={keyIndex() !== -1}>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-sm text-[13px] leading-5 text-v2-text-text-muted underline underline-offset-2 hover:text-v2-text-text-base focus-visible:outline focus-visible:outline-2"
|
||||
onClick={() => void controller.auth.select(keyIndex())}
|
||||
>
|
||||
{language.t("provider.connect.chatgpt.useApiKey")}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={codex().length > 0}>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-sm text-[13px] leading-5 text-v2-text-text-muted underline underline-offset-2 hover:text-v2-text-text-base focus-visible:outline focus-visible:outline-2"
|
||||
aria-expanded={selection.codex}
|
||||
onClick={() => setSelection("codex", !selection.codex)}
|
||||
>
|
||||
{language.t("provider.connect.chatgpt.codexSignIn")}
|
||||
</button>
|
||||
<Show when={selection.codex}>
|
||||
<div class="flex flex-col gap-1 ps-2">
|
||||
<For each={codex()}>
|
||||
{({ method, index }) => (
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-sm px-1 py-1 text-start text-[13px] leading-5 text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline focus-visible:outline-2"
|
||||
onClick={() => void controller.auth.select(index)}
|
||||
>
|
||||
<span>{methodDetails(method).label}</span>{" "}
|
||||
<span class="text-v2-text-text-muted">{methodDetails(method).hint}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={other().length > 0}>
|
||||
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<For each={other()}>
|
||||
{({ method: item, index }) => {
|
||||
const details = () => methodDetails(item)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={() => void controller.auth.select(index)}
|
||||
>
|
||||
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
|
||||
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
|
||||
</span>
|
||||
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
|
||||
<Show when={details().hint}>
|
||||
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { createSignal } from "solid-js"
|
||||
import type { ModelSelection } from "./selection"
|
||||
import { ModelSelectorPopoverView } from "./select-dialog"
|
||||
|
||||
const chatgpt = {
|
||||
id: "gpt-5.6-sol",
|
||||
providerID: "openai",
|
||||
api: { id: "gpt-5.6-sol", url: "https://api.openai.com/v1", npm: "@opencode/ai/providers/openai" },
|
||||
name: "GPT-5.6 Sol",
|
||||
family: "gpt",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
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 },
|
||||
interleaved: true,
|
||||
},
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
limit: { context: 400_000, output: 64_000 },
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-09-01",
|
||||
variants: {},
|
||||
provider: {
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
source: "custom",
|
||||
env: [],
|
||||
options: {},
|
||||
models: {},
|
||||
},
|
||||
latest: true,
|
||||
} satisfies NonNullable<ReturnType<ModelSelection["current"]>>
|
||||
|
||||
const models = [
|
||||
chatgpt,
|
||||
{
|
||||
...chatgpt,
|
||||
id: "gpt-5.6-terra",
|
||||
api: { ...chatgpt.api, id: "gpt-5.6-terra" },
|
||||
name: "GPT-5.6 Terra",
|
||||
latest: false,
|
||||
},
|
||||
]
|
||||
|
||||
function SelectorStory(props: { plan: boolean }) {
|
||||
const [current, setCurrent] = createSignal(models[0].id)
|
||||
return (
|
||||
<div class="flex min-h-[280px] items-end justify-center">
|
||||
<ModelSelectorPopoverView
|
||||
trigger={(trigger) => (
|
||||
<Button {...trigger} variant="ghost-muted">
|
||||
{models.find((model) => model.id === current())?.name}
|
||||
</Button>
|
||||
)}
|
||||
models={(search) => models.filter((model) => model.name.toLowerCase().includes(search.toLowerCase()))}
|
||||
groups={(items) => [{ category: "openai", items }]}
|
||||
current={`openai:${current()}`}
|
||||
chatgptPlan={props.plan}
|
||||
select={(item) => setCurrent(item.id)}
|
||||
onManage={() => undefined}
|
||||
onClose={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/Dialogs/Model Selector",
|
||||
id: "app-dialog-model-selector",
|
||||
}
|
||||
|
||||
export const ChatGPTPlan = { render: () => <SelectorStory plan /> }
|
||||
export const CodexOAuth = { render: () => <SelectorStory plan={false} /> }
|
||||
export const ApiKey = { render: () => <SelectorStory plan={false} /> }
|
||||
@@ -15,13 +15,14 @@ import { Menu } from "@opencode/ui/menu"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { ModelTooltip } from "./tooltip"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown"
|
||||
import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { matchesModelSearch } from "./search"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelSections } from "@/providers/models/provider-group"
|
||||
import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelIcon, ProviderModelSections } from "@/providers/models/provider-group"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
@@ -230,6 +231,8 @@ export function ModelSelectorPopover(props: {
|
||||
models={controller.models}
|
||||
groups={controller.groups}
|
||||
current={controller.current()}
|
||||
// ConnectionInfo reports only "oauth", not the active method ID. Never infer SIWC from Codex OAuth.
|
||||
chatgptPlan={false}
|
||||
select={controller.select}
|
||||
onManage={() => {
|
||||
void import("./manage").then((module) => {
|
||||
@@ -281,11 +284,12 @@ function createModelSelectorController(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function ModelSelectorPopoverView(props: {
|
||||
export function ModelSelectorPopoverView(props: {
|
||||
trigger: ModelSelectorTrigger
|
||||
models: (search: string) => ModelItem[]
|
||||
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||
current: string | undefined
|
||||
chatgptPlan?: boolean
|
||||
select: (item: ModelItem) => void
|
||||
onManage: () => void
|
||||
onClose: () => void
|
||||
@@ -368,7 +372,8 @@ function ModelSelectorPopoverView(props: {
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
ref={(element: HTMLDivElement) => (contentRef = element)}
|
||||
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
class="w-[284px] max-w-[calc(100vw-16px)] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
classList={{ "!w-[320px]": props.chatgptPlan }}
|
||||
onPointerDownOutside={dismiss.preventTriggerRestore}
|
||||
onFocusOutside={dismiss.preventTriggerRestore}
|
||||
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||
@@ -505,6 +510,20 @@ function ModelSelectorPopoverView(props: {
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
<Show when={props.chatgptPlan}>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex min-h-10 items-center gap-2 px-3 py-2 text-[13px] leading-5 text-v2-text-text-base">
|
||||
<ProviderModelIcon provider={{ id: "openai", name: "OpenAI" }} class="shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("dialog.model.chatgptPlan")}</span>
|
||||
<ExternalLink
|
||||
href="https://chatgpt.com/#settings/Usage"
|
||||
class="flex shrink-0 items-center gap-1 rounded-sm text-v2-text-text-muted no-underline hover:text-v2-text-text-base focus-visible:outline focus-visible:outline-2"
|
||||
>
|
||||
{language.t("dialog.model.chatgptManageUsage")}
|
||||
<Icon name="arrow-up-right" size="small" />
|
||||
</ExternalLink>
|
||||
</div>
|
||||
</Show>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
|
||||
@@ -186,6 +186,8 @@ export const dict = {
|
||||
"dialog.model.empty": "No model results",
|
||||
"dialog.model.manage": "Manage models",
|
||||
"dialog.model.manage.description": "Customize which models appear in the model selector.",
|
||||
"dialog.model.chatgptPlan": "Using ChatGPT plan",
|
||||
"dialog.model.chatgptManageUsage": "Manage usage",
|
||||
"dialog.model.manage.provider.toggle": "Toggle all {{provider}} models",
|
||||
|
||||
"dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
|
||||
@@ -217,6 +219,13 @@ export const dict = {
|
||||
"provider.connect.models.title": "Connected to {{provider}}",
|
||||
"provider.connect.models.description": "Choose a model to start with. You can switch models anytime.",
|
||||
"provider.connect.models.available": "Available models",
|
||||
"provider.connect.chatgpt.signIn": "Sign in with ChatGPT",
|
||||
"provider.connect.chatgpt.codexSignIn": "Sign in with Codex instead",
|
||||
"provider.connect.chatgpt.useApiKey": "Use API key",
|
||||
"provider.connect.chatgptWelcome.title": "You're using your ChatGPT plan",
|
||||
"provider.connect.chatgptWelcome.description": "Eligible requests in OpenCode use your ChatGPT plan.",
|
||||
"provider.connect.chatgptWelcome.usage": "Manage usage in ChatGPT settings",
|
||||
"provider.connect.chatgptWelcome.confirm": "Got it",
|
||||
"provider.connect.models.list": "Models available from {{provider}}",
|
||||
"provider.connect.console.refreshFailed":
|
||||
"Your account is connected, but we couldn't load your models. Try again to refresh them.",
|
||||
|
||||
@@ -48,7 +48,17 @@ export type ConnectMethod = Exclude<IntegrationMethod, { type: "env" }>
|
||||
export function connectMethods(integration: IntegrationInfo) {
|
||||
return integration.methods
|
||||
.filter((method): method is ConnectMethod => method.type !== "env")
|
||||
.toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key"))
|
||||
.toSorted((a, b) => {
|
||||
if (integration.id !== "openai") return Number(a.type === "key") - Number(b.type === "key")
|
||||
const rank = (method: ConnectMethod) => {
|
||||
if (method.type === "key") return 1
|
||||
if (method.id === "chatgpt-token-sharing") return 0
|
||||
if (method.id === "chatgpt-browser") return 2
|
||||
if (method.id === "chatgpt-headless") return 3
|
||||
return 4
|
||||
}
|
||||
return rank(a) - rank(b)
|
||||
})
|
||||
}
|
||||
|
||||
export const resolveMethod = Effect.fn("cli.auth.resolve-method")(function* (methods: ConnectMethod[], target: string) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { IntegrationInfo } from "@opencode/client"
|
||||
import { loginChoices } from "../src/commands/handlers/auth/login"
|
||||
import { connectMethods } from "../src/commands/handlers/auth/shared"
|
||||
|
||||
const integration = (value: Partial<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">): IntegrationInfo => ({
|
||||
methods: [{ type: "key" }],
|
||||
@@ -8,6 +9,23 @@ const integration = (value: Partial<IntegrationInfo> & Pick<IntegrationInfo, "id
|
||||
...value,
|
||||
})
|
||||
|
||||
test("orders OpenAI authentication methods without changing other providers", () => {
|
||||
const methods: IntegrationInfo["methods"] = [
|
||||
{ type: "oauth", id: "chatgpt-headless", label: "Codex headless" },
|
||||
{ type: "key", label: "API key" },
|
||||
{ type: "oauth", id: "chatgpt-browser", label: "Codex browser" },
|
||||
{ type: "oauth", id: "chatgpt-token-sharing", label: "Sign in with ChatGPT" },
|
||||
]
|
||||
expect(
|
||||
connectMethods(integration({ id: "openai", name: "OpenAI", methods })).map((method) =>
|
||||
method.type === "key" ? "key" : method.id,
|
||||
),
|
||||
).toEqual(["chatgpt-token-sharing", "key", "chatgpt-browser", "chatgpt-headless"])
|
||||
expect(connectMethods(integration({ id: "example", name: "Example", methods })).map((method) => method.type)).toEqual(
|
||||
["oauth", "oauth", "oauth", "key"],
|
||||
)
|
||||
})
|
||||
|
||||
test("groups the CLI choices like /connect while keeping stable login IDs", () => {
|
||||
expect(
|
||||
loginChoices([
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jose": "6.0.11",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
|
||||
@@ -5,6 +5,8 @@ import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#s
|
||||
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
|
||||
import type { SqlClient } from "effect/unstable/sql"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { closeSync, existsSync, openSync } from "node:fs"
|
||||
import { chmod } from "node:fs/promises"
|
||||
import { isAbsolute, join } from "path"
|
||||
import { DatabaseMigration } from "./migration.js"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
@@ -70,13 +72,31 @@ export function layer(options: Options = { path: ":memory:" }) {
|
||||
Layer.provide(sqliteLayer({ filename })),
|
||||
)
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
const global = yield* Global.Service
|
||||
return provide(join(global.data, filename))
|
||||
if (filename === ":memory:") return provide(filename)
|
||||
const file = isAbsolute(filename) ? filename : join((yield* Global.Service).data, filename)
|
||||
yield* Effect.promise(() => restrictToOwner(file))
|
||||
return provide(file)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// SQLite creates new sidecars with the database's mode, but does not tighten existing sidecars.
|
||||
// Windows relies on the user profile directory's inherited ACLs instead of POSIX modes.
|
||||
async function restrictToOwner(filename: string) {
|
||||
if (process.platform === "win32") return
|
||||
// Opening and closing an existing database can release another connection's POSIX locks.
|
||||
// Create missing files synchronously so another fiber cannot open one before it is restricted.
|
||||
if (!existsSync(filename)) closeSync(openSync(filename, "a", 0o600))
|
||||
await chmod(filename, 0o600)
|
||||
await Promise.all(
|
||||
[`${filename}-wal`, `${filename}-shm`].map((file) =>
|
||||
chmod(file, 0o600).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== "ENOENT") throw error
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// The database service over an injected SqlClient, for runtimes that receive
|
||||
// database storage instead of opening a filesystem path. Any client provided
|
||||
// here still goes through the pragma guards and migrations; Global is required
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
|
||||
import type { Context } from "@opencode/plugin/effect/plugin"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import type { SessionRequest } from "@opencode/plugin/effect/session"
|
||||
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import type { Server } from "node:http"
|
||||
import { Deferred, Duration, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import type { Server, ServerResponse } from "node:http"
|
||||
import { App } from "../../app.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { IntegrationConnection } from "../../integration/connection.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { OauthCallbackPage } from "../../oauth/page.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { SessionAffinity } from "../../session/affinity.js"
|
||||
@@ -20,6 +23,11 @@ const callbackBindAttempts = 10
|
||||
const callbackBindRetryDelay = 200
|
||||
const pollingSafetyMargin = 3000
|
||||
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
|
||||
const sharingClientID = "dynamic_agent_client"
|
||||
const sharingTokenURL = `${issuer}/api/accounts/oauth/token`
|
||||
const sharingResource = "https://api.openai.com/v1"
|
||||
const sharingScope = "chatgpt.tokens.use.direct"
|
||||
const sharingMethodID = Integration.MethodID.make("chatgpt-token-sharing")
|
||||
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
|
||||
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
||||
// ChatGPT accounts lost gpt-5.4 and gpt-5.4-mini in Codex on 2026-08-31 (replacements: gpt-5.6-terra, gpt-5.6-luna).
|
||||
@@ -38,6 +46,33 @@ type TokenResponse = {
|
||||
expires_in?: number
|
||||
}
|
||||
|
||||
type SharingTokenResponse = {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
id_token?: string
|
||||
expires_in?: number
|
||||
scope?: string
|
||||
}
|
||||
|
||||
const RemoteModel = Schema.Struct({
|
||||
slug: Schema.String,
|
||||
display_name: Schema.String,
|
||||
visibility: Schema.String,
|
||||
supported_in_api: Schema.Boolean,
|
||||
context_window: Schema.Int.check(Schema.isGreaterThan(1)),
|
||||
input_modalities: Schema.Array(Schema.String),
|
||||
supported_reasoning_levels: Schema.optional(Schema.Array(Schema.Struct({ effort: Schema.String }))),
|
||||
})
|
||||
type RemoteModel = typeof RemoteModel.Type
|
||||
const decodeModels = Schema.decodeUnknownEffect(Schema.Struct({ models: Schema.Array(RemoteModel) }))
|
||||
const decodeSharingMetadata = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
clientID: Schema.String,
|
||||
scopes: Schema.optional(Schema.Array(Schema.String)),
|
||||
models: Schema.optional(Schema.Array(RemoteModel)),
|
||||
}),
|
||||
)
|
||||
|
||||
const Claims = Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
chatgpt_account_id: Schema.optional(Schema.String),
|
||||
@@ -229,14 +264,122 @@ const headless = (app: App.Info) =>
|
||||
refresh: (value) => refresh(headlessMethodID, value, app),
|
||||
}) satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
const signIn = (app: App.Info, savedClientID: () => string | undefined, storage: Context["storage"]) =>
|
||||
({
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
method: { id: sharingMethodID, type: "oauth", label: "Sign in with ChatGPT" },
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const storedHostID = yield* storage.get("chatgpt-agent-host-id")
|
||||
const hostID = typeof storedHostID === "string" ? storedHostID : `urn:uuid:${crypto.randomUUID()}`
|
||||
if (typeof storedHostID !== "string") yield* storage.set("chatgpt-agent-host-id", hostID)
|
||||
const pkce = yield* Effect.promise(generatePKCE)
|
||||
const state = randomValue()
|
||||
const nonce = randomValue()
|
||||
const savedID = savedClientID()
|
||||
const received = yield* Deferred.make<{ code: string; clientID?: string; response: ServerResponse }, Error>()
|
||||
const { createServer } = yield* Effect.promise(() => import("node:http"))
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1")
|
||||
if (url.pathname !== "/auth/callback") {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||
const code = url.searchParams.get("code")
|
||||
if (error) {
|
||||
Effect.runFork(Deferred.fail(received, new Error(error)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(error, { provider: "ChatGPT" }))
|
||||
return
|
||||
}
|
||||
if (!code || url.searchParams.get("state") !== state) {
|
||||
const message = code ? "Invalid OAuth state" : "Missing authorization code"
|
||||
Effect.runFork(Deferred.fail(received, new Error(message)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(message, { provider: "ChatGPT" }))
|
||||
return
|
||||
}
|
||||
if (
|
||||
!Effect.runSync(
|
||||
Deferred.succeed(received, { code, clientID: url.searchParams.get("client_id") ?? undefined, response }),
|
||||
)
|
||||
)
|
||||
response.writeHead(409).end("OAuth callback already received")
|
||||
})
|
||||
const port = yield* Effect.callback<number, Error>((resume) => {
|
||||
const onError = (error: Error) => resume(Effect.fail(error))
|
||||
server.once("error", onError)
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", onError)
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string")
|
||||
return resume(Effect.fail(new Error("Missing OAuth callback port")))
|
||||
resume(Effect.succeed(address.port))
|
||||
})
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
const redirect = `http://127.0.0.1:${port}/auth/callback`
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: sharingAuthorizeURL(redirect, pkce, state, nonce, savedID, hostID),
|
||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||
callback: Effect.gen(function* () {
|
||||
const result = yield* Deferred.await(received)
|
||||
const respond = (error?: string) =>
|
||||
Effect.sync(() =>
|
||||
result.response
|
||||
.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
|
||||
.end(
|
||||
error
|
||||
? OauthCallbackPage.error(error, { provider: "ChatGPT" })
|
||||
: OauthCallbackPage.success({ provider: "ChatGPT" }),
|
||||
),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const clientID = result.clientID ?? savedID
|
||||
if (!clientID)
|
||||
return yield* Effect.fail(new Error("ChatGPT sign-in did not return a client ID. Connect again."))
|
||||
const tokens = yield* sharingExchange(result.code, clientID, redirect, pkce, app)
|
||||
if (!tokens.scope?.split(" ").includes(sharingScope))
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
"ChatGPT sign-in finished without token sharing. Sign in again and allow token sharing, or connect OpenAI with an API key.",
|
||||
),
|
||||
)
|
||||
if (!tokens.id_token) return yield* Effect.fail(new Error("ChatGPT sign-in did not return an ID token."))
|
||||
yield* verifySharingIDToken(tokens.id_token, clientID, nonce)
|
||||
return sharingCredential(
|
||||
tokens,
|
||||
clientID,
|
||||
undefined,
|
||||
yield* fetchSharingModels(tokens.access_token, app).pipe(Effect.timeout(15_000)),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.tap(() => respond()),
|
||||
Effect.tapError((error) => respond(error instanceof Error ? error.message : "ChatGPT sign-in failed")),
|
||||
Effect.onInterrupt(() => Effect.sync(() => result.response.destroy())),
|
||||
)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
refresh: (value) => sharingRefresh(value, app),
|
||||
}) satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
export const OpenAIPlugin = define({
|
||||
id: "opencode.provider.openai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const bus = yield* Bus.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let chatgpt: Credential.OAuth | undefined
|
||||
let sharing: Credential.OAuth | undefined
|
||||
let available: ReadonlyArray<RemoteModel> | undefined
|
||||
let source: Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
|
||||
|
||||
const load = Effect.fn("OpenAIPlugin.load")(function* () {
|
||||
const previous = IntegrationConnection.key(source)
|
||||
const connection = yield* ctx.integration.connection.active("openai")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
@@ -246,9 +389,16 @@ export const OpenAIPlugin = define({
|
||||
(credential.methodID === browserMethodID || credential.methodID === headlessMethodID)
|
||||
? credential
|
||||
: undefined
|
||||
sharing = credential?.type === "oauth" && credential.methodID === sharingMethodID ? credential : undefined
|
||||
source = sharing ? connection : undefined
|
||||
if (previous !== IntegrationConnection.key(source))
|
||||
available = Option.getOrUndefined(decodeSharingMetadata(sharing?.metadata))?.models
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
editor.method.update(
|
||||
signIn(ctx.app, () => Option.getOrUndefined(decodeSharingMetadata(sharing?.metadata))?.clientID, ctx.storage),
|
||||
)
|
||||
editor.method.update(browser(ctx.app))
|
||||
editor.method.update(headless(ctx.app))
|
||||
})
|
||||
@@ -259,8 +409,9 @@ export const OpenAIPlugin = define({
|
||||
const account = chatgpt?.metadata?.accountID
|
||||
providers.update(item.provider.id, (provider) => {
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, {
|
||||
transport: provider.settings?.transport ?? "websocket",
|
||||
transport: sharing ? "http" : (provider.settings?.transport ?? "websocket"),
|
||||
...(chatgpt ? { baseURL: codexBaseURL } : {}),
|
||||
...(sharing ? { compaction: { type: "summary" } } : {}),
|
||||
})
|
||||
if (!chatgpt) return
|
||||
provider.headers = Provider.mergeHeaders(provider.headers, {
|
||||
@@ -269,12 +420,50 @@ export const OpenAIPlugin = define({
|
||||
...(typeof account === "string" ? { "chatgpt-account-id": account } : {}),
|
||||
})
|
||||
})
|
||||
if (!sharing || !available || !source) return
|
||||
const updated = providers.get(Provider.ID.openai)
|
||||
if (!updated) return
|
||||
providers.add({
|
||||
info: updated.provider,
|
||||
models: deriveSharingModels(available, Array.from(updated.models.values())),
|
||||
sourceConnection: source,
|
||||
})
|
||||
})
|
||||
yield* ctx.model.transform((models) => {
|
||||
for (const model of models.list(Provider.ID.openai)) {
|
||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||
// subscription covers usage, so hide the rest and zero the cost.
|
||||
models.update(model.providerID, model.id, (draft) => {
|
||||
if (sharing) {
|
||||
draft.settings = { ...draft.settings, compaction: { type: "summary" } }
|
||||
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
const apiID = draft.modelID ?? draft.id
|
||||
if (available) {
|
||||
if (!available.some((remote) => remote.slug === apiID)) {
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
draft.cost = []
|
||||
return
|
||||
}
|
||||
// A connection saved before model discovery has a catalog fallback until reauthorization.
|
||||
const match = apiID.match(/^gpt-(\d+)(?:\.(\d+))?/)
|
||||
const major = Number(match?.[1])
|
||||
const minor = Number(match?.[2] ?? 0)
|
||||
if (
|
||||
!codexAllowed.has(apiID) &&
|
||||
(codexDisallowed.has(apiID) || !match || !(major > 5 || (major === 5 && minor > 4)))
|
||||
) {
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
draft.cost = []
|
||||
draft.limit = { ...draft.limit, context: 400_000, input: 272_000 }
|
||||
return
|
||||
}
|
||||
if (!chatgpt) return
|
||||
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
|
||||
draft.enabled = false
|
||||
@@ -326,6 +515,29 @@ export const OpenAIPlugin = define({
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.sleep(Duration.minutes(30)).pipe(
|
||||
Effect.andThen(
|
||||
loading.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (!sharing || !source || sharing.expires <= Date.now() + 60_000) return
|
||||
const models = yield* fetchSharingModels(sharing.access, ctx.app).pipe(
|
||||
Effect.timeout(15_000),
|
||||
Effect.catch(() => Effect.logWarning("failed to refresh ChatGPT models").pipe(Effect.as(undefined))),
|
||||
)
|
||||
if (!models || JSON.stringify(models) === JSON.stringify(available)) return
|
||||
if (
|
||||
IntegrationConnection.key(source) !==
|
||||
IntegrationConnection.key(yield* ctx.integration.connection.active("openai"))
|
||||
)
|
||||
return
|
||||
available = models
|
||||
yield* ctx.provider.reload()
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.forever,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
|
||||
@@ -428,3 +640,149 @@ function claim(token: string) {
|
||||
claims.organizations?.[0]?.id
|
||||
)
|
||||
}
|
||||
|
||||
function sharingExchange(code: string, clientID: string, redirect: string, pkce: Pkce, app: App.Info) {
|
||||
return request<SharingTokenResponse>(sharingTokenURL, {
|
||||
method: "POST",
|
||||
headers: headers("application/x-www-form-urlencoded", app),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: clientID,
|
||||
code,
|
||||
code_verifier: pkce.verifier,
|
||||
redirect_uri: redirect,
|
||||
resource: sharingResource,
|
||||
}).toString(),
|
||||
})
|
||||
}
|
||||
|
||||
function sharingRefresh(value: Credential.OAuth, app: App.Info) {
|
||||
return Effect.gen(function* () {
|
||||
const metadata = Option.getOrUndefined(decodeSharingMetadata(value.metadata))
|
||||
if (!metadata)
|
||||
return yield* Effect.fail(new Error("This ChatGPT connection has no registered client ID. Connect again."))
|
||||
const tokens = yield* request<SharingTokenResponse>(sharingTokenURL, {
|
||||
method: "POST",
|
||||
headers: headers("application/x-www-form-urlencoded", app),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: metadata.clientID,
|
||||
refresh_token: value.refresh,
|
||||
resource: sharingResource,
|
||||
}).toString(),
|
||||
})
|
||||
return sharingCredential(tokens, metadata.clientID, metadata.scopes, metadata.models)
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchSharingModels(token: string, app: App.Info, baseURL = sharingResource) {
|
||||
return request<unknown>(`${baseURL}/models`, {
|
||||
headers: { Authorization: `Bearer ${token}`, "User-Agent": App.useragent(app) },
|
||||
}).pipe(
|
||||
Effect.flatMap(decodeModels),
|
||||
Effect.flatMap((response) => {
|
||||
const models = response.models.filter((model) => model.visibility === "list" && model.supported_in_api)
|
||||
return models.length
|
||||
? Effect.succeed(models)
|
||||
: Effect.fail(new Error("No ChatGPT models are available for this account."))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function deriveSharingModels(remote: ReadonlyArray<RemoteModel>, existing: ReadonlyArray<Model.Info>) {
|
||||
const byID = new Map(remote.map((model) => [model.slug, model]))
|
||||
const known = new Set(existing.map((model) => model.id))
|
||||
const derive = (item: RemoteModel, previous: Model.Info): Model.Info => ({
|
||||
...previous,
|
||||
name: previous.id === previous.modelID ? item.display_name : previous.name,
|
||||
package: previous.package ?? "@opencode/ai/providers/openai",
|
||||
capabilities: {
|
||||
...previous.capabilities,
|
||||
input: item.input_modalities.filter((modality) => modality === "text" || modality === "image"),
|
||||
},
|
||||
variants: [
|
||||
...previous.variants.filter((variant) => typeof variant.settings?.reasoningEffort !== "string"),
|
||||
...(item.supported_reasoning_levels ?? []).map(
|
||||
({ effort }) =>
|
||||
previous.variants.find(
|
||||
(variant) => variant.id === effort && variant.settings?.reasoningEffort === effort,
|
||||
) ?? { id: Model.VariantID.make(effort), settings: { reasoningEffort: effort } },
|
||||
),
|
||||
],
|
||||
limit: { context: item.context_window, output: previous.limit.output },
|
||||
})
|
||||
return [
|
||||
...existing.flatMap((model) => {
|
||||
const found = byID.get(model.modelID)
|
||||
return found ? [derive(found, model)] : []
|
||||
}),
|
||||
...remote
|
||||
.filter((model) => !known.has(Model.ID.make(model.slug)))
|
||||
.map((model) => derive(model, Model.Info.default(Provider.ID.openai, Model.ID.make(model.slug)))),
|
||||
]
|
||||
}
|
||||
|
||||
export function verifySharingIDToken(
|
||||
token: string,
|
||||
clientID: string,
|
||||
nonce: string,
|
||||
jwksURL = new URL(`${issuer}/.well-known/jwks.json`),
|
||||
) {
|
||||
return Effect.tryPromise({
|
||||
try: async () => {
|
||||
const { createRemoteJWKSet, jwtVerify } = await import("jose")
|
||||
const { payload } = await jwtVerify(token, createRemoteJWKSet(jwksURL), {
|
||||
issuer,
|
||||
audience: clientID,
|
||||
algorithms: ["RS256"],
|
||||
requiredClaims: ["exp", "nonce", "sub"],
|
||||
})
|
||||
if (typeof payload.sub !== "string" || !payload.sub.trim()) throw new Error("ID token subject is missing")
|
||||
if (payload.nonce !== nonce) throw new Error("ID token nonce does not match this sign-in attempt")
|
||||
},
|
||||
catch: (cause) => new Error("ChatGPT sign-in returned an invalid ID token.", { cause }),
|
||||
})
|
||||
}
|
||||
|
||||
function sharingCredential(
|
||||
tokens: SharingTokenResponse,
|
||||
clientID: string,
|
||||
scopes?: ReadonlyArray<string>,
|
||||
models?: ReadonlyArray<RemoteModel>,
|
||||
) {
|
||||
return Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: sharingMethodID,
|
||||
refresh: tokens.refresh_token,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||
metadata: { clientID, scopes: tokens.scope?.split(" ").filter(Boolean) ?? scopes ?? [], models },
|
||||
})
|
||||
}
|
||||
|
||||
function randomValue() {
|
||||
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
}
|
||||
|
||||
function sharingAuthorizeURL(
|
||||
redirect: string,
|
||||
pkce: Pkce,
|
||||
state: string,
|
||||
nonce: string,
|
||||
savedID: string | undefined,
|
||||
hostID: string,
|
||||
) {
|
||||
return `${issuer}/api/accounts/authorize?${new URLSearchParams({
|
||||
client_id: savedID ?? sharingClientID,
|
||||
...(savedID ? {} : { agent_name_hint: "OpenCode" }),
|
||||
ext_agent_host_id: hostID,
|
||||
response_type: "code",
|
||||
redirect_uri: redirect,
|
||||
scope: `openid profile email offline_access resource.invoke ${sharingScope}`,
|
||||
resource: sharingResource,
|
||||
state,
|
||||
nonce,
|
||||
code_challenge_method: "S256",
|
||||
code_challenge: pkce.challenge,
|
||||
})}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { chmod, stat, writeFile } from "node:fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
// Read while the connection is open: SQLite may remove the sidecars on close.
|
||||
const openModes = (filename: string) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
yield* Layer.build(Database.layer({ path: filename }))
|
||||
return yield* Effect.promise(() =>
|
||||
Promise.all(["", "-wal", "-shm"].map(async (suffix) => (await stat(filename + suffix)).mode & 0o777)),
|
||||
)
|
||||
}).pipe(Effect.scoped, Effect.provideService(Global.Service, Global.make({ data: path.dirname(filename) }))),
|
||||
)
|
||||
|
||||
describe.skipIf(process.platform === "win32")("Database file permissions", () => {
|
||||
test("creates the database and sidecars readable only by the owner", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
expect(await openModes(path.join(tmp.path, "opencode.db"))).toEqual([0o600, 0o600, 0o600])
|
||||
})
|
||||
|
||||
test("tightens an existing database and sidecars", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "opencode.db")
|
||||
await Promise.all(
|
||||
["", "-wal", "-shm"].map(async (suffix) => {
|
||||
await writeFile(filename + suffix, "")
|
||||
await chmod(filename + suffix, 0o644)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await openModes(filename)).toEqual([0o600, 0o600, 0o600])
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,9 @@ import { Session } from "@opencode/core/session"
|
||||
import { OpenAIResponses } from "@opencode/ai/protocols/openai-responses"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, DateTime, Effect } from "effect"
|
||||
import { exportJWK, generateKeyPair, SignJWT } from "jose"
|
||||
import { Credential } from "@opencode/core/credential"
|
||||
import { App } from "@opencode/core/app"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { Model } from "@opencode/core/model"
|
||||
@@ -12,7 +14,7 @@ import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { GithubCopilotPlugin } from "@opencode/core/plugin/provider/github-copilot"
|
||||
import { OpenAIPlugin } from "@opencode/core/plugin/provider/openai"
|
||||
import { OpenAIPlugin, fetchSharingModels, verifySharingIDToken } from "@opencode/core/plugin/provider/openai"
|
||||
import { Project } from "@opencode/core/project"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
@@ -65,11 +67,16 @@ const request = Effect.fn(function* (
|
||||
})
|
||||
|
||||
describe("OpenAIPlugin", () => {
|
||||
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
|
||||
it.effect("registers ChatGPT token sharing without replacing either Codex OAuth method", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
expect((yield* integrations.get(Integration.ID.make("openai")))?.methods).toEqual([
|
||||
{
|
||||
id: Integration.MethodID.make("chatgpt-token-sharing"),
|
||||
type: "oauth",
|
||||
label: "Sign in with ChatGPT",
|
||||
},
|
||||
{
|
||||
id: Integration.MethodID.make("chatgpt-browser"),
|
||||
type: "oauth",
|
||||
@@ -84,6 +91,153 @@ describe("OpenAIPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("registers a user-owned ChatGPT agent with a separate IPv4 callback", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const signIn = yield* integrations.oauth.connect({
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
methodID: Integration.MethodID.make("chatgpt-token-sharing"),
|
||||
})
|
||||
const url = new URL(signIn.url)
|
||||
const callback = new URL(url.searchParams.get("redirect_uri") ?? "")
|
||||
expect(`${url.origin}${url.pathname}`).toBe("https://auth.openai.com/api/accounts/authorize")
|
||||
expect(url.searchParams.get("client_id")).toBe("dynamic_agent_client")
|
||||
expect(url.searchParams.get("scope")).toContain("chatgpt.tokens.use.direct")
|
||||
expect(url.searchParams.get("ext_agent_host_id")).toMatch(/^urn:uuid:/)
|
||||
expect(url.searchParams.get("nonce")).toBeTruthy()
|
||||
expect(callback.hostname).toBe("127.0.0.1")
|
||||
expect(Number(callback.port)).toBeGreaterThan(0)
|
||||
yield* integrations.oauth.cancel({ integrationID: Integration.ID.make("openai"), attemptID: signIn.attemptID })
|
||||
|
||||
const codex = yield* integrations.oauth.connect({
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
methodID: Integration.MethodID.make("chatgpt-browser"),
|
||||
})
|
||||
expect(new URL(codex.url).pathname).toBe("/oauth/authorize")
|
||||
yield* integrations.oauth.cancel({ integrationID: Integration.ID.make("openai"), attemptID: codex.attemptID })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("discovers only visible ChatGPT Responses models", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: () =>
|
||||
Response.json({
|
||||
models: [
|
||||
{
|
||||
slug: "gpt-visible",
|
||||
display_name: "Visible",
|
||||
visibility: "list",
|
||||
supported_in_api: true,
|
||||
context_window: 272_000,
|
||||
input_modalities: ["text"],
|
||||
},
|
||||
{
|
||||
slug: "gpt-hidden",
|
||||
display_name: "Hidden",
|
||||
visibility: "hide",
|
||||
supported_in_api: true,
|
||||
context_window: 272_000,
|
||||
input_modalities: ["text"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
(server) => Effect.sync(() => server.stop()),
|
||||
)
|
||||
expect(
|
||||
(yield* fetchSharingModels("test-token", App.make(), `http://127.0.0.1:${server.port}`)).map(
|
||||
(model) => model.slug,
|
||||
),
|
||||
).toEqual(["gpt-visible"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("verifies the ChatGPT ID token signature, client ID, and sign-in nonce", () =>
|
||||
Effect.gen(function* () {
|
||||
const keys = yield* Effect.promise(() => generateKeyPair("RS256"))
|
||||
const publicKey = yield* Effect.promise(() => exportJWK(keys.publicKey))
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => Response.json({ keys: [{ ...publicKey, kid: "sign-in", alg: "RS256", use: "sig" }] }),
|
||||
}),
|
||||
),
|
||||
(server) => Effect.sync(() => server.stop()),
|
||||
)
|
||||
const jwksURL = new URL(`http://127.0.0.1:${server.port}/jwks`)
|
||||
const sign = (nonce: string, audience: string) =>
|
||||
new SignJWT({ nonce })
|
||||
.setProtectedHeader({ alg: "RS256", kid: "sign-in" })
|
||||
.setIssuer("https://auth.openai.com")
|
||||
.setAudience(audience)
|
||||
.setSubject("user_123")
|
||||
.setExpirationTime(Math.floor(Date.now() / 1000) + 60)
|
||||
.sign(keys.privateKey)
|
||||
const valid = yield* Effect.promise(() => sign("expected", "oaiapp_issued"))
|
||||
yield* verifySharingIDToken(valid, "oaiapp_issued", "expected", jwksURL)
|
||||
expect(yield* Effect.flip(verifySharingIDToken(valid, "wrong-client", "expected", jwksURL))).toBeInstanceOf(Error)
|
||||
expect(yield* Effect.flip(verifySharingIDToken(valid, "oaiapp_issued", "wrong-nonce", jwksURL))).toBeInstanceOf(
|
||||
Error,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses account-visible models and HTTP without changing Codex routing", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
const credentials = yield* Credential.Service
|
||||
yield* catalog.transform((editor) => {
|
||||
editor.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5"), () => {})
|
||||
editor.models.update(Provider.ID.openai, Model.ID.make("gpt-4.1"), () => {})
|
||||
})
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("chatgpt-token-sharing"),
|
||||
access: "sharing-token",
|
||||
refresh: "sharing-refresh",
|
||||
expires: Date.now() + 60 * 60_000,
|
||||
metadata: {
|
||||
clientID: "oaiapp_issued",
|
||||
models: [
|
||||
{
|
||||
slug: "gpt-4.1",
|
||||
display_name: "GPT-4.1",
|
||||
visibility: "list",
|
||||
supported_in_api: true,
|
||||
context_window: 128_000,
|
||||
input_modalities: ["text", "image"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
})
|
||||
yield* addPlugin()
|
||||
const provider = required(yield* catalog.get(Provider.ID.openai))
|
||||
expect(provider.settings?.transport).toBe("http")
|
||||
expect(provider.settings?.baseURL).toBeUndefined()
|
||||
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
|
||||
expect((yield* request(Provider.ID.openai, "https://api.openai.com/v1")).baseURL).toBe(
|
||||
"https://api.openai.com/v1",
|
||||
)
|
||||
expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-4.1")))).toMatchObject({
|
||||
enabled: true,
|
||||
cost: [],
|
||||
limit: { context: 128_000 },
|
||||
})
|
||||
expect(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
|
||||
@@ -58,7 +58,17 @@ export function integrationOptions(list: IntegrationInfo[]) {
|
||||
export function connectMethods(integration: IntegrationInfo): ConnectMethod[] {
|
||||
return integration.methods
|
||||
.filter((method): method is ConnectMethod => method.type !== "env")
|
||||
.toSorted((a, b) => Number(a.type === "key") - Number(b.type === "key"))
|
||||
.toSorted((a, b) => {
|
||||
if (integration.id !== "openai") return Number(a.type === "key") - Number(b.type === "key")
|
||||
const rank = (method: ConnectMethod) => {
|
||||
if (method.type === "key") return 1
|
||||
if (method.id === "chatgpt-token-sharing") return 0
|
||||
if (method.id === "chatgpt-browser") return 2
|
||||
if (method.id === "chatgpt-headless") return 3
|
||||
return 4
|
||||
}
|
||||
return rank(a) - rank(b)
|
||||
})
|
||||
}
|
||||
|
||||
export function credentialConnections(integration: IntegrationInfo) {
|
||||
|
||||
@@ -41,6 +41,23 @@ describe("integrationOptions", () => {
|
||||
})
|
||||
|
||||
describe("connectMethods", () => {
|
||||
test("orders OpenAI sign-in, API key, and both Codex methods", () => {
|
||||
expect(
|
||||
connectMethods(
|
||||
integration({
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [
|
||||
{ type: "oauth", id: "chatgpt-headless", label: "Codex headless" },
|
||||
{ type: "key", label: "API key" },
|
||||
{ type: "oauth", id: "chatgpt-browser", label: "Codex browser" },
|
||||
{ type: "oauth", id: "chatgpt-token-sharing", label: "Sign in with ChatGPT" },
|
||||
],
|
||||
}),
|
||||
).map((method) => (method.type === "key" ? "key" : method.id)),
|
||||
).toEqual(["chatgpt-token-sharing", "key", "chatgpt-browser", "chatgpt-headless"])
|
||||
})
|
||||
|
||||
test("offers key and OAuth methods but not environment discovery", () => {
|
||||
expect(
|
||||
connectMethods(
|
||||
|
||||
Reference in New Issue
Block a user