Compare commits

..
35 changed files with 197 additions and 401 deletions

No files matched your search

-20
View File
@@ -24,10 +24,6 @@ on:
description: "Override version (optional)"
required: false
type: string
release_notes:
description: "Reviewed V2 release notes for the Discord announcement (optional)"
required: false
type: string
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'v2' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }}
@@ -657,19 +653,3 @@ jobs:
OPENCODE_DESKTOP_DIST: /tmp/desktop
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
notify-discord-v2:
needs:
- version
- publish
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2' && needs.version.outputs.release && needs.publish.result == 'success'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
# Unlike dev, V2 publishes a tag rather than a GitHub Release event.
- name: Announce V2 release in Discord
uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
release_name: OpenCode V2 ${{ needs.version.outputs.tag }}
release_body: ${{ inputs.release_notes }}
release_html_url: https://github.com/${{ github.repository }}/tree/${{ needs.version.outputs.tag }}
+2 -3
View File
@@ -129,9 +129,8 @@ VercelAIGateway.configure().experimental.evaluation("typesafe-ai/jev")
OpenRouter reads `OPENROUTER_API_KEY`. Vercel reads `AI_GATEWAY_API_KEY`, then `VERCEL_OIDC_TOKEN`.
The common API uses `boolean`; System One routes lower it to native `noul`.
Choice and score answers include `confidence` when the provider returns it, such as
`response.answers.department.confidence`. Score legends remain available in provider metadata, and
the provider's rounded probabilities are returned unchanged.
Choice and score confidence plus score legends remain available in provider metadata, and the
provider's rounded probabilities are returned unchanged.
## Alibaba Cloud Model Studio
@@ -63,7 +63,6 @@ export const ChoiceAnswer = Schema.Struct({
type: Schema.Literal("choice"),
choice: Schema.String,
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
confidence: Schema.optional(Probability),
})
export type ChoiceAnswer = Schema.Schema.Type<typeof ChoiceAnswer>
@@ -71,7 +70,6 @@ export const ScoreAnswer = Schema.Struct({
type: Schema.Literal("score"),
score: Schema.Number,
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
confidence: Schema.optional(Probability),
})
export type ScoreAnswer = Schema.Schema.Type<typeof ScoreAnswer>
@@ -94,7 +92,6 @@ export type AnswerFor<Question extends EvaluationQuestion> = Question extends {
readonly type: "choice"
readonly choice: Extract<keyof Criteria, string>
readonly probabilities?: Readonly<Record<Extract<keyof Criteria, string>, number>>
readonly confidence?: number
}
: Question extends { readonly type: "score" }
? ScoreAnswer
+5 -10
View File
@@ -142,37 +142,32 @@ export const model = <Options extends EvaluationOptions = EvaluationOptions>(cfg
Effect.mapError((cause) => fail("System One returned an invalid response", cause, text)),
)
const confidence: Record<string, number> = {}
const legend: Record<string, Record<string, Schema.Json>> = {}
const answers = Object.fromEntries(
Object.entries(data.answers).map(([id, answer]): [string, EvaluationAnswer] => {
if (answer.type === "noul") return [id, { type: "boolean", probability: answer.noul }]
if (answer.type === "choice") {
if (answer.confidence !== undefined) confidence[id] = answer.confidence
return [
id,
{
type: "choice",
choice: answer.choice,
probabilities: answer.probabilities,
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
},
]
}
if (answer.confidence !== undefined) confidence[id] = answer.confidence
if (answer.legend !== undefined) legend[id] = answer.legend
return [
id,
{
type: "score",
score: answer.score,
probabilities: answer.probabilities,
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
},
]
return [id, { type: "score", score: answer.score, probabilities: answer.probabilities }]
}),
)
const meta = {
...(data.id === undefined ? {} : { responseId: data.id }),
...(data.provider === undefined ? {} : { provider: data.provider }),
...data.provider_metadata?.[cfg.providerMetadataKey],
...(Object.keys(confidence).length === 0 ? {} : { confidence }),
...(Object.keys(legend).length === 0 ? {} : { legend }),
}
return new EvaluationResponse({
@@ -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" } },
+1 -2
View File
@@ -39,18 +39,17 @@ describe("experimental Evaluation", () => {
type: "choice",
choice: "billing",
probabilities: { billing: 0.9, technical: 0.1 },
confidence: 0.8,
})
expect(response.answers.urgency).toEqual({
type: "score",
score: 1.2,
probabilities: { "0": 0, "1": 0.8, "2": 0.2 },
confidence: 0.6,
})
expect(response.answers.refund).toEqual({ type: "boolean", probability: 0.97 })
expect(response.usage?.totalTokens).toBe(36)
expect(response.providerMetadata).toEqual({
typesafe: {
confidence: { department: 0.8, urgency: 0.6 },
legend: { urgency: { "0": "Can wait", "1": "Needs attention", "2": "Blocking" } },
},
})
-2
View File
@@ -26,10 +26,8 @@ const request = Evaluation.request({
const result = EvaluationClient.evaluate(request)
type Result = Success<typeof result>
type Choice = Assert<Equal<Result["answers"]["topic"]["choice"], "billing" | "support">>
type Confidence = Assert<Equal<Result["answers"]["topic"]["confidence"], number | undefined>>
type ClientRequirements = Assert<Equal<Requirements<typeof result>, Service>>
void (true satisfies Choice)
void (true satisfies Confidence)
void (true satisfies ClientRequirements)
Effect.gen(function* () {
@@ -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",
]
@@ -92,6 +92,5 @@ const assertEvaluation = <Options extends EvaluationOptions>(
expect(response.answers.refund.probability).toBeGreaterThan(0.5)
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
expect(response.answers.department.confidence).toBeGreaterThan(0)
expect(response.answers.urgency.confidence).toBeGreaterThan(0)
expect(response.providerMetadata?.[metadataKey]?.confidence).toBeDefined()
})
@@ -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(
+3 -3
View File
@@ -51,7 +51,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
Flag.withDescription("Session ID to continue"),
Flag.optional,
),
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
@@ -328,7 +328,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
Flag.withDescription("Session ID to continue"),
Flag.optional,
),
fork: Flag.boolean("fork").pipe(
@@ -368,7 +368,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
Flag.withDescription("Session ID to continue"),
Flag.optional,
),
fork: Flag.boolean("fork").pipe(
+1 -15
View File
@@ -11,10 +11,6 @@ import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode/util/npm"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { Env } from "../../env"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
import { findSession } from "../../session-target"
import { errorMessage } from "../../util/error"
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
@@ -50,15 +46,6 @@ export default Runtime.handler(Commands, (input) =>
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
),
)
const session = Option.getOrUndefined(input.session)
// A missing --session ID becomes the ID of the session the first prompt creates.
const sessionExists =
session !== undefined &&
(yield* Effect.tryPromise({
try: () =>
findSession(OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }), session),
catch: (cause) => new Error(errorMessage(cause)),
})) !== undefined
const updater = yield* Updater.Service
let installing: string | undefined
const updateListeners = new Set<(version: string) => void>()
@@ -94,8 +81,7 @@ export default Runtime.handler(Commands, (input) =>
},
args: {
continue: input.continue,
sessionID: sessionExists ? session : undefined,
newSessionID: sessionExists ? undefined : session,
sessionID: Option.getOrUndefined(input.session),
prompt: Option.getOrUndefined(input.prompt),
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
},
+2 -6
View File
@@ -135,7 +135,6 @@ export async function runNonInteractivePrompt(input: Input) {
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
if (!input.auto) {
permissionRejected = true
if (input.compatibility !== "v1") process.exitCode = 1
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
@@ -494,8 +493,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.execution.interrupted") {
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) return
if (event.data.reason === "user" && interrupted) process.exitCode = 130
// A declined tool call ends the step with an interruption; it was already reported above.
if (event.data.reason !== "user" && !emittedError && !permissionRejected && !formCancelled) {
if (event.data.reason !== "user" && !emittedError) {
emittedError = true
process.exitCode = 1
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
@@ -622,9 +620,7 @@ export async function runNonInteractivePrompt(input: Input) {
UI.error(item.state.error.message)
}
// A declined tool call ends its step with an interrupted-step error that is
// only a consequence of our own rejection; it was already reported above.
if (message.error && !emittedError && !permissionRejected && !formCancelled) {
if (message.error && !emittedError) {
emittedError = true
process.exitCode = 1
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
+8 -13
View File
@@ -63,7 +63,6 @@ export async function resolveSessionTarget(input: {
(await input.client.session
.create(
{
id: input.session,
agent: prepared.agent,
model: prepared.model,
location: { directory: location.directory },
@@ -102,11 +101,14 @@ async function selectSession(input: {
fork?: boolean
signal?: AbortSignal
}) {
const explicit = input.session ? await findSession(input.client, input.session, input.signal) : undefined
if (input.session && !explicit) {
if (input.fork) throw new Error("Session not found")
return { session: undefined }
}
const explicit = input.session
? await input.client.session.get({ sessionID: input.session }, ...requestOptions(input.signal)).catch((error) => {
if (error && typeof error === "object" && "_tag" in error && error._tag === "SessionNotFoundError")
return undefined
throw error
})
: undefined
if (input.session && !explicit) throw new Error("Session not found")
if (explicit)
return {
session: input.fork
@@ -131,13 +133,6 @@ async function selectSession(input: {
}
}
export function findSession(client: OpenCodeClient, sessionID: string, signal?: AbortSignal) {
return client.session.get({ sessionID }, ...requestOptions(signal)).catch((error) => {
if (error && typeof error === "object" && "_tag" in error && error._tag === "SessionNotFoundError") return undefined
throw error
})
}
async function latestSession(
client: OpenCodeClient,
location: LocationGetOutput,
-22
View File
@@ -42,28 +42,6 @@ describe("session target resolver", () => {
})
})
test("creates a missing explicit Session with its ID", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.session, "get").mockRejectedValue({ _tag: "SessionNotFoundError" })
spyOn(client.location, "get").mockResolvedValue(location("/project"))
const create = spyOn(client.session, "create").mockResolvedValue(session("ses_chosen", "/project"))
const target = await resolveSessionTarget({ client, session: "ses_chosen", prepare })
expect(create).toHaveBeenCalledWith(expect.objectContaining({ id: "ses_chosen" }))
expect(target).toMatchObject({ session: { id: "ses_chosen" }, resume: false })
})
test("does not create a missing explicit Session to fork", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.session, "get").mockRejectedValue({ _tag: "SessionNotFoundError" })
const create = spyOn(client.session, "create")
await expect(resolveSessionTarget({ client, session: "ses_chosen", fork: true, prepare })).rejects.toThrow(
"Session not found",
)
expect(create).not.toHaveBeenCalled()
})
test("paginates to continue the exact directory", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project"))
@@ -1460,6 +1460,8 @@ export type ModelCompatibility = {
requireFinishReason?: boolean
requireAssistantAfterTool?: boolean
supportsPromptCacheKey?: boolean
supportsEffortUpdates?: boolean
supportsThinkingBlockBinding?: boolean
}
export type ModelCost = {
+9 -19
View File
@@ -1,7 +1,6 @@
export * as Generate from "./generate.js"
import { LLM, LLMClient, AIError } from "@opencode/ai"
import { SessionID } from "@opencode/schema/session-id"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { llmClient } from "./effect/app-node-platform.js"
@@ -61,24 +60,15 @@ export const layer = Layer.effect(
? `Model unavailable: ${input.model.providerID}/${input.model.id}`
: "No model specified and no supported model is available",
})
const response = yield* llm
.generate(
LLM.request({
model: resolved.model,
prompt: input.prompt,
// Gateways require session attribution even for a stateless call; no Session is stored.
http: { headers: { "x-opencode-session": SessionID.create() } },
}),
)
.pipe(
Effect.mapError(
(error: AIError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
}),
),
)
const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe(
Effect.mapError(
(error: AIError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
}),
),
)
return response.text
})
@@ -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")
) {
+3 -3
View File
@@ -273,12 +273,12 @@ export const layer = Layer.effect(
model: model.model,
http: {
headers: {
"x-session-affinity": affinity,
"X-Session-Id": affinity,
"x-session-affinity": session.id,
"X-Session-Id": session.id,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
"User-Agent": App.useragent(app),
"x-opencode-project": session.projectID,
"x-opencode-session": affinity,
"x-opencode-session": session.id,
"x-opencode-client": app.name,
},
},
+1 -56
View File
@@ -1,6 +1,5 @@
import { expect } from "bun:test"
import { LanguageModel, LLMClient } from "@opencode/ai"
import { RequestExecutor } from "@opencode/ai/route"
import { LanguageModel } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
import { TestLLM } from "@opencode/ai/testing"
import { AISDK } from "@opencode/core/aisdk"
@@ -11,7 +10,6 @@ import { ID, Info, Model, Ref } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { Npm } from "@opencode/util/npm"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { testEffect } from "./lib/effect"
const selected = Info.make({
@@ -91,56 +89,3 @@ resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
})
}),
)
testEffect(Layer.empty).effect("attributes each stateless completion without creating a stored session", () =>
Effect.gen(function* () {
const sessions: string[] = []
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
const session = request.headers["x-opencode-session"]
if (!session)
return HttpClientResponse.fromWeb(
request,
Response.json(
{
error: { type: "MissingSessionID", message: "Session ID is required" },
},
{ status: 400 },
),
)
sessions.push(session)
return HttpClientResponse.fromWeb(
request,
new Response(
`data: ${JSON.stringify({
id: "completion",
object: "chat.completion.chunk",
created: 1,
model: "gemini",
choices: [{ index: 0, delta: { content: "OK" }, finish_reason: "stop" }],
})}\n\ndata: [DONE]\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
)
}),
),
)
const native = LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(http))))
yield* Effect.gen(function* () {
const generate = yield* Generate.Service
for (let index = 0; index < 2; index++) {
expect(
yield* generate.text({
prompt: "Return exactly OK",
model: Ref.make({ providerID: selected.providerID, id: selected.id }),
}),
).toBe("OK")
}
}).pipe(Effect.provide(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, native)))))
expect(sessions).toHaveLength(2)
expect(sessions[0]).toStartWith("ses_")
expect(sessions[1]).not.toBe(sessions[0])
}),
)
@@ -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(
@@ -445,12 +445,12 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(requests).toHaveLength(1)
expect(requests[0]?.promptCacheKey).toBe(parentID)
expect(requests[0]?.http?.headers).toEqual({
"x-session-affinity": parentID,
"X-Session-Id": parentID,
"x-session-affinity": sessionID,
"X-Session-Id": sessionID,
"x-parent-session-id": parentID,
"User-Agent": App.useragent(App.make()),
"x-opencode-project": Project.ID.global,
"x-opencode-session": parentID,
"x-opencode-session": sessionID,
"x-opencode-client": "opencode",
})
expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 20_000 }))
+1 -6
View File
@@ -4693,12 +4693,7 @@ describe("SessionRunnerLLM", () => {
.pipe(Effect.orDie)
yield* s.runPrompt("Run child request")
expect(s.requests[0]?.http?.headers).toMatchObject({
"x-session-affinity": parentID,
"X-Session-Id": parentID,
"x-parent-session-id": parentID,
"x-opencode-session": parentID,
})
expect(s.requests[0]?.http?.headers?.["x-parent-session-id"]).toBe(parentID)
expect(s.requests[0]?.promptCacheKey).toBe(parentID)
})
+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> {}
@@ -208,8 +208,6 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
const projectOptions = data.project
.list()
.filter((project) => project.canonical !== "/")
// Historical project identities can share a checkout. The list is newest-active first.
.filter((project, index, projects) => projects.findIndex((item) => item.canonical === project.canonical) === index)
.map((project) => ({ directory: project.canonical, project }))
.map((item) => {
const title =
@@ -53,7 +53,6 @@ import { usePromptMove } from "./move"
import { resolvePastedAttachments } from "./local-attachment"
import { locationKey, useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { useArgs } from "../../context/args"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { useInteractivity } from "../../context/interactivity"
import { abbreviateHome } from "../../runtime"
@@ -207,7 +206,6 @@ export function Prompt(props: PromptProps) {
const directoryRecents = useDirectoryRecents()
const keymapCommands = Keymap.useCommands()
const currentLocation = useLocation()
const args = useArgs()
const config = useConfig().data
const dialog = useDialog()
const toast = useToast()
@@ -1231,9 +1229,7 @@ export function Prompt(props: PromptProps) {
// a local session record synchronously, so the navigation below happens
// immediately — enter feels sent even while the create round-trip is in
// flight. Sends against the new session gate on the request.
const newSessionID = args.takeNewSessionID()
const created = data.session.create({
id: newSessionID,
location: directory ? { directory } : location,
agent: agent.id,
model: {
@@ -1242,7 +1238,6 @@ export function Prompt(props: PromptProps) {
variant,
},
})
if (newSessionID !== undefined) created.request.catch(() => args.restoreNewSessionID(newSessionID))
sessionID = created.id
session = data.session.get(created.id)
newSession = {
+1 -16
View File
@@ -1,4 +1,3 @@
import { mergeProps } from "solid-js"
import { createSimpleContext } from "./helper"
export interface Args {
@@ -7,25 +6,11 @@ export interface Args {
prompt?: string
continue?: boolean
sessionID?: string
newSessionID?: string
fork?: boolean
auto?: boolean
}
export const { use: useArgs, provider: ArgsProvider } = createSimpleContext({
name: "Args",
init: (props: Args) => {
// The first new session created from home takes this ID; later ones mint their own.
let pending = props.newSessionID
return mergeProps(props, {
takeNewSessionID() {
const id = pending
pending = undefined
return id
},
restoreNewSessionID(id: string) {
pending ??= id
},
})
},
init: (props: Args) => props,
})
+2 -3
View File
@@ -65,13 +65,12 @@ export function Home() {
untrack(() => composer.set(prompt))
})
// Wait for everything submit needs before auto-submitting --prompt; it runs once.
// Wait for the model store to be ready before auto-submitting --prompt.
createEffect(() => {
const r = ref()
if (sent) return
if (!r) return
if (!local.model.ready || !local.model.catalogReady) return
if (!local.agent.current() || !local.model.current()) return
if (!local.model.ready) return
if (!args.prompt) return
if (r.current.text !== args.prompt) return
sent = true
-55
View File
@@ -876,61 +876,6 @@ test("session startup prompt is submitted exactly once", async () => {
}
})
test("home startup prompt is submitted exactly once", async () => {
await using state = await tmpdir()
const cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd, canonical: cwd } }
const bodies: unknown[] = []
const submitted = Promise.withResolvers<void>()
let session: unknown
await using setup = await createAppFixture({
state: state.path,
args: { prompt: "HOME_READY" },
config: { animations: false, tabs: { mode: "off" } },
fetch: async (url, request) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
if (url.pathname === "/api/session" && request.method === "POST") {
const input: unknown = await request.json()
if (typeof input !== "object" || input === null) throw new Error("Expected a session input")
session = {
...input,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
return json({ data: session })
}
if (/^\/api\/session\/[^/]+\/prompt$/.test(url.pathname)) {
bodies.push(await request.json())
submitted.resolve()
return json({ data: {} })
}
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
if (session && /^\/api\/session\/[^/]+$/.test(url.pathname)) return json({ data: session })
return undefined
},
})
await setup.ready
await Promise.race([
submitted.promise,
Bun.sleep(2000).then(() => {
throw new Error("startup prompt was not submitted")
}),
])
await Bun.sleep(20)
expect(bodies).toHaveLength(1)
expect(bodies[0]).toMatchObject({ text: "HOME_READY" })
})
test.each([false, true])("uses the resolved launch directory for new prompts (fallback: %s)", async (fallback) => {
await using state = await tmpdir()
const target = fallback ? directory : process.cwd()
-100
View File
@@ -1,100 +0,0 @@
import { expect, test } from "bun:test"
import { createAppFixture } from "./fixture/app"
import { json } from "./fixture/tui-client"
type SessionInput = { id?: string; location?: { directory: string } }
const location = {
directory: "/tmp/opencode/packages/tui",
project: { id: "project", directory: "/tmp/opencode", canonical: "/tmp/opencode" },
}
function sessionInfo(record: SessionInput) {
return {
...record,
location: record.location ?? { directory: location.directory },
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
}
async function launch(options: { failCreates?: number } = {}) {
const attempts: (string | undefined)[] = []
const created: SessionInput[] = []
const prompts: string[] = []
let failures = options.failCreates ?? 0
const setup = await createAppFixture({
args: { newSessionID: "ses_chosen" },
config: { animations: false, keybinds: { "session.new": "f6" } },
fetch: async (url, request) => {
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "demo", name: "Demo" }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "demo", name: "Demo Model", variants: [] }] })
if (url.pathname === "/api/session" && request.method === "POST") {
const record: SessionInput = await request.json()
attempts.push(record.id)
if (failures > 0) {
failures--
return json({ message: "create failed" }, { status: 500 })
}
created.push(record)
return json({ data: sessionInfo(record) })
}
if (/^\/api\/session\/[^/]+\/prompt$/.test(url.pathname)) {
prompts.push(url.pathname.split("/")[3] ?? "")
return json({ data: {} })
}
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
if (/^\/api\/session\/[^/]+\/(agent|model)$/.test(url.pathname)) return new Response(null, { status: 204 })
if (/^\/api\/session\/[^/]+$/.test(url.pathname)) {
const record = created.find((item) => item.id === url.pathname.split("/")[3])
if (!record) return json({ message: "not found" }, { status: 404 })
return json({ data: sessionInfo(record) })
}
return undefined
},
})
return { setup, attempts, created, prompts }
}
test("the first new session uses the launch session ID and later ones mint their own", async () => {
const run = await launch()
await using setup = run.setup
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Demo Model"))
await setup.mockInput.typeText("hello")
setup.mockInput.pressEnter()
await setup.waitForFrame(() => run.prompts.length === 1)
expect(run.prompts[0]).toBe("ses_chosen")
expect(run.created.map((item) => item.id)).toEqual(["ses_chosen"])
setup.mockInput.pressKey("F6")
await setup.renderOnce()
await setup.mockInput.typeText("again")
setup.mockInput.pressEnter()
await setup.waitForFrame(() => run.created.length === 2)
expect(run.created[1]?.id).toMatch(/^ses/)
expect(run.created[1]?.id).not.toBe("ses_chosen")
})
test("a failed first create keeps the launch session ID for the retry", async () => {
const run = await launch({ failCreates: 1 })
await using setup = run.setup
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Demo Model"))
await setup.mockInput.typeText("hello")
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Creating a session failed") && frame.includes("hello"))
expect(run.attempts).toEqual(["ses_chosen"])
setup.mockInput.pressEnter()
await setup.waitForFrame(() => run.created.length === 1)
expect(run.attempts).toEqual(["ses_chosen", "ses_chosen"])
})
+2 -4
View File
@@ -61,9 +61,7 @@ if ((await editor.exited) !== 0) {
const document = await Bun.file(review).text()
const notes = document.match(/<!-- changelog:start -->\s*([\s\S]*?)\s*<!-- changelog:end -->/)
if (!notes) throw new Error("Release review is missing its changelog markers")
const releaseNotes = notes[1].trim()
if (!releaseNotes) throw new Error("Release review has no changelog")
await Bun.write(changelog, `${releaseNotes}\n`)
await Bun.write(changelog, `${notes[1].trim()}\n`)
const answer = prompt(`Trigger the ${version} release? [y/N]`)
if (answer?.trim().toLowerCase() !== "y" && answer?.trim().toLowerCase() !== "yes") {
@@ -71,7 +69,7 @@ if (answer?.trim().toLowerCase() !== "y" && answer?.trim().toLowerCase() !== "ye
process.exit(0)
}
await $`gh workflow run publish.yml --ref v2 ${input} -f release_notes=${releaseNotes}`
await $`gh workflow run publish.yml --ref v2 ${input}`
console.log(`Triggered the ${version} release`)
async function generateReview(base: string) {