Skip to content

Commit dd7374a

Browse files
authored
Merge pull request #18 from AltimateAI/test
feat: port Anthropic OAuth plugin in-tree
2 parents c5a5be8 + a1f971e commit dd7374a

2 files changed

Lines changed: 285 additions & 4 deletions

File tree

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import type { Hooks, PluginInput } from "@altimate/cli-plugin"
2+
import { generatePKCE } from "@openauthjs/openauth/pkce"
3+
import { Auth, OAUTH_DUMMY_KEY } from "@/auth"
4+
5+
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
6+
const TOOL_PREFIX = "mcp_"
7+
8+
async function authorize(mode: "max" | "console"): Promise<{ url: string; verifier: string }> {
9+
const pkce = await generatePKCE()
10+
const base = mode === "console" ? "console.anthropic.com" : "claude.ai"
11+
const url = new URL(`https://${base}/oauth/authorize`)
12+
url.searchParams.set("code", "true")
13+
url.searchParams.set("client_id", CLIENT_ID)
14+
url.searchParams.set("response_type", "code")
15+
url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback")
16+
url.searchParams.set("scope", "org:create_api_key user:profile user:inference")
17+
url.searchParams.set("code_challenge", pkce.challenge)
18+
url.searchParams.set("code_challenge_method", "S256")
19+
url.searchParams.set("state", pkce.verifier)
20+
return { url: url.toString(), verifier: pkce.verifier }
21+
}
22+
23+
interface TokenResponse {
24+
access_token: string
25+
refresh_token: string
26+
expires_in: number
27+
}
28+
29+
async function exchange(code: string, verifier: string) {
30+
const splits = code.split("#")
31+
const result = await fetch("https://console.anthropic.com/v1/oauth/token", {
32+
method: "POST",
33+
headers: { "Content-Type": "application/json" },
34+
body: JSON.stringify({
35+
code: splits[0],
36+
state: splits[1],
37+
grant_type: "authorization_code",
38+
client_id: CLIENT_ID,
39+
redirect_uri: "https://console.anthropic.com/oauth/code/callback",
40+
code_verifier: verifier,
41+
}),
42+
})
43+
if (!result.ok) return { type: "failed" as const }
44+
const json: TokenResponse = await result.json()
45+
return {
46+
type: "success" as const,
47+
refresh: json.refresh_token,
48+
access: json.access_token,
49+
expires: Date.now() + json.expires_in * 1000,
50+
}
51+
}
52+
53+
export async function AnthropicAuthPlugin(input: PluginInput): Promise<Hooks> {
54+
return {
55+
"experimental.chat.system.transform": (hookInput, output) => {
56+
const prefix = "You are Claude Code, Anthropic's official CLI for Claude."
57+
if (hookInput.model?.providerID === "anthropic") {
58+
output.system.unshift(prefix)
59+
if (output.system[1]) output.system[1] = prefix + "\n\n" + output.system[1]
60+
}
61+
},
62+
auth: {
63+
provider: "anthropic",
64+
async loader(getAuth, provider) {
65+
const auth = await getAuth()
66+
if (auth.type !== "oauth") return {}
67+
68+
// Zero out costs for Pro/Max subscription
69+
for (const model of Object.values(provider.models)) {
70+
model.cost = { input: 0, output: 0, cache: { read: 0, write: 0 } }
71+
}
72+
73+
return {
74+
apiKey: OAUTH_DUMMY_KEY,
75+
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
76+
const currentAuth = await getAuth()
77+
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
78+
79+
// Refresh token if expired
80+
if (!currentAuth.access || currentAuth.expires < Date.now()) {
81+
const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
82+
method: "POST",
83+
headers: { "Content-Type": "application/json" },
84+
body: JSON.stringify({
85+
grant_type: "refresh_token",
86+
refresh_token: currentAuth.refresh,
87+
client_id: CLIENT_ID,
88+
}),
89+
})
90+
if (!response.ok) throw new Error(`Token refresh failed: ${response.status}`)
91+
const json: TokenResponse = await response.json()
92+
await input.client.auth.set({
93+
path: { id: "anthropic" },
94+
body: {
95+
type: "oauth",
96+
refresh: json.refresh_token,
97+
access: json.access_token,
98+
expires: Date.now() + json.expires_in * 1000,
99+
},
100+
})
101+
currentAuth.access = json.access_token
102+
}
103+
104+
// Build headers from incoming request
105+
const requestHeaders = new Headers()
106+
if (requestInput instanceof Request) {
107+
requestInput.headers.forEach((value, key) => requestHeaders.set(key, value))
108+
}
109+
const requestInit = init ?? {}
110+
if (requestInit.headers) {
111+
if (requestInit.headers instanceof Headers) {
112+
requestInit.headers.forEach((value, key) => requestHeaders.set(key, value))
113+
} else if (Array.isArray(requestInit.headers)) {
114+
for (const [key, value] of requestInit.headers) {
115+
if (value !== undefined) requestHeaders.set(key, String(value))
116+
}
117+
} else {
118+
for (const [key, value] of Object.entries(requestInit.headers)) {
119+
if (value !== undefined) requestHeaders.set(key, String(value))
120+
}
121+
}
122+
}
123+
124+
// Merge required OAuth betas with any existing betas
125+
const incomingBetas = (requestHeaders.get("anthropic-beta") || "")
126+
.split(",")
127+
.map((b) => b.trim())
128+
.filter(Boolean)
129+
const mergedBetas = [...new Set(["oauth-2025-04-20", "interleaved-thinking-2025-05-14", ...incomingBetas])].join(",")
130+
131+
requestHeaders.set("authorization", `Bearer ${currentAuth.access}`)
132+
requestHeaders.set("anthropic-beta", mergedBetas)
133+
requestHeaders.set("user-agent", "claude-cli/2.1.2 (external, cli)")
134+
requestHeaders.delete("x-api-key")
135+
136+
// Prefix tool names with mcp_ (required by Anthropic's OAuth endpoint)
137+
let body = requestInit.body
138+
if (body && typeof body === "string") {
139+
try {
140+
const parsed = JSON.parse(body)
141+
142+
// Sanitize system prompt
143+
if (parsed.system && Array.isArray(parsed.system)) {
144+
parsed.system = parsed.system.map((item: any) => {
145+
if (item.type === "text" && item.text) {
146+
return {
147+
...item,
148+
text: item.text.replace(/OpenCode/g, "Claude Code").replace(/opencode/gi, "Claude"),
149+
}
150+
}
151+
return item
152+
})
153+
}
154+
155+
if (parsed.tools && Array.isArray(parsed.tools)) {
156+
parsed.tools = parsed.tools.map((tool: any) => ({
157+
...tool,
158+
name: tool.name ? `${TOOL_PREFIX}${tool.name}` : tool.name,
159+
}))
160+
}
161+
162+
if (parsed.messages && Array.isArray(parsed.messages)) {
163+
parsed.messages = parsed.messages.map((msg: any) => {
164+
if (msg.content && Array.isArray(msg.content)) {
165+
msg.content = msg.content.map((block: any) => {
166+
if (block.type === "tool_use" && block.name) {
167+
return { ...block, name: `${TOOL_PREFIX}${block.name}` }
168+
}
169+
return block
170+
})
171+
}
172+
return msg
173+
})
174+
}
175+
176+
body = JSON.stringify(parsed)
177+
} catch {
178+
// ignore parse errors
179+
}
180+
}
181+
182+
// Add ?beta=true to /v1/messages requests
183+
let finalInput = requestInput
184+
try {
185+
let requestUrl: URL | null = null
186+
if (typeof requestInput === "string" || requestInput instanceof URL) {
187+
requestUrl = new URL(requestInput.toString())
188+
} else if (requestInput instanceof Request) {
189+
requestUrl = new URL(requestInput.url)
190+
}
191+
if (requestUrl && requestUrl.pathname === "/v1/messages" && !requestUrl.searchParams.has("beta")) {
192+
requestUrl.searchParams.set("beta", "true")
193+
finalInput = requestInput instanceof Request ? new Request(requestUrl.toString(), requestInput) : requestUrl
194+
}
195+
} catch {
196+
// ignore URL parse errors
197+
}
198+
199+
const response = await fetch(finalInput, { ...requestInit, body, headers: requestHeaders })
200+
201+
// Strip mcp_ prefix from tool names in streaming response
202+
if (response.body) {
203+
const reader = response.body.getReader()
204+
const decoder = new TextDecoder()
205+
const encoder = new TextEncoder()
206+
const stream = new ReadableStream({
207+
async pull(controller) {
208+
const { done, value } = await reader.read()
209+
if (done) {
210+
controller.close()
211+
return
212+
}
213+
let text = decoder.decode(value, { stream: true })
214+
text = text.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"')
215+
controller.enqueue(encoder.encode(text))
216+
},
217+
})
218+
return new Response(stream, {
219+
status: response.status,
220+
statusText: response.statusText,
221+
headers: response.headers,
222+
})
223+
}
224+
225+
return response
226+
},
227+
}
228+
},
229+
methods: [
230+
{
231+
label: "Claude Pro/Max",
232+
type: "oauth",
233+
authorize: async () => {
234+
const { url, verifier } = await authorize("max")
235+
return {
236+
url,
237+
instructions: "Paste the authorization code here: ",
238+
method: "code" as const,
239+
callback: async (code: string) => exchange(code, verifier),
240+
}
241+
},
242+
},
243+
{
244+
label: "Create an API Key",
245+
type: "oauth",
246+
authorize: async () => {
247+
const { url, verifier } = await authorize("console")
248+
return {
249+
url,
250+
instructions: "Paste the authorization code here: ",
251+
method: "code" as const,
252+
callback: async (code: string) => {
253+
const credentials = await exchange(code, verifier)
254+
if (credentials.type === "failed") return credentials
255+
const result = await fetch("https://api.anthropic.com/api/oauth/claude_cli/create_api_key", {
256+
method: "POST",
257+
headers: {
258+
"Content-Type": "application/json",
259+
authorization: `Bearer ${credentials.access}`,
260+
},
261+
}).then((r) => r.json())
262+
return { type: "success" as const, key: result.raw_key }
263+
},
264+
}
265+
},
266+
},
267+
{
268+
label: "Manually enter API Key",
269+
type: "api",
270+
},
271+
],
272+
},
273+
}
274+
}

packages/altimate-code/src/plugin/index.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,17 @@ import { CodexAuthPlugin } from "./codex"
1111
import { Session } from "../session"
1212
import { NamedError } from "@altimate/cli-util/error"
1313
import { CopilotAuthPlugin } from "./copilot"
14+
import { AnthropicAuthPlugin } from "./anthropic"
1415
// @ts-ignore - @gitlab/opencode-gitlab-auth exports Plugin from @opencode-ai/plugin, not @altimate/cli-plugin
1516
import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-auth"
1617

1718
export namespace Plugin {
1819
const log = Log.create({ service: "plugin" })
1920

20-
const BUILTIN = ["altimate-code-anthropic-auth@0.0.13"]
21+
const BUILTIN: string[] = []
2122

2223
// Built-in plugins that are directly imported (not installed from npm)
23-
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin as unknown as PluginInstance]
24+
const INTERNAL_PLUGINS: PluginInstance[] = [AnthropicAuthPlugin, CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin as unknown as PluginInstance]
2425

2526
const state = Instance.state(async () => {
2627
const client = createOpencodeClient({
@@ -55,8 +56,14 @@ export namespace Plugin {
5556
}
5657

5758
for (let plugin of plugins) {
58-
// ignore old codex plugin since it is supported first party now
59-
if (plugin.includes("altimate-code-openai-codex-auth") || plugin.includes("altimate-code-copilot-auth")) continue
59+
// ignore old plugins now supported first party
60+
if (
61+
plugin.includes("altimate-code-openai-codex-auth") ||
62+
plugin.includes("altimate-code-copilot-auth") ||
63+
plugin.includes("altimate-code-anthropic-auth") ||
64+
plugin.includes("opencode-anthropic-auth")
65+
)
66+
continue
6067
log.info("loading plugin", { path: plugin })
6168
if (!plugin.startsWith("file://")) {
6269
const lastAtIndex = plugin.lastIndexOf("@")

0 commit comments

Comments
 (0)