Compare commits

...
11 changed files with 152 additions and 28 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(
@@ -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> {}