mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(core): add separate ChatGPT token-sharing OAuth method (#52147)
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",
|
||||
|
||||
@@ -4996,6 +4996,42 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains the token-sharing HTTP 429 body for retry hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = JSON.stringify({ error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" } })
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(body, { status: 429, headers: { "content-type": "application/json" } }),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "RateLimit", http: { status: 429 }, body } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains the token-sharing response.failed event for retry hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
type: "response.failed",
|
||||
response: {
|
||||
id: "resp_usage_limit",
|
||||
error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "RateLimit" } })
|
||||
expect(error.reason.body).toContain("subscription_sharing_usage_limit_exceeded")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("surfaces response.failed code when no nested message is present", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AmazonBedrockPlugin } from "./provider/amazon-bedrock.js"
|
||||
import { AzurePlugin } from "./provider/azure.js"
|
||||
import { CerebrasPlugin } from "./provider/cerebras.js"
|
||||
import { ChatGPTPlugin } from "./provider/chatgpt.js"
|
||||
import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway.js"
|
||||
import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai.js"
|
||||
import { CoherePlugin } from "./provider/cohere.js"
|
||||
@@ -35,6 +36,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
AmazonBedrockPlugin,
|
||||
AzurePlugin,
|
||||
CerebrasPlugin,
|
||||
ChatGPTPlugin,
|
||||
CloudflareAIGatewayPlugin,
|
||||
CloudflareWorkersAIPlugin,
|
||||
CoherePlugin,
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
|
||||
import type { Context } from "@opencode/plugin/effect/plugin"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Deferred, Duration, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
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 type { PluginInternal } from "../internal.js"
|
||||
|
||||
// First-time sign-in registers a user-owned client; OpenAI returns its issued client ID on the callback.
|
||||
const registrationClientID = "dynamic_agent_client"
|
||||
const agentName = "OpenCode"
|
||||
const issuer = "https://auth.openai.com"
|
||||
const tokenURL = `${issuer}/api/accounts/oauth/token`
|
||||
const resource = "https://api.openai.com/v1"
|
||||
const tokenSharingScope = "chatgpt.tokens.use.direct"
|
||||
const providerID = Provider.ID.openai
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
const methodID = Integration.MethodID.make("chatgpt-token-sharing")
|
||||
const modelCacheKey = (clientID: string) => `models:${clientID}`
|
||||
const fallbackModels = new Set([
|
||||
"gpt-5.5",
|
||||
"gpt-5.5-fast",
|
||||
"gpt-5.6-luna",
|
||||
"gpt-5.6-luna-fast",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.6-sol-fast",
|
||||
"gpt-5.6-terra",
|
||||
"gpt-5.6-terra-fast",
|
||||
"gpt-6-astra",
|
||||
"gpt-6-astra-fast",
|
||||
])
|
||||
const nonRetryableSharingCodes = [
|
||||
"subscription_sharing_usage_limit_exceeded",
|
||||
"subscription_sharing_v2_user_not_eligible",
|
||||
"subscription_sharing_unsupported_capability",
|
||||
"subscription_sharing_v2_client_not_enabled",
|
||||
"subscription_sharing_v2_route_not_supported",
|
||||
"subscription_sharing_v2_invalid_user",
|
||||
]
|
||||
type Pkce = {
|
||||
verifier: string
|
||||
challenge: string
|
||||
}
|
||||
|
||||
type TokenResponse = {
|
||||
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 RemoteModels = Schema.Struct({ models: Schema.Array(RemoteModel) })
|
||||
const decodeCachedModels = Schema.decodeUnknownOption(Schema.Array(RemoteModel))
|
||||
|
||||
// Credential metadata is merged into provider settings; these keys are not OpenAI request options.
|
||||
const decodeMetadata = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
clientID: Schema.String,
|
||||
scopes: Schema.optional(Schema.Array(Schema.String)),
|
||||
models: Schema.optional(Schema.Array(RemoteModel)),
|
||||
}),
|
||||
)
|
||||
|
||||
const signIn = (app: App.Info, savedClientID: () => string | undefined, storage: Context["storage"]) =>
|
||||
({
|
||||
integrationID,
|
||||
method: {
|
||||
id: methodID,
|
||||
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>()
|
||||
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
|
||||
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 authorizationCode = 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 (!authorizationCode || url.searchParams.get("state") !== state) {
|
||||
const message = authorizationCode ? "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: authorizationCode,
|
||||
clientID: url.searchParams.get("client_id") ?? undefined,
|
||||
response,
|
||||
}),
|
||||
)
|
||||
)
|
||||
response.writeHead(409).end("OAuth callback already received")
|
||||
})
|
||||
const port = yield* listen(server)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
const redirect = `http://127.0.0.1:${port}/auth/callback`
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: authorizeURL(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* () {
|
||||
// Reauthorization callbacks may omit the client ID; reuse the one this attempt started with.
|
||||
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* exchange(result.code, clientID, redirect, pkce, app)
|
||||
if (!tokens.scope?.split(" ").includes(tokenSharingScope))
|
||||
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* verifyIDToken(tokens.id_token, clientID, nonce)
|
||||
return credential(tokens, clientID)
|
||||
}).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) => refresh(value, app),
|
||||
}) satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
function listen(server: Server) {
|
||||
return 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))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const ChatGPTPlugin = define({
|
||||
id: "opencode.provider.chatgpt",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const bus = yield* Bus.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let chatgpt: Credential.OAuth | undefined
|
||||
let available: ReadonlyArray<RemoteModel> | undefined
|
||||
let source: Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
|
||||
|
||||
const load = Effect.fn("ChatGPTPlugin.load")(function* () {
|
||||
const previous = IntegrationConnection.key(source)
|
||||
const connection = yield* ctx.integration.connection.active(integrationID)
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
chatgpt = credential?.type === "oauth" && credential.methodID === methodID ? credential : undefined
|
||||
source = chatgpt ? connection : undefined
|
||||
if (previous !== IntegrationConnection.key(source)) {
|
||||
const metadata = Option.getOrUndefined(decodeMetadata(chatgpt?.metadata))
|
||||
const stored = metadata ? yield* ctx.storage.get(modelCacheKey(metadata.clientID)) : undefined
|
||||
available = Option.getOrUndefined(decodeCachedModels(stored))
|
||||
}
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
editor.method.update(
|
||||
signIn(ctx.app, () => Option.getOrUndefined(decodeMetadata(chatgpt?.metadata))?.clientID, ctx.storage),
|
||||
)
|
||||
})
|
||||
yield* Effect.forEach(
|
||||
yield* credentials.list(Integration.ID.make("openai")),
|
||||
(entry) => {
|
||||
const value = entry.value
|
||||
if (value.type !== "oauth" || value.methodID !== Integration.MethodID.make("chatgpt-browser"))
|
||||
return Effect.void
|
||||
const metadata = Option.getOrUndefined(decodeMetadata(value.metadata))
|
||||
if (!metadata) return Effect.void
|
||||
return Effect.gen(function* () {
|
||||
const saved = yield* ctx.storage.get(modelCacheKey(metadata.clientID))
|
||||
if (!Option.isSome(decodeCachedModels(saved)) && metadata.models)
|
||||
yield* ctx.storage.set(modelCacheKey(metadata.clientID), metadata.models)
|
||||
const { models: _, ...rest } = value.metadata ?? {}
|
||||
yield* credentials.update(entry.id, {
|
||||
value: Credential.OAuth.make({ ...value, methodID, metadata: rest }),
|
||||
})
|
||||
})
|
||||
},
|
||||
{ discard: true },
|
||||
)
|
||||
yield* load()
|
||||
yield* ctx.session.hook(
|
||||
"retry",
|
||||
(event) =>
|
||||
Effect.sync(() => {
|
||||
if (!chatgpt || !nonRetryableSharingCodes.some((code) => event.error.response?.body.includes(code))) return
|
||||
event.decision = { retry: false }
|
||||
}),
|
||||
{ providerID },
|
||||
)
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
const item = providers.get(providerID)
|
||||
if (!item) return
|
||||
if (!chatgpt || !source) return
|
||||
providers.update(providerID, (provider) => {
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, {
|
||||
baseURL: resource,
|
||||
transport: "http",
|
||||
compaction: { type: "summary" },
|
||||
})
|
||||
})
|
||||
const updated = providers.get(providerID)
|
||||
if (!updated) return
|
||||
providers.add({
|
||||
info: updated.provider,
|
||||
models: available
|
||||
? deriveModels(available, Array.from(item.models.values()))
|
||||
: Array.from(item.models.values()).filter((model) => fallbackModels.has(model.id)),
|
||||
sourceConnection: source,
|
||||
})
|
||||
})
|
||||
yield* ctx.model.transform((models) => {
|
||||
for (const model of models.list(providerID)) {
|
||||
models.update(model.providerID, model.id, (draft) => {
|
||||
if (!chatgpt) return
|
||||
// Token sharing does not support native /responses/compact.
|
||||
draft.settings = { ...draft.settings, compaction: { type: "summary" } }
|
||||
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
if (available && !available.some((remote) => remote.slug === (draft.modelID ?? draft.id))) {
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
draft.cost = []
|
||||
})
|
||||
}
|
||||
})
|
||||
const refreshModels = Effect.fn("ChatGPTPlugin.refreshModels")(function* () {
|
||||
const connection = source
|
||||
const credential =
|
||||
connection?.type === "credential"
|
||||
? (yield* credentials.get(Credential.ID.make(connection.id)))?.value
|
||||
: undefined
|
||||
if (credential?.type !== "oauth" || credential.methodID !== methodID || credential.expires <= Date.now() + 60_000)
|
||||
return
|
||||
const metadata = Option.getOrUndefined(decodeMetadata(credential.metadata))
|
||||
if (!metadata) return
|
||||
const models = yield* fetchModels(credential.access, ctx.app).pipe(
|
||||
Effect.timeout(15_000),
|
||||
Effect.catch(() => Effect.logWarning("failed to refresh ChatGPT models").pipe(Effect.as(undefined))),
|
||||
)
|
||||
if (!models) return
|
||||
yield* loading.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (
|
||||
IntegrationConnection.key(connection) !== IntegrationConnection.key(source) ||
|
||||
IntegrationConnection.key(connection) !==
|
||||
IntegrationConnection.key(yield* ctx.integration.connection.active(integrationID))
|
||||
)
|
||||
return
|
||||
if (JSON.stringify(models) === JSON.stringify(available)) return
|
||||
yield* ctx.storage.set(modelCacheKey(metadata.clientID), models)
|
||||
available = models
|
||||
yield* ctx.provider.reload()
|
||||
}),
|
||||
)
|
||||
})
|
||||
const reload = () =>
|
||||
loading
|
||||
.withPermit(load().pipe(Effect.andThen(ctx.provider.reload())))
|
||||
.pipe(Effect.andThen(refreshModels().pipe(Effect.forkScoped, Effect.asVoid)))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === integrationID),
|
||||
Stream.runForEach(reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refreshModels().pipe(Effect.forkScoped)
|
||||
yield* Effect.sleep(Duration.minutes(15)).pipe(Effect.andThen(refreshModels), Effect.forever, Effect.forkScoped)
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
|
||||
function headers(app: App.Info) {
|
||||
return { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": App.useragent(app) }
|
||||
}
|
||||
|
||||
function exchange(code: string, clientID: string, redirect: string, pkce: Pkce, app: App.Info) {
|
||||
return request<TokenResponse>(tokenURL, {
|
||||
method: "POST",
|
||||
headers: headers(app),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: clientID,
|
||||
code,
|
||||
code_verifier: pkce.verifier,
|
||||
redirect_uri: redirect,
|
||||
resource,
|
||||
}).toString(),
|
||||
})
|
||||
}
|
||||
|
||||
function refresh(value: Credential.OAuth, app: App.Info) {
|
||||
return Effect.gen(function* () {
|
||||
const metadata = Option.getOrUndefined(decodeMetadata(value.metadata))
|
||||
if (!metadata)
|
||||
return yield* Effect.fail(new Error("This ChatGPT connection has no registered client ID. Connect again."))
|
||||
const tokens = yield* request<TokenResponse>(tokenURL, {
|
||||
method: "POST",
|
||||
headers: headers(app),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: metadata.clientID,
|
||||
refresh_token: value.refresh,
|
||||
resource,
|
||||
}).toString(),
|
||||
})
|
||||
return credential(tokens, metadata.clientID, metadata.scopes)
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchModels(token: string, app: App.Info, baseURL = resource) {
|
||||
return Effect.gen(function* () {
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const response = yield* http.execute(
|
||||
HttpClientRequest.get(`${baseURL}/models`).pipe(
|
||||
HttpClientRequest.bearerToken(token),
|
||||
HttpClientRequest.setHeader("User-Agent", App.useragent(app)),
|
||||
),
|
||||
)
|
||||
const data = yield* HttpClientResponse.schemaBodyJson(RemoteModels)(response)
|
||||
const models = data.models.filter((model) => model.visibility === "list" && model.supported_in_api)
|
||||
if (!models.length) return yield* Effect.fail(new Error("No ChatGPT models are available for this account."))
|
||||
return models
|
||||
})
|
||||
}
|
||||
|
||||
export function deriveModels(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))
|
||||
return [
|
||||
...existing.flatMap((model) => {
|
||||
const found = byID.get(model.modelID)
|
||||
return found ? [deriveModel(found, model)] : []
|
||||
}),
|
||||
...remote
|
||||
.filter((model) => !known.has(Model.ID.make(model.slug)))
|
||||
.map((model) => deriveModel(model, Model.Info.default(Provider.ID.openai, Model.ID.make(model.slug)))),
|
||||
]
|
||||
}
|
||||
|
||||
function deriveModel(remote: RemoteModel, previous: Model.Info): Model.Info {
|
||||
return {
|
||||
...previous,
|
||||
name: previous.id === previous.modelID ? remote.display_name : previous.name,
|
||||
package: previous.package ?? "@opencode/ai/providers/openai",
|
||||
capabilities: {
|
||||
...previous.capabilities,
|
||||
input: remote.input_modalities.filter((modality) => modality === "text" || modality === "image"),
|
||||
},
|
||||
variants: [
|
||||
...previous.variants.filter((variant) => typeof variant.settings?.reasoningEffort !== "string"),
|
||||
...(remote.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: remote.context_window, output: previous.limit.output },
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyIDToken(
|
||||
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 request<A>(url: string, init: RequestInit) {
|
||||
return Effect.tryPromise({
|
||||
try: async (signal) => {
|
||||
const response = await fetch(url, { ...init, signal })
|
||||
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
|
||||
return response.json() as Promise<A>
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
function credential(tokens: TokenResponse, clientID: string, scopes?: ReadonlyArray<string>) {
|
||||
return Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
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 ?? [] },
|
||||
})
|
||||
}
|
||||
|
||||
async function generatePKCE(): Promise<Pkce> {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("")
|
||||
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function randomValue() {
|
||||
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
}
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer) {
|
||||
return Buffer.from(buffer).toString("base64url")
|
||||
}
|
||||
|
||||
function authorizeURL(
|
||||
redirect: string,
|
||||
pkce: Pkce,
|
||||
state: string,
|
||||
nonce: string,
|
||||
savedID: string | undefined,
|
||||
hostID: string,
|
||||
) {
|
||||
return `${issuer}/api/accounts/authorize?${new URLSearchParams({
|
||||
client_id: savedID ?? registrationClientID,
|
||||
...(savedID ? {} : { agent_name_hint: agentName }),
|
||||
ext_agent_host_id: hostID,
|
||||
// Enable only for user-requested consent retries after OpenAI confirms deployment;
|
||||
// ordinary sign-ins must not force reconsent.
|
||||
// force_reconsent: "true",
|
||||
response_type: "code",
|
||||
redirect_uri: redirect,
|
||||
scope: `openid profile email offline_access resource.invoke ${tokenSharingScope}`,
|
||||
resource,
|
||||
state,
|
||||
nonce,
|
||||
code_challenge_method: "S256",
|
||||
code_challenge: pkce.challenge,
|
||||
})}`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user