From 7bc170fb86a4c66fee47971a8196f557ef5fa852 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 16 Apr 2026 14:28:56 +0900 Subject: [PATCH] fix: installer writes hyphenated anthropic IDs, variant=max Anthropic OAuth compat (#3429, #3459) --- .../__snapshots__/model-fallback.test.ts.snap | 309 ++++++++---------- .../generate-omo-config.test.ts | 6 +- src/cli/model-fallback.test.ts | 14 +- src/cli/provider-model-id-transform.test.ts | 28 +- src/cli/provider-model-id-transform.ts | 7 +- src/hooks/anthropic-effort/hook.ts | 30 +- src/hooks/anthropic-effort/index.test.ts | 95 +++++- src/shared/index.ts | 1 + src/shared/opencode-provider-auth.test.ts | 107 ++++++ src/shared/opencode-provider-auth.ts | 84 +++++ 10 files changed, 474 insertions(+), 207 deletions(-) create mode 100644 src/shared/opencode-provider-auth.test.ts create mode 100644 src/shared/opencode-provider-auth.ts diff --git a/src/cli/__snapshots__/model-fallback.test.ts.snap b/src/cli/__snapshots__/model-fallback.test.ts.snap index 88f86f97b..92769a5b8 100644 --- a/src/cli/__snapshots__/model-fallback.test.ts.snap +++ b/src/cli/__snapshots__/model-fallback.test.ts.snap @@ -69,67 +69,62 @@ exports[`generateModelConfig single native provider uses Claude models when only "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { "atlas": { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "explore": { - "fallback_models": [ - { - "model": "anthropic/claude-haiku-4.5", - }, - ], "model": "anthropic/claude-haiku-4-5", }, "metis": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "momus": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "multimodal-looker": { "model": "opencode/gpt-5-nano", }, "oracle": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "prometheus": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus-junior": { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, }, "categories": { "deep": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "quick": { - "model": "anthropic/claude-haiku-4.5", + "model": "anthropic/claude-haiku-4-5", }, "ultrabrain": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "unspecified-high": { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "unspecified-low": { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "visual-engineering": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "writing": { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, }, } @@ -140,68 +135,63 @@ exports[`generateModelConfig single native provider uses Claude models with isMa "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { "atlas": { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "explore": { - "fallback_models": [ - { - "model": "anthropic/claude-haiku-4.5", - }, - ], "model": "anthropic/claude-haiku-4-5", }, "metis": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "momus": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "multimodal-looker": { "model": "opencode/gpt-5-nano", }, "oracle": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "prometheus": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus-junior": { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, }, "categories": { "deep": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "quick": { - "model": "anthropic/claude-haiku-4.5", + "model": "anthropic/claude-haiku-4-5", }, "ultrabrain": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "unspecified-high": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "unspecified-low": { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "visual-engineering": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "writing": { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, }, } @@ -526,14 +516,9 @@ exports[`generateModelConfig all native providers uses preferred models from fal "variant": "medium", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "explore": { - "fallback_models": [ - { - "model": "anthropic/claude-haiku-4.5", - }, - ], "model": "anthropic/claude-haiku-4-5", }, "hephaestus": { @@ -547,13 +532,13 @@ exports[`generateModelConfig all native providers uses preferred models from fal "variant": "high", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "momus": { "fallback_models": [ { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -580,7 +565,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal "variant": "high", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, ], @@ -597,7 +582,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal "model": "google/gemini-3.1-pro-preview", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus": { @@ -607,7 +592,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal "variant": "medium", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus-junior": { @@ -617,14 +602,14 @@ exports[`generateModelConfig all native providers uses preferred models from fal "variant": "medium", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, }, "categories": { "artistry": { "fallback_models": [ { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -637,7 +622,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal "deep": { "fallback_models": [ { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -651,7 +636,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal "quick": { "fallback_models": [ { - "model": "anthropic/claude-haiku-4.5", + "model": "anthropic/claude-haiku-4-5", }, { "model": "google/gemini-3-flash-preview", @@ -666,7 +651,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal "variant": "high", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, ], @@ -683,7 +668,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal "model": "google/gemini-3-flash-preview", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "unspecified-low": { "fallback_models": [ @@ -695,12 +680,12 @@ exports[`generateModelConfig all native providers uses preferred models from fal "model": "google/gemini-3-flash-preview", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "visual-engineering": { "fallback_models": [ { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, ], @@ -710,7 +695,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal "writing": { "fallback_models": [ { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, ], "model": "google/gemini-3-flash-preview", @@ -730,14 +715,9 @@ exports[`generateModelConfig all native providers uses preferred models with isM "variant": "medium", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "explore": { - "fallback_models": [ - { - "model": "anthropic/claude-haiku-4.5", - }, - ], "model": "anthropic/claude-haiku-4-5", }, "hephaestus": { @@ -751,13 +731,13 @@ exports[`generateModelConfig all native providers uses preferred models with isM "variant": "high", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "momus": { "fallback_models": [ { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -784,7 +764,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM "variant": "high", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, ], @@ -801,7 +781,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM "model": "google/gemini-3.1-pro-preview", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus": { @@ -811,7 +791,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM "variant": "medium", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus-junior": { @@ -821,14 +801,14 @@ exports[`generateModelConfig all native providers uses preferred models with isM "variant": "medium", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, }, "categories": { "artistry": { "fallback_models": [ { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -841,7 +821,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM "deep": { "fallback_models": [ { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -855,7 +835,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM "quick": { "fallback_models": [ { - "model": "anthropic/claude-haiku-4.5", + "model": "anthropic/claude-haiku-4-5", }, { "model": "google/gemini-3-flash-preview", @@ -870,7 +850,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM "variant": "high", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, ], @@ -884,7 +864,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM "variant": "high", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "unspecified-low": { @@ -897,12 +877,12 @@ exports[`generateModelConfig all native providers uses preferred models with isM "model": "google/gemini-3-flash-preview", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "visual-engineering": { "fallback_models": [ { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, ], @@ -912,7 +892,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM "writing": { "fallback_models": [ { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, ], "model": "google/gemini-3-flash-preview", @@ -1885,16 +1865,13 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen "variant": "medium", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "explore": { "fallback_models": [ { "model": "opencode/minimax-m2.7", }, - { - "model": "anthropic/claude-haiku-4.5", - }, { "model": "opencode/claude-haiku-4-5", }, @@ -1919,13 +1896,13 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen "variant": "high", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "momus": { "fallback_models": [ { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -1956,7 +1933,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen "variant": "high", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -1981,7 +1958,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen "model": "opencode/gemini-3.1-pro", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus": { @@ -2004,7 +1981,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen "model": "opencode/big-pickle", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus-junior": { @@ -2020,14 +1997,14 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen "model": "opencode/big-pickle", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, }, "categories": { "artistry": { "fallback_models": [ { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -2044,7 +2021,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen "deep": { "fallback_models": [ { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -2062,7 +2039,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen "quick": { "fallback_models": [ { - "model": "anthropic/claude-haiku-4.5", + "model": "anthropic/claude-haiku-4-5", }, { "model": "opencode/claude-haiku-4-5", @@ -2083,7 +2060,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen "variant": "high", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -2107,7 +2084,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen "model": "opencode/gemini-3-flash", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "unspecified-low": { "fallback_models": [ @@ -2122,7 +2099,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen "model": "opencode/gemini-3-flash", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "visual-engineering": { "fallback_models": [ @@ -2130,7 +2107,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen "model": "opencode/glm-5", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -2144,7 +2121,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen "writing": { "fallback_models": [ { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, { "model": "opencode/claude-sonnet-4-6", @@ -2412,41 +2389,36 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { "atlas": { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "explore": { - "fallback_models": [ - { - "model": "anthropic/claude-haiku-4.5", - }, - ], "model": "anthropic/claude-haiku-4-5", }, "librarian": { "fallback_models": [ { - "model": "anthropic/claude-haiku-4.5", + "model": "anthropic/claude-haiku-4-5", }, ], "model": "zai-coding-plan/glm-4.7", }, "metis": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "momus": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "multimodal-looker": { "model": "zai-coding-plan/glm-4.6v", }, "oracle": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "prometheus": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus": { @@ -2455,42 +2427,42 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat "model": "zai-coding-plan/glm-5", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus-junior": { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, }, "categories": { "deep": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "quick": { - "model": "anthropic/claude-haiku-4.5", + "model": "anthropic/claude-haiku-4-5", }, "ultrabrain": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "unspecified-high": { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "unspecified-low": { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "visual-engineering": { "fallback_models": [ { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, ], "model": "zai-coding-plan/glm-5", }, "writing": { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, }, } @@ -2501,18 +2473,13 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi "$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json", "agents": { "atlas": { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "explore": { - "fallback_models": [ - { - "model": "anthropic/claude-haiku-4.5", - }, - ], "model": "anthropic/claude-haiku-4-5", }, "metis": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "momus": { @@ -2522,7 +2489,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi "variant": "high", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "multimodal-looker": { @@ -2531,7 +2498,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi "oracle": { "fallback_models": [ { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, ], @@ -2544,22 +2511,22 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi "model": "google/gemini-3.1-pro-preview", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus": { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus-junior": { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, }, "categories": { "artistry": { "fallback_models": [ { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, ], @@ -2573,7 +2540,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi "variant": "high", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "quick": { @@ -2582,12 +2549,12 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi "model": "google/gemini-3-flash-preview", }, ], - "model": "anthropic/claude-haiku-4.5", + "model": "anthropic/claude-haiku-4-5", }, "ultrabrain": { "fallback_models": [ { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, ], @@ -2600,7 +2567,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi "model": "google/gemini-3-flash-preview", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "unspecified-low": { "fallback_models": [ @@ -2608,12 +2575,12 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi "model": "google/gemini-3-flash-preview", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "visual-engineering": { "fallback_models": [ { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, ], @@ -2623,7 +2590,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi "writing": { "fallback_models": [ { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, ], "model": "google/gemini-3-flash-preview", @@ -3048,7 +3015,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "variant": "medium", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "explore": { "fallback_models": [ @@ -3058,9 +3025,6 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe { "model": "opencode/minimax-m2.7", }, - { - "model": "anthropic/claude-haiku-4.5", - }, { "model": "opencode/claude-haiku-4-5", }, @@ -3090,7 +3054,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "model": "opencode/minimax-m2.7-highspeed", }, { - "model": "anthropic/claude-haiku-4.5", + "model": "anthropic/claude-haiku-4-5", }, { "model": "opencode/claude-haiku-4-5", @@ -3124,7 +3088,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "variant": "high", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "momus": { @@ -3138,7 +3102,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "variant": "xhigh", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -3210,7 +3174,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "variant": "high", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -3257,7 +3221,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "model": "opencode/gemini-3.1-pro", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus": { @@ -3295,7 +3259,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "model": "opencode/big-pickle", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus-junior": { @@ -3322,7 +3286,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "model": "opencode/big-pickle", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, }, "categories": { @@ -3337,7 +3301,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "variant": "high", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -3372,7 +3336,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "variant": "medium", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -3408,7 +3372,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "model": "opencode/gpt-5.4-mini", }, { - "model": "anthropic/claude-haiku-4.5", + "model": "anthropic/claude-haiku-4-5", }, { "model": "github-copilot/claude-haiku-4.5", @@ -3450,7 +3414,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "variant": "high", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -3491,7 +3455,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "model": "opencode/gemini-3-flash", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "unspecified-low": { "fallback_models": [ @@ -3519,7 +3483,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "model": "opencode/gemini-3-flash", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "visual-engineering": { "fallback_models": [ @@ -3538,7 +3502,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "model": "opencode/glm-5", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -3562,7 +3526,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe "model": "opencode/gemini-3-flash", }, { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, { "model": "github-copilot/claude-sonnet-4.6", @@ -3602,7 +3566,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "variant": "medium", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "explore": { "fallback_models": [ @@ -3612,9 +3576,6 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is { "model": "opencode/minimax-m2.7", }, - { - "model": "anthropic/claude-haiku-4.5", - }, { "model": "opencode/claude-haiku-4-5", }, @@ -3644,7 +3605,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "model": "opencode/minimax-m2.7-highspeed", }, { - "model": "anthropic/claude-haiku-4.5", + "model": "anthropic/claude-haiku-4-5", }, { "model": "opencode/claude-haiku-4-5", @@ -3678,7 +3639,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "variant": "high", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "momus": { @@ -3692,7 +3653,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "variant": "xhigh", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -3764,7 +3725,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "variant": "high", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -3811,7 +3772,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "model": "opencode/gemini-3.1-pro", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus": { @@ -3849,7 +3810,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "model": "opencode/big-pickle", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "sisyphus-junior": { @@ -3876,7 +3837,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "model": "opencode/big-pickle", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, }, "categories": { @@ -3891,7 +3852,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "variant": "high", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -3926,7 +3887,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "variant": "medium", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -3962,7 +3923,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "model": "opencode/gpt-5.4-mini", }, { - "model": "anthropic/claude-haiku-4.5", + "model": "anthropic/claude-haiku-4-5", }, { "model": "github-copilot/claude-haiku-4.5", @@ -4004,7 +3965,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "variant": "high", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -4051,7 +4012,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "model": "opencode/kimi-k2.5", }, ], - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, "unspecified-low": { @@ -4080,7 +4041,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "model": "opencode/gemini-3-flash", }, ], - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, "visual-engineering": { "fallback_models": [ @@ -4099,7 +4060,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "model": "opencode/glm-5", }, { - "model": "anthropic/claude-opus-4.6", + "model": "anthropic/claude-opus-4-6", "variant": "max", }, { @@ -4123,7 +4084,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is "model": "opencode/gemini-3-flash", }, { - "model": "anthropic/claude-sonnet-4.6", + "model": "anthropic/claude-sonnet-4-6", }, { "model": "github-copilot/claude-sonnet-4.6", diff --git a/src/cli/config-manager/generate-omo-config.test.ts b/src/cli/config-manager/generate-omo-config.test.ts index 50fe4e3d8..2d13046ec 100644 --- a/src/cli/config-manager/generate-omo-config.test.ts +++ b/src/cli/config-manager/generate-omo-config.test.ts @@ -74,7 +74,7 @@ describe("generateOmoConfig - model fallback system", () => { //#then expect((result.agents as Record).librarian.model).toBe("zai-coding-plan/glm-4.7") - expect((result.agents as Record).sisyphus.model).toBe("anthropic/claude-opus-4.6") + expect((result.agents as Record).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", }, ]) diff --git a/src/cli/model-fallback.test.ts b/src/cli/model-fallback.test.ts index f1df3cc0a..7e7816100 100644 --- a/src/cli/model-fallback.test.ts +++ b/src/cli/model-fallback.test.ts @@ -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") }) }) diff --git a/src/cli/provider-model-id-transform.test.ts b/src/cli/provider-model-id-transform.test.ts index d745ff097..fd2f32f07 100644 --- a/src/cli/provider-model-id-transform.test.ts +++ b/src/cli/provider-model-id-transform.test.ts @@ -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") }) }) diff --git a/src/cli/provider-model-id-transform.ts b/src/cli/provider-model-id-transform.ts index 82942fe74..0eab2d0e2 100644 --- a/src/cli/provider-model-id-transform.ts +++ b/src/cli/provider-model-id-transform.ts @@ -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 diff --git a/src/hooks/anthropic-effort/hook.ts b/src/hooks/anthropic-effort/hook.ts index 6d4cc965c..a8f5ecb85 100644 --- a/src/hooks/anthropic-effort/hook.ts +++ b/src/hooks/anthropic-effort/hook.ts @@ -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 = { 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", { diff --git a/src/hooks/anthropic-effort/index.test.ts b/src/hooks/anthropic-effort/index.test.ts index cea012eb9..ef8dc944d 100644 --- a/src/hooks/anthropic-effort/index.test.ts +++ b/src/hooks/anthropic-effort/index.test.ts @@ -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>): 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") + }) + }) }) diff --git a/src/shared/index.ts b/src/shared/index.ts index 826434d42..80ffa751b 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -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" diff --git a/src/shared/opencode-provider-auth.test.ts b/src/shared/opencode-provider-auth.test.ts new file mode 100644 index 000000000..56adcc9fe --- /dev/null +++ b/src/shared/opencode-provider-auth.test.ts @@ -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) + }) +}) diff --git a/src/shared/opencode-provider-auth.ts b/src/shared/opencode-provider-auth.ts new file mode 100644 index 000000000..235bea4c8 --- /dev/null +++ b/src/shared/opencode-provider-auth.ts @@ -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 `/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 +} + +let cached: AuthCacheEntry | null = null + +function getAuthFilePath(): string { + return path.join(getDataDir(), "opencode", "auth.json") +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function loadAuthMap(): Map { + 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() + 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 +}