diff --git a/packages/core/src/plugin/provider/chatgpt.ts b/packages/core/src/plugin/provider/chatgpt.ts index d09c647b7876..0c3c58fc064a 100644 --- a/packages/core/src/plugin/provider/chatgpt.ts +++ b/packages/core/src/plugin/provider/chatgpt.ts @@ -1,14 +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 { Deferred, Duration, Effect, Option, Schema, Semaphore, Stream } from "effect" +// import { Deferred, Duration, Effect, Option, Schema, Semaphore, Stream } from "effect" +import { Deferred, 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 { IntegrationConnection } from "../../integration/connection.js" import { Model } from "../../model.js" import { OauthCallbackPage } from "../../oauth/page.js" import { Provider } from "../../provider.js" @@ -36,6 +37,12 @@ const fallbackModels = new Set([ "gpt-5.6-terra-fast", "gpt-6-astra", "gpt-6-astra-fast", + "gpt-6-luna", + "gpt-6-luna-fast", + "gpt-6-sol", + "gpt-6-sol-fast", + "gpt-6.1-sol", + "gpt-6.1-sol-fast", ]) const nonRetryableSharingCodes = [ "subscription_sharing_usage_limit_exceeded", @@ -204,22 +211,22 @@ export const ChatGPTPlugin = define({ const credentials = yield* Credential.Service const loading = Semaphore.makeUnsafe(1) let chatgpt: Credential.OAuth | undefined - let available: ReadonlyArray | undefined + // let available: ReadonlyArray | undefined let source: Effect.Success> const load = Effect.fn("ChatGPTPlugin.load")(function* () { - const previous = IntegrationConnection.key(source) + // 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)) - } + // 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) => { @@ -272,9 +279,10 @@ export const ChatGPTPlugin = define({ 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)), + // models: available + // ? deriveModels(available, Array.from(item.models.values())) + // : Array.from(item.models.values()).filter((model) => fallbackModels.has(model.id)), + models: Array.from(item.models.values()).filter((model) => fallbackModels.has(model.id)), sourceConnection: source, }) }) @@ -288,55 +296,53 @@ export const ChatGPTPlugin = define({ draft.enabled = false return } - if (available && !available.some((remote) => remote.slug === (draft.modelID ?? draft.id))) { - 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))) + // 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) + // yield* refreshModels().pipe(Effect.forkScoped) + // yield* Effect.sleep(Duration.minutes(15)).pipe(Effect.andThen(refreshModels), Effect.forever, Effect.forkScoped) }), } satisfies PluginInternal.InternalPlugin) diff --git a/packages/core/test/plugin/provider-chatgpt.test.ts b/packages/core/test/plugin/provider-chatgpt.test.ts index e8e79c8db719..f74ac8c83f5e 100644 --- a/packages/core/test/plugin/provider-chatgpt.test.ts +++ b/packages/core/test/plugin/provider-chatgpt.test.ts @@ -5,7 +5,8 @@ import { OpenAIResponses } from "@opencode/ai/protocols/openai-responses" import { AIError, HttpContext, RateLimitError } from "@opencode/ai" import { classifyProviderFailure } from "@opencode/ai/provider-error" import { describe, expect } from "bun:test" -import { DateTime, Deferred, Effect, Schedule } from "effect" +// import { DateTime, Deferred, Effect, Schedule } from "effect" +import { DateTime, Effect, Schedule } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { exportJWK, generateKeyPair, SignJWT } from "jose" import { App } from "@opencode/core/app" @@ -540,149 +541,149 @@ describe("ChatGPTPlugin", () => { }), ) - it.effect("merges account models with catalog metadata and leaves input budgeting to compaction", () => - Effect.gen(function* () { - const catalog = yield* Provider.Service - const models = yield* Model.Service - const credentials = yield* Credential.Service - const kv = yield* KV.Service - yield* catalog.transform((editor) => { - editor.update(Provider.ID.openai, (provider) => { - provider.package = "@opencode/ai/providers/openai" - }) - editor.models.update(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"), (model) => { - model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } - model.capabilities = { tools: true, input: ["text", "image", "pdf"], output: ["text"] } - model.variants = [ - { id: Model.VariantID.make("low"), settings: { reasoningEffort: "low" } }, - { id: Model.VariantID.make("high"), settings: { reasoningEffort: "high" } }, - ] - }) - editor.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5"), () => {}) - }) - yield* PluginHost.storage(kv, ChatGPTPlugin.id).set("models:oaiapp_issued", [ - { - slug: "gpt-5.6-sol", - display_name: "GPT-5.6-Sol", - visibility: "list", - supported_in_api: true, - context_window: 272_000, - input_modalities: ["text", "image"], - supported_reasoning_levels: [{ effort: "low" }, { effort: "ultra" }], - }, - { - slug: "gpt-6-future", - display_name: "GPT-6-Future", - visibility: "list", - supported_in_api: true, - context_window: 272_000, - input_modalities: ["text"], - supported_reasoning_levels: [], - }, - ]) - yield* credentials.create({ - integrationID: Integration.ID.make("openai"), - value: Credential.OAuth.make({ - type: "oauth", - methodID: Integration.MethodID.make("chatgpt-token-sharing"), - access: "chatgpt-token", - refresh: "refresh", - expires: Date.now() + 60 * 60_000, - metadata: { clientID: "oaiapp_issued" }, - }), - }) - yield* addPlugin() - - const available = (yield* models.available()).filter((model) => model.providerID === Provider.ID.openai) - expect(available.map((model) => model.id).sort()).toEqual([ - Model.ID.make("gpt-5.6-sol"), - Model.ID.make("gpt-6-future"), - ]) - const sol = required(available.find((model) => model.id === "gpt-5.6-sol")) - expect(sol.name).toBe("GPT-5.6-Sol") - expect(sol.capabilities.input).toEqual(["text", "image"]) - expect(sol.variants.map((variant) => variant.id)).toEqual([ - Model.VariantID.make("low"), - Model.VariantID.make("ultra"), - ]) - expect(sol.limit).toEqual({ context: 272_000, output: 128_000 }) - expect(sol.cost).toEqual([]) - expect(sol.settings?.compaction).toEqual({ type: "summary" }) - const future = required(available.find((model) => model.id === "gpt-6-future")) - expect(future.name).toBe("GPT-6-Future") - expect(future.package).toBe("@opencode/ai/providers/openai") - expect(future.limit).toEqual({ context: 272_000, output: 32_000 }) - }), - ) - - it.live("shows catalog models while account models refresh in the background and saves them to KV", () => - Effect.gen(function* () { - const catalog = yield* Provider.Service - const models = yield* Model.Service - const credentials = yield* Credential.Service - const kv = yield* KV.Service - yield* catalog.transform((editor) => { - editor.update(Provider.ID.openai, (provider) => { - provider.package = "@opencode/ai/providers/openai" - }) - for (const id of ["gpt-5.5", "gpt-6-astra", "gpt-6-sol"]) - editor.models.update(Provider.ID.openai, Model.ID.make(id), () => {}) - }) - const requested = yield* Deferred.make() - const release = yield* Deferred.make() - const remote = [ - { - slug: "gpt-6-future", - display_name: "GPT-6-Future", - visibility: "list", - supported_in_api: true, - context_window: 272_000, - input_modalities: ["text"], - }, - ] - const http = HttpClient.make((request) => - Effect.gen(function* () { - expect(request.url).toBe("https://api.openai.com/v1/models") - expect(request.headers.authorization).toBe("Bearer chatgpt-token") - yield* Deferred.succeed(requested, undefined) - yield* Deferred.await(release) - return HttpClientResponse.fromWeb(request, Response.json({ models: remote })) - }), - ) - yield* addPlugin(http) - yield* credentials.create({ - integrationID: Integration.ID.make("openai"), - value: Credential.OAuth.make({ - type: "oauth", - methodID: Integration.MethodID.make("chatgpt-token-sharing"), - access: "chatgpt-token", - refresh: "refresh", - expires: Date.now() + 60 * 60_000, - metadata: { clientID: "oaiapp_issued" }, - }), - }) - yield* Deferred.await(requested).pipe(Effect.timeout("2 seconds")) - expect((yield* models.available()).filter((model) => model.providerID === Provider.ID.openai).map((model) => model.id)).toEqual([ - Model.ID.make("gpt-5.5"), - Model.ID.make("gpt-6-astra"), - ]) - const storage = PluginHost.storage(kv, ChatGPTPlugin.id) - expect(yield* storage.get("models:oaiapp_issued")).toBeUndefined() - - yield* Deferred.succeed(release, undefined) - const available = yield* models.available().pipe( - Effect.repeat({ - until: (current) => current.some((model) => model.providerID === Provider.ID.openai && model.id === "gpt-6-future"), - schedule: Schedule.spaced("10 millis"), - }), - Effect.timeout("2 seconds"), - ) - expect(available.filter((model) => model.providerID === Provider.ID.openai).map((model) => model.id)).toEqual([ - Model.ID.make("gpt-6-future"), - ]) - expect(yield* storage.get("models:oaiapp_issued")).toEqual(remote) - }), - ) + // it.effect("merges account models with catalog metadata and leaves input budgeting to compaction", () => + // Effect.gen(function* () { + // const catalog = yield* Provider.Service + // const models = yield* Model.Service + // const credentials = yield* Credential.Service + // const kv = yield* KV.Service + // yield* catalog.transform((editor) => { + // editor.update(Provider.ID.openai, (provider) => { + // provider.package = "@opencode/ai/providers/openai" + // }) + // editor.models.update(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"), (model) => { + // model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } + // model.capabilities = { tools: true, input: ["text", "image", "pdf"], output: ["text"] } + // model.variants = [ + // { id: Model.VariantID.make("low"), settings: { reasoningEffort: "low" } }, + // { id: Model.VariantID.make("high"), settings: { reasoningEffort: "high" } }, + // ] + // }) + // editor.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5"), () => {}) + // }) + // yield* PluginHost.storage(kv, ChatGPTPlugin.id).set("models:oaiapp_issued", [ + // { + // slug: "gpt-5.6-sol", + // display_name: "GPT-5.6-Sol", + // visibility: "list", + // supported_in_api: true, + // context_window: 272_000, + // input_modalities: ["text", "image"], + // supported_reasoning_levels: [{ effort: "low" }, { effort: "ultra" }], + // }, + // { + // slug: "gpt-6-future", + // display_name: "GPT-6-Future", + // visibility: "list", + // supported_in_api: true, + // context_window: 272_000, + // input_modalities: ["text"], + // supported_reasoning_levels: [], + // }, + // ]) + // yield* credentials.create({ + // integrationID: Integration.ID.make("openai"), + // value: Credential.OAuth.make({ + // type: "oauth", + // methodID: Integration.MethodID.make("chatgpt-token-sharing"), + // access: "chatgpt-token", + // refresh: "refresh", + // expires: Date.now() + 60 * 60_000, + // metadata: { clientID: "oaiapp_issued" }, + // }), + // }) + // yield* addPlugin() + // + // const available = (yield* models.available()).filter((model) => model.providerID === Provider.ID.openai) + // expect(available.map((model) => model.id).sort()).toEqual([ + // Model.ID.make("gpt-5.6-sol"), + // Model.ID.make("gpt-6-future"), + // ]) + // const sol = required(available.find((model) => model.id === "gpt-5.6-sol")) + // expect(sol.name).toBe("GPT-5.6-Sol") + // expect(sol.capabilities.input).toEqual(["text", "image"]) + // expect(sol.variants.map((variant) => variant.id)).toEqual([ + // Model.VariantID.make("low"), + // Model.VariantID.make("ultra"), + // ]) + // expect(sol.limit).toEqual({ context: 272_000, output: 128_000 }) + // expect(sol.cost).toEqual([]) + // expect(sol.settings?.compaction).toEqual({ type: "summary" }) + // const future = required(available.find((model) => model.id === "gpt-6-future")) + // expect(future.name).toBe("GPT-6-Future") + // expect(future.package).toBe("@opencode/ai/providers/openai") + // expect(future.limit).toEqual({ context: 272_000, output: 32_000 }) + // }), + // ) + // + // it.live("shows catalog models while account models refresh in the background and saves them to KV", () => + // Effect.gen(function* () { + // const catalog = yield* Provider.Service + // const models = yield* Model.Service + // const credentials = yield* Credential.Service + // const kv = yield* KV.Service + // yield* catalog.transform((editor) => { + // editor.update(Provider.ID.openai, (provider) => { + // provider.package = "@opencode/ai/providers/openai" + // }) + // for (const id of ["gpt-5.5", "gpt-6-astra", "gpt-6-sol"]) + // editor.models.update(Provider.ID.openai, Model.ID.make(id), () => {}) + // }) + // const requested = yield* Deferred.make() + // const release = yield* Deferred.make() + // const remote = [ + // { + // slug: "gpt-6-future", + // display_name: "GPT-6-Future", + // visibility: "list", + // supported_in_api: true, + // context_window: 272_000, + // input_modalities: ["text"], + // }, + // ] + // const http = HttpClient.make((request) => + // Effect.gen(function* () { + // expect(request.url).toBe("https://api.openai.com/v1/models") + // expect(request.headers.authorization).toBe("Bearer chatgpt-token") + // yield* Deferred.succeed(requested, undefined) + // yield* Deferred.await(release) + // return HttpClientResponse.fromWeb(request, Response.json({ models: remote })) + // }), + // ) + // yield* addPlugin(http) + // yield* credentials.create({ + // integrationID: Integration.ID.make("openai"), + // value: Credential.OAuth.make({ + // type: "oauth", + // methodID: Integration.MethodID.make("chatgpt-token-sharing"), + // access: "chatgpt-token", + // refresh: "refresh", + // expires: Date.now() + 60 * 60_000, + // metadata: { clientID: "oaiapp_issued" }, + // }), + // }) + // yield* Deferred.await(requested).pipe(Effect.timeout("2 seconds")) + // expect((yield* models.available()).filter((model) => model.providerID === Provider.ID.openai).map((model) => model.id)).toEqual([ + // Model.ID.make("gpt-5.5"), + // Model.ID.make("gpt-6-astra"), + // ]) + // const storage = PluginHost.storage(kv, ChatGPTPlugin.id) + // expect(yield* storage.get("models:oaiapp_issued")).toBeUndefined() + // + // yield* Deferred.succeed(release, undefined) + // const available = yield* models.available().pipe( + // Effect.repeat({ + // until: (current) => current.some((model) => model.providerID === Provider.ID.openai && model.id === "gpt-6-future"), + // schedule: Schedule.spaced("10 millis"), + // }), + // Effect.timeout("2 seconds"), + // ) + // expect(available.filter((model) => model.providerID === Provider.ID.openai).map((model) => model.id)).toEqual([ + // Model.ID.make("gpt-6-future"), + // ]) + // expect(yield* storage.get("models:oaiapp_issued")).toEqual(remote) + // }), + // ) it.effect("routes ChatGPT over HTTP with only the listed catalog fallback models", () => Effect.gen(function* () { @@ -737,8 +738,12 @@ describe("ChatGPTPlugin", () => { "gpt-5.6-terra", "gpt-5.6-terra-fast", "gpt-6-astra-fast", + "gpt-6-luna", + "gpt-6-luna-fast", "gpt-6-sol", + "gpt-6-sol-fast", "gpt-6.1-sol", + "gpt-6.1-sol-fast", ]) catalog.models.update(Provider.ID.openai, Model.ID.make(id), () => {}) }) @@ -784,8 +789,6 @@ describe("ChatGPTPlugin", () => { "gpt-5", "gpt-5.04-astra", "gpt-4.99", - "gpt-6-sol", - "gpt-6.1-sol", ]) expect(yield* models.get(Provider.ID.openai, Model.ID.make(id))).toBeUndefined() expect((yield* models.available()).filter((model) => model.providerID === Provider.ID.openai).map((model) => model.id).sort()).toEqual( @@ -800,6 +803,12 @@ describe("ChatGPTPlugin", () => { "gpt-5.6-terra-fast", "gpt-6-astra", "gpt-6-astra-fast", + "gpt-6-luna", + "gpt-6-luna-fast", + "gpt-6-sol", + "gpt-6-sol-fast", + "gpt-6.1-sol", + "gpt-6.1-sol-fast", ].map((id) => Model.ID.make(id)), ) }), @@ -946,9 +955,9 @@ describe("ChatGPTPlugin", () => { const credentials = yield* Credential.Service const providerID = Provider.ID.openai const baseID = Model.ID.make("gpt-5.5") - const modelID = Model.ID.make("gpt-5.5-model") - const variantID = Model.ID.make("gpt-5.5-variant") - const kv = yield* KV.Service + const modelID = Model.ID.make("gpt-5.6-sol") + const variantID = Model.ID.make("gpt-6-astra") + // const kv = yield* KV.Service yield* catalog.transform((editor) => { editor.update(providerID, (provider) => { provider.package = "@opencode/ai/providers/openai/responses" @@ -975,17 +984,17 @@ describe("ChatGPTPlugin", () => { }) : Credential.Key.make({ type: "key", key: "sk-test" }), }) - if (connection === "chatgpt") - yield* PluginHost.storage(kv, ChatGPTPlugin.id).set("models:oaiapp_issued", [ - { - slug: "gpt-5.5", - display_name: "GPT-5.5", - visibility: "list", - supported_in_api: true, - context_window: 272_000, - input_modalities: ["text"], - }, - ]) + // if (connection === "chatgpt") + // yield* PluginHost.storage(kv, ChatGPTPlugin.id).set("models:oaiapp_issued", [ + // { + // slug: "gpt-5.5", + // display_name: "GPT-5.5", + // visibility: "list", + // supported_in_api: true, + // context_window: 272_000, + // input_modalities: ["text"], + // }, + // ]) yield* addPlugin() yield* addLegacyPlugin() const resolver = yield* ModelResolver.Service