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
File diff suppressed because it is too large Load Diff
@@ -74,7 +74,7 @@ describe("generateOmoConfig - model fallback system", () => {
//#then
expect((result.agents as Record<string, { model: string }>).librarian.model).toBe("zai-coding-plan/glm-4.7")
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4.6")
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-6")
})
test("uses native OpenAI models when only ChatGPT available", () => {
@@ -131,7 +131,7 @@ describe("generateOmoConfig - model fallback system", () => {
}>
//#then
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4.6")
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-6")
expect(agents.sisyphus.fallback_models).toEqual([
{
model: "openai/gpt-5.4",
@@ -141,7 +141,7 @@ describe("generateOmoConfig - model fallback system", () => {
expect(categories.deep.model).toBe("openai/gpt-5.4")
expect(categories.deep.fallback_models).toEqual([
{
model: "anthropic/claude-opus-4.6",
model: "anthropic/claude-opus-4-6",
variant: "max",
},
])
+5 -9
View File
@@ -381,7 +381,7 @@ describe("generateModelConfig", () => {
const result = generateModelConfig(config)
// #then
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4.6")
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-6")
})
test("Sisyphus is created when multiple fallback providers are available", () => {
@@ -398,7 +398,7 @@ describe("generateModelConfig", () => {
const result = generateModelConfig(config)
// #then
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4.6")
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-6")
})
test("Sisyphus resolves to gpt-5.4 medium when only OpenAI is available", () => {
@@ -573,13 +573,9 @@ describe("generateModelConfig", () => {
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then explore should not have fallback_models (only one chain entry matches)
// #then explore should not have fallback_models (only one distinct chain entry matches)
expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5")
expect(result.agents?.explore?.fallback_models).toEqual([
{
model: "anthropic/claude-haiku-4.5",
},
])
expect(result.agents?.explore?.fallback_models).toBeUndefined()
})
test("librarian includes fallback_models when opencode-go and Claude are both available", () => {
@@ -672,7 +668,7 @@ describe("generateModelConfig", () => {
const result = generateModelConfig(config)
// #then should prefer native anthropic over gateway
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4.6")
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-6")
})
})
+15 -13
View File
@@ -165,7 +165,7 @@ describe("transformModelForProvider", () => {
})
describe("anthropic provider", () => {
test("transforms claude-opus-4-6 to claude-opus-4.6", () => {
test("preserves hyphenated claude-opus-4-6 for config output (regression: installer must not write dotted IDs)", () => {
// #given anthropic provider and claude-opus-4-6 model
const provider = "anthropic"
const model = "claude-opus-4-6"
@@ -173,11 +173,11 @@ describe("transformModelForProvider", () => {
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to claude-opus-4.6
expect(result).toBe("claude-opus-4.6")
// #then should keep hyphenated form so Anthropic provider resolution succeeds on fresh installs
expect(result).toBe("claude-opus-4-6")
})
test("transforms claude-sonnet-4-6 to claude-sonnet-4.6", () => {
test("preserves hyphenated claude-sonnet-4-6 for config output", () => {
// #given anthropic provider and claude-sonnet-4-6 model
const provider = "anthropic"
const model = "claude-sonnet-4-6"
@@ -185,11 +185,11 @@ describe("transformModelForProvider", () => {
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to claude-sonnet-4.6
expect(result).toBe("claude-sonnet-4.6")
// #then should keep hyphenated form
expect(result).toBe("claude-sonnet-4-6")
})
test("transforms claude-haiku-4-5 to claude-haiku-4.5", () => {
test("preserves hyphenated claude-haiku-4-5 for config output", () => {
// #given anthropic provider and claude-haiku-4-5 model
const provider = "anthropic"
const model = "claude-haiku-4-5"
@@ -197,8 +197,8 @@ describe("transformModelForProvider", () => {
// #when transformModelForProvider is called
const result = transformModelForProvider(provider, model)
// #then should transform to claude-haiku-4.5
expect(result).toBe("claude-haiku-4.5")
// #then should keep hyphenated form
expect(result).toBe("claude-haiku-4-5")
})
})
@@ -338,14 +338,16 @@ describe("transformModelForProvider", () => {
})
})
test("uses a CLI-local transform implementation", () => {
// #given
test("uses a CLI-local transform implementation distinct from the shared runtime transform", () => {
// #given the CLI transform (used by the installer) and the shared runtime transform
const cliResult = transformModelForProvider("anthropic", "claude-opus-4-6")
const sharedResult = transformSharedModelForProvider("anthropic", "claude-opus-4-6")
// #when
// #when both are called with the same anthropic claude input
// #then the CLI preserves hyphenated form for config output,
// the shared runtime transform converts dash→dot for API calls
expect(transformModelForProvider).not.toBe(transformSharedModelForProvider)
expect(cliResult).toBe("claude-opus-4.6")
expect(cliResult).toBe("claude-opus-4-6")
expect(sharedResult).toBe("claude-opus-4.6")
})
})
+6 -1
View File
@@ -54,7 +54,12 @@ export function transformModelForProvider(provider: string, model: string): stri
}
if (provider === "anthropic") {
return claudeVersionDot(model)
// Installer writes hyphenated IDs (claude-opus-4-6) to the config. The
// runtime provider-model-id-transform converts dash→dot when calling the
// Anthropic API. Keeping the dotted form in the config breaks fresh
// installs with ProviderModelNotFoundError because Anthropic's provider
// registers models under hyphenated IDs.
return model
}
return model
+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")
})
})
})
+1
View File
@@ -57,6 +57,7 @@ export * from "./session-utils"
export * from "./tmux"
export * from "./model-suggestion-retry"
export * from "./opencode-server-auth"
export * from "./opencode-provider-auth"
export * from "./opencode-http-api"
export * from "./port-utils"
export * from "./git-worktree"
+107
View File
@@ -0,0 +1,107 @@
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,
getProviderAuthType,
isProviderUsingOAuth,
} from "./opencode-provider-auth"
describe("opencode-provider-auth", () => {
let tempDataDir: string
const originalXdgDataHome = process.env.XDG_DATA_HOME
function writeAuthFile(contents: string): void {
const opencodeDir = path.join(tempDataDir, "opencode")
mkdirSync(opencodeDir, { recursive: true })
writeFileSync(path.join(opencodeDir, "auth.json"), contents, "utf-8")
_resetProviderAuthCacheForTesting()
}
beforeAll(() => {
tempDataDir = path.join(tmpdir(), `opencode-provider-auth-${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("#given auth.json with oauth entry #then detects OAuth for that provider", () => {
// given auth.json where anthropic is OAuth
writeAuthFile(JSON.stringify({
anthropic: { type: "oauth", refresh: "r", access: "a", expires: 1 },
opencode: { type: "api", key: "sk-x" },
}))
// when isProviderUsingOAuth queries each provider
const anthropicOauth = isProviderUsingOAuth("anthropic")
const opencodeOauth = isProviderUsingOAuth("opencode")
// then only OAuth providers return true
expect(anthropicOauth).toBe(true)
expect(opencodeOauth).toBe(false)
})
it("#given api-key auth.json entry #then returns the api auth type", () => {
// given auth.json with an API key for anthropic
writeAuthFile(JSON.stringify({ anthropic: { type: "api", key: "sk-ant-xxx" } }))
// when getProviderAuthType queries the provider
const authType = getProviderAuthType("anthropic")
// then the api type is returned
expect(authType).toBe("api")
expect(isProviderUsingOAuth("anthropic")).toBe(false)
})
it("#given missing auth.json #then returns undefined with no throw", () => {
// given no auth.json exists (XDG_DATA_HOME points to an empty dir)
rmSync(path.join(tempDataDir, "opencode"), { recursive: true, force: true })
_resetProviderAuthCacheForTesting()
// when isProviderUsingOAuth queries a provider
const anthropicOauth = isProviderUsingOAuth("anthropic")
const anthropicType = getProviderAuthType("anthropic")
// then callers get a safe undefined/false
expect(anthropicOauth).toBe(false)
expect(anthropicType).toBeUndefined()
})
it("#given malformed auth.json #then does not throw and returns undefined", () => {
// given a malformed JSON auth file
writeAuthFile("not json at all")
// when isProviderUsingOAuth queries a provider
const anthropicOauth = isProviderUsingOAuth("anthropic")
// then detection degrades safely
expect(anthropicOauth).toBe(false)
})
it("#given unknown provider #then returns undefined", () => {
// given auth.json without an entry for the queried provider
writeAuthFile(JSON.stringify({ anthropic: { type: "oauth", refresh: "r", access: "a", expires: 1 } }))
// when querying a provider that is not present
const openai = getProviderAuthType("openai")
// then undefined
expect(openai).toBeUndefined()
expect(isProviderUsingOAuth("openai")).toBe(false)
})
})
+84
View File
@@ -0,0 +1,84 @@
import { readFileSync, statSync } from "node:fs"
import * as path from "node:path"
import { getDataDir } from "./data-path"
import { log } from "./logger"
/**
* Reads OpenCode's auth.json to detect the auth type used by a provider.
*
* OpenCode stores auth credentials at `<dataDir>/opencode/auth.json` in the
* shape `{ [providerID]: { type: "oauth" | "api" | "wellknown", ... } }`.
*
* The file is read with mtime-based caching so we do not stat/parse it on
* every chat.params invocation.
*/
type AuthRecord = {
type?: unknown
}
type AuthCacheEntry = {
mtimeMs: number
map: Map<string, string>
}
let cached: AuthCacheEntry | null = null
function getAuthFilePath(): string {
return path.join(getDataDir(), "opencode", "auth.json")
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function loadAuthMap(): Map<string, string> {
const filePath = getAuthFilePath()
let mtimeMs: number
try {
mtimeMs = statSync(filePath).mtimeMs
} catch {
cached = null
return new Map()
}
if (cached && cached.mtimeMs === mtimeMs) {
return cached.map
}
try {
const raw = readFileSync(filePath, "utf-8")
const parsed: unknown = JSON.parse(raw)
const map = new Map<string, string>()
if (isRecord(parsed)) {
for (const [providerID, entry] of Object.entries(parsed)) {
if (!isRecord(entry)) continue
const type = (entry as AuthRecord).type
if (typeof type === "string") {
map.set(providerID, type)
}
}
}
cached = { mtimeMs, map }
return map
} catch (error) {
log("[opencode-provider-auth] Failed to read auth.json", {
error: error instanceof Error ? error.message : String(error),
})
return new Map()
}
}
export function getProviderAuthType(providerID: string): string | undefined {
return loadAuthMap().get(providerID)
}
export function isProviderUsingOAuth(providerID: string): boolean {
return getProviderAuthType(providerID) === "oauth"
}
export function _resetProviderAuthCacheForTesting(): void {
cached = null
}