mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-11-14 07:42:03 +08:00
fix(core): fit Claude output to remaining context
This commit is contained in:
@@ -1064,6 +1064,16 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
}
|
||||
const output_config =
|
||||
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
|
||||
const maxTokens = generation?.maxTokens ?? DEFAULT_MAX_TOKENS
|
||||
// A context-fitted Claude output limit can fall below a configured thinking budget.
|
||||
// Anthropic requires budget_tokens < max_tokens, with a minimum budget of 1,024.
|
||||
const thinking =
|
||||
/(?:^|[./])claude-/.test(request.model.id.toLowerCase()) &&
|
||||
options.thinking?.type === "enabled" &&
|
||||
maxTokens > 1_024 &&
|
||||
options.thinking.budget_tokens >= maxTokens
|
||||
? { ...options.thinking, budget_tokens: maxTokens - 1 }
|
||||
: options.thinking
|
||||
const body = {
|
||||
model: request.model.id,
|
||||
system,
|
||||
@@ -1071,12 +1081,12 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
tools,
|
||||
tool_choice: toolChoice,
|
||||
stream: true as const,
|
||||
max_tokens: generation?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
||||
max_tokens: maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: applyThinkingBindingDefault(request.model, options.thinking),
|
||||
thinking: applyThinkingBindingDefault(request.model, thinking),
|
||||
output_config,
|
||||
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
|
||||
cache_control: options.cache_control ?? options.cacheControl,
|
||||
|
||||
@@ -181,6 +181,34 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fits Claude's explicit thinking budget below a context-fitted output limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
generation: { maxTokens: 5_000 },
|
||||
providerOptions: { thinking: { type: "enabled", budgetTokens: 32_000 } },
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.max_tokens).toBe(5_000)
|
||||
expect(prepared.body.thinking).toMatchObject({ type: "enabled", budget_tokens: 4_999 })
|
||||
const minimal = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
generation: { maxTokens: 1_025 },
|
||||
providerOptions: { thinking: { type: "enabled", budgetTokens: 32_000 } },
|
||||
}),
|
||||
)
|
||||
expect(minimal.body.thinking).toMatchObject({ type: "enabled", budget_tokens: 1_024 })
|
||||
const otherModel = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
model: AnthropicMessages.route.model({ id: "kimi-k2" }),
|
||||
generation: { maxTokens: 5_000 },
|
||||
providerOptions: { thinking: { type: "enabled", budgetTokens: 32_000 } },
|
||||
}),
|
||||
)
|
||||
expect(otherModel.body.thinking).toMatchObject({ type: "enabled", budget_tokens: 32_000 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers passthrough provider options and accepts either key spelling", () =>
|
||||
Effect.gen(function* () {
|
||||
const snake = yield* compileRequest(
|
||||
|
||||
@@ -46,6 +46,9 @@ const IMAGE_REMOVED =
|
||||
const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields))
|
||||
const CLAUDE_OUTPUT_CAP = 128_000
|
||||
const ANTHROPIC_DEFAULT_OUTPUT = 32_000
|
||||
const CONTEXT_MARGIN = 4_096
|
||||
// Manual Anthropic thinking requires at least 1,024 tokens and must be strictly below max_tokens.
|
||||
const MIN_OUTPUT = 1_025
|
||||
|
||||
/** Tool errors, plus the user declining a permission or dismissing a question. */
|
||||
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
|
||||
@@ -71,8 +74,13 @@ export interface Input {
|
||||
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||
/** Only the durable runner may use a stateful WebSocket. */
|
||||
readonly webSocket?: "session"
|
||||
/** Estimated prompt size for fitting Claude's requested output into its context window. */
|
||||
readonly inputTokens?: number
|
||||
}
|
||||
|
||||
export const isClaudeMessages = (model: SessionRunnerModel.Resolved) =>
|
||||
model.model.route.protocol === "anthropic-messages" && /(?:^|[./])claude-/.test(model.model.id.toLowerCase())
|
||||
|
||||
export const baseTranscript = (input: {
|
||||
readonly agent: Agent.Info
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
@@ -220,21 +228,21 @@ export const layer = Layer.effect(
|
||||
const given = new Map(
|
||||
tools.definitions.map((t) => [{ description: t.description, input: { ...t.inputSchema } }, t] as const),
|
||||
)
|
||||
// Temporary Claude Messages limit until the context-aware policy in #51021 lands.
|
||||
// Temporary Claude Messages limit until the provider-wide policy in #51021 lands.
|
||||
// Leave other models and request kinds on their existing provider defaults.
|
||||
const claudeMessages =
|
||||
kind === "primary" &&
|
||||
model.model.route.protocol === "anthropic-messages" &&
|
||||
/(?:^|[./])claude-/.test(model.model.id.toLowerCase())
|
||||
const claudeMessages = kind === "primary" && isClaudeMessages(model)
|
||||
const outputLimit = Math.min(model.limit.output || ANTHROPIC_DEFAULT_OUTPUT, CLAUDE_OUTPUT_CAP)
|
||||
const maxTokens =
|
||||
input.inputTokens === undefined || model.limit.context <= 0
|
||||
? outputLimit
|
||||
: Math.min(outputLimit, Math.max(MIN_OUTPUT, model.limit.context - input.inputTokens - CONTEXT_MARGIN))
|
||||
const shaped = yield* shape(
|
||||
{
|
||||
sessionID: session.id,
|
||||
model: model.ref,
|
||||
system: input.system,
|
||||
messages: input.messages,
|
||||
options: claudeMessages
|
||||
? { maxTokens: Math.min(model.limit.output || ANTHROPIC_DEFAULT_OUTPUT, CLAUDE_OUTPUT_CAP) }
|
||||
: {},
|
||||
options: claudeMessages ? { maxTokens } : {},
|
||||
},
|
||||
Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])),
|
||||
)
|
||||
|
||||
@@ -244,6 +244,15 @@ const layer = Layer.effect(
|
||||
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
webSocket: "session",
|
||||
...(SessionModelRequest.isClaudeMessages(loaded.model)
|
||||
? {
|
||||
inputTokens: SessionCompaction.estimateTokens({
|
||||
messages: loaded.messages,
|
||||
resolved: loaded.model,
|
||||
context: loaded,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
const outcome = yield* steps.attempt({
|
||||
isLocationClosed: lifecycle.isClosed,
|
||||
|
||||
@@ -69,6 +69,40 @@ describe("SessionModelRequest Claude output limit", () => {
|
||||
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
|
||||
)
|
||||
|
||||
it.effect("leaves space for the prompt near Claude's context window", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
|
||||
for (const context of [200_000, 1_000_000]) {
|
||||
const prepared = yield* requests.primary({
|
||||
session,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: SessionRunnerModel.resolved(AnthropicMessages.route.model({ id: "claude-sonnet-4-5" }), {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context, output: 128_000 },
|
||||
}),
|
||||
system: [],
|
||||
messages: [Message.user("hello")],
|
||||
inputTokens: context - 100_000,
|
||||
})
|
||||
expect((yield* compileRequest(prepared.request)).body.max_tokens).toBe(95_904)
|
||||
}
|
||||
const minimal = yield* requests.primary({
|
||||
session,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: SessionRunnerModel.resolved(AnthropicMessages.route.model({ id: "claude-opus-4-8" }), {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 128_000 },
|
||||
}),
|
||||
system: [],
|
||||
messages: [],
|
||||
inputTokens: 196_000,
|
||||
})
|
||||
expect(minimal.request.generation?.maxTokens).toBe(1_025)
|
||||
}).pipe(Effect.provideService(SessionModelTransport.Service, transport)),
|
||||
)
|
||||
|
||||
it.effect("does not change non-primary requests or non-Claude models", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer))
|
||||
|
||||
@@ -2971,6 +2971,37 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(yield* s.context).not.toContainEqual(expect.objectContaining({ type: "compaction" }))
|
||||
})
|
||||
|
||||
scenario("fits Claude's output request when compaction has not yet triggered", function* (s) {
|
||||
const id = "claude-sonnet-4-5-context-fixture"
|
||||
s.currentModel = AnthropicMessages.route.model({ id })
|
||||
modelLimits.set(id, { context: 100_000, output: 80_000 })
|
||||
yield* s.llm.push(TestLLM.text("Done", "claude-context-response"))
|
||||
yield* s.runPrompt("x".repeat(160_000))
|
||||
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect(s.requests[0]?.generation?.maxTokens).toBeGreaterThan(10_000)
|
||||
expect(s.requests[0]?.generation?.maxTokens).toBeLessThan(60_000)
|
||||
expect(yield* s.context).not.toContainEqual(expect.objectContaining({ type: "compaction" }))
|
||||
})
|
||||
|
||||
scenario("records context-window truncation as a length finish without auto-continuation", function* (s) {
|
||||
s.currentModel = AnthropicMessages.route.model({ id: "claude-sonnet-4-5" })
|
||||
yield* s.llm.push(
|
||||
TestLLM.complete(
|
||||
{ reason: { normalized: "length", raw: "model_context_window_exceeded" } },
|
||||
LLMEvent.textStart({ id: "truncated" }),
|
||||
LLMEvent.textDelta({ id: "truncated", text: "Partial answer" }),
|
||||
LLMEvent.textEnd({ id: "truncated" }),
|
||||
),
|
||||
)
|
||||
yield* s.runPrompt("Long conversation")
|
||||
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect(yield* s.context).toContainEqual(
|
||||
expect.objectContaining({ type: "assistant", finish: "length", rawFinish: "model_context_window_exceeded" }),
|
||||
)
|
||||
})
|
||||
|
||||
scenario("stops after required automatic compaction fails", function* (s) {
|
||||
yield* s.llm.push(TestLLM.textWithUsage("Earlier answer", "text-before-failed-compaction", 3_950))
|
||||
yield* s.runPrompt("Earlier question ".repeat(180))
|
||||
|
||||
Reference in New Issue
Block a user