Compare commits

...

4 Commits

Author SHA1 Message Date
opencode-agent[bot] 6272c77912 chore(server): publish api package 2026-07-02 06:20:58 +00:00
Aiden Cline 140224b0fc fix(core): derive models.dev reasoning variants (#34726) 2026-07-02 00:23:46 -05:00
opencode-agent[bot] cfd35c9354 fix(tui): include variant in model switch notice (#34856)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-02 00:17:18 -05:00
Kit Langton 674d08f9be fix(core): route ChatGPT OAuth to the codex backend (#34843) 2026-07-02 00:15:34 -04:00
43 changed files with 853 additions and 140 deletions
+5
View File
@@ -151,6 +151,7 @@ export type AgentListOutput = {
readonly id: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly request: {
readonly settings: { readonly [x: string]: JsonValue }
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
}
@@ -2192,12 +2193,14 @@ export type ModelListOutput = {
readonly output: ReadonlyArray<string>
}
readonly request: {
readonly settings: { readonly [x: string]: JsonValue }
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
readonly variant?: string
}
readonly variants: ReadonlyArray<{
readonly id: string
readonly settings: { readonly [x: string]: JsonValue }
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
}>
@@ -2256,6 +2259,7 @@ export type ProviderListOutput = {
}
| { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } }
readonly request: {
readonly settings: { readonly [x: string]: JsonValue }
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
}
@@ -2289,6 +2293,7 @@ export type ProviderGetOutput = {
}
| { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } }
readonly request: {
readonly settings: { readonly [x: string]: JsonValue }
readonly headers: { readonly [x: string]: string }
readonly body: { readonly [x: string]: JsonValue }
}
+1
View File
@@ -85,6 +85,7 @@ const layer = Layer.effect(
? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } }
: model.api
const request = {
settings: { ...provider.request.settings, ...model.request.settings },
headers: { ...provider.request.headers, ...model.request.headers },
body: { ...provider.request.body, ...model.request.body },
variant: model.request.variant,
+4 -1
View File
@@ -4,7 +4,6 @@ import { define } from "../../plugin/internal"
import { Effect } from "effect"
import { Config } from "../../config"
import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
export const Plugin = define({
id: "config-provider",
@@ -54,6 +53,7 @@ export const Plugin = define({
if (item.name !== undefined) provider.name = item.name
if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) {
Object.assign(provider.request.settings, item.request.settings)
Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.body, item.request.body)
}
@@ -71,6 +71,7 @@ export const Plugin = define({
}
}
if (config.request !== undefined) {
Object.assign(model.request.settings, config.request.settings)
Object.assign(model.request.headers, config.request.headers)
Object.assign(model.request.body, config.request.body)
if (config.request.variant !== undefined) model.request.variant = config.request.variant
@@ -81,11 +82,13 @@ export const Plugin = define({
if (!existing) {
existing = {
id: variant.id,
settings: {},
headers: {},
body: {},
}
model.variants.push(existing)
}
Object.assign(existing.settings, variant.settings)
Object.assign(existing.headers, variant.headers)
Object.assign(existing.body, variant.body)
}
+1
View File
@@ -5,6 +5,7 @@ import { ProviderV2 } from "../provider"
import { ModelV2 } from "../model"
export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")({
settings: ProviderV2.Settings.pipe(Schema.optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
+6 -1
View File
@@ -26,8 +26,13 @@ export type Api = Model.Api
export const Info = Model.Info
export type Info = Model.Info
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
export type MutableRequest = ProviderV2.MutableRequest & { variant?: string }
export type MutableVariant = ProviderV2.MutableRequest & { id: VariantID }
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api" | "request" | "variants"> & {
api: ProviderV2.MutableApi<Api>
request: MutableRequest
variants: MutableVariant[]
}
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
+66 -18
View File
@@ -70,25 +70,73 @@ function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"]
return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()]
}
function reasoningVariants(model: ModelsDev.Model, packageName: string | undefined): ModelV2Info["variants"] {
const result = new Map<ModelV2.VariantID, ModelV2Info["variants"][number]>()
if (packageName === "@ai-sdk/openai" || packageName === "@ai-sdk/openai-compatible") {
const option = model.reasoning_options?.find((option) => option.type === "effort")
for (const value of option?.values ?? []) {
const id = value === null ? "none" : value
if (typeof id !== "string") continue
const variantID = ModelV2.VariantID.make(id)
result.set(variantID, {
id: variantID,
headers: {},
body:
packageName === "@ai-sdk/openai"
? { include: ["reasoning.encrypted_content"], reasoning: { effort: id, summary: "auto" } }
: { reasoning_effort: id },
})
const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): ModelV2Info["variants"] {
const npm = model.provider?.npm ?? provider.npm
const options = model.reasoning_options ?? []
const effort = options.find((option) => option.type === "effort")
if (effort?.type === "effort") {
return effort.values.flatMap((value) => {
const raw: unknown = value
const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined
if (id === undefined) return []
const settings = settingsForEffort(npm, id)
return settings ? [{ id, settings, headers: {}, body: {} }] : []
})
}
const budget = options.find((option) => option.type === "budget_tokens")
if (budget?.type === "budget_tokens") return budgetVariants(npm, budget)
// Toggle-only reasoning is intentionally left for a follow-up because V1 has
// provider/model-specific behavior like MiniMax M3 adaptive thinking and
// Qwen/GLM enable_thinking request shapes in packages/opencode.
return []
}
function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.Settings | undefined {
if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { effort } }
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
return { thinking: { type: "adaptive", display: "summarized" }, effort }
}
if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") {
return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
}
if (npm === "@ai-sdk/azure") return { reasoningEffort: effort }
if (npm === "@ai-sdk/openai") {
return {
reasoningEffort: effort,
reasoningSummary: "auto",
include: OPENAI_INCLUDE_ENCRYPTED_REASONING,
}
}
return [...result.values()]
if (npm === "@ai-sdk/openai-compatible") return { reasoningEffort: effort }
}
function budgetVariants(
npm: string | undefined,
option: Extract<NonNullable<ModelsDev.Model["reasoning_options"]>[number], { type: "budget_tokens" }>,
): ModelV2Info["variants"] {
const max = option.max
const high = option.max === undefined ? Math.max(option.min ?? 0, 16_000) : Math.min(Math.max(option.min ?? 0, 16_000), option.max)
return [
{ id: "high", budget: high },
...(max === undefined || max === high ? [] : [{ id: "max", budget: max }]),
].flatMap((item) => {
const settings = settingsForBudget(npm, item.budget)
return settings ? [{ id: item.id, settings, headers: {}, body: {} }] : []
})
}
function settingsForBudget(npm: string | undefined, budget: number): ProviderV2.Settings | undefined {
if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { max_tokens: budget } }
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
return { thinking: { type: "enabled", budgetTokens: budget } }
}
if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") {
return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } }
}
}
function modeName(model: ModelsDev.Model, mode: string) {
@@ -193,7 +241,7 @@ export const ModelsDevPlugin = define({
for (const model of Object.values(item.models)) {
const baseCost = cost(model.cost)
const variants = reasoningVariants(model, model.provider?.npm ?? item.npm)
const variants = reasoningVariants(item, model)
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants }))
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
@@ -0,0 +1,42 @@
export * as OpenAICodex from "./openai-codex"
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
// codex routing lives in SessionRunnerModel.fromCatalogModel and catalog filtering
// in OpenAIPlugin, sharing this module. Once the native provider packages land
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
// plan-eligibility data for OpenAI today, but models other vendors' subscriptions
// as dedicated providers (e.g. zai-coding-plan) - a future openai-chatgpt-plan
// provider entry could replace the hardcoded rules with catalog data.
/** ChatGPT-plan requests must target the codex backend instead of the public API. */
export const baseURL = "https://chatgpt.com/backend-api/codex"
const methodIDs: readonly string[] = ["chatgpt-browser", "chatgpt-headless"]
/** Structural credential shape so both core and plugin-facing credential types fit. */
type CredentialLike = {
readonly type: string
readonly methodID?: string
readonly metadata?: Record<string, unknown> | undefined
}
export const isChatGPT = (credential: CredentialLike | undefined) =>
credential?.type === "oauth" && credential.methodID !== undefined && methodIDs.includes(credential.methodID)
export const accountID = (credential: CredentialLike | undefined) => {
if (!isChatGPT(credential)) return undefined
const value = credential?.metadata?.accountID
return typeof value === "string" ? value : undefined
}
const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
const disallowed = new Set(["gpt-5.5-pro"])
/** Which API model ids a ChatGPT subscription may call through the codex backend. */
export const eligible = (apiID: string) => {
if (allowed.has(apiID)) return true
if (disallowed.has(apiID)) return false
const match = apiID.match(/^gpt-(\d+\.\d+)/)
return match ? Number.parseFloat(match[1]) > 5.4 : false
}
+37 -1
View File
@@ -1,15 +1,17 @@
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, Semaphore, 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"
import { OauthCallbackPage } from "../../oauth/page"
import { ProviderV2 } from "../../provider"
import type { PluginInternal } from "../internal"
import { OpenAICodex } from "./openai-codex"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
const issuer = "https://auth.openai.com"
@@ -154,6 +156,18 @@ const headless = {
export const OpenAIPlugin = define({
id: "openai",
effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
const loading = Semaphore.makeUnsafe(1)
let chatgpt = false
const load = Effect.fn("OpenAIPlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("openai")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
chatgpt = OpenAICodex.isChatGPT(credential)
})
yield* ctx.integration.transform((draft) => {
draft.method.update(browser)
draft.method.update(headless)
@@ -170,8 +184,30 @@ export const OpenAIPlugin = define({
model.enabled = false
})
}
if (!chatgpt) return
const item = evt.provider.get(ProviderV2.ID.openai)
if (!item) return
for (const model of item.models.values()) {
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
evt.model.update(item.provider.id, model.id, (draft) => {
if (!OpenAICodex.eligible(draft.api.id)) {
draft.enabled = false
return
}
draft.cost = []
})
}
}),
)
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh().pipe(Effect.forkScoped)
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/openai") return
@@ -146,7 +146,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
const variantID = ModelV2.VariantID.make(id)
let existing = model.variants.find((item) => item.id === variantID)
if (!existing) {
existing = { id: variantID, headers: {}, body: {} }
existing = { id: variantID, settings: {}, headers: {}, body: {} }
model.variants.push(existing)
}
Object.assign(existing.headers, options.headers)
+2 -1
View File
@@ -33,7 +33,8 @@ export function generate(model: ModelV2Info): ModelV2Info["variants"] {
if (!["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))) return []
return ["high", "max"].map((id) => ({
id,
settings: { reasoningEffort: id },
headers: {},
body: { reasoning_effort: id },
body: {},
}))
}
+9 -1
View File
@@ -19,7 +19,15 @@ export type MutableApi<T extends Api = Api> = T extends Api
export const Request = Provider.Request
export type Request = Provider.Request
export const Settings = Provider.Settings
export type Settings = Provider.Settings
export const Info = Provider.Info
export type Info = Provider.Info
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & { api: MutableApi }
export type MutableRequest = Types.DeepMutable<Request>
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api" | "request"> & {
api: MutableApi
request: MutableRequest
}
+26
View File
@@ -12,6 +12,7 @@ import { Catalog } from "../../catalog"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
import { OpenAICodex } from "../../plugin/provider/openai-codex"
import { ProviderV2 } from "../../provider"
import { SessionSchema } from "../schema"
@@ -96,11 +97,20 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
provider: model.providerID,
endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url },
headers: model.request.headers,
providerOptions: providerOptions(model),
http: { body: httpBody },
limits: { context: model.limit.context, output: model.limit.output },
})
}
const providerOptions = (model: ModelV2.Info) => {
if (Object.keys(model.request.settings).length === 0) return undefined
if (model.api.type !== "aisdk") return undefined
if (model.api.package === "@ai-sdk/openai") return { openai: model.request.settings }
if (model.api.package === "@ai-sdk/anthropic") return { anthropic: model.request.settings }
if (model.api.package === "@ai-sdk/openai-compatible") return { openai: model.request.settings }
}
export const withVariant = (
model: ModelV2.Info,
variantID: ModelV2.VariantID | undefined,
@@ -118,6 +128,7 @@ export const withVariant = (
return Effect.succeed(
variant
? produce(model, (draft) => {
Object.assign(draft.request.settings, variant.settings)
Object.assign(draft.request.headers, variant.headers)
Object.assign(draft.request.body, variant.body)
})
@@ -140,6 +151,21 @@ export const fromCatalogModel = (
})
const key = apiKey(resolved, credential)
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
// ChatGPT-plan OAuth tokens are not API-key credentials: the public API rejects
// them, so requests must target the codex backend with the account header.
if (OpenAICodex.isChatGPT(credential)) {
const account = OpenAICodex.accountID(credential)
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({
endpoint: { baseURL: OpenAICodex.baseURL },
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
),
})
.model({ id: resolved.api.id }),
)
}
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
@@ -0,0 +1,67 @@
{
"openai": {
"id": "openai",
"name": "OpenAI",
"env": ["OPENAI_API_KEY"],
"npm": "@ai-sdk/openai",
"api": "https://api.openai.com/v1",
"models": {
"gpt-reasoning": {
"id": "gpt-reasoning",
"name": "GPT Reasoning",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [
{ "type": "effort", "values": ["low", "high"] },
{ "type": "budget_tokens", "min": 1024, "max": 64000 },
{ "type": "toggle" }
],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 8192 },
"experimental": {
"modes": {
"high": {
"provider": {
"headers": { "x-mode": "high" },
"body": { "service_tier": "priority" }
}
}
}
}
}
}
},
"anthropic": {
"id": "anthropic",
"name": "Anthropic",
"env": ["ANTHROPIC_API_KEY"],
"npm": "@ai-sdk/anthropic",
"api": "https://api.anthropic.com/v1",
"models": {
"claude-budget": {
"id": "claude-budget",
"name": "Claude Budget",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "budget_tokens", "min": 1024, "max": 64000 }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 8192 }
},
"claude-effort": {
"id": "claude-effort",
"name": "Claude Effort",
"release_date": "2026-01-01",
"attachment": false,
"reasoning": true,
"reasoning_options": [{ "type": "effort", "values": ["low"] }],
"temperature": true,
"tool_call": true,
"limit": { "context": 128000, "output": 8192 }
}
}
}
}
+3 -1
View File
@@ -288,7 +288,7 @@ function providerInfo(value: ProviderV2.MutableInfo) {
return {
...value,
api: { ...value.api, settings: value.api.settings && { ...value.api.settings } },
request: { headers: { ...value.request.headers }, body: { ...value.request.body } },
request: { settings: { ...value.request.settings }, headers: { ...value.request.headers }, body: { ...value.request.body } },
}
}
@@ -303,11 +303,13 @@ function modelInfo(value: ModelV2.Info | ModelV2.MutableInfo) {
},
request: {
...value.request,
settings: { ...value.request.settings },
headers: { ...value.request.headers },
body: { ...value.request.body },
},
variants: value.variants.map((variant) => ({
...variant,
settings: { ...variant.settings },
headers: { ...variant.headers },
body: { ...variant.body },
})),
+62 -49
View File
@@ -168,14 +168,14 @@ describe("ModelsDevPlugin", () => {
),
)
it.effect("derives OpenAI reasoning variants from models.dev reasoning options", () =>
it.effect("converts reasoning options into settings variants", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = {
path: Flag.OPENCODE_MODELS_PATH,
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
}
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json")
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
return previous
}),
@@ -183,17 +183,6 @@ describe("ModelsDevPlugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
yield* catalog.transform((catalog) => {
catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5"), (model) => {
model.variants = [
{
id: ModelV2.VariantID.make("high"),
headers: { custom: "true" },
body: { custom: true },
},
]
})
})
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
@@ -201,42 +190,67 @@ describe("ModelsDevPlugin", () => {
}),
)
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5")))?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
headers: {},
body: {
include: ["reasoning.encrypted_content"],
reasoning: { effort: "none", summary: "auto" },
},
},
expect.objectContaining({
id: "low",
body: {
include: ["reasoning.encrypted_content"],
reasoning: { effort: "low", summary: "auto" },
},
}),
expect.objectContaining({
id: "medium",
body: {
include: ["reasoning.encrypted_content"],
reasoning: { effort: "medium", summary: "auto" },
},
}),
expect.objectContaining({
id: "high",
headers: { custom: "true" },
body: { custom: true },
}),
expect.objectContaining({
id: "xhigh",
body: {
include: ["reasoning.encrypted_content"],
reasoning: { effort: "xhigh", summary: "auto" },
},
}),
const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning"))
expect(model?.variants.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("low"),
settings: {
reasoningEffort: "low",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
headers: {},
body: {},
})
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
settings: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
headers: {},
body: {},
})
const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high"))
expect(mode).toMatchObject({
id: "gpt-reasoning-high",
name: "GPT Reasoning High",
request: {
headers: { "x-mode": "high" },
body: { service_tier: "priority" },
},
})
expect(mode?.variants.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget"))
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
headers: {},
body: {},
})
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("max"),
settings: { thinking: { type: "enabled", budgetTokens: 64000 } },
headers: {},
body: {},
})
const anthropicEffortModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-effort"))
expect(anthropicEffortModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("low"),
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
headers: {},
body: {},
})
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
(previous) =>
Effect.sync(() => {
@@ -245,5 +259,4 @@ describe("ModelsDevPlugin", () => {
}),
),
)
})
@@ -3,6 +3,7 @@ import { describe, expect } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
@@ -27,6 +28,20 @@ function required<T>(value: T | undefined): T {
return value
}
function eventually<A>(
effect: Effect.Effect<A>,
predicate: (value: A) => boolean,
remaining = 1000,
): Effect.Effect<A, Error> {
return Effect.gen(function* () {
const value = yield* effect
if (predicate(value)) return value
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* eventually(effect, predicate, remaining - 1)
})
}
function fakeSelectorSdk(calls: string[]) {
const make = (method: string) => (id: string) => {
calls.push(`${method}:${id}`)
@@ -153,6 +168,80 @@ describe("OpenAIPlugin", () => {
}),
)
it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
yield* catalog.transform((catalog) => {
const item = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.openai),
api: { type: "aisdk", package: "@ai-sdk/openai" },
})
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => {
model.cost = [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }]
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5-pro"), () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
})
yield* credentials.create({
integrationID: Integration.ID.make("openai"),
value: 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* addPlugin()
const eligible = required(
yield* eventually(
catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5")),
(model) => model?.cost.length === 0,
),
)
expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5-pro"))).enabled).toBe(
false,
)
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(false)
}),
)
it.effect("keeps the full OpenAI catalog under an API key connection", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
yield* catalog.transform((catalog) => {
const item = ProviderV2.Info.make({
...ProviderV2.Info.empty(ProviderV2.ID.openai),
api: { type: "aisdk", package: "@ai-sdk/openai" },
})
catalog.provider.update(item.id, (draft) => {
draft.api = item.api
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
})
yield* credentials.create({
integrationID: Integration.ID.make("openai"),
value: Credential.Key.make({ type: "key", key: "sk-test" }),
})
yield* addPlugin()
// The connection refresh is asynchronous; give it time to settle before
// asserting nothing was filtered.
yield* Effect.promise(() => Bun.sleep(25))
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5"))).enabled).toBe(true)
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -142,6 +142,7 @@ describe("OpencodePlugin", () => {
model.variants = [
{
id: ModelV2.VariantID.make("custom"),
settings: {},
headers: { "x-custom": "true" },
body: { custom: true },
},
@@ -177,7 +178,7 @@ describe("OpencodePlugin", () => {
url: `${server.url.origin}/v1`,
},
})
expect(provider.request).toEqual({ headers: { "x-org-id": "org" }, body: { custom: "value" } })
expect(provider.request).toEqual({ settings: {}, headers: { "x-org-id": "org" }, body: { custom: "value" } })
expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
const model = required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model")))
@@ -192,11 +193,13 @@ describe("OpencodePlugin", () => {
expect(model.variants).toEqual([
{
id: ModelV2.VariantID.make("custom"),
settings: {},
headers: { "x-custom": "true" },
body: { custom: true },
},
{
id: ModelV2.VariantID.make("high"),
settings: {},
headers: {},
body: { temperature: 0.2 },
},
@@ -359,6 +362,7 @@ describe("OpencodePlugin", () => {
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
api: { type: "aisdk", package: "test-provider" },
request: {
settings: {},
headers: {},
body: { apiKey: "configured" },
},
+4 -4
View File
@@ -37,8 +37,8 @@ describe("VariantPlugin", () => {
yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) }))
expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2")))?.variants).toEqual([
expect.objectContaining({ id: "high", body: { reasoning_effort: "high" } }),
expect.objectContaining({ id: "max", body: { reasoning_effort: "max" } }),
expect.objectContaining({ id: "high", settings: { reasoningEffort: "high" } }),
expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
])
}),
)
@@ -53,14 +53,14 @@ describe("VariantPlugin", () => {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
}
model.variants = [{ id: ModelV2.VariantID.make("high"), headers: { custom: "true" }, body: {} }]
model.variants = [{ id: ModelV2.VariantID.make("high"), settings: {}, headers: { custom: "true" }, body: {} }]
})
})
yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) }))
expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2")))?.variants).toEqual([
expect.objectContaining({ id: "high", headers: { custom: "true" } }),
expect.objectContaining({ id: "max", body: { reasoning_effort: "max" } }),
expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
])
}),
)
+112 -10
View File
@@ -30,6 +30,7 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
api: { id: ModelV2.ID.make("api-test-model"), ...api },
capabilities: { tools: true, input: ["text"], output: ["text"] },
request: {
settings: {},
headers: { "x-test": "header" },
body: { apiKey: "secret", custom_extension: { enabled: true } },
},
@@ -83,7 +84,7 @@ describe("SessionRunnerModel", () => {
url: "https://compatible.example/v1",
settings: { apiKey: "settings-secret", compatibility: "strict" },
}),
request: { headers: {}, body: {} },
request: { settings: {}, headers: {}, body: {} },
}),
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
@@ -100,17 +101,17 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("overlays selected OpenAI Session variant bodies", () =>
it.effect("overlays selected OpenAI Session variant settings and bodies", () =>
Effect.gen(function* () {
const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningEffort: "high" },
headers: { "x-variant": "high" },
body: {
store: false,
service_tier: "priority",
temperature: 0.2,
reasoning: { effort: "high" },
},
},
])
@@ -137,7 +138,9 @@ describe("SessionRunnerModel", () => {
store: false,
service_tier: "priority",
temperature: 0.2,
reasoning: { effort: "high" },
})
expect(resolved.route.defaults.providerOptions).toEqual({
openai: { store: false, reasoningEffort: "high" },
})
}),
)
@@ -149,6 +152,7 @@ describe("SessionRunnerModel", () => {
[
{
id: ModelV2.VariantID.make("high"),
settings: {},
headers: {},
body: { store: false, reasoning_effort: "high" },
},
@@ -205,13 +209,14 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("overlays selected Anthropic Session variant bodies", () =>
it.effect("overlays selected Anthropic Session variant settings", () =>
Effect.gen(function* () {
const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [
{
id: ModelV2.VariantID.make("high"),
settings: { thinking: { type: "enabled", budgetTokens: 12000 } },
headers: {},
body: { thinking: { type: "enabled", budget_tokens: 12000 } },
body: {},
},
])
const session = SessionV2.Info.make({
@@ -229,7 +234,9 @@ describe("SessionRunnerModel", () => {
expect(resolved.route.defaults.http?.body).toEqual({
custom_extension: { enabled: true },
thinking: { type: "enabled", budget_tokens: 12000 },
})
expect(resolved.route.defaults.providerOptions).toEqual({
anthropic: { thinking: { type: "enabled", budgetTokens: 12000 } },
})
}),
)
@@ -252,7 +259,7 @@ describe("SessionRunnerModel", () => {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: {} },
request: { settings: {}, headers: {}, body: {} },
}),
Credential.Key.make({ type: "key", key: "secret" }),
)
@@ -275,7 +282,7 @@ describe("SessionRunnerModel", () => {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: { apiKey: "configured-secret" } },
request: { settings: {}, headers: {}, body: { apiKey: "configured-secret" } },
}),
credential,
)
@@ -297,7 +304,7 @@ describe("SessionRunnerModel", () => {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: {} },
request: { settings: {}, headers: {}, body: {} },
}),
Credential.OAuth.make({
type: "oauth",
@@ -313,6 +320,101 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: {} },
}),
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" },
}),
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
const headers = yield* resolved.route.auth.apply({
request,
method: "POST",
url: "https://chatgpt.com/backend-api/codex/responses",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route).toMatchObject({
id: "openai-responses",
endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
})
expect(headers.authorization).toBe("Bearer chatgpt-token")
expect(headers["chatgpt-account-id"]).toBe("acct_123")
}),
)
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { 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://chatgpt.com/backend-api/codex/responses",
body: "{}",
headers: Headers.empty,
})
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
expect(headers.authorization).toBe("Bearer chatgpt-token")
expect(headers["chatgpt-account-id"]).toBeUndefined()
}),
)
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
ModelV2.Info.make({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: {} },
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "oauth-token",
refresh: "refresh",
expires: Date.now() + 60_000,
metadata: { accountID: "acct_123" },
}),
)
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 oauth-token")
expect(headers["chatgpt-account-id"]).toBeUndefined()
}),
)
it.effect("rejects catalog APIs without a native route", () =>
Effect.gen(function* () {
const failure = yield* SessionRunnerModel.fromCatalogModel(
@@ -148,9 +148,22 @@ const AnthropicToolChoice = Schema.Union([
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
])
const AnthropicThinking = Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
const AnthropicThinking = Schema.Union([
Schema.Struct({
type: Schema.tag("enabled"),
budget_tokens: Schema.Number,
}),
Schema.Struct({
type: Schema.tag("adaptive"),
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
}),
Schema.Struct({
type: Schema.tag("disabled"),
}),
])
const AnthropicOutputConfig = Schema.Struct({
effort: Schema.optional(Schema.String),
})
const AnthropicBodyFields = {
@@ -166,6 +179,7 @@ const AnthropicBodyFields = {
top_k: Schema.optional(Schema.Number),
stop_sequences: optionalArray(Schema.String),
thinking: Schema.optional(AnthropicThinking),
output_config: Schema.optional(AnthropicOutputConfig),
}
const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
@@ -492,7 +506,16 @@ const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthr
const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) {
const thinking = anthropicOptions(request)?.thinking
if (!ProviderShared.isRecord(thinking) || thinking.type !== "enabled") return undefined
if (!ProviderShared.isRecord(thinking)) return undefined
if (thinking.type === "adaptive") {
const display = thinking.display
return {
type: "adaptive" as const,
...(display === "summarized" || display === "omitted" ? { display } : {}),
}
}
if (thinking.type === "disabled") return { type: "disabled" as const }
if (thinking.type !== "enabled") return undefined
const budget =
typeof thinking.budgetTokens === "number"
? thinking.budgetTokens
@@ -503,6 +526,11 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re
return { type: "enabled" as const, budget_tokens: budget }
})
const outputConfig = (request: LLMRequest) => {
const effort = anthropicOptions(request)?.effort
return typeof effort === "string" ? { effort } : undefined
}
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const generation = request.generation
@@ -549,6 +577,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: yield* lowerThinking(request),
output_config: outputConfig(request),
}
})
@@ -168,8 +168,6 @@ interface ParserState {
readonly lifecycle: Lifecycle.State
}
const invalid = ProviderShared.invalidRequest
// =============================================================================
// Request Lowering
// =============================================================================
@@ -333,8 +331,6 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) {
const store = OpenAIOptions.store(request)
const reasoningEffort = OpenAIOptions.reasoningEffort(request)
if (reasoningEffort && !OpenAIOptions.isReasoningEffort(reasoningEffort))
return yield* invalid(`OpenAI Chat does not support reasoning effort ${reasoningEffort}`)
return {
...(store !== undefined ? { store } : {}),
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
@@ -457,8 +457,6 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
const store = OpenAIOptions.store(request)
const promptCacheKey = OpenAIOptions.promptCacheKey(request)
const effort = OpenAIOptions.reasoningEffort(request)
if (effort && !OpenAIOptions.isReasoningEffort(effort))
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
const summary = OpenAIOptions.reasoningSummary(request)
const include = OpenAIOptions.include(request)
const verbosity = OpenAIOptions.textVerbosity(request)
@@ -1,11 +1,9 @@
import { Schema } from "effect"
import type { LLMRequest, ReasoningEffort, TextVerbosity as TextVerbosityValue } from "../../schema"
import type { LLMRequest, TextVerbosity as TextVerbosityValue } from "../../schema"
import { ReasoningEfforts, TextVerbosity } from "../../schema"
export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
(effort): effort is Exclude<ReasoningEffort, "max"> => effort !== "max",
)
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
export const OpenAIReasoningEfforts = ReasoningEfforts
export type OpenAIReasoningEffort = string
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
@@ -23,22 +21,16 @@ export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number
export const OpenAIServiceTiers = ["auto", "default", "flex", "priority"] as const
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number]
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
const SERVICE_TIERS = new Set<string>(OpenAIServiceTiers)
export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
export const OpenAIReasoningEffort = Schema.String
export const OpenAITextVerbosity = TextVerbosity
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
export const OpenAIServiceTier = Schema.Literals(OpenAIServiceTiers)
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
typeof effort === "string" && REASONING_EFFORTS.has(effort)
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort =>
typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort)
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => typeof effort === "string"
const isTextVerbosity = (value: unknown): value is TextVerbosityValue =>
typeof value === "string" && TEXT_VERBOSITY.has(value)
@@ -50,9 +42,9 @@ export const store = (request: LLMRequest): boolean | undefined => {
return typeof value === "boolean" ? value : undefined
}
export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefined => {
export const reasoningEffort = (request: LLMRequest): string | undefined => {
const value = options(request)?.reasoningEffort
return isAnyReasoningEffort(value) ? value : undefined
return typeof value === "string" ? value : undefined
}
export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
+1 -1
View File
@@ -27,7 +27,7 @@ export const ToolCallID = Schema.String
export type ToolCallID = Schema.Schema.Type<typeof ToolCallID>
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export const ReasoningEffort = Schema.Literals(ReasoningEfforts)
export const ReasoningEffort = Schema.String
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
export const TextVerbosity = Schema.Literals(["low", "medium", "high"])
@@ -57,6 +57,23 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("lowers adaptive thinking settings with effort", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
LLM.updateRequest(request, {
providerOptions: {
anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
},
}),
)
expect(prepared.body).toMatchObject({
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "low" },
})
}),
)
it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
+16 -2
View File
@@ -98,12 +98,26 @@ describe("OpenAI Chat route", () => {
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
prompt: "think",
providerOptions: { openai: { reasoningEffort: "low" } },
providerOptions: { openai: { reasoningEffort: "max" } },
}),
)
expect(prepared.body.store).toBe(false)
expect(prepared.body.reasoning_effort).toBe("low")
expect(prepared.body.reasoning_effort).toBe("max")
}),
)
it.effect("passes through custom OpenAI-compatible reasoning effort strings", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
prompt: "think",
providerOptions: { openai: { reasoningEffort: "experimental" } },
}),
)
expect(prepared.body.reasoning_effort).toBe("experimental")
}),
)
@@ -69,6 +69,16 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("passes through custom OpenAI reasoning effort strings", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.updateRequest(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
)
expect(prepared.body.reasoning).toEqual({ effort: "experimental" })
}),
)
it.effect("omits unsupported semantic service tiers", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
+1 -1
View File
@@ -38,7 +38,7 @@ export const Info = Schema.Struct({
empty: (id: ID) =>
schema.make({
id,
request: { headers: {}, body: {} },
request: { settings: {}, headers: {}, body: {} },
mode: "all",
hidden: false,
permissions: [
+1 -1
View File
@@ -94,7 +94,7 @@ export const Info = Schema.Struct({
name: modelID,
api: { id: modelID, type: "native", settings: {} },
capabilities: { tools: false, input: [], output: [] },
request: { headers: {}, body: {} },
request: { settings: {}, headers: {}, body: {} },
variants: [],
time: { released: 0 },
cost: [],
+6 -2
View File
@@ -1,6 +1,6 @@
export * as Provider from "./provider.js"
import { Schema } from "effect"
import { Effect, Schema } from "effect"
import { optional } from "./schema.js"
import { Integration } from "./integration.js"
import { statics } from "./schema.js"
@@ -43,8 +43,12 @@ export const Api = Schema.Union([AISDK, Native])
.annotate({ identifier: "Provider.Api" })
export type Api = typeof Api.Type
export const Settings = Schema.Record(Schema.String, Schema.Unknown).annotate({ identifier: "Provider.Settings" })
export type Settings = typeof Settings.Type
export interface Request extends Schema.Schema.Type<typeof Request> {}
export const Request = Schema.Struct({
settings: Settings.pipe(Schema.withConstructorDefault(Effect.succeed({}))),
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.Record(Schema.String, Schema.Json),
}).annotate({ identifier: "Provider.Request" })
@@ -66,7 +70,7 @@ export const Info = Schema.Struct({
id,
name: id,
api: { type: "native", settings: {} },
request: { headers: {}, body: {} },
request: { settings: {}, headers: {}, body: {} },
}),
})),
)
+43
View File
@@ -399,6 +399,8 @@ import type {
V2SessionSwitchAgentResponses,
V2SessionSwitchModelErrors,
V2SessionSwitchModelResponses,
V2SessionSyntheticErrors,
V2SessionSyntheticResponses,
V2SessionWaitErrors,
V2SessionWaitResponses,
V2ShellCreateErrors,
@@ -5816,6 +5818,47 @@ export class Session3 extends HeyApiClient {
})
}
/**
* Add synthetic message
*
* Append a synthetic message to a session and resume execution.
*/
public synthetic<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
text?: string
description?: string
metadata?: {
[key: string]: unknown
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "body", key: "text" },
{ in: "body", key: "description" },
{ in: "body", key: "metadata" },
],
},
],
)
return (options?.client ?? this.client).post<V2SessionSyntheticResponses, V2SessionSyntheticErrors, ThrowOnError>({
url: "/api/session/{sessionID}/synthetic",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
/**
* Compact session
*
+60
View File
@@ -942,6 +942,9 @@ export type GlobalEvent = {
messageID: string
text: string
description?: string
metadata?: {
[key: string]: unknown
}
}
}
| {
@@ -3618,6 +3621,9 @@ export type SyncEventSessionNextSynthetic = {
messageID: string
text: string
description?: string
metadata?: {
[key: string]: unknown
}
}
}
}
@@ -4091,7 +4097,12 @@ export type LocationInfo = {
}
}
export type ProviderSettings = {
[key: string]: unknown
}
export type ProviderRequest = {
settings: ProviderSettings
headers: {
[key: string]: string
}
@@ -4583,6 +4594,9 @@ export type SessionNextSynthetic = {
messageID: string
text: string
description?: string
metadata?: {
[key: string]: unknown
}
}
}
@@ -5120,6 +5134,7 @@ export type ModelV2Info = {
api: ModelApi
capabilities: ModelCapabilities
request: {
settings: ProviderSettings
headers: {
[key: string]: string
}
@@ -5130,6 +5145,7 @@ export type ModelV2Info = {
}
variants: Array<{
id: string
settings: ProviderSettings
headers: {
[key: string]: string
}
@@ -6806,6 +6822,9 @@ export type EventSessionNextSynthetic = {
messageID: string
text: string
description?: string
metadata?: {
[key: string]: unknown
}
}
}
@@ -12284,6 +12303,47 @@ export type V2SessionSkillResponses = {
export type V2SessionSkillResponse = V2SessionSkillResponses[keyof V2SessionSkillResponses]
export type V2SessionSyntheticData = {
body: {
text: string
description?: string
metadata?: {
[key: string]: unknown
}
}
path: {
sessionID: string
}
query?: never
url: "/api/session/{sessionID}/synthetic"
}
export type V2SessionSyntheticErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundError
}
export type V2SessionSyntheticError = V2SessionSyntheticErrors[keyof V2SessionSyntheticErrors]
export type V2SessionSyntheticResponses = {
/**
* <No Content>
*/
204: void
}
export type V2SessionSyntheticResponse = V2SessionSyntheticResponses[keyof V2SessionSyntheticResponses]
export type V2SessionCompactData = {
body?: never
path: {
+9
View File
@@ -5,10 +5,19 @@
"private": true,
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/server"
},
"publishConfig": {
"access": "public"
},
"exports": {
"./*": "./src/*.ts"
},
"scripts": {
"build:publish": "bun tsc -p tsconfig.publish.json",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
private?: boolean
files?: Array<string>
scripts?: Record<string, string>
dependencies: Record<string, string>
devDependencies?: Record<string, string>
exports: Record<string, unknown>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`rm -rf dist`
await $`bun run build:publish`
delete pkg.private
delete pkg.scripts
delete pkg.devDependencies
pkg.files = ["dist"]
pkg.exports = {
"./api": {
import: "./dist/api.js",
types: "./dist/api.d.ts",
},
}
pkg.dependencies = {
"@opencode-ai/protocol": pkg.dependencies["@opencode-ai/protocol"],
effect: pkg.dependencies.effect,
}
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}
+1 -2
View File
@@ -1,6 +1,5 @@
import { makeDefaultApi } from "@opencode-ai/protocol/api"
import { LocationMiddleware } from "./location"
import { SessionLocationMiddleware } from "./middleware/session-location"
import { LocationMiddleware, SessionLocationMiddleware } from "./middleware/location.js"
export const Api = makeDefaultApi({
locationMiddleware: LocationMiddleware,
+1 -5
View File
@@ -4,14 +4,10 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Effect, Layer } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { LocationMiddleware } from "./middleware/location.js"
export type LocationServices = Layer.Success<ReturnType<(typeof LocationServiceMap.Service)["get"]>>
export class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware, { provides: LocationServices }>()(
"@opencode/HttpApiLocation",
) {}
export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const location = yield* Location.Service
@@ -0,0 +1,13 @@
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
export class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
"@opencode/HttpApiLocation",
) {}
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocationMiddleware>()(
"@opencode/HttpApiSessionLocation",
{
error: [InvalidRequestError, SessionNotFoundError],
},
) {}
@@ -8,16 +8,8 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { eq } from "drizzle-orm"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import type { LocationServices } from "../location"
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
SessionLocationMiddleware,
{ provides: LocationServices }
>()("@opencode/HttpApiSessionLocation", {
error: [InvalidRequestError, SessionNotFoundError],
}) {}
import { SessionLocationMiddleware } from "./location.js"
const decodeSessionID = Schema.decodeUnknownEffect(SessionV2.ID)
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": false,
"declaration": true,
"allowImportingTsExtensions": false
},
"include": ["src/api.ts", "src/middleware/location.ts"]
}
+2 -2
View File
@@ -71,6 +71,7 @@ import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keyma
import { usePathFormatter } from "../../context/path-format"
import { LocationProvider } from "../../context/location"
import { createSessionRows, type PartRef, type SessionRow } from "./rows"
import { switchLabel } from "../../util/model"
addDefaultParsers(parsers.parsers)
@@ -1231,8 +1232,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) {
const { theme } = useTheme()
const text = () => {
if (props.message.type === "agent-switched") return `Switched agent to ${props.message.agent}`
if (props.message.type === "model-switched")
return `Switched model to ${props.message.model.providerID}/${props.message.model.id}`
if (props.message.type === "model-switched") return switchLabel(props.message.model)
return ""
}
return <text fg={theme.textMuted}>{text()}</text>
+8
View File
@@ -26,3 +26,11 @@ export function name(
) {
return get(list, providerID, modelID)?.name ?? modelID
}
export function formatRef(model: { providerID: string; id: string; variant?: string }) {
return [model.providerID, model.id, model.variant].filter((value) => value !== undefined).join("/")
}
export function switchLabel(model: { providerID: string; id: string; variant?: string }) {
return `Switched model to ${formatRef(model)}`
}
+12 -1
View File
@@ -1,9 +1,20 @@
import { describe, expect, test } from "bun:test"
import { parse } from "../../src/util/model"
import { formatRef, parse, switchLabel } from "../../src/util/model"
describe("util.model", () => {
test("splits provider from a nested model identifier", () => {
expect(parse("provider/org/model")).toEqual({ providerID: "provider", modelID: "org/model" })
expect(parse("invalid")).toEqual({ providerID: "invalid", modelID: "" })
})
test("includes the selected variant in model refs", () => {
expect(formatRef({ providerID: "anthropic", id: "sonnet", variant: "thinking" })).toBe("anthropic/sonnet/thinking")
expect(formatRef({ providerID: "anthropic", id: "sonnet" })).toBe("anthropic/sonnet")
})
test("includes the selected variant in model switch notices", () => {
expect(switchLabel({ providerID: "anthropic", id: "sonnet", variant: "thinking" })).toBe(
"Switched model to anthropic/sonnet/thinking",
)
})
})
+3
View File
@@ -41,6 +41,9 @@ await $`bun ./packages/schema/script/publish.ts`
console.log("\n=== protocol ===\n")
await $`bun ./packages/protocol/script/publish.ts`
console.log("\n=== server ===\n")
await $`bun ./packages/server/script/publish.ts`
console.log("\n=== cli ===\n")
await $`bun ./packages/cli/script/publish.ts`