mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-27 02:44:47 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bcfb31e7f | |||
| 0cbcbfdd4c | |||
| 8409754e0d |
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }],
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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> {}
|
||||
|
||||
Reference in New Issue
Block a user