fix: installer writes hyphenated anthropic IDs, variant=max Anthropic OAuth compat (#3429, #3459)

This commit is contained in:
YeonGyu-Kim
2026-04-16 14:28:56 +09:00
parent 80e73f5727
commit 7bc170fb86
10 changed files with 474 additions and 207 deletions
+24 -6
View File
@@ -1,4 +1,4 @@
import { log, normalizeModelID } from "../../shared"
import { isProviderUsingOAuth, log, normalizeModelID } from "../../shared"
const OPUS_PATTERN = /claude-.*opus/i
const EFFORT_UNSUPPORTED_PATTERN = /claude-.*haiku/i
@@ -25,6 +25,16 @@ function shouldSkipForInternalAgent(agentName: string | undefined): boolean {
return INTERNAL_SKIP_AGENTS.has(agentName.trim().toLowerCase())
}
/**
* Claude Pro/Max subscriptions expose a constrained OAuth API that rejects
* `output_config.effort: "max"` (supported values: low | medium | high) even on
* Opus models. Detect OAuth auth by inspecting OpenCode's auth.json.
*/
function isAnthropicOAuth(providerID: string): boolean {
if (providerID !== "anthropic") return false
return isProviderUsingOAuth(providerID)
}
interface ChatParamsInput {
sessionID: string
agent: { name?: string }
@@ -49,8 +59,9 @@ const MAX_VARIANT_BY_TIER: Record<string, string> = {
default: "high",
}
function clampVariant(variant: string, isOpus: boolean): string {
function clampVariant(variant: string, isOpus: boolean, isOAuth: boolean): string {
if (variant !== "max") return variant
if (isOAuth) return MAX_VARIANT_BY_TIER.default
return isOpus ? MAX_VARIANT_BY_TIER.opus : MAX_VARIANT_BY_TIER.default
}
@@ -70,16 +81,23 @@ export function createAnthropicEffortHook() {
if (output.options.effort !== undefined) return
const opus = isOpusModel(model.modelID)
const clamped = clampVariant(message.variant, opus)
const oauth = isAnthropicOAuth(model.providerID)
const clamped = clampVariant(message.variant, opus, oauth)
output.options.effort = clamped
if (!opus) {
// Override the variant so OpenCode doesn't pass "max" to the API
const shouldOverrideMessageVariant = !opus || oauth
if (shouldOverrideMessageVariant) {
// Override the variant so OpenCode doesn't pass "max" to the API.
// Non-Opus models cap at high; Anthropic OAuth (Claude Pro/Max) also
// caps at high even on Opus because the OAuth API only accepts
// low | medium | high.
;(message as { variant?: string }).variant = clamped
log("anthropic-effort: clamped variant max→high for non-Opus model", {
log("anthropic-effort: clamped variant max→high", {
sessionID: input.sessionID,
provider: model.providerID,
model: model.modelID,
reason: oauth ? "anthropic-oauth" : "non-opus",
})
} else {
log("anthropic-effort: injected effort=max", {
+94 -1
View File
@@ -1,4 +1,9 @@
import { describe, expect, it } from "bun:test"
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import * as path from "node:path"
import { _resetProviderAuthCacheForTesting } from "../../shared/opencode-provider-auth"
import { createAnthropicEffortHook } from "./index"
interface ChatParamsInput {
@@ -199,4 +204,92 @@ describe("createAnthropicEffortHook", () => {
expect(output.options.effort).toBe("high")
})
})
describe("#given anthropic OAuth auth (Claude Pro/Max) — regression for #3429", () => {
let tempDataDir: string
const originalXdgDataHome = process.env.XDG_DATA_HOME
function writeAuthFile(providerEntries: Record<string, Record<string, unknown>>): void {
const opencodeDir = path.join(tempDataDir, "opencode")
mkdirSync(opencodeDir, { recursive: true })
writeFileSync(path.join(opencodeDir, "auth.json"), JSON.stringify(providerEntries), "utf-8")
_resetProviderAuthCacheForTesting()
}
beforeAll(() => {
tempDataDir = path.join(tmpdir(), `anthropic-effort-oauth-${Date.now()}-${Math.random().toString(36).slice(2)}`)
mkdirSync(tempDataDir, { recursive: true })
process.env.XDG_DATA_HOME = tempDataDir
})
afterAll(() => {
if (originalXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME
} else {
process.env.XDG_DATA_HOME = originalXdgDataHome
}
rmSync(tempDataDir, { recursive: true, force: true })
_resetProviderAuthCacheForTesting()
})
afterEach(() => {
_resetProviderAuthCacheForTesting()
})
it("clamps opus-4-6 + max to high when anthropic provider uses oauth", async () => {
// given an Anthropic OAuth session and variant=max on an Opus model
writeAuthFile({ anthropic: { type: "oauth" } })
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ modelID: "claude-opus-4-6" })
// when chat.params fires
await hook["chat.params"](input, output)
// then effort must be clamped to high so Anthropic's OAuth API accepts it
expect(output.options.effort).toBe("high")
expect(input.message.variant).toBe("high")
})
it("clamps dotted opus id + max to high under OAuth", async () => {
// given an Anthropic OAuth session and a dotted opus id
writeAuthFile({ anthropic: { type: "oauth" } })
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ modelID: "claude-opus-4.6" })
// when chat.params fires
await hook["chat.params"](input, output)
// then effort must be clamped to high
expect(output.options.effort).toBe("high")
expect(input.message.variant).toBe("high")
})
it("still injects effort=max when anthropic auth is an API key", async () => {
// given an Anthropic API-key session (not OAuth)
writeAuthFile({ anthropic: { type: "api", key: "sk-ant-xxx" } })
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ modelID: "claude-opus-4-6" })
// when chat.params fires
await hook["chat.params"](input, output)
// then API-key users keep the original max behaviour for Opus
expect(output.options.effort).toBe("max")
expect(input.message.variant).toBe("max")
})
it("does not clamp when OAuth belongs to a different provider", async () => {
// given OAuth entries for unrelated providers only
writeAuthFile({ "github-copilot": { type: "oauth" }, opencode: { type: "api", key: "sk-x" } })
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ modelID: "claude-opus-4-6", providerID: "anthropic" })
// when chat.params fires for the anthropic provider
await hook["chat.params"](input, output)
// then max stays because anthropic itself is not OAuth
expect(output.options.effort).toBe("max")
expect(input.message.variant).toBe("max")
})
})
})