feat(llm): provider packages own model request construction

Provider packages in @opencode-ai/llm/providers/* now implement a uniform
ProviderPackage contract: model(modelID, settings) => Model. SessionRunnerModel
becomes provider-agnostic: it resolves a package specifier, folds catalog
settings, credentials, and transport overlays into one Settings object, and
delegates request construction to the package.

- llm: add ProviderPackage (Settings, Definition, define), flat model(id,
  config) constructors on the openai-responses, anthropic-messages, and
  openai-compatible-chat protocols, contract-shaped model exports on the
  openai, anthropic, and openai-compatible providers, and a new
  providers/openai/codex entry point that targets the ChatGPT codex backend
  and sets the chatgpt-account-id header from settings.accountID.
- schema: Provider.Native gains optional package.
- core: SessionRunnerModel loads packages through a static built-in map
  (dynamic import for foreign specifiers) and applies one settings fold;
  the ChatGPT conditional is deleted from the runner. The OpenAI plugin's
  catalog transform now assigns the codex package to eligible models when a
  ChatGPT connection is active, alongside the existing eligibility and cost
  rewrites.
- llm schema: hoist ToolResultValue union out of its Object.assign self
  reference; the previous shape only typechecked under lucky check ordering
  and broke under core's typecheck with the new import graph.

Closes #34765
This commit is contained in:
Kit Langton 2026-07-03 14:01:17 -04:00
commit 38398fd450
22 changed files with 634 additions and 114 deletions

View file

@ -1,9 +1,10 @@
import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Deferred, Effect } from "effect"
import { Deferred, Effect, Stream } from "effect"
import type { Scope } from "effect"
import { Credential } from "../../credential"
import { EventV2 } from "../../event"
import { InstallationVersion } from "../../installation/version"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
@ -17,6 +18,7 @@ const callbackPort = 1455
const pollingSafetyMargin = 3000
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
const codexPackage = "@opencode-ai/llm/providers/openai/codex"
type Pkce = {
verifier: string
@ -154,24 +156,55 @@ const headless = {
export const OpenAIPlugin = define({
id: "openai",
effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
yield* ctx.integration.transform((draft) => {
draft.method.update(browser)
draft.method.update(headless)
})
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
const connection = yield* ctx.integration.connection.active("openai")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
const chatgpt = isChatGPT(credential)
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai") continue
if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
// chat-completions-only model, so hide it only from OpenAI's catalog.
model.enabled = false
})
if (item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) {
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
// chat-completions-only model, so hide it only from OpenAI's catalog.
model.enabled = false
})
}
if (!chatgpt) continue
for (const model of item.models.values()) {
evt.model.update(item.provider.id, model.id, (draft) => {
if (!eligible(draft)) {
draft.enabled = false
return
}
draft.cost = draft.cost.map((cost) => ({ ...cost, input: 0, output: 0, cache: { read: 0, write: 0 } }))
draft.api = {
type: "native",
id: draft.api.id,
package: codexPackage,
settings:
draft.api.type === "aisdk"
? { ...item.provider.api.settings, ...draft.api.settings }
: draft.api.settings,
}
})
}
}
}),
)
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
Stream.runForEach(() => ctx.catalog.reload()),
Effect.forkScoped({ startImmediately: true }),
)
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/openai") return
@ -269,16 +302,27 @@ function authorizeURL(redirect: string, pkce: Pkce, state: string) {
codex_cli_simplified_flow: "true",
state,
originator: "opencode",
})}`
}).toString()}`
}
function extractAccountID(tokens: TokenResponse) {
return claim(tokens.id_token) ?? claim(tokens.access_token)
}
function isChatGPT(credential: { readonly type: string; readonly methodID?: string } | undefined) {
return (
credential?.type === "oauth" &&
(credential.methodID === browserMethodID || credential.methodID === headlessMethodID)
)
}
function eligible(model: { readonly id: string }) {
return model.id.includes("codex")
}
function claim(token: string) {
const part = token.split(".")[1]
if (!part) return
if (!part) return undefined
try {
const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
return (
@ -287,6 +331,6 @@ function claim(token: string) {
claims.organizations?.[0]?.id
)
} catch {
return
return undefined
}
}