Compare commits

..

3 Commits

Author SHA1 Message Date
Shoubhit Dash 0bcfb31e7f feat(schema): expose effort and thinking binding overrides 2026-09-28 18:44:15 +05:30
Shoubhit Dash 0cbcbfdd4c fix(core): treat gpt-6+ as reasoning models on copilot 2026-09-28 18:44:14 +05:30
Shoubhit Dash 8409754e0d fix(ai): assume newer models support known features 2026-09-28 18:44:14 +05:30
12 changed files with 152 additions and 70 deletions
@@ -800,18 +800,13 @@ const requireThinkingSignature = (request: LLMRequest) => {
return true
}
// Mid-conversation system messages became available with Opus 4.8 and version
// 5 of the other supported Claude families. Treat later family versions as
// compatible without assuming that every Anthropic Messages model is Claude.
// Mid-conversation system messages became available with Opus 4.8 and every
// Claude 5 model. Treat later Claude versions of any family as compatible
// without assuming that every Anthropic Messages model is Claude.
const supportsNativeSystemUpdates = (request: LLMRequest) => {
const match = /(?:^|[./])claude-(fable|haiku|mythos|opus|sonnet)-(\d+)(?:[.-](\d+))?/.exec(
String(request.model.id).toLowerCase(),
)
if (!match) return false
const major = Number(match[2])
if (match[1] !== "opus") return major >= 5
if (major !== 4) return major >= 5
return match[3] !== undefined && match[3].length <= 2 && Number(match[3]) >= 8
const version = claudeVersion(request.model.id)
if (version === undefined) return false
return version.major >= 5 || (version.family === "opus" && version.major === 4 && version.minor >= 8)
}
const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
@@ -1004,14 +999,14 @@ const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => {
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
}
// Sonnet 5 and Fable 5 reject mid-conversation effort.
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
const version = claudeVersion(model.id)
if (version === undefined) return false
if (version.family === "opus") return version.major >= 5
if (version.family !== "fable" && version.family !== "mythos") return false
return version.major > 5 || (version.major === 5 && version.minor >= 1)
if (version.major !== 5) return version.major > 5
return version.family === "opus" || version.minor >= 1
}
const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: AnthropicThinking | undefined) => {
@@ -143,14 +143,15 @@ const adapter = {
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
// GPT-6 Astra, Sol, and Luna accept `configuration_update` only in standard mode (not `reasoning.mode: "pro"` or
// `-pro` slugs), and never alongside automatic `context_management` compaction.
// Pro mode (`reasoning.mode: "pro"` or `-pro` slugs) rejects `configuration_update`, and it never combines with
// automatic `context_management` compaction. Bedrock Mantle's `openai.` ids stay off until support is confirmed.
const supportsEffortUpdates = (request: LLMRequest) => {
if (request.providerOptions?.contextManagement !== undefined) return false
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(request.http?.body?.reasoning)) return false
const override = request.model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
return /(?:^|\/)gpt-6-(?:astra|sol|luna)$/i.test(request.model.id)
const gpt = /(?:^|\/)gpt-(\d+)(?:[.-]|$)/i.exec(request.model.id)
return gpt !== null && Number(gpt[1]) >= 6 && !/-pro(?:-|$)/i.test(request.model.id)
}
const nativeImageToolInput = (tool: ToolDefinition) => {
+5 -4
View File
@@ -32,13 +32,14 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
return result
}
export const gpt5DefaultOptions = (modelID: string): ProviderOptions | undefined => {
export const reasoningDefaultOptions = (modelID: string): ProviderOptions | undefined => {
const id = modelID.toLowerCase()
if (!id.includes("gpt-5") || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined
const reasoning = Number(/gpt-(\d+)/.exec(id)?.[1]) >= 5
if (!reasoning || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined
return openAIProviderOptions({
reasoningEffort: "medium",
reasoningSummary: "auto",
// GPT-5 reasoning models are configured stateless (`store: false`) by
// GPT reasoning models are configured stateless (`store: false`) by
// `openAIDefaultOptions` below, so the only way a follow-up turn can
// carry reasoning state is via the encrypted reasoning include. Without
// this, callers using the default model facade get reasoning summaries
@@ -48,7 +49,7 @@ export const gpt5DefaultOptions = (modelID: string): ProviderOptions | undefined
}
export const openAIDefaultOptions = (modelID: string): ProviderOptions | undefined =>
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID))
mergeProviderOptions(openAIProviderOptions({ store: false }), reasoningDefaultOptions(modelID))
export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>(
modelID: string,
+14 -4
View File
@@ -197,9 +197,14 @@ describe("Anthropic Messages effort updates", () => {
["anthropic/claude-opus-5", true],
["claude-fable-5-1", true],
["claude-mythos-5-1", true],
["claude-opus-5-5-fast", true],
["claude-sonnet-5-1", true],
["claude-haiku-6", true],
["claude-nova-6", true],
["claude-fable-5", false],
["claude-opus-4-8", false],
["claude-sonnet-5", false],
["claude-sonnet-5-20260901", false],
["kimi-k2.5", false],
] as const) {
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
@@ -364,7 +369,7 @@ describe("OpenAI Responses effort updates", () => {
)
expect(updates(prepared.body)).toEqual([])
expect(prepared.body.reasoning).toEqual({ effort: "low" })
expect(prepared.body.reasoning).toEqual({ effort: "low", summary: "auto" })
}),
)
@@ -375,10 +380,15 @@ describe("OpenAI Responses effort updates", () => {
["openai/gpt-6-sol", true],
["gpt-6-luna", true],
["openai/gpt-6-luna", true],
["gpt-6-astra-2026-09-01", false],
["gpt-6-astra-2026-09-01", true],
["gpt-6-sol-fast", true],
["gpt-6-terra", true],
["gpt-6.1-astra", true],
["gpt-7", true],
["gpt-6-sol-pro", false],
["gpt-6-luna-pro", false],
["gpt-6-sol-fast", false],
["gpt-6-sol-pro-2026-09-01", false],
["openai.gpt-6-sol", false],
["gpt-5.6-sol", false],
] as const) {
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
@@ -426,7 +436,7 @@ describe("OpenAI Responses effort updates", () => {
dynamicResponse(({ text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(body.reasoning).toEqual({ effort: "high" })
expect(body.reasoning).toEqual({ effort: "high", summary: "auto" })
expect(body.input).toEqual([
{ type: "message", role: "user", content: [{ type: "input_text", text: "Before." }] },
{ type: "configuration_update", reasoning: { effort: "low" } },
@@ -305,14 +305,17 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("supports native chronological system updates on documented and later Claude family versions", () =>
it.effect("supports native chronological system updates on documented and later Claude versions", () =>
Effect.gen(function* () {
const ids = [
"claude-opus-4-8",
"claude-opus-4-8-fast",
"claude-opus-5-1",
"claude-sonnet-5",
"claude-sonnet-5-20260901",
"claude-haiku-5-1",
"claude-fable-6",
"claude-nova-6",
"anthropic/claude-mythos-7.2",
]
@@ -359,13 +362,14 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("does not infer native system update support for older or undocumented Claude families", () =>
it.effect("does not infer native system update support for older Claude versions or non-Claude models", () =>
Effect.gen(function* () {
const ids = [
"claude-opus-4-7",
"claude-opus-4-20250514",
"claude-sonnet-4-9",
"claude-haiku-4-9",
"claude-3-7-sonnet-20250219",
"custom-model-7",
]
@@ -2069,6 +2069,31 @@ describe("OpenAI Responses route", () => {
}),
)
for (const [id, defaults] of [
["gpt-5", true],
["gpt-5.6-sol", true],
["gpt-6-sol", true],
["gpt-6.1-astra", true],
["openai/gpt-7-terra", true],
["gpt-5-chat-latest", false],
["gpt-5-pro", false],
["gpt-4.1", false],
["o3", false],
] as const) {
it.effect(`${defaults ? "applies" : "skips"} reasoning defaults for ${id}`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(id),
prompt: "hi",
}),
)
expect(prepared.body.reasoning).toEqual(defaults ? { effort: "medium", summary: "auto" } : undefined)
}),
)
}
it.effect("lets callers opt out of the GPT-5 default include", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1,42 +0,0 @@
# RFC: The `.exe` name of the npm-installed CLI on Unix
Status: Proposed decision record (2026-09-28)
## Problem
A Linux user can run `opencode` and see `opencode.exe` in a process list. That looks like a Windows program, even though the running file is a native Linux ELF executable. The suffix is a pathname chosen by our npm packaging, not the executable format or a compatibility layer.
This RFC records a deliberate *non-change*: why that pathname exists and the cost of changing it. It does not propose changing the binary in this PR.
## How it works today
- The published `@opencode/cli` package has a single `bin` target, `./bin/opencode.exe`, for Linux, macOS, and Windows. The same target also serves the `opencode2` command. See [`script/publish.ts`](../script/publish.ts).
- Each platform-specific package contains a native binary: `opencode` on Linux/macOS and `opencode.exe` on Windows. The generic package's [`postinstall.mjs`](../script/postinstall.mjs) selects the appropriate optional dependency and hard-links or copies its binary to that *same* `bin` target. Therefore, on Unix, a native executable ends up at a pathname ending in `.exe`.
- The direct [install script](../../../install) and release archives keep the Unix executable named `opencode`. This is specific to the generic npm package; the public command users type remains `opencode`.
The constraint is the combination of **one cross-platform npm package, one static `bin` target, and direct execution of the native binary without a launcher**. Windows needs an executable target there, while the generic package cannot select a different `bin` path per OS at install time. Choosing `opencode.exe` lets the Windows target be a real executable and lets npm expose the same command everywhere. Unix permits an ELF or Mach-O file to have that suffix, but process tools can then display it.
The package also ships a text placeholder at the target path until postinstall replaces it. A skipped postinstall is a *separate functional install problem*; removing the misleading Unix suffix alone would not resolve that failure mode.
## Other npm CLIs
These are different choices, checked against their published npm packages on 2026-09-28:
- [Pi (`@earendil-works/pi-coding-agent@0.87.1`)](https://registry.npmjs.org/%40earendil-works%2Fpi-coding-agent/0.87.1) maps `pi` to a JavaScript entrypoint, `dist/bundle/cli.js`, run with Node. It is not trying to expose one direct native binary through npm.
- [Codex (`@openai/codex@0.158.0`)](https://registry.npmjs.org/%40openai%2Fcodex/0.158.0) maps `codex` to `bin/codex.js`. That Node launcher selects `codex` on Unix or `codex.exe` on Windows from a platform package, spawns it, and handles signals and exit status. This avoids a `.exe`-named Unix process at the cost of a launcher process.
- [Claude Code (`@anthropic-ai/claude-code@2.1.283`)](https://registry.npmjs.org/%40anthropic-ai%2Fclaude-code/2.1.283) makes the same direct-execution choice as OpenCode: its npm `bin` is `bin/claude.exe` on all platforms, with postinstall replacing the placeholder with the native binary. Unix users can see `.exe` in the pathname there too.
- [esbuild (`esbuild@0.28.2`)](https://registry.npmjs.org/esbuild/0.28.2) uses an extensionless JavaScript `bin/esbuild` that locates the native binary. Its postinstall *sometimes* replaces that entrypoint with a Unix native binary for direct execution; Windows keeps the launcher. This avoids the Unix suffix and often the extra Unix process, but adds platform- and package-manager-dependent behavior and still needs a Windows launcher.
## Options
1. **Keep the direct native target (recommended for now).** No new process, signal forwarding, runtime dependency at launch, or install-path migration. Cost: Unix process lists can say `opencode.exe`, which is confusing. Explain the packaging trade-off when asked.
2. **Use a platform-aware launcher as the npm `bin` target.** It could select `opencode` on Unix and `opencode.exe` on Windows from the platform-specific package, so the child process has its native filename. Cost: an additional process or platform-specific `exec` behavior, startup work, signal and exit-code forwarding, and a larger test surface across package managers. A launcher already exists for development in [`bin/opencode.cjs`](../bin/opencode.cjs); using one in release packaging would be a deliberate change to today's direct-execution behavior, not a rename.
3. **Use an esbuild-style hybrid launcher.** Publish a suffix-free JavaScript entrypoint; replace it with the native executable on Unix where safe, but keep the launcher on Windows and as a fallback. This improves the Unix process name and often avoids a second Unix process, but complicates postinstall, upgrades, cross-platform startup, and script-disabled behavior. It still crosses the no-launcher boundary on Windows.
4. **Publish different top-level packages or mutate package-manager links per OS.** This can give Unix a suffix-free target without a launcher, but loses the simple single-package install or makes startup depend on nonportable install/link behavior. Do not assume rewriting `package.json` in postinstall will reliably relink a previously created command.
5. **Change the displayed process title.** This might cosmetically affect some monitors, but leaves the actual filename and packaging contract unchanged. It is not a fix for the underlying discrepancy.
## Proposal and decision boundary
**Proposed decision: do not add a launcher or rename the npm target just to change the process-list label.** Keep the existing packaging until the name causes enough user confusion to justify a packaging change, or a broader install/reliability redesign makes the launcher cost worthwhile. Treat a skipped-postinstall/broken-upgrade fix on its own merits rather than claiming the filename change fixes it. This RFC is a record for review, not approval or implementation of a launcher.
If we choose an alternative, prove it against the current behavior before release: native binary format and process name on Linux and macOS; `.exe` startup on Windows; npm, Bun, pnpm, and Yarn installs; blocked or skipped install scripts; upgrades; exit codes and signals; and both `opencode` and the legacy `opencode2` command. Keep direct-install and archive filenames unchanged unless there is a separate reason to alter them.
@@ -1460,6 +1460,8 @@ export type ModelCompatibility = {
requireFinishReason?: boolean
requireAssistantAfterTool?: boolean
supportsPromptCacheKey?: boolean
supportsEffortUpdates?: boolean
supportsThinkingBlockBinding?: boolean
}
export type ModelCost = {
@@ -1695,7 +1695,7 @@ function getResponsesModelConfig(modelId: string): ResponsesModelConfig {
// o series reasoning models:
if (
modelId.startsWith("o") ||
modelId.startsWith("gpt-5") ||
Number(/^gpt-(\d+)/.exec(modelId)?.[1]) >= 5 ||
modelId.startsWith("codex-") ||
modelId.startsWith("computer-use")
) {
@@ -0,0 +1,53 @@
import { OpenAIResponsesLanguageModel } from "@opencode/core/github-copilot/responses/openai-responses-language-model"
import { afterAll, describe, expect, test } from "bun:test"
const bodies: unknown[] = []
const server = Bun.serve({
port: 0,
fetch: async (request) => {
bodies.push(await request.json())
return new Response("", { headers: { "Content-Type": "text/event-stream" } })
},
})
afterAll(() => server.stop())
async function requestBody(modelID: string) {
const model = new OpenAIResponsesLanguageModel(modelID, {
provider: "copilot.responses",
url: ({ path }) => new URL(path, server.url).href,
headers: () => ({ Authorization: "Bearer test-token" }),
})
await model.doStream({
prompt: [
{ role: "system", content: "Be brief." },
{ role: "user", content: [{ type: "text", text: "Hello" }] },
],
temperature: 0.5,
providerOptions: { copilot: { reasoningEffort: "high" } },
})
return bodies.at(-1)
}
describe("reasoning model detection", () => {
for (const id of ["gpt-5.5", "gpt-6-sol", "gpt-6.1-astra", "gpt-7"]) {
test(`treats ${id} as a reasoning model`, async () => {
const body = await requestBody(id)
expect(body).toMatchObject({
reasoning: { effort: "high" },
input: [{ role: "developer", content: "Be brief." }, { role: "user" }],
})
expect(body).not.toHaveProperty("temperature")
})
}
for (const id of ["gpt-4.1", "gpt-5-chat-latest"]) {
test(`treats ${id} as a non-reasoning model`, async () => {
const body = await requestBody(id)
expect(body).not.toHaveProperty("reasoning")
expect(body).toMatchObject({
temperature: 0.5,
input: [{ role: "system", content: "Be brief." }, { role: "user" }],
})
})
}
})
+31
View File
@@ -449,6 +449,37 @@ describe("ModelResolver", () => {
),
)
it.effect("lets catalog compatibility disable default effort updates", () =>
Effect.gen(function* () {
const request = (compatibility?: Compatibility) =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode/ai/providers/openai", {
modelID: "gpt-6-sol",
compatibility,
settings: { apiKey: "test" },
}),
)
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
messages: [
Message.user("Before."),
Message.effort({ effort: "low", previous: "high" }),
Message.user("After."),
],
providerOptions: { reasoningEffort: "low" },
}),
)
return prepared.body.input
})
const update = { type: "configuration_update", reasoning: { effort: "low" } }
expect(yield* request()).toContainEqual(update)
expect(yield* request({ supportsEffortUpdates: false })).not.toContainEqual(update)
}),
)
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
+2
View File
@@ -80,6 +80,8 @@ export const Compatibility = Schema.Struct({
requireFinishReason: Schema.Boolean.pipe(optional),
requireAssistantAfterTool: Schema.Boolean.pipe(optional),
supportsPromptCacheKey: Schema.Boolean.pipe(optional),
supportsEffortUpdates: Schema.Boolean.pipe(optional),
supportsThinkingBlockBinding: Schema.Boolean.pipe(optional),
}).annotate({ identifier: "Model.Compatibility" })
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}