refactor(core): keep Codex routing in plugin
This commit is contained in:
parent
2de8ab95d1
commit
aef37e4514
4 changed files with 39 additions and 72 deletions
|
|
@ -17,7 +17,6 @@ import { Credential } from "./credential"
|
||||||
import { Integration } from "./integration"
|
import { Integration } from "./integration"
|
||||||
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
|
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
|
||||||
import { Npm } from "@opencode-ai/util/npm"
|
import { Npm } from "@opencode-ai/util/npm"
|
||||||
import { OpenAICodex } from "./plugin/provider/openai-codex"
|
|
||||||
import { Provider } from "./provider"
|
import { Provider } from "./provider"
|
||||||
|
|
||||||
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
||||||
|
|
@ -152,12 +151,7 @@ export const fromCatalogModel = (
|
||||||
const packageName = Provider.packageName(resolved.package)
|
const packageName = Provider.packageName(resolved.package)
|
||||||
const key = apiKey(resolved, credential)
|
const key = apiKey(resolved, credential)
|
||||||
|
|
||||||
if (OpenAICodex.isChatGPT(credential) && !Provider.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
|
|
||||||
return Effect.succeed(codexModel(resolved, credential, key))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
||||||
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
|
|
||||||
return Effect.succeed(
|
return Effect.succeed(
|
||||||
withDefaults(resolved, OpenAIResponses.route)
|
withDefaults(resolved, OpenAIResponses.route)
|
||||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||||
|
|
@ -223,10 +217,6 @@ export const fromCatalogModel = (
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNativeOpenAI = (packageName: string | undefined) =>
|
|
||||||
packageName === "@opencode-ai/ai/providers/openai" ||
|
|
||||||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
|
|
||||||
|
|
||||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||||
if (!credential) return {}
|
if (!credential) return {}
|
||||||
if (credential.type === "key") return { apiKey: credential.key }
|
if (credential.type === "key") return { apiKey: credential.key }
|
||||||
|
|
@ -248,21 +238,6 @@ const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
|
||||||
return rest
|
return rest
|
||||||
}
|
}
|
||||||
|
|
||||||
const codexModel = (
|
|
||||||
model: Info,
|
|
||||||
credential: Credential.Value | undefined,
|
|
||||||
key: ReturnType<typeof Auth.value> | undefined,
|
|
||||||
) => {
|
|
||||||
const account = OpenAICodex.accountID(credential)
|
|
||||||
return withDefaults(model, OpenAIResponses.route)
|
|
||||||
.with({
|
|
||||||
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
|
|
||||||
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsupported = (model: Info) =>
|
const unsupported = (model: Info) =>
|
||||||
new UnsupportedPackageError({
|
new UnsupportedPackageError({
|
||||||
providerID: model.providerID,
|
providerID: model.providerID,
|
||||||
|
|
|
||||||
|
|
@ -164,14 +164,14 @@ export const OpenAIPlugin = define({
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const loading = Semaphore.makeUnsafe(1)
|
const loading = Semaphore.makeUnsafe(1)
|
||||||
let chatgpt = false
|
let chatgpt: Credential.OAuth | undefined
|
||||||
|
|
||||||
const load = Effect.fn("OpenAIPlugin.load")(function* () {
|
const load = Effect.fn("OpenAIPlugin.load")(function* () {
|
||||||
const connection = yield* ctx.integration.connection.active("openai")
|
const connection = yield* ctx.integration.connection.active("openai")
|
||||||
const credential = connection
|
const credential = connection
|
||||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
: undefined
|
: undefined
|
||||||
chatgpt = OpenAICodex.isChatGPT(credential)
|
chatgpt = credential?.type === "oauth" && OpenAICodex.isChatGPT(credential) ? credential : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* ctx.integration.transform((draft) => {
|
yield* ctx.integration.transform((draft) => {
|
||||||
|
|
@ -194,6 +194,11 @@ export const OpenAIPlugin = define({
|
||||||
const item = evt.provider.get(Provider.ID.openai)
|
const item = evt.provider.get(Provider.ID.openai)
|
||||||
if (!item) return
|
if (!item) return
|
||||||
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: OpenAICodex.baseURL })
|
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: OpenAICodex.baseURL })
|
||||||
|
const account = OpenAICodex.accountID(chatgpt)
|
||||||
|
item.provider.headers = Provider.mergeHeaders(
|
||||||
|
item.provider.headers,
|
||||||
|
account === undefined ? undefined : { "chatgpt-account-id": account },
|
||||||
|
)
|
||||||
for (const model of item.models.values()) {
|
for (const model of item.models.values()) {
|
||||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||||
// subscription covers usage, so hide the rest and zero the cost.
|
// subscription covers usage, so hide the rest and zero the cost.
|
||||||
|
|
|
||||||
|
|
@ -307,7 +307,7 @@ describe("ModelResolver", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("keeps an explicit endpoint for ChatGPT OAuth credentials", () =>
|
it.effect("does not reinterpret an explicit endpoint based on the OAuth method", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||||
model(Provider.aisdk("@ai-sdk/openai"), {
|
model(Provider.aisdk("@ai-sdk/openai"), {
|
||||||
|
|
@ -338,7 +338,7 @@ describe("ModelResolver", () => {
|
||||||
endpoint: { baseURL: "https://openai.example/v1" },
|
endpoint: { baseURL: "https://openai.example/v1" },
|
||||||
})
|
})
|
||||||
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||||
expect(headers["chatgpt-account-id"]).toBe("acct_123")
|
expect(headers["chatgpt-account-id"]).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -367,7 +367,7 @@ describe("ModelResolver", () => {
|
||||||
|
|
||||||
expect(resolved.route.endpoint.baseURL).toBe("https://openai.example/v1")
|
expect(resolved.route.endpoint.baseURL).toBe("https://openai.example/v1")
|
||||||
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||||
expect(headers["chatgpt-account-id"]).toBe("acct_123")
|
expect(headers["chatgpt-account-id"]).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -407,37 +407,6 @@ describe("ModelResolver", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("keeps an explicit endpoint for ChatGPT OAuth credentials without an account id", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
|
||||||
model(Provider.aisdk("@ai-sdk/openai"), {
|
|
||||||
settings: { baseURL: "https://openai.example/v1" },
|
|
||||||
headers: {},
|
|
||||||
body: {},
|
|
||||||
}),
|
|
||||||
Credential.OAuth.make({
|
|
||||||
type: "oauth",
|
|
||||||
methodID: Integration.MethodID.make("chatgpt-headless"),
|
|
||||||
access: "chatgpt-token",
|
|
||||||
refresh: "refresh",
|
|
||||||
expires: Date.now() + 60_000,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
|
||||||
const headers = yield* resolved.route.auth.apply({
|
|
||||||
request,
|
|
||||||
method: "POST",
|
|
||||||
url: "https://openai.example/v1/responses",
|
|
||||||
body: "{}",
|
|
||||||
headers: Headers.empty,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(resolved.route.endpoint.baseURL).toBe("https://openai.example/v1")
|
|
||||||
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
|
||||||
expect(headers["chatgpt-account-id"]).toBeUndefined()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
|
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import { LLM } from "@opencode-ai/ai"
|
||||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { Headers } from "effect/unstable/http"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { Model } from "@opencode-ai/core/model"
|
import { Model } from "@opencode-ai/core/model"
|
||||||
|
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||||
import { Plugin } from "@opencode-ai/core/plugin"
|
import { Plugin } from "@opencode-ai/core/plugin"
|
||||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||||
|
|
@ -192,16 +195,17 @@ describe("OpenAIPlugin", () => {
|
||||||
catalog.model.update(item.id, Model.ID.make("gpt-5.6-sol"), () => {})
|
catalog.model.update(item.id, Model.ID.make("gpt-5.6-sol"), () => {})
|
||||||
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
|
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
|
||||||
})
|
})
|
||||||
|
const credential = Credential.OAuth.make({
|
||||||
|
type: "oauth",
|
||||||
|
methodID: Integration.MethodID.make("chatgpt-browser"),
|
||||||
|
access: "chatgpt-token",
|
||||||
|
refresh: "refresh",
|
||||||
|
expires: Date.now() + 60_000,
|
||||||
|
metadata: { accountID: "acct_123" },
|
||||||
|
})
|
||||||
yield* credentials.create({
|
yield* credentials.create({
|
||||||
integrationID: Integration.ID.make("openai"),
|
integrationID: Integration.ID.make("openai"),
|
||||||
value: Credential.OAuth.make({
|
value: credential,
|
||||||
type: "oauth",
|
|
||||||
methodID: Integration.MethodID.make("chatgpt-browser"),
|
|
||||||
access: "chatgpt-token",
|
|
||||||
refresh: "refresh",
|
|
||||||
expires: Date.now() + 60_000,
|
|
||||||
metadata: { accountID: "acct_123" },
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
|
|
||||||
|
|
@ -209,6 +213,18 @@ describe("OpenAIPlugin", () => {
|
||||||
expect(eligible.cost).toEqual([])
|
expect(eligible.cost).toEqual([])
|
||||||
expect(eligible.enabled).toBe(true)
|
expect(eligible.enabled).toBe(true)
|
||||||
expect(eligible.settings?.baseURL).toBe("https://chatgpt.com/backend-api/codex")
|
expect(eligible.settings?.baseURL).toBe("https://chatgpt.com/backend-api/codex")
|
||||||
|
expect(eligible.headers?.["chatgpt-account-id"]).toBe("acct_123")
|
||||||
|
const resolved = yield* ModelResolver.fromCatalogModel(eligible, credential)
|
||||||
|
const headers = yield* resolved.route.auth.apply({
|
||||||
|
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||||
|
method: "POST",
|
||||||
|
url: "https://chatgpt.com/backend-api/codex/responses",
|
||||||
|
body: "{}",
|
||||||
|
headers: Headers.fromInput(resolved.route.defaults.headers),
|
||||||
|
})
|
||||||
|
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
|
||||||
|
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||||
|
expect(headers["chatgpt-account-id"]).toBe("acct_123")
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|
@ -226,9 +242,11 @@ describe("OpenAIPlugin", () => {
|
||||||
provider.settings = Provider.mergeOverlay(provider.settings, { baseURL: "https://proxy.example/v1" })
|
provider.settings = Provider.mergeOverlay(provider.settings, { baseURL: "https://proxy.example/v1" })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
expect(
|
const configured = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||||
required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))).settings?.baseURL,
|
expect(configured.settings?.baseURL).toBe("https://proxy.example/v1")
|
||||||
).toBe("https://proxy.example/v1")
|
expect((yield* ModelResolver.fromCatalogModel(configured, credential)).route.endpoint.baseURL).toBe(
|
||||||
|
"https://proxy.example/v1",
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue