From 9b6b3719ecd8ccbf96ddb4c7b32974b9fea0b9b0 Mon Sep 17 00:00:00 2001 From: CreatorGhost Date: Tue, 14 Jul 2026 18:16:20 +0530 Subject: [PATCH 1/4] feat(provider): add Claude subscription OAuth --- packages/core/src/integration.ts | 63 +++- packages/core/src/plugin/provider.ts | 2 + .../plugin/provider/anthropic-subscription.ts | 199 ++++++++++++ packages/core/src/session/runner/model.ts | 52 +++- .../provider-anthropic-subscription.test.ts | 278 +++++++++++++++++ ...sion-runner-anthropic-subscription.test.ts | 223 +++++++++++++ .../core/test/session-runner-model.test.ts | 58 ++++ packages/llm/package.json | 1 + .../src/providers/anthropic-subscription.ts | 249 +++++++++++++++ packages/llm/src/providers/index.ts | 1 + .../provider/anthropic-subscription.test.ts | 138 +++++++++ packages/opencode/src/cli/cmd/providers.ts | 147 +++++++-- .../src/plugin/anthropic/subscription.ts | 293 ++++++++++++++++++ packages/opencode/src/plugin/index.ts | 2 + packages/opencode/src/plugin/openai/codex.ts | 11 +- .../anthropic-subscription-credential.ts | 6 + packages/opencode/src/provider/auth.ts | 48 ++- packages/opencode/src/provider/provider.ts | 83 ++++- packages/opencode/src/provider/transform.ts | 2 + .../instance/httpapi/handlers/control.ts | 80 ++++- .../instance/httpapi/handlers/provider.ts | 2 +- .../src/session/llm/native-runtime.ts | 36 ++- .../plugin/anthropic-subscription.test.ts | 293 ++++++++++++++++++ packages/opencode/test/plugin/codex.test.ts | 5 + .../opencode/test/provider/provider.test.ts | 89 ++++++ .../test/server/httpapi-provider.test.ts | 102 ++++++ .../opencode/test/session/llm-native.test.ts | 104 +++++++ .../tui/src/component/dialog-provider.tsx | 19 +- packages/tui/src/context/sync.tsx | 12 +- .../test/cli/cmd/tui/provider-options.test.ts | 11 +- 30 files changed, 2547 insertions(+), 62 deletions(-) create mode 100644 packages/core/src/plugin/provider/anthropic-subscription.ts create mode 100644 packages/core/test/plugin/provider-anthropic-subscription.test.ts create mode 100644 packages/core/test/session-runner-anthropic-subscription.test.ts create mode 100644 packages/llm/src/providers/anthropic-subscription.ts create mode 100644 packages/llm/test/provider/anthropic-subscription.test.ts create mode 100644 packages/opencode/src/plugin/anthropic/subscription.ts create mode 100644 packages/opencode/src/provider/anthropic-subscription-credential.ts create mode 100644 packages/opencode/test/plugin/anthropic-subscription.test.ts diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index f61bac393a82..f752ad9fcd01 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -5,6 +5,7 @@ import { Cause, Clock, Context, + Deferred, Duration, Effect, Exit, @@ -195,6 +196,26 @@ export interface Interface extends State.Transformable { export class Service extends Context.Service()("@opencode/v2/Integration") {} +const refreshes = new Map>() + +const refresh = ( + credentialID: Credential.ID, + effect: Effect.Effect, +) => + Effect.suspend(() => { + const current = refreshes.get(credentialID) + if (current) return Deferred.await(current) + const deferred = Deferred.makeUnsafe() + refreshes.set(credentialID, deferred) + return effect.pipe( + Effect.onExit((exit) => + Deferred.done(deferred, exit).pipe(Effect.andThen(Effect.sync(() => refreshes.delete(credentialID)))), + ), + Effect.forkDetach({ startImmediately: true }), + Effect.andThen(Deferred.await(deferred)), + ) + }) + const attemptLifetime = Duration.toMillis(Duration.minutes(10)) const terminalRetention = Duration.toMillis(Duration.minutes(1)) const scrubInterval = Duration.seconds(30) @@ -390,16 +411,38 @@ export const locationLayer = Layer.effect( const credential = yield* credentials.get(connection.id) if (!credential) return undefined if (credential.value.type === "key") return credential.value - const implementation = state - .get() - .integrations.get(credential.integrationID) - ?.implementations.get(credential.value.methodID) - if (!implementation?.refresh) return credential.value - const now = yield* Clock.currentTimeMillis - if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value - const value = yield* authorize(implementation.refresh(credential.value)) - yield* credentials.update(credential.id, { value }) - return value + const oauth = credential.value + return yield* refresh( + credential.id, + Effect.gen(function* () { + const latest = yield* credentials.get(credential.id) + if (!latest || latest.value.type === "key") return latest?.value + if ( + latest.value.access !== oauth.access || + latest.value.refresh !== oauth.refresh || + latest.value.expires !== oauth.expires + ) + return latest.value + const implementation = state + .get() + .integrations.get(latest.integrationID) + ?.implementations.get(latest.value.methodID) + if (!implementation?.refresh) return latest.value + const now = yield* Clock.currentTimeMillis + if (latest.value.expires > now + Duration.toMillis(Duration.minutes(5))) return latest.value + const value = yield* authorize(implementation.refresh(latest.value)) + const current = yield* credentials.get(latest.id) + if (!current || current.value.type === "key") return current?.value + if ( + current.value.access !== latest.value.access || + current.value.refresh !== latest.value.refresh || + current.value.expires !== latest.value.expires + ) + return current.value + yield* credentials.update(latest.id, { value }) + return value + }), + ) }), key: Effect.fn("Integration.connection.key")(function* (input) { const method = state diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 1749b474ed33..67e50d66b2e5 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -1,6 +1,7 @@ import { AlibabaPlugin } from "./provider/alibaba" import { AmazonBedrockPlugin } from "./provider/amazon-bedrock" import { AnthropicPlugin } from "./provider/anthropic" +import { AnthropicSubscriptionPlugin } from "./provider/anthropic-subscription" import { AzureCognitiveServicesPlugin, AzurePlugin } from "./provider/azure" import { CerebrasPlugin } from "./provider/cerebras" import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway" @@ -37,6 +38,7 @@ export const ProviderPlugins: PluginInternal.Plugin number +} + +export type AnthropicSubscriptionTokenResponse = { + access_token: string + refresh_token?: string + expires_in?: number +} + +type Pkce = { + verifier: string + challenge: string +} + +type Fetch = (input: Parameters[0], init?: Parameters[1]) => Promise +const refreshRequests = new Map>() + +export function refreshAnthropicSubscriptionToken(input: { + readonly refresh: string + readonly request?: Fetch + readonly tokenEndpoint?: string +}) { + const endpoint = input.tokenEndpoint ?? tokenEndpoint + const key = `${endpoint}\u0000${input.refresh}` + const current = refreshRequests.get(key) + if (current) return current + const pending = tokenRequestPromise(input.request ?? fetch, endpoint, { + grant_type: "refresh_token", + refresh_token: input.refresh, + client_id: clientID, + }).finally(() => { + if (refreshRequests.get(key) === pending) refreshRequests.delete(key) + }) + refreshRequests.set(key, pending) + return pending +} + +export const makeAnthropicSubscriptionPlugin = (options: Options = {}) => { + const authOrigin = options.authorizationOrigin ?? authorizationOrigin + const tokens = options.tokenEndpoint ?? tokenEndpoint + const request = options.request ?? fetch + const now = options.now ?? Date.now + + const exchange = (code: string, verifier: string, state: string) => { + const [authorizationCode, returnedState] = code.trim().split("#") + if (!authorizationCode || returnedState !== state) return Effect.fail(new Error("Invalid OAuth state")) + return tokenRequest(request, tokens, { + code: authorizationCode, + state: returnedState, + grant_type: "authorization_code", + client_id: clientID, + redirect_uri: redirectURI, + code_verifier: verifier, + }).pipe( + Effect.flatMap((result) => { + if (!result.refresh_token) return Effect.fail(new Error("Anthropic did not return a refresh token")) + return Effect.succeed(credential(result, result.refresh_token, now)) + }), + ) + } + + const oauth = { + integrationID: AnthropicSubscriptionIntegrationID, + method: { + id: AnthropicSubscriptionMethodID, + type: "oauth", + label: "Claude Pro/Max subscription", + }, + authorize: () => + Effect.promise(generatePKCE).pipe( + Effect.map((pkce) => { + const state = randomState() + const url = new URL("/oauth/authorize", authOrigin) + url.searchParams.set("code", "true") + url.searchParams.set("client_id", clientID) + url.searchParams.set("response_type", "code") + url.searchParams.set("redirect_uri", redirectURI) + url.searchParams.set("scope", "org:create_api_key user:profile user:inference") + url.searchParams.set("code_challenge", pkce.challenge) + url.searchParams.set("code_challenge_method", "S256") + url.searchParams.set("state", state) + return { + mode: "code" as const, + url: url.toString(), + instructions: "Paste the authorization code here:", + callback: (code: string) => exchange(code, pkce.verifier, state), + } + }), + ), + refresh: (value) => + Effect.tryPromise({ + try: () => refreshAnthropicSubscriptionToken({ refresh: value.refresh, request, tokenEndpoint: tokens }), + catch: (cause) => cause, + }).pipe(Effect.map((result) => credential(result, result.refresh_token ?? value.refresh, now))), + } satisfies IntegrationOAuthMethodRegistration + + return define({ + id: "anthropic-subscription", + effect: Effect.fn(function* (ctx) { + yield* ctx.integration.transform((draft) => { + draft.update(AnthropicSubscriptionIntegrationID, (integration) => { + integration.name = "Claude Pro/Max" + }) + draft.method.update(oauth) + }) + yield* ctx.catalog.transform((catalog) => { + const source = catalog.provider.get(ProviderV2.ID.anthropic) + if (!source) return + catalog.provider.update(AnthropicSubscriptionProviderID, (provider) => { + Object.assign(provider, structuredClone(source.provider)) + provider.id = AnthropicSubscriptionProviderID + provider.integrationID = AnthropicSubscriptionIntegrationID + provider.name = "Claude Pro/Max" + provider.disabled = source.provider.disabled + }) + for (const model of source.models.values()) { + if (!model.api.id.startsWith("claude-")) continue + catalog.model.update(AnthropicSubscriptionProviderID, model.id, (draft) => { + Object.assign(draft, structuredClone(model)) + draft.providerID = AnthropicSubscriptionProviderID + draft.cost = model.cost.map((cost) => ({ + ...cost, + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + })) + }) + } + }) + }), + } satisfies PluginInternal.Plugin) +} + +export const AnthropicSubscriptionPlugin = makeAnthropicSubscriptionPlugin() + +function tokenRequest(request: Fetch, url: string, body: Record) { + return Effect.tryPromise({ + try: (signal) => tokenRequestPromise(request, url, body, signal), + catch: (cause) => cause, + }) +} + +async function tokenRequestPromise(request: Fetch, url: string, body: Record, signal?: AbortSignal) { + const response = await request(url, { + method: "POST", + headers: { "Content-Type": "application/json", "anthropic-beta": "oauth-2025-04-20" }, + body: JSON.stringify(body), + signal, + }) + if (!response.ok) throw new Error(`Anthropic OAuth request failed: ${response.status}`) + const result = (await response.json()) as AnthropicSubscriptionTokenResponse + if (!result.access_token) throw new Error("Anthropic did not return an access token") + return result +} + +function credential(tokens: AnthropicSubscriptionTokenResponse, refresh: string, now: () => number) { + return Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: tokens.access_token, + refresh, + expires: now() + (tokens.expires_in ?? 3600) * 1000, + }) +} + +async function generatePKCE(): Promise { + const verifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(48)).buffer) + const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))) + return { verifier, challenge } +} + +function randomState() { + return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) +} + +function base64UrlEncode(buffer: ArrayBuffer) { + return Buffer.from(buffer).toString("base64url") +} diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 74e78120c20e..3cdf1b9b2a4c 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -3,6 +3,7 @@ export * as SessionRunnerModel from "./model" import { makeLocationNode } from "../../effect/app-node" import { type Model } from "@opencode-ai/llm" import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" +import { AnthropicSubscription } from "@opencode-ai/llm/providers" import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat" import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses" import { Auth, type AnyRoute } from "@opencode-ai/llm/route" @@ -64,11 +65,24 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass()( + "SessionRunnerModel.CredentialRequiredError", + { + providerID: ProviderV2.ID, + modelID: ModelV2.ID, + }, +) { + override get message() { + return `OAuth credential required for ${this.providerID}/${this.modelID}` + } +} + export type Error = | ModelNotSelectedError | ModelUnavailableError | VariantUnavailableError | UnsupportedApiError + | CredentialRequiredError | Integration.AuthorizationError export interface Interface { @@ -131,7 +145,8 @@ const apiName = (model: ModelV2.Info) => export const fromCatalogModel = ( model: ModelV2.Info, credential?: Credential.Value, -): Effect.Effect => { + sessionID: SessionSchema.ID = SessionSchema.ID.create(), +): Effect.Effect => { const resolved = credential?.type !== "key" || credential.metadata === undefined ? model @@ -139,6 +154,37 @@ export const fromCatalogModel = ( Object.assign(draft.request.body, credential.metadata) }) const key = apiKey(resolved, credential) + if (resolved.providerID === ProviderV2.ID.make("anthropic-subscription")) { + if (resolved.api.type !== "aisdk" || resolved.api.package !== "@ai-sdk/anthropic") { + return Effect.fail( + new UnsupportedApiError({ + providerID: resolved.providerID, + modelID: resolved.id, + api: apiName(resolved), + }), + ) + } + if (credential?.type !== "oauth") + return Effect.fail( + new CredentialRequiredError({ + providerID: resolved.providerID, + modelID: resolved.id, + }), + ) + const body = Object.hasOwn(resolved.request.body, "apiKey") + ? Object.fromEntries(Object.entries(resolved.request.body).filter(([name]) => name !== "apiKey")) + : resolved.request.body + return Effect.succeed( + AnthropicSubscription.configure({ + accessToken: credential.access, + sessionID, + baseURL: resolved.api.url, + headers: resolved.request.headers, + http: { body }, + limits: { context: resolved.limit.context, output: resolved.limit.output }, + }).model(resolved.api.id), + ) + } if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") { return Effect.succeed( withDefaults(resolved, OpenAIResponses.route) @@ -170,7 +216,9 @@ export const fromCatalogModel = ( } export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, credential?: Credential.Value) => - withVariant(model, session.model?.variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential))) + withVariant(model, session.model?.variant).pipe( + Effect.flatMap((model) => fromCatalogModel(model, credential, session.id)), + ) export const supported = (model: ModelV2.Info) => model.api.type === "aisdk" && diff --git a/packages/core/test/plugin/provider-anthropic-subscription.test.ts b/packages/core/test/plugin/provider-anthropic-subscription.test.ts new file mode 100644 index 000000000000..3bc61e4eedd9 --- /dev/null +++ b/packages/core/test/plugin/provider-anthropic-subscription.test.ts @@ -0,0 +1,278 @@ +import { Catalog } from "@opencode-ai/core/catalog" +import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { + AnthropicSubscriptionIntegrationID, + AnthropicSubscriptionMethodID, + AnthropicSubscriptionProviderID, + makeAnthropicSubscriptionPlugin, +} from "@opencode-ai/core/plugin/provider/anthropic-subscription" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ProjectV2 } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { describe, expect } from "bun:test" +import { DateTime, Effect, Fiber } from "effect" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* (plugin: ReturnType) { + const plugins = yield* PluginV2.Service + yield* plugin.effect(yield* PluginHost.make(plugins)) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +describe("AnthropicSubscriptionPlugin", () => { + it.effect("clones Claude catalog models under a zero-cost provider", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* catalog.transform((draft) => { + draft.provider.update(ProviderV2.ID.anthropic, (provider) => { + provider.name = "Anthropic" + provider.api = { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://api.anthropic.com/v1" } + provider.request.headers["anthropic-beta"] = "existing-beta" + }) + draft.model.update(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-test"), (model) => { + model.name = "Claude Sonnet Test" + model.api = { + type: "aisdk", + package: "@ai-sdk/anthropic", + id: ModelV2.ID.make("claude-sonnet-test"), + } + model.cost = [{ input: 3, output: 15, cache: { read: 0.3, write: 3.75 } }] + model.limit = { context: 200_000, output: 8_192 } + }) + draft.model.update(ProviderV2.ID.anthropic, ModelV2.ID.make("not-claude"), (model) => { + model.api = { type: "aisdk", package: "@ai-sdk/anthropic", id: ModelV2.ID.make("not-claude") } + }) + }) + + yield* addPlugin(makeAnthropicSubscriptionPlugin()) + + const provider = required(yield* catalog.provider.get(AnthropicSubscriptionProviderID)) + expect(provider).toMatchObject({ + id: AnthropicSubscriptionProviderID, + integrationID: AnthropicSubscriptionIntegrationID, + name: "Claude Pro/Max", + api: { type: "aisdk", package: "@ai-sdk/anthropic", url: "https://api.anthropic.com/v1" }, + }) + expect(provider.request.headers["anthropic-beta"]).toBe("existing-beta") + const model = required( + yield* catalog.model.get(AnthropicSubscriptionProviderID, ModelV2.ID.make("claude-sonnet-test")), + ) + expect(model.providerID).toBe(AnthropicSubscriptionProviderID) + expect(model.cost).toEqual([{ input: 0, output: 0, cache: { read: 0, write: 0 } }]) + expect(yield* catalog.model.get(AnthropicSubscriptionProviderID, ModelV2.ID.make("not-claude"))).toBeUndefined() + yield* (yield* Credential.Service).create({ + integrationID: AnthropicSubscriptionIntegrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 3_600_000, + }), + }) + const session = SessionV2.Info.make({ + id: SessionV2.ID.make("ses_subscription_plugin"), + projectID: ProjectV2.ID.global, + title: "test", + model: { providerID: AnthropicSubscriptionProviderID, id: model.id }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: { directory: AbsolutePath.make("/project") }, + }) + const resolved = yield* Effect.gen(function* () { + return yield* (yield* SessionRunnerModel.Service).resolve(session) + }).pipe(Effect.provide(SessionRunnerModel.locationLayer)) + expect(resolved).toMatchObject({ + provider: "anthropic-subscription", + route: { id: "anthropic-subscription" }, + }) + }), + ) + + it.effect("validates OAuth state and refreshes rotated credentials", () => + Effect.gen(function* () { + const requests: Request[] = [] + const clock = { now: -3_599_000 } + yield* addPlugin( + makeAnthropicSubscriptionPlugin({ + authorizationOrigin: "https://login.test", + tokenEndpoint: "https://tokens.test/oauth/token", + now: () => clock.now, + async request(input, init) { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + const request = new Request(url, init) + requests.push(request) + const body = (await request.clone().json()) as { grant_type: string } + if (body.grant_type === "refresh_token") { + return Response.json({ + access_token: "access-refreshed", + refresh_token: "refresh-rotated", + expires_in: 3600, + }) + } + return Response.json({ access_token: "access-initial", refresh_token: "refresh-initial", expires_in: 3600 }) + }, + }), + ) + const integrations = yield* Integration.Service + const info = required(yield* integrations.get(AnthropicSubscriptionIntegrationID)) + expect(info.name).toBe("Claude Pro/Max") + expect(info.methods).toEqual([ + { + id: AnthropicSubscriptionMethodID, + type: "oauth", + label: "Claude Pro/Max subscription", + }, + ]) + + const invalid = yield* integrations.connection.oauth({ + integrationID: AnthropicSubscriptionIntegrationID, + methodID: AnthropicSubscriptionMethodID, + inputs: {}, + }) + yield* integrations.attempt.complete({ attemptID: invalid.attemptID, code: "code#wrong" }).pipe(Effect.flip) + expect(requests).toHaveLength(0) + + const attempt = yield* integrations.connection.oauth({ + integrationID: AnthropicSubscriptionIntegrationID, + methodID: AnthropicSubscriptionMethodID, + inputs: {}, + }) + const state = new URL(attempt.url).searchParams.get("state") + expect(state).toBeTruthy() + yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: `code#${state}` }) + expect(requests).toHaveLength(1) + + const connection = required(yield* integrations.connection.active(AnthropicSubscriptionIntegrationID)) + clock.now = 0 + const resolved = yield* Effect.all( + [integrations.connection.resolve(connection), integrations.connection.resolve(connection)], + { concurrency: "unbounded" }, + ) + resolved.forEach((credential) => + expect(credential).toMatchObject({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "access-refreshed", + refresh: "refresh-rotated", + expires: 3_600_000, + }), + ) + expect(requests).toHaveLength(2) + expect(requests[0].headers.get("anthropic-beta")).toBe("oauth-2025-04-20") + }), + ) + + it.effect("shares failed refreshes across concurrent credential resolution", () => + Effect.gen(function* () { + const gate = Promise.withResolvers() + const requests: Request[] = [] + yield* addPlugin( + makeAnthropicSubscriptionPlugin({ + tokenEndpoint: "https://tokens.test/oauth/token", + async request(input, init) { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + requests.push(new Request(url, init)) + await gate.promise + return new Response("{}", { status: 500 }) + }, + }), + ) + yield* (yield* Credential.Service).create({ + integrationID: AnthropicSubscriptionIntegrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "expired", + refresh: "refresh-failure", + expires: 0, + }), + }) + const integrations = yield* Integration.Service + const connection = required(yield* integrations.connection.active(AnthropicSubscriptionIntegrationID)) + const first = yield* integrations.connection.resolve(connection).pipe(Effect.exit, Effect.forkChild) + const second = yield* integrations.connection.resolve(connection).pipe(Effect.exit, Effect.forkChild) + yield* Effect.promise(async () => { + while (requests.length === 0) await Bun.sleep(1) + }) + expect(requests).toHaveLength(1) + gate.resolve() + const exits = yield* Effect.all([Fiber.join(first), Fiber.join(second)]) + + expect(exits.map((exit) => exit._tag)).toEqual(["Failure", "Failure"]) + expect(requests).toHaveLength(1) + }), + ) + + it.effect("does not overwrite a credential reconnected during refresh", () => + Effect.gen(function* () { + const gate = Promise.withResolvers() + const requests: Request[] = [] + yield* addPlugin( + makeAnthropicSubscriptionPlugin({ + tokenEndpoint: "https://tokens.test/oauth/token", + async request(input, init) { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + requests.push(new Request(url, init)) + await gate.promise + return Response.json({ + access_token: "stale-access", + refresh_token: "stale-refresh", + expires_in: 3600, + }) + }, + }), + ) + const credentials = yield* Credential.Service + const stored = yield* credentials.create({ + integrationID: AnthropicSubscriptionIntegrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "expired", + refresh: "refresh-race", + expires: 0, + }), + }) + const integrations = yield* Integration.Service + const connection = required(yield* integrations.connection.active(AnthropicSubscriptionIntegrationID)) + const running = yield* integrations.connection.resolve(connection).pipe(Effect.forkChild) + yield* Effect.promise(async () => { + while (requests.length === 0) await Bun.sleep(1) + }) + yield* credentials.update(stored.id, { + value: Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "reconnected-access", + refresh: "reconnected-refresh", + expires: 7_200_000, + }), + }) + gate.resolve() + + expect(yield* Fiber.join(running)).toMatchObject({ + access: "reconnected-access", + refresh: "reconnected-refresh", + }) + expect((yield* credentials.get(stored.id))?.value).toMatchObject({ + access: "reconnected-access", + refresh: "reconnected-refresh", + }) + }), + ) +}) diff --git a/packages/core/test/session-runner-anthropic-subscription.test.ts b/packages/core/test/session-runner-anthropic-subscription.test.ts new file mode 100644 index 000000000000..f151abe097f8 --- /dev/null +++ b/packages/core/test/session-runner-anthropic-subscription.test.ts @@ -0,0 +1,223 @@ +import { AgentV2 } from "@opencode-ai/core/agent" +import { Config } from "@opencode-ai/core/config" +import { Credential } from "@opencode-ai/core/credential" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" +import { PermissionV2 } from "@opencode-ai/core/permission" +import { + AnthropicSubscriptionMethodID, + AnthropicSubscriptionProviderID, +} from "@opencode-ai/core/plugin/provider/anthropic-subscription" +import { Project } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { Prompt } from "@opencode-ai/core/session/prompt" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" +import { SessionRunner } from "@opencode-ai/core/session/runner" +import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionStore } from "@opencode-ai/core/session/store" +import { SkillGuidance } from "@opencode-ai/core/skill/guidance" +import { Snapshot } from "@opencode-ai/core/snapshot" +import { SystemContext } from "@opencode-ai/core/system-context" +import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" +import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { testEffect } from "./lib/effect" + +const requests: Array<{ url: string; headers: Headers; body: string }> = [] +const http = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) + requests.push({ url: web.url, headers: web.headers, body: yield* Effect.promise(() => web.text()) }) + return HttpClientResponse.fromWeb( + request, + new Response( + [ + { type: "message_start", message: { usage: { input_tokens: 7 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello from Claude." } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 4 } }, + { type: "message_stop" }, + ] + .map((event) => `data: ${JSON.stringify(event)}\n\n`) + .join(""), + { headers: { "content-type": "text/event-stream" } }, + ), + ) + }), + ), +) +const executor = RequestExecutor.layer.pipe(Layer.provide(http)) +const client = LLMClient.layer.pipe(Layer.provide(executor)) +const permission = Layer.succeed( + PermissionV2.Service, + PermissionV2.Service.of({ + assert: () => Effect.die("unused"), + ask: () => Effect.die("unused"), + reply: () => Effect.die("unused"), + get: () => Effect.die("unused"), + forSession: () => Effect.die("unused"), + list: () => Effect.die("unused"), + }), +) +const catalogModel = ModelV2.Info.make({ + ...ModelV2.Info.empty(AnthropicSubscriptionProviderID, ModelV2.ID.make("claude-sonnet-test")), + name: "Claude Sonnet Test", + api: { + id: ModelV2.ID.make("claude-sonnet-test"), + type: "aisdk", + package: "@ai-sdk/anthropic", + url: "https://api.anthropic.com/v1", + }, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + request: { headers: { "x-api-key": "must-not-leak" }, body: {} }, + limit: { context: 200_000, output: 8_192 }, +}) +const oauth = Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 3_600_000, +}) +const models = SessionRunnerModel.layerWith((session) => + SessionRunnerModel.fromCatalogModel(catalogModel, oauth, session.id), +) +const systemContext = AppNodeBuilder.build(SystemContextRegistry.node) +const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) +const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) +const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) +const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ + [Snapshot.node, Snapshot.noopLayer], + [LayerNodePlatform.llmClient, client], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [Config.node, config], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], +]) +const execution = Layer.effect( + SessionExecution.Service, + Effect.gen(function* () { + const runner = yield* SessionRunner.Service + const coordinator = yield* SessionRunCoordinator.make({ + drain: (sessionID, force) => runner.run({ sessionID, force }), + }) + return SessionExecution.Service.of({ + active: coordinator.active, + resume: coordinator.run, + wake: coordinator.wake, + interrupt: coordinator.interrupt, + }) + }), +).pipe(Layer.provide(runnerLayer)) +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + EventV2.node, + SessionProjector.node, + SessionStore.node, + AgentV2.node, + ToolRegistry.node, + SessionRunnerModel.node, + SystemContextRegistry.node, + SkillGuidance.node, + ReferenceGuidance.node, + Config.node, + Snapshot.node, + SessionRunnerLLM.node, + SessionV2.node, + ]), + [ + [LayerNodePlatform.llmClient, client], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [Config.node, config], + [Snapshot.node, Snapshot.noopLayer], + [SessionExecution.node, execution], + ], + ), +) + +describe("SessionRunner Anthropic subscription", () => { + it.effect("runs one durable prompt through the subscription transport", () => + Effect.gen(function* () { + requests.length = 0 + const sessionID = SessionV2.ID.make("ses_anthropic_subscription") + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + const sessions = yield* SessionV2.Service + yield* sessions.prompt({ + sessionID, + prompt: Prompt.make({ text: "Say hello in one short sentence." }), + resume: false, + }) + + yield* sessions.resume(sessionID) + + expect(requests).toHaveLength(1) + expect(requests[0].url).toBe("https://api.anthropic.com/v1/messages?beta=true") + expect(requests[0].headers.get("authorization")).toBe("Bearer oauth-access") + expect(requests[0].headers.get("x-api-key")).toBeNull() + expect(requests[0].headers.get("x-claude-code-session-id")).toBe(sessionID) + const body = JSON.parse(requests[0].body) as { + system: Array<{ text: string }> + messages: Array<{ role: string; content: Array<{ text?: string }> }> + } + expect(body.system[0].text).toStartWith("x-anthropic-billing-header:") + expect(body.system[1].text).toBe("You are Claude Code, Anthropic's official CLI for Claude.") + expect(body.messages[0].content.some((part) => part.text === "Say hello in one short sentence.")).toBeTrue() + const context = yield* sessions.context(sessionID) + const last = context.at(-1) + expect(last).toMatchObject({ type: "assistant", finish: "stop" }) + expect(last?.type === "assistant" ? last.content : []).toMatchObject([ + { type: "text", text: "Hello from Claude." }, + ]) + }), + ) +}) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 49bbce95a381..5e9c366e53bd 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -11,6 +11,7 @@ import { ProjectV2 } from "@opencode-ai/core/project" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionV2 } from "@opencode-ai/core/session" import { AbsolutePath } from "@opencode-ai/core/schema" +import { AnthropicSubscriptionMethodID } from "@opencode-ai/core/plugin/provider/anthropic-subscription" import { it } from "./lib/effect" type Api = @@ -247,6 +248,63 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("maps Claude subscription OAuth models into the dedicated native route", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + ModelV2.Info.make({ + ...model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://api.anthropic.com/v1" }), + providerID: ProviderV2.ID.make("anthropic-subscription"), + }), + Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "oauth-access", + refresh: "oauth-refresh", + expires: Date.now() + 3_600_000, + }), + SessionV2.ID.make("ses_subscription"), + ) + const headers = yield* resolved.route.auth.apply({ + request: LLM.request({ model: resolved, prompt: "Hello" }), + method: "POST", + url: "https://api.anthropic.com/v1/messages?beta=true", + body: "{}", + headers: Headers.fromInput({ "x-api-key": "configured-secret", "anthropic-beta": "existing-beta" }), + }) + + expect(resolved).toMatchObject({ + provider: "anthropic-subscription", + route: { + id: "anthropic-subscription", + endpoint: { baseURL: "https://api.anthropic.com/v1", query: { beta: "true" } }, + transport: { id: "http-json/sse/anthropic-subscription" }, + }, + }) + expect(headers.authorization).toBe("Bearer oauth-access") + expect(headers["x-api-key"]).toBeUndefined() + expect(headers["x-claude-code-session-id"]).toBe("ses_subscription") + expect(headers["anthropic-beta"]).toContain("oauth-2025-04-20") + }), + ) + + it.effect("rejects Claude subscription models without OAuth credentials", () => + Effect.gen(function* () { + const failure = yield* SessionRunnerModel.fromCatalogModel( + ModelV2.Info.make({ + ...model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }), + providerID: ProviderV2.ID.make("anthropic-subscription"), + }), + Credential.Key.make({ type: "key", key: "not-oauth" }), + ).pipe(Effect.flip) + + expect(failure).toMatchObject({ + _tag: "SessionRunnerModel.CredentialRequiredError", + providerID: "anthropic-subscription", + modelID: "test-model", + }) + }), + ) + it.effect("uses resolved credentials for bearer auth", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( diff --git a/packages/llm/package.json b/packages/llm/package.json index 91103fd4d090..3a776e2baa96 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -17,6 +17,7 @@ "./providers": "./src/providers/index.ts", "./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts", "./providers/anthropic": "./src/providers/anthropic.ts", + "./providers/anthropic-subscription": "./src/providers/anthropic-subscription.ts", "./providers/azure": "./src/providers/azure.ts", "./providers/cloudflare": "./src/providers/cloudflare.ts", "./providers/github-copilot": "./src/providers/github-copilot.ts", diff --git a/packages/llm/src/providers/anthropic-subscription.ts b/packages/llm/src/providers/anthropic-subscription.ts new file mode 100644 index 000000000000..84161af607e1 --- /dev/null +++ b/packages/llm/src/providers/anthropic-subscription.ts @@ -0,0 +1,249 @@ +import { Effect, Stream } from "effect" +import { Headers } from "effect/unstable/http" +import * as AnthropicMessages from "../protocols/anthropic-messages" +import { Auth } from "../route/auth" +import { Route, type RouteDefaultsInput } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { HttpTransport, type Transport } from "../route/transport" +import { InvalidRequestReason, LLMError, ProviderID, type ModelID } from "../schema" + +export const id = ProviderID.make("anthropic-subscription") +export const defaultClaudeCodeVersion = "2.1.185" +export const systemIdentity = "You are Claude Code, Anthropic's official CLI for Claude." + +const billingSalt = "59cf53e54c78" +const toolPrefix = "mcp_" +const requiredBetas = ["claude-code-20250219", "oauth-2025-04-20", "interleaved-thinking-2025-05-14"] +const apiOrigin = "https://api.anthropic.com" + +type ContentBlock = Record & { + type?: string + text?: string + name?: string +} + +type Message = { + role?: string + content?: string | ContentBlock[] +} + +type RequestBody = Record & { + system?: ContentBlock[] + tools?: Array & { name?: string }> + tool_choice?: Record & { type?: string; name?: string } + messages?: Message[] +} + +export type Config = RouteDefaultsInput & { + readonly accessToken: string + readonly sessionID: string + readonly baseURL?: string + readonly claudeCodeVersion?: string +} + +const prefixToolName = (name: string) => { + if (name.startsWith(toolPrefix)) return name + return `${toolPrefix}${name.charAt(0).toUpperCase()}${name.slice(1)}` +} + +const unprefixToolName = (name: string) => `${name.charAt(0).toLowerCase()}${name.slice(1)}` + +const toolNames = (tools: RequestBody["tools"]) => { + const outbound = new Map() + const inbound = new Map() + for (const tool of tools ?? []) { + if (!tool.name) continue + const base = prefixToolName(tool.name) + let encoded = base + let suffix = 2 + while (inbound.has(encoded)) encoded = `${base}_${suffix++}` + outbound.set(tool.name, encoded) + inbound.set(encoded, tool.name) + } + return { outbound, inbound } +} + +const firstUserText = (messages: Message[]) => { + const content = messages.find((message) => message.role === "user")?.content + if (typeof content === "string") return content + if (!Array.isArray(content)) return "" + return content.find((block) => block.type === "text")?.text ?? "" +} + +const hash = async (value: string) => + [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)))] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + +const billingHeader = async (messages: Message[], version: string) => { + const text = firstUserText(messages) + const sampled = [4, 7, 20].map((index) => text[index] ?? "0").join("") + const suffix = (await hash(`${billingSalt}${sampled}${version}`)).slice(0, 3) + const contentHash = (await hash(text)).slice(0, 5) + return `x-anthropic-billing-header: cc_version=${version}.${suffix}; cc_entrypoint=sdk-cli; cch=${contentHash};` +} + +const transformBody = async (parsed: RequestBody, version: string): Promise => { + const system = Array.isArray(parsed.system) ? parsed.system : [] + const messages = Array.isArray(parsed.messages) ? parsed.messages.map((message) => ({ ...message })) : [] + const billing = await billingHeader(messages, version) + const names = toolNames(parsed.tools) + const moved = system.flatMap((item) => { + if (item.type !== "text" || !item.text) return [] + const text = item.text.startsWith(systemIdentity) + ? item.text.slice(systemIdentity.length).replace(/^\n+/, "") + : item.text + return text && !text.startsWith("x-anthropic-billing-header") ? [text] : [] + }) + const firstUser = messages.find((message) => message.role === "user") + if (firstUser && moved.length > 0) { + const text = moved.join("\n\n") + if (typeof firstUser.content === "string") firstUser.content = `${text}\n\n${firstUser.content}` + if (Array.isArray(firstUser.content)) firstUser.content = [{ type: "text", text }, ...firstUser.content] + if (firstUser.content === undefined) firstUser.content = text + } + const transformedMessages = messages.map((message) => ({ + ...message, + content: Array.isArray(message.content) + ? message.content.map((block) => ({ + ...block, + name: + block.type === "tool_use" && block.name + ? (names.outbound.get(block.name) ?? prefixToolName(block.name)) + : block.name, + })) + : message.content, + })) + const keptSystem = firstUser + ? [] + : system.filter( + (item) => + item.type !== "text" || + (!item.text?.startsWith(systemIdentity) && !item.text?.startsWith("x-anthropic-billing-header")), + ) + return { + ...parsed, + system: [{ type: "text", text: billing }, { type: "text", text: systemIdentity }, ...keptSystem], + messages: transformedMessages, + tools: parsed.tools?.map((tool) => ({ + ...tool, + name: tool.name ? (names.outbound.get(tool.name) ?? prefixToolName(tool.name)) : tool.name, + })), + tool_choice: + parsed.tool_choice?.type === "tool" && parsed.tool_choice.name + ? { + ...parsed.tool_choice, + name: names.outbound.get(parsed.tool_choice.name) ?? prefixToolName(parsed.tool_choice.name), + } + : parsed.tool_choice, + } +} + +export async function transformBodyText(body: string, version = defaultClaudeCodeVersion) { + try { + const parsed = JSON.parse(body) as unknown + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return body + return JSON.stringify(await transformBody(parsed as RequestBody, version)) + } catch { + return body + } +} + +export const toolNameMap = (body: string): ReadonlyMap => { + try { + const parsed = JSON.parse(body) as RequestBody + return toolNames(parsed.tools).inbound + } catch { + return new Map() + } +} + +export const restoreToolNames = (frame: string, names?: ReadonlyMap) => + frame.replace( + /"name"\s*:\s*"mcp_([^"]+)"/g, + (_match, name: string) => `"name":${JSON.stringify(names?.get(`mcp_${name}`) ?? unprefixToolName(name))}`, + ) + +const transport = (version: string) => { + const base = HttpTransport.sseJson.with() + type Prepared = { + readonly http: HttpTransport.HttpPrepared + readonly toolNames: ReadonlyMap + } + return { + id: "http-json/sse/anthropic-subscription", + prepare: (input: Parameters[0]) => { + const bodyText = input.encodeBody(input.body) + const toolNames = toolNameMap(bodyText) + return Effect.promise(async () => JSON.parse(await transformBodyText(bodyText, version))).pipe( + Effect.flatMap((body) => base.prepare({ ...input, body })), + Effect.map((http) => ({ http, toolNames })), + ) + }, + frames: (prepared: Prepared, request, runtime) => + base + .frames(prepared.http, request, runtime) + .pipe(Stream.map((frame) => restoreToolNames(frame, prepared.toolNames))), + } satisfies Transport +} + +const authentication = (input: Config, version: string) => + Auth.custom((request) => { + if (new URL(request.url).origin !== apiOrigin) { + return Effect.fail( + new LLMError({ + module: "AnthropicSubscription", + method: "authenticate", + reason: new InvalidRequestReason({ message: "Claude subscription credentials require api.anthropic.com" }), + }), + ) + } + const existing = Object.entries(request.headers).find(([name]) => name.toLowerCase() === "anthropic-beta")?.[1] + const betas = new Set( + (existing ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ) + requiredBetas.forEach((beta) => betas.add(beta)) + return Effect.succeed( + Headers.setAll(Headers.remove(request.headers, "x-api-key"), { + authorization: `Bearer ${input.accessToken}`, + "anthropic-version": "2023-06-01", + "anthropic-beta": [...betas].join(","), + "anthropic-dangerous-direct-browser-access": "true", + "x-app": "cli", + "x-client-request-id": crypto.randomUUID(), + "x-claude-code-session-id": input.sessionID, + "user-agent": `claude-cli/${version} (external, sdk-cli)`, + }), + ) + }) + +const configuredRoute = (input: Config) => { + const version = input.claudeCodeVersion ?? defaultClaudeCodeVersion + const { accessToken: _, sessionID: __, baseURL, claudeCodeVersion: ___, ...defaults } = input + return Route.make({ + id: "anthropic-subscription", + provider: id, + protocol: AnthropicMessages.protocol, + endpoint: Endpoint.path(AnthropicMessages.PATH, { + baseURL: baseURL ?? AnthropicMessages.DEFAULT_BASE_URL, + query: { beta: "true" }, + }), + auth: authentication(input, version), + transport: transport(version), + defaults, + }) +} + +export const configure = (input: Config) => { + const route = configuredRoute(input) + return { + id, + model: (modelID: string | ModelID) => route.model({ id: modelID }), + configure, + } +} + +export const provider = { id, configure } diff --git a/packages/llm/src/providers/index.ts b/packages/llm/src/providers/index.ts index 774274cf2d4a..3fd596cf31d8 100644 --- a/packages/llm/src/providers/index.ts +++ b/packages/llm/src/providers/index.ts @@ -1,4 +1,5 @@ export * as Anthropic from "./anthropic" +export * as AnthropicSubscription from "./anthropic-subscription" export * as AmazonBedrock from "./amazon-bedrock" export * as Azure from "./azure" export * as Cloudflare from "./cloudflare" diff --git a/packages/llm/test/provider/anthropic-subscription.test.ts b/packages/llm/test/provider/anthropic-subscription.test.ts new file mode 100644 index 000000000000..414d8a2662c1 --- /dev/null +++ b/packages/llm/test/provider/anthropic-subscription.test.ts @@ -0,0 +1,138 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM, Message } from "../../src" +import { AnthropicSubscription } from "../../src/providers" +import { LLMClient } from "../../src/route" +import { dynamicResponse } from "../lib/http" +import { it } from "../lib/effect" +import { sseEvents } from "../lib/sse" + +describe("Anthropic subscription provider", () => { + it.effect("rewrites requests and restores streamed tool names", () => + Effect.gen(function* () { + const model = AnthropicSubscription.configure({ + accessToken: "oauth-access", + sessionID: "session-123", + claudeCodeVersion: "2.1.185", + baseURL: "https://api.anthropic.com/v1", + headers: { "anthropic-beta": "existing-beta", "x-api-key": "must-not-leak" }, + }).model("claude-sonnet-test") + const response = yield* LLMClient.generate( + LLM.request({ + model, + system: "DCode session instructions", + messages: [ + Message.user("Run the tool"), + Message.assistant({ type: "tool-call", id: "old", name: "read", input: {} }), + Message.tool({ id: "old", name: "read", result: "ok", resultType: "text" }), + ], + tools: [ + { name: "bash", description: "Run a command", inputSchema: { type: "object" } }, + { name: "mcp_probe", description: "Probe", inputSchema: { type: "object" } }, + { name: "URLFetch", description: "Fetch a URL", inputSchema: { type: "object" } }, + { name: "mcp_URLFetch", description: "Fetch through MCP", inputSchema: { type: "object" } }, + { name: "mcp_Foo_3", description: "Pre-suffixed Foo", inputSchema: { type: "object" } }, + { name: "Foo", description: "Uppercase Foo", inputSchema: { type: "object" } }, + { name: "foo", description: "Lowercase foo", inputSchema: { type: "object" } }, + ], + toolChoice: { type: "tool", name: "bash" }, + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + expect(input.request.url).toBe("https://api.anthropic.com/v1/messages?beta=true") + expect(input.request.headers.authorization).toBe("Bearer oauth-access") + expect(input.request.headers["x-api-key"]).toBeUndefined() + expect(input.request.headers["anthropic-beta"]).toContain("existing-beta") + expect(input.request.headers["anthropic-beta"]).toContain("oauth-2025-04-20") + expect(input.request.headers["x-claude-code-session-id"]).toBe("session-123") + const body = JSON.parse(input.text) as { + system: Array<{ text: string }> + messages: Array<{ role: string; content: Array<{ type: string; text?: string; name?: string }> }> + tools: Array<{ name: string }> + tool_choice: { name: string } + } + expect(body.system).toHaveLength(2) + expect(body.system[0].text).toStartWith("x-anthropic-billing-header:") + expect(body.system[1].text).toBe("You are Claude Code, Anthropic's official CLI for Claude.") + expect(body.messages[0].content[0].text).toBe("DCode session instructions") + expect(body.messages[1].content[0].name).toBe("mcp_Read") + expect(body.tools.map((tool) => tool.name)).toEqual([ + "mcp_Bash", + "mcp_probe", + "mcp_URLFetch", + "mcp_URLFetch_2", + "mcp_Foo_3", + "mcp_Foo", + "mcp_Foo_2", + ]) + expect(body.tool_choice.name).toBe("mcp_Bash") + return input.respond( + sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "call_1", name: "mcp_probe" }, + }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: "{}" } }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "call_2", name: "mcp_URLFetch" }, + }, + { type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: "{}" } }, + { type: "content_block_stop", index: 1 }, + { + type: "content_block_start", + index: 2, + content_block: { type: "tool_use", id: "call_3", name: "mcp_URLFetch_2" }, + }, + { type: "content_block_delta", index: 2, delta: { type: "input_json_delta", partial_json: "{}" } }, + { type: "content_block_stop", index: 2 }, + { + type: "content_block_start", + index: 3, + content_block: { type: "tool_use", id: "call_4", name: "mcp_Foo_2" }, + }, + { type: "content_block_delta", index: 3, delta: { type: "input_json_delta", partial_json: "{}" } }, + { type: "content_block_stop", index: 3 }, + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "mcp_probe", input: {} }), + expect.objectContaining({ name: "URLFetch", input: {} }), + expect.objectContaining({ name: "mcp_URLFetch", input: {} }), + expect.objectContaining({ name: "foo", input: {} }), + ]), + ) + }), + ) + + it.effect("rejects non-Anthropic API origins before applying OAuth credentials", () => + Effect.gen(function* () { + const model = AnthropicSubscription.configure({ + accessToken: "oauth-access", + sessionID: "session-123", + baseURL: "https://proxy.example/v1", + }).model("claude-sonnet-test") + const failure = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe( + Effect.provide(dynamicResponse((input) => Effect.succeed(input.respond("")))), + Effect.flip, + ) + + expect(failure.message).toContain("Claude subscription credentials require api.anthropic.com") + }), + ) +}) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 884cd79c0353..c440c69c41c3 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -5,11 +5,20 @@ import { CliError, effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" import * as Prompt from "../effect/prompt" import { ModelsDev } from "@opencode-ai/core/models-dev" +import { Credential } from "@opencode-ai/core/credential" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { + AnthropicSubscriptionIntegrationID, + AnthropicSubscriptionMethodID, + AnthropicSubscriptionProviderID, +} from "@opencode-ai/core/plugin/provider/anthropic-subscription" +import { withAnthropicSubscriptionCredentialLock } from "@/provider/anthropic-subscription-credential" import { map, pipe, sortBy, values } from "remeda" import path from "path" import os from "os" import { Config } from "@/config/config" +import { Provider } from "@/provider/provider" import { Global } from "@opencode-ai/core/global" import { Plugin } from "../../plugin" import type { Hooks } from "@opencode-ai/plugin" @@ -19,6 +28,7 @@ import { text } from "node:stream/consumers" import { Effect, Option } from "effect" type PluginAuth = NonNullable +const credentialLayer = AppNodeBuilder.build(Credential.node) const promptValue = (value: Option.Option) => { if (Option.isNone(value)) return Effect.die(new UI.CancelledError()) @@ -30,6 +40,51 @@ const put = Effect.fn("Cli.providers.put")(function* (key: string, info: Auth.In yield* Effect.orDie(auth.set(key, info)) }) +const putOAuth = Effect.fn("Cli.providers.putOAuth")(function* ( + provider: string, + value: { refresh: string; access: string; expires: number }, + extra: Omit, +) { + if (provider !== AnthropicSubscriptionProviderID) { + yield* put(provider, { type: "oauth", ...value, ...extra }) + return + } + yield* withAnthropicSubscriptionCredentialLock( + Effect.gen(function* () { + const durable = yield* Effect.gen(function* () { + const credentials = yield* Credential.Service + const previous = (yield* credentials.list(AnthropicSubscriptionIntegrationID)).at(-1) + const next = Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + ...value, + }) + const created = previous + ? yield* credentials.update(previous.id, { value: next }).pipe(Effect.as(previous)) + : yield* credentials.create({ + integrationID: AnthropicSubscriptionIntegrationID, + label: "Claude Pro/Max", + value: next, + }) + return { created, previous } + }).pipe(Effect.provide(credentialLayer)) + const auth = yield* Auth.Service + yield* Effect.orDie(auth.remove(provider)).pipe( + Effect.onError(() => + Effect.gen(function* () { + const credentials = yield* Credential.Service + if (durable.previous) { + yield* credentials.update(durable.previous.id, { value: durable.previous.value }) + return + } + yield* credentials.remove(durable.created.id) + }).pipe(Effect.provide(credentialLayer)), + ), + ) + }), + ) +}) + const cliTry = (message: string, fn: () => PromiseLike) => Effect.tryPromise({ try: fn, @@ -113,13 +168,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - yield* put(saveProvider, { - type: "oauth", - refresh, - access, - expires, - ...extraFields, - }) + yield* putOAuth(saveProvider, { refresh, access, expires }, extraFields) } if ("key" in result) { yield* put(saveProvider, { @@ -146,13 +195,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - yield* put(saveProvider, { - type: "oauth", - refresh, - access, - expires, - ...extraFields, - }) + yield* putOAuth(saveProvider, { refresh, access, expires }, extraFields) } if ("key" in result) { yield* put(saveProvider, { @@ -260,8 +303,22 @@ export const ProvidersListCommand = effectCmd({ const homedir = os.homedir() const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = Object.entries(yield* Effect.orDie(authSvc.all())) - const database = yield* modelsDev.get() + const results: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) + const subscription = yield* Effect.gen(function* () { + return (yield* (yield* Credential.Service).list(AnthropicSubscriptionIntegrationID)).at(-1) + }).pipe(Effect.provide(credentialLayer)) + if (subscription?.value.type === "oauth" && !results.some(([id]) => id === AnthropicSubscriptionProviderID)) { + results.push([ + AnthropicSubscriptionProviderID, + { + type: "oauth", + access: subscription.value.access, + refresh: subscription.value.refresh, + expires: subscription.value.expires, + }, + ]) + } + const database = Provider.withSyntheticProviders(yield* modelsDev.get()) for (const [providerID, result] of results) { const name = database[providerID]?.name || providerID @@ -361,7 +418,7 @@ export const ProvidersLoginCommand = effectCmd({ const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const allProviders = yield* modelsDev.get() + const allProviders = Provider.withSyntheticProviders(yield* modelsDev.get()) const providers: Record = {} for (const [key, value] of Object.entries(allProviders)) { if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) providers[key] = value @@ -372,10 +429,11 @@ export const ProvidersLoginCommand = effectCmd({ opencode: 0, openai: 1, "github-copilot": 2, - google: 3, - anthropic: 4, - openrouter: 5, - vercel: 6, + "anthropic-subscription": 3, + google: 4, + anthropic: 5, + openrouter: 6, + vercel: 7, } const pluginProviders = resolvePluginProviders({ hooks, @@ -398,6 +456,7 @@ export const ProvidersLoginCommand = effectCmd({ hint: { opencode: "recommended", openai: "ChatGPT Plus/Pro or API key", + "anthropic-subscription": "Claude Pro/Max subscription", }[x.id], })), ), @@ -504,12 +563,26 @@ export const ProvidersLogoutCommand = effectCmd({ UI.empty() const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) + const subscription = yield* Effect.gen(function* () { + return (yield* (yield* Credential.Service).list(AnthropicSubscriptionIntegrationID)).at(-1) + }).pipe(Effect.provide(credentialLayer)) + if (subscription?.value.type === "oauth" && !credentials.some(([id]) => id === AnthropicSubscriptionProviderID)) { + credentials.push([ + AnthropicSubscriptionProviderID, + { + type: "oauth", + access: subscription.value.access, + refresh: subscription.value.refresh, + expires: subscription.value.expires, + }, + ]) + } yield* Prompt.intro("Remove credential") if (credentials.length === 0) { yield* Prompt.log.error("No credentials found") return } - const database = yield* modelsDev.get() + const database = Provider.withSyntheticProviders(yield* modelsDev.get()) const options = credentials.map(([key, value]) => ({ label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", value: key, @@ -528,6 +601,36 @@ export const ProvidersLogoutCommand = effectCmd({ }), ) if (!provider) return yield* fail(`Unknown configured provider "${args.provider}"`) + if (provider === AnthropicSubscriptionProviderID) { + yield* withAnthropicSubscriptionCredentialLock( + Effect.gen(function* () { + const previous = yield* Effect.gen(function* () { + const credentials = yield* Credential.Service + const stored = yield* credentials.list(AnthropicSubscriptionIntegrationID) + for (const credential of stored) { + yield* credentials.remove(credential.id) + } + return stored.at(-1) + }).pipe(Effect.provide(credentialLayer)) + yield* Effect.orDie(authSvc.remove(provider)).pipe( + Effect.onError(() => + previous + ? Effect.gen(function* () { + const credentials = yield* Credential.Service + yield* credentials.create({ + integrationID: previous.integrationID, + label: previous.label, + value: previous.value, + }) + }).pipe(Effect.provide(credentialLayer)) + : Effect.void, + ), + ) + }), + ) + yield* Prompt.outro("Logout successful") + return + } yield* Effect.orDie(authSvc.remove(provider)) yield* Prompt.outro("Logout successful") }), diff --git a/packages/opencode/src/plugin/anthropic/subscription.ts b/packages/opencode/src/plugin/anthropic/subscription.ts new file mode 100644 index 000000000000..69c73a5b01ef --- /dev/null +++ b/packages/opencode/src/plugin/anthropic/subscription.ts @@ -0,0 +1,293 @@ +import type { Hooks, PluginInput } from "@opencode-ai/plugin" +import { randomUUID } from "node:crypto" +import { + defaultClaudeCodeVersion, + restoreToolNames, + systemIdentity, + toolNameMap, + transformBodyText, +} from "@opencode-ai/llm/providers/anthropic-subscription" +import { OAUTH_DUMMY_KEY } from "../../auth" +import { Credential } from "@opencode-ai/core/credential" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { + AnthropicSubscriptionIntegrationID, + AnthropicSubscriptionMethodID, + refreshAnthropicSubscriptionToken, +} from "@opencode-ai/core/plugin/provider/anthropic-subscription" +import { Effect } from "effect" +import { withAnthropicSubscriptionCredentialLock } from "../../provider/anthropic-subscription-credential" + +export const PROVIDER_ID = "anthropic-subscription" + +const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" +const AUTHORIZATION_ORIGIN = "https://claude.ai" +const TOKEN_ENDPOINT = "https://console.anthropic.com/v1/oauth/token" +const API_ORIGIN = "https://api.anthropic.com" +const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback" +const REFRESH_MARGIN = 5 * 60 * 1000 +const REQUIRED_BETAS = ["claude-code-20250219", "oauth-2025-04-20", "interleaved-thinking-2025-05-14"] +const credentialLayer = AppNodeBuilder.build(Credential.node) + +interface Options { + authorizationOrigin?: string + tokenEndpoint?: string + apiOrigin?: string + fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise + now?: () => number + claudeCodeVersion?: string + persist?: (value: { access: string; refresh: string; expires: number; expectedRefresh: string }) => Promise +} + +interface Pkce { + verifier: string + challenge: string +} + +interface TokenResponse { + access_token: string + refresh_token?: string + expires_in?: number +} + +function persistDurable(value: { access: string; refresh: string; expires: number; expectedRefresh: string }) { + return Effect.runPromise( + withAnthropicSubscriptionCredentialLock( + Effect.gen(function* () { + const credentials = yield* Credential.Service + const current = (yield* credentials.list(AnthropicSubscriptionIntegrationID)).at(-1) + const { expectedRefresh: _, ...tokens } = value + const credential = Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + ...tokens, + }) + if (current?.value.type === "oauth" && current.value.refresh === value.expectedRefresh) { + yield* credentials.update(current.id, { value: credential }) + return + } + if ( + current?.value.type === "oauth" && + current.value.access === value.access && + current.value.refresh === value.refresh + ) + return + return yield* Effect.die("Anthropic subscription is disconnected") + }), + ).pipe(Effect.provide(credentialLayer)), + ) +} + +function base64UrlEncode(buffer: ArrayBuffer) { + return Buffer.from(buffer).toString("base64url") +} + +async function generatePKCE(): Promise { + const verifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(48)).buffer) + const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))) + return { verifier, challenge } +} + +function randomState() { + return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) +} + +function transformResponse(response: Response, names: ReadonlyMap) { + if (!response.body) return response + const decoder = new TextDecoder() + const encoder = new TextEncoder() + let pending = "" + const stream = response.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + pending += decoder.decode(chunk, { stream: true }) + const lines = pending.split("\n") + pending = lines.pop() ?? "" + for (const line of lines) { + controller.enqueue(encoder.encode(`${restoreToolNames(line, names)}\n`)) + } + }, + flush(controller) { + pending += decoder.decode() + if (pending) controller.enqueue(encoder.encode(restoreToolNames(pending, names))) + }, + }), + ) + return new Response(stream, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) +} + +export async function AnthropicSubscriptionAuthPlugin(input: PluginInput, options: Options = {}): Promise { + const authorizationOrigin = options.authorizationOrigin ?? AUTHORIZATION_ORIGIN + const tokenEndpoint = options.tokenEndpoint ?? TOKEN_ENDPOINT + const apiOrigin = options.apiOrigin ?? API_ORIGIN + const request = options.fetch ?? fetch + const now = options.now ?? Date.now + const persist = options.persist ?? persistDurable + const claudeCodeVersion = options.claudeCodeVersion ?? process.env.ANTHROPIC_CLI_VERSION ?? defaultClaudeCodeVersion + const sessionID = randomUUID() + + async function exchange(code: string, verifier: string, state: string) { + const [authorizationCode, returnedState] = code.trim().split("#") + if (!authorizationCode || returnedState !== state) return { type: "failed" as const } + const response = await request(tokenEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json", "anthropic-beta": "oauth-2025-04-20" }, + body: JSON.stringify({ + code: authorizationCode, + state: returnedState, + grant_type: "authorization_code", + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }), + }) + if (!response.ok) return { type: "failed" as const } + const token = (await response.json()) as TokenResponse + if (!token.access_token || !token.refresh_token) return { type: "failed" as const } + return { + type: "success" as const, + access: token.access_token, + refresh: token.refresh_token, + expires: now() + (token.expires_in ?? 3600) * 1000, + } + } + + return { + "experimental.chat.system.transform": async (context, output) => { + if (context.model?.providerID !== PROVIDER_ID) return + if (!output.system.some((item) => item.includes(systemIdentity))) output.system.unshift(systemIdentity) + }, + provider: { + id: PROVIDER_ID, + async models(provider, context) { + if (context.auth?.type !== "oauth") return provider.models + return Object.fromEntries( + Object.entries(provider.models) + .filter(([, model]) => model.api.id.startsWith("claude-")) + .map(([id, model]) => [ + id, + { + ...model, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + }, + ]), + ) + }, + }, + auth: { + provider: PROVIDER_ID, + async loader(getAuth) { + const initial = await getAuth() + if (initial?.type !== "oauth") return {} + + const refresh = (refreshToken: string) => + refreshAnthropicSubscriptionToken({ + refresh: refreshToken, + request, + tokenEndpoint, + }).then(async (token) => { + if (!token.access_token) throw new Error("Anthropic subscription expired; reconnect with /connect") + const updated = { + access: token.access_token, + refresh: token.refresh_token ?? refreshToken, + expires: now() + (token.expires_in ?? 3600) * 1000, + } + await persist({ ...updated, expectedRefresh: refreshToken }) + return updated + }) + + return { + apiKey: OAUTH_DUMMY_KEY, + async fetch(requestInput: RequestInfo | URL, init?: RequestInit) { + const original = new Request(requestInput, init) + const url = new URL(original.url) + if (url.origin !== apiOrigin) + throw new Error("Anthropic subscription credentials cannot be sent to this origin") + + const auth = await getAuth() + if (auth?.type !== "oauth") + throw new Error("Anthropic subscription is disconnected; reconnect with /connect") + if (!auth.access || auth.expires <= now() + REFRESH_MARGIN) { + const refreshed = await refresh(auth.refresh) + auth.access = refreshed.access + auth.refresh = refreshed.refresh + auth.expires = refreshed.expires + } + + if (url.pathname === "/v1/messages" && !url.searchParams.has("beta")) url.searchParams.set("beta", "true") + const originalBody = original.body ? await original.clone().text() : undefined + const names = toolNameMap(originalBody ?? "") + const body = originalBody ? await transformBodyText(originalBody, claudeCodeVersion) : undefined + const send = (access: string) => { + const headers = new Headers(original.headers) + const beta = new Set( + (headers.get("anthropic-beta") ?? "") + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ) + for (const required of REQUIRED_BETAS) beta.add(required) + headers.delete("x-api-key") + headers.set("authorization", `Bearer ${access}`) + headers.set("anthropic-version", headers.get("anthropic-version") ?? "2023-06-01") + headers.set("anthropic-beta", [...beta].join(",")) + headers.set("anthropic-dangerous-direct-browser-access", "true") + headers.set("x-app", "cli") + headers.set("x-client-request-id", randomUUID()) + headers.set("x-claude-code-session-id", sessionID) + headers.set("user-agent", `claude-cli/${claudeCodeVersion} (external, sdk-cli)`) + return request(url, { + method: original.method, + headers, + body, + signal: original.signal, + redirect: original.redirect, + }) + } + let response = await send(auth.access) + if (response.status === 401) { + response.body?.cancel().catch(() => {}) + const latest = await getAuth() + if (latest?.type === "oauth" && latest.access && latest.access !== auth.access) { + response = await send(latest.access) + } else { + const refreshed = await refresh(latest?.type === "oauth" ? latest.refresh : auth.refresh) + response = await send(refreshed.access) + } + } + return transformResponse(response, names) + }, + } + }, + methods: [ + { + label: "Claude Pro/Max subscription", + type: "oauth", + authorize: async () => { + const pkce = await generatePKCE() + const state = randomState() + const url = new URL("/oauth/authorize", authorizationOrigin) + url.searchParams.set("code", "true") + url.searchParams.set("client_id", CLIENT_ID) + url.searchParams.set("response_type", "code") + url.searchParams.set("redirect_uri", REDIRECT_URI) + url.searchParams.set("scope", "org:create_api_key user:profile user:inference") + url.searchParams.set("code_challenge", pkce.challenge) + url.searchParams.set("code_challenge_method", "S256") + url.searchParams.set("state", state) + return { + url: url.toString(), + instructions: "Paste the authorization code here:", + method: "code" as const, + callback: (code: string) => exchange(code, pkce.verifier, state), + } + }, + }, + ], + }, + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 8ae7d3f31b31..a91ff940693f 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -10,6 +10,7 @@ import { Config } from "@/config/config" import { createOpencodeClient } from "@opencode-ai/sdk" import { ServerAuth } from "@/server/auth" import { CodexAuthPlugin } from "./openai/codex" +import { AnthropicSubscriptionAuthPlugin } from "./anthropic/subscription" import { Session } from "@/session/session" import { NamedError } from "@opencode-ai/core/util/error" import { CopilotAuthPlugin } from "./github-copilot/copilot" @@ -69,6 +70,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] { CodexAuthPlugin(input, { experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }), }), + AnthropicSubscriptionAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin, diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index f14d1269769e..d378a389c17e 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -403,10 +403,13 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug requestInput instanceof URL ? requestInput : new URL(typeof requestInput === "string" ? requestInput : requestInput.url) - const url = - parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions") - ? new URL(codexApiEndpoint) - : parsed + const providerRequest = + parsed.pathname.endsWith("/responses") || parsed.pathname.endsWith("/chat/completions") + const endpoint = new URL(codexApiEndpoint) + if (!providerRequest && parsed.origin !== endpoint.origin) { + throw new Error("OpenAI OAuth credentials cannot be sent to this origin") + } + const url = providerRequest ? endpoint : parsed const requestInit = { ...init, diff --git a/packages/opencode/src/provider/anthropic-subscription-credential.ts b/packages/opencode/src/provider/anthropic-subscription-credential.ts new file mode 100644 index 000000000000..e4c66e50e4e5 --- /dev/null +++ b/packages/opencode/src/provider/anthropic-subscription-credential.ts @@ -0,0 +1,6 @@ +import { Effect, Semaphore } from "effect" + +const lock = Semaphore.makeUnsafe(1) + +export const withAnthropicSubscriptionCredentialLock = (effect: Effect.Effect) => + lock.withPermit(effect) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 416a3ea7b0db..4b80bc9b96c7 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -6,6 +6,13 @@ import { InstanceState } from "@/effect/instance-state" import { optional } from "@opencode-ai/core/schema" import { Plugin } from "../plugin" import { ProviderV2 } from "@opencode-ai/core/provider" +import { Credential } from "@opencode-ai/core/credential" +import { + AnthropicSubscriptionIntegrationID, + AnthropicSubscriptionMethodID, + AnthropicSubscriptionProviderID, +} from "@opencode-ai/core/plugin/provider/anthropic-subscription" +import { withAnthropicSubscriptionCredentialLock } from "./anthropic-subscription-credential" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" const When = Schema.Struct({ @@ -106,10 +113,11 @@ export class Service extends Context.Service()("@opencode/Pr export const use = serviceUse(Service) -const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const auth = yield* Auth.Service + const credentials = yield* Credential.Service const plugin = yield* Plugin.Service const state = yield* InstanceState.make( Effect.fn("ProviderAuth.state")(function* () { @@ -210,13 +218,45 @@ const layer: Layer.Layer = Layer. if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extra } = result - yield* auth.set(input.providerID, { + const value = { type: "oauth", access, refresh, expires, ...extra, - }) + } as const + if (input.providerID === AnthropicSubscriptionProviderID) { + yield* withAnthropicSubscriptionCredentialLock( + Effect.gen(function* () { + const previous = (yield* credentials.list(AnthropicSubscriptionIntegrationID)).at(-1) + const next = Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access, + refresh, + expires, + }) + const created = previous + ? yield* credentials.update(previous.id, { value: next }).pipe(Effect.as(previous)) + : yield* credentials.create({ + integrationID: AnthropicSubscriptionIntegrationID, + label: "Claude Pro/Max", + value: next, + }) + yield* auth + .remove(input.providerID) + .pipe( + Effect.tapError(() => + previous + ? credentials.update(previous.id, { value: previous.value }) + : credentials.remove(created.id), + ), + ) + }), + ) + return + } + yield* auth.set(input.providerID, value) } }) @@ -224,6 +264,6 @@ const layer: Layer.Layer = Layer. }), ) -export const node = LayerNode.make({ service: Service, layer: layer, deps: [Auth.node, Plugin.node] }) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Auth.node, Credential.node, Plugin.node] }) export * as ProviderAuth from "./auth" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 536fa8e16b79..cc916bb05e24 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -31,6 +31,13 @@ import { ModelV2 } from "@opencode-ai/core/model" import { ModelStatus } from "./model-status" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderError } from "./error" +import { Credential } from "@opencode-ai/core/credential" +import { + AnthropicSubscriptionIntegrationID, + AnthropicSubscriptionMethodID, + AnthropicSubscriptionProviderID, +} from "@opencode-ai/core/plugin/provider/anthropic-subscription" +import { withAnthropicSubscriptionCredentialLock } from "./anthropic-subscription-credential" const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000 @@ -1314,6 +1321,20 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { } } +export function withSyntheticProviders(providers: Record) { + const anthropic = providers.anthropic + if (!anthropic) return providers + return { + ...providers, + "anthropic-subscription": { + ...anthropic, + id: "anthropic-subscription", + name: "Claude Pro/Max", + env: [], + }, + } +} + function modelSuggestions(provider: Info | undefined, modelID: ModelV2.ID, enableExperimentalModels: boolean) { const available = provider ? Object.keys(provider.models).filter((id) => { @@ -1349,6 +1370,7 @@ const layer = Layer.effect( const fs = yield* FSUtil.Service const config = yield* Config.Service const auth = yield* Auth.Service + const credentials = yield* Credential.Service const env = yield* Env.Service const plugin = yield* Plugin.Service const modelsDevSvc = yield* ModelsDev.Service @@ -1358,11 +1380,48 @@ const layer = Layer.effect( Effect.gen(function* () { const bridge = yield* EffectBridge.make() const cfg = yield* config.get() - const modelsDev = yield* modelsDevSvc.get() + const modelsDev = withSyntheticProviders(yield* modelsDevSvc.get()) const catalog = mapValues(modelsDev, fromModelsDevProvider) const database = mapValues(catalog, toPublicInfo) const providers: Record = {} as Record + const storedAuth = Effect.fn("Provider.storedAuth")(function* (providerID: ProviderV2.ID) { + if (providerID !== AnthropicSubscriptionProviderID) return yield* auth.get(providerID).pipe(Effect.orDie) + return yield* withAnthropicSubscriptionCredentialLock( + Effect.gen(function* () { + const credential = (yield* credentials.list(AnthropicSubscriptionIntegrationID)).at(-1) + if (credential?.value.type === "oauth") { + if (yield* auth.get(providerID).pipe(Effect.orDie)) yield* auth.remove(providerID).pipe(Effect.orDie) + return { + type: "oauth" as const, + access: credential.value.access, + refresh: credential.value.refresh, + expires: credential.value.expires, + } + } + const legacy = yield* auth.get(providerID).pipe(Effect.orDie) + if (legacy?.type !== "oauth") return legacy + yield* credentials.create({ + integrationID: AnthropicSubscriptionIntegrationID, + label: "Claude Pro/Max", + value: Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: legacy.access, + refresh: legacy.refresh, + expires: legacy.expires, + }), + }) + yield* auth.remove(providerID).pipe(Effect.orDie) + return { + type: "oauth" as const, + access: legacy.access, + refresh: legacy.refresh, + expires: legacy.expires, + } + }), + ) + }) const languages = new Map() const modelLoaders: { [providerID: string]: CustomModelLoader @@ -1418,7 +1477,7 @@ const layer = Layer.effect( const provider = database[providerID] if (!provider) continue - const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) + const pluginAuth = yield* storedAuth(providerID) provider.models = yield* Effect.promise(async () => { const next = await models(toPublicInfo(provider), { auth: pluginAuth }) @@ -1561,15 +1620,14 @@ const layer = Layer.effect( const providerID = ProviderV2.ID.make(plugin.auth.provider) if (disabled.has(providerID)) continue - const stored = yield* auth.get(providerID).pipe(Effect.orDie) + const stored = yield* storedAuth(providerID) if (!stored) continue if (!plugin.auth.loader) continue + const data = database[plugin.auth.provider] + if (!data) continue const options = yield* Effect.promise(() => - plugin.auth!.loader!( - () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any, - toPublicInfo(database[plugin.auth!.provider]), - ), + plugin.auth!.loader!(() => bridge.promise(storedAuth(providerID)) as any, toPublicInfo(data)), ) const opts = options ?? {} const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } @@ -2096,7 +2154,16 @@ function matchesSelector(alias: ReturnType, requested: Re export const node = LayerNode.make({ service: Service, layer: layer, - deps: [FSUtil.node, Config.node, Auth.node, Env.node, Plugin.node, ModelsDev.node, RuntimeFlags.node], + deps: [ + FSUtil.node, + Config.node, + Auth.node, + Credential.node, + Env.node, + Plugin.node, + ModelsDev.node, + RuntimeFlags.node, + ], }) export * as Provider from "./provider" diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index bc0e5f7c7da2..31da56f3cf73 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -353,6 +353,7 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage for (const msg of unique([...system, ...final])) { const useMessageLevelOptions = model.providerID === "anthropic" || + model.providerID === "anthropic-subscription" || model.providerID.includes("bedrock") || model.api.npm === "@ai-sdk/amazon-bedrock" const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 @@ -437,6 +438,7 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re msgs = normalizeMessages(msgs, model, options) if ( (model.providerID === "anthropic" || + model.providerID === "anthropic-subscription" || model.providerID === "google-vertex-anthropic" || (model.api.npm === "@openrouter/ai-sdk-provider" && model.api.id.includes("gemini")) || model.api.id.includes("anthropic") || diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts index 9974b19b4dfd..729a1444a0d7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts @@ -5,6 +5,58 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { RootHttpApi } from "../api" import { LogInput } from "../groups/control" import { ProviderV2 } from "@opencode-ai/core/provider" +import { Credential } from "@opencode-ai/core/credential" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { + AnthropicSubscriptionIntegrationID, + AnthropicSubscriptionMethodID, + AnthropicSubscriptionProviderID, +} from "@opencode-ai/core/plugin/provider/anthropic-subscription" +import { withAnthropicSubscriptionCredentialLock } from "@/provider/anthropic-subscription-credential" + +const credentialLayer = AppNodeBuilder.build(Credential.node) + +function updateDurable(value?: Auth.Info) { + return Effect.gen(function* () { + const credentials = yield* Credential.Service + const previous = (yield* credentials.list(AnthropicSubscriptionIntegrationID)).at(-1) + if (value?.type === "oauth") { + const next = Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: value.access, + refresh: value.refresh, + expires: value.expires, + }) + if (previous) yield* credentials.update(previous.id, { value: next }) + else + yield* credentials.create({ + integrationID: AnthropicSubscriptionIntegrationID, + label: "Claude Pro/Max", + value: next, + }) + return previous + } + for (const credential of yield* credentials.list(AnthropicSubscriptionIntegrationID)) { + yield* credentials.remove(credential.id) + } + return previous + }).pipe(Effect.provide(credentialLayer)) +} + +function restoreDurable(previous: Credential.Info | undefined) { + if (!previous) return updateDurable() + return Effect.gen(function* () { + const credentials = yield* Credential.Service + if (yield* credentials.get(previous.id)) yield* credentials.update(previous.id, { value: previous.value }) + else + yield* credentials.create({ + integrationID: previous.integrationID, + label: previous.label, + value: previous.value, + }) + }).pipe(Effect.provide(credentialLayer)) +} export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (handlers) => Effect.gen(function* () { @@ -14,14 +66,38 @@ export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (han params: { providerID: ProviderV2.ID } payload: Auth.Info }) { - yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie) + if (ctx.params.providerID !== AnthropicSubscriptionProviderID) { + yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie) + return true + } + yield* withAnthropicSubscriptionCredentialLock( + Effect.gen(function* () { + const previous = yield* updateDurable(ctx.payload) + yield* auth.remove(ctx.params.providerID).pipe( + Effect.tapError(() => restoreDurable(previous)), + Effect.orDie, + ) + }), + ) return true }) const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderV2.ID } }) { - yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie) + if (ctx.params.providerID !== AnthropicSubscriptionProviderID) { + yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie) + return true + } + yield* withAnthropicSubscriptionCredentialLock( + Effect.gen(function* () { + const previous = yield* updateDurable() + yield* auth.remove(ctx.params.providerID).pipe( + Effect.tapError(() => restoreDurable(previous)), + Effect.orDie, + ) + }), + ) return true }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index e1377b6f75c5..c0fb65ad2a28 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -39,7 +39,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const list = Effect.fn("ProviderHttpApi.list")(function* () { const config = yield* cfg.get() - const all = yield* ModelsDev.Service.use((s) => s.get()) + const all = Provider.withSyntheticProviders(yield* ModelsDev.Service.use((s) => s.get())) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const filtered: Record = {} diff --git a/packages/opencode/src/session/llm/native-runtime.ts b/packages/opencode/src/session/llm/native-runtime.ts index 632e178260b0..ebc325ea0e0d 100644 --- a/packages/opencode/src/session/llm/native-runtime.ts +++ b/packages/opencode/src/session/llm/native-runtime.ts @@ -52,12 +52,31 @@ function statusWithFetch( fetch: typeof globalThis.fetch | undefined, ): RuntimeStatus { const providerID = input.model.providerID - if (providerID !== "openai" && providerID !== "anthropic" && !providerID.startsWith("opencode")) + if (input.provider.id !== providerID) { + return { type: "unsupported", reason: "provider does not match model provider" } + } + if ( + providerID !== "openai" && + providerID !== "anthropic" && + providerID !== "anthropic-subscription" && + !providerID.startsWith("opencode") + ) return { type: "unsupported", reason: "provider is not openai, opencode, or anthropic" } const npm = input.model.api.npm if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/openai-compatible" && npm !== "@ai-sdk/anthropic") return { type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" } - if (input.auth?.type === "oauth" && !(input.provider.id === "openai" && fetch)) { + if (providerID === "anthropic-subscription" && npm !== "@ai-sdk/anthropic") { + return { type: "unsupported", reason: "Claude subscription requires the Anthropic provider package" } + } + if ( + providerID === "openai" && + input.auth?.type === "oauth" && + npm !== "@ai-sdk/openai" && + npm !== "@ai-sdk/openai-compatible" + ) { + return { type: "unsupported", reason: "OpenAI OAuth requires an OpenAI provider package" } + } + if (input.auth?.type === "oauth" && !fetch) { return { type: "unsupported", reason: "OAuth auth requires a provider fetch override" } } @@ -145,8 +164,17 @@ export function stream(input: StreamInput): StreamResult { } } -function providerFetch(input: Pick): typeof globalThis.fetch | undefined { - if (input.provider.id !== "openai" || input.auth?.type !== "oauth") return undefined +function providerFetch(input: Pick): typeof globalThis.fetch | undefined { + if ( + input.model.providerID !== input.provider.id || + (input.provider.id === "anthropic-subscription" && input.model.api.npm !== "@ai-sdk/anthropic") || + (input.provider.id === "openai" && + input.model.api.npm !== "@ai-sdk/openai" && + input.model.api.npm !== "@ai-sdk/openai-compatible") || + (input.provider.id !== "openai" && input.provider.id !== "anthropic-subscription") || + input.auth?.type !== "oauth" + ) + return undefined const value: unknown = input.provider.options.fetch if (typeof value !== "function") return undefined return value as typeof globalThis.fetch diff --git a/packages/opencode/test/plugin/anthropic-subscription.test.ts b/packages/opencode/test/plugin/anthropic-subscription.test.ts new file mode 100644 index 000000000000..35505a4fa762 --- /dev/null +++ b/packages/opencode/test/plugin/anthropic-subscription.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, test } from "bun:test" +import { AnthropicSubscriptionAuthPlugin, PROVIDER_ID } from "../../src/plugin/anthropic/subscription" +import { withSyntheticProviders } from "../../src/provider/provider" + +const oauth = { + type: "oauth" as const, + refresh: "refresh-token", + access: "access-token", + expires: 4_000_000, +} + +const input = (set: (value: typeof oauth) => void = () => {}) => + ({ + client: { + auth: { + async set(value: { body: typeof oauth }) { + set(value.body) + }, + }, + }, + }) as never + +describe("plugin.anthropic-subscription", () => { + test("validates OAuth state before exchanging the authorization code", async () => { + const exchanges: Request[] = [] + const hooks = await AnthropicSubscriptionAuthPlugin(input(), { + authorizationOrigin: "https://login.test", + tokenEndpoint: "https://tokens.test/oauth/token", + now: () => 1_000, + async fetch(request, init) { + exchanges.push(new Request(request, init)) + return Response.json({ + access_token: "access-new", + refresh_token: "refresh-new", + expires_in: 3600, + }) + }, + }) + const method = hooks.auth!.methods[0] + if (method.type !== "oauth") throw new Error("expected OAuth method") + const authorization = await method.authorize() + const url = new URL(authorization.url) + const state = url.searchParams.get("state") + + expect(url.origin).toBe("https://login.test") + expect(url.pathname).toBe("/oauth/authorize") + expect(url.searchParams.get("code_challenge_method")).toBe("S256") + expect(url.searchParams.get("scope")).toBe("org:create_api_key user:profile user:inference") + expect(state).toBeTruthy() + if (authorization.method !== "code") throw new Error("expected code callback") + + expect(await authorization.callback("code#wrong-state")).toEqual({ type: "failed" }) + expect(exchanges).toHaveLength(0) + + expect(await authorization.callback(`code#${state}`)).toEqual({ + type: "success", + access: "access-new", + refresh: "refresh-new", + expires: 3_601_000, + }) + expect(exchanges).toHaveLength(1) + expect(exchanges[0].headers.get("anthropic-beta")).toBe("oauth-2025-04-20") + expect(await exchanges[0].json()).toMatchObject({ + code: "code", + state, + grant_type: "authorization_code", + }) + }) + + test("rewrites native session requests for Claude subscription auth", async () => { + const requests: Request[] = [] + const hooks = await AnthropicSubscriptionAuthPlugin(input(), { + apiOrigin: "https://api.test", + now: () => 1_000, + claudeCodeVersion: "2.1.185", + async fetch(request, init) { + requests.push(new Request(request, init)) + return new Response('data: {"content_block":{"type":"tool_use","name":"mcp_Bash"}}\n\n', { + headers: { "Content-Type": "text/event-stream" }, + }) + }, + }) + const loaded = await hooks.auth!.loader!(async () => oauth as never, {} as never) + const response = await loaded.fetch!("https://api.test/v1/messages", { + method: "POST", + headers: { + "anthropic-beta": "existing-beta", + "x-api-key": "must-not-leak", + }, + body: JSON.stringify({ + model: "claude-sonnet-4-6", + system: [ + { + type: "text", + text: "You are Claude Code, Anthropic's official CLI for Claude.\n\nDCode session instructions", + }, + ], + tools: [{ name: "bash" }], + messages: [ + { role: "user", content: [{ type: "text", text: "Fix the test" }] }, + { role: "assistant", content: [{ type: "tool_use", name: "read" }] }, + ], + }), + }) + + expect(requests).toHaveLength(1) + expect(requests[0].url).toBe("https://api.test/v1/messages?beta=true") + expect(requests[0].headers.get("authorization")).toBe("Bearer access-token") + expect(requests[0].headers.has("x-api-key")).toBe(false) + expect(requests[0].headers.get("anthropic-beta")).toContain("oauth-2025-04-20") + expect(requests[0].headers.get("anthropic-beta")).toContain("existing-beta") + expect(requests[0].headers.get("x-app")).toBe("cli") + expect(requests[0].headers.get("x-claude-code-session-id")).toBeTruthy() + + const body = (await requests[0].json()) as { + system: Array<{ text: string }> + tools: Array<{ name: string }> + messages: Array<{ content: Array<{ text?: string; name?: string }> }> + } + expect(body.system).toHaveLength(2) + expect(body.system[0].text).toStartWith("x-anthropic-billing-header:") + expect(body.system[1].text).toBe("You are Claude Code, Anthropic's official CLI for Claude.") + expect(body.messages[0].content[0].text).toBe("DCode session instructions") + expect(body.tools[0].name).toBe("mcp_Bash") + expect(body.messages[1].content[0].name).toBe("mcp_Read") + expect(await response.text()).toContain('"name":"bash"') + + await expect(loaded.fetch!("https://attacker.test/v1/messages")).rejects.toThrow( + "Anthropic subscription credentials cannot be sent to this origin", + ) + expect(requests).toHaveLength(1) + }) + + test("deduplicates concurrent refreshes and persists rotated tokens", async () => { + let auth = { ...oauth, access: "", expires: 0 } + const updates: Array = [] + const durable: Array & { expectedRefresh: string }> = [] + const authorizations: Array = [] + let refreshRequests = 0 + let releaseRefresh: (() => void) | undefined + const refreshReady = new Promise((resolve) => { + releaseRefresh = resolve + }) + const hooks = await AnthropicSubscriptionAuthPlugin( + input((value) => { + updates.push(value) + auth = value + }), + { + apiOrigin: "https://api.test", + tokenEndpoint: "https://tokens.test/oauth/token", + now: () => 1_000, + async persist(value) { + durable.push(value) + }, + async fetch(request, init) { + const current = new Request(request, init) + if (current.url === "https://tokens.test/oauth/token") { + refreshRequests += 1 + await refreshReady + return Response.json({ + access_token: "access-new", + refresh_token: "refresh-new", + expires_in: 3600, + }) + } + authorizations.push(current.headers.get("authorization")) + return new Response("{}") + }, + }, + ) + const loaded = await hooks.auth!.loader!(async () => auth as never, {} as never) + const body = JSON.stringify({ messages: [{ role: "user", content: "hello" }] }) + const first = loaded.fetch!("https://api.test/v1/messages", { method: "POST", body }) + const second = loaded.fetch!("https://api.test/v1/messages", { method: "POST", body }) + + await waitFor(() => refreshRequests === 1) + expect(authorizations).toHaveLength(0) + releaseRefresh!() + await Promise.all([first, second]) + + expect(refreshRequests).toBe(1) + expect(updates).toEqual([]) + expect(authorizations).toEqual(["Bearer access-new", "Bearer access-new"]) + expect(durable).toEqual([ + { + access: "access-new", + refresh: "refresh-new", + expires: 3_601_000, + expectedRefresh: "refresh-token", + }, + { + access: "access-new", + refresh: "refresh-new", + expires: 3_601_000, + expectedRefresh: "refresh-token", + }, + ]) + }) + + test("retries a staggered 401 with the already-rotated durable token", async () => { + const latest = { ...oauth, access: "access-new", refresh: "refresh-new" } + const authorizations: Array = [] + let reads = 0 + let refreshes = 0 + const hooks = await AnthropicSubscriptionAuthPlugin(input(), { + apiOrigin: "https://api.test", + tokenEndpoint: "https://tokens.test/oauth/token", + now: () => 1_000, + async fetch(request, init) { + const current = new Request(request, init) + if (current.url === "https://tokens.test/oauth/token") { + refreshes += 1 + return Response.json({ access_token: "unexpected", refresh_token: "unexpected", expires_in: 3600 }) + } + authorizations.push(current.headers.get("authorization")) + return new Response("{}", { status: authorizations.length === 1 ? 401 : 200 }) + }, + }) + const loaded = await hooks.auth!.loader!(async () => (reads++ < 2 ? oauth : latest) as never, {} as never) + + await loaded.fetch!("https://api.test/v1/messages", { + method: "POST", + body: JSON.stringify({ messages: [{ role: "user", content: "hello" }] }), + }) + + expect(refreshes).toBe(0) + expect(authorizations).toEqual(["Bearer access-token", "Bearer access-new"]) + }) + + test("handles credentials removed during loader initialization and requests", async () => { + const hooks = await AnthropicSubscriptionAuthPlugin(input(), { apiOrigin: "https://api.test" }) + expect(await hooks.auth!.loader!(async () => undefined as never, {} as never)).toEqual({}) + let reads = 0 + const loaded = await hooks.auth!.loader!(async () => (reads++ === 0 ? oauth : undefined) as never, {} as never) + + await expect(loaded.fetch!("https://api.test/v1/messages")).rejects.toThrow( + "Anthropic subscription is disconnected", + ) + }) + + test("exposes only Claude models at zero metered cost", async () => { + const hooks = await AnthropicSubscriptionAuthPlugin(input()) + const models = await hooks.provider!.models!( + { + models: { + claude: { + api: { id: "claude-sonnet-4-6" }, + cost: { input: 3, output: 15, cache: { read: 0.3, write: 3.75 } }, + }, + other: { + api: { id: "not-claude" }, + cost: { input: 1, output: 1, cache: { read: 1, write: 1 } }, + }, + }, + } as never, + { auth: oauth as never }, + ) + + expect(Object.keys(models)).toEqual(["claude"]) + expect(models.claude.cost).toEqual({ input: 0, output: 0, cache: { read: 0, write: 0 } }) + + const output = { system: ["DCode session instructions"] } + await hooks["experimental.chat.system.transform"]!({ model: { providerID: PROVIDER_ID } } as never, output as never) + expect(output.system[0]).toBe("You are Claude Code, Anthropic's official CLI for Claude.") + }) + + test("clones the Anthropic catalog under a separate provider identity", () => { + const anthropic = { + id: "anthropic", + name: "Anthropic", + env: ["ANTHROPIC_API_KEY"], + models: { claude: { id: "claude-sonnet-4-6" } }, + } + const providers = withSyntheticProviders({ anthropic } as never) + + expect(providers.anthropic.id).toBe("anthropic") + expect(providers[PROVIDER_ID]).toMatchObject({ + id: PROVIDER_ID, + name: "Claude Pro/Max", + env: [], + models: anthropic.models, + }) + }) +}) + +async function waitFor(predicate: () => boolean) { + const started = Date.now() + while (!predicate()) { + if (Date.now() - started > 1_000) throw new Error("timed out waiting for condition") + await new Promise((resolve) => setTimeout(resolve, 1)) + } +} diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index 7142bb3e20c9..1fc703883cea 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -244,6 +244,11 @@ describe("plugin.codex", () => { { authorization: "Bearer access-new", accountId: "acc-123" }, { authorization: "Bearer access-new", accountId: "acc-123" }, ]) + await loaded.fetch!("https://attacker.test/custom/responses") + expect(apiRequests).toHaveLength(3) + await expect(loaded.fetch!("https://attacker.test/custom")).rejects.toThrow( + "OpenAI OAuth credentials cannot be sent to this origin", + ) }) }) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index c27877c1f7be..855b98700d8d 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -26,6 +26,40 @@ import { ModelV2 } from "@opencode-ai/core/model" const originalEnv = new Map() +test("synthetic Claude subscription catalog is derived from Anthropic", () => { + const anthropic = { + id: "anthropic", + name: "Anthropic", + env: ["ANTHROPIC_API_KEY"], + npm: "@ai-sdk/anthropic", + api: "https://api.anthropic.com/v1", + models: { + "claude-sonnet-test": { + id: "claude-sonnet-test", + name: "Claude Sonnet Test", + release_date: "2026-01-01", + attachment: true, + reasoning: true, + temperature: true, + tool_call: true, + limit: { context: 200_000, output: 8_192 }, + }, + }, + } satisfies ModelsDev.Provider + + const providers = Provider.withSyntheticProviders({ anthropic }) + + expect(providers.anthropic).toBe(anthropic) + expect(providers["anthropic-subscription"]).toMatchObject({ + id: "anthropic-subscription", + name: "Claude Pro/Max", + env: [], + npm: "@ai-sdk/anthropic", + api: "https://api.anthropic.com/v1", + models: { "claude-sonnet-test": { id: "claude-sonnet-test" } }, + }) +}) + const rememberEnv = (k: string) => { if (!originalEnv.has(k)) originalEnv.set(k, process.env[k]) } @@ -121,6 +155,61 @@ it.instance("provider loaded from env variable", () => }), ) +it.instance( + "Anthropic subscription OAuth loads as a separate native provider", + () => + Effect.gen(function* () { + yield* setProcessEnv( + "OPENCODE_AUTH_CONTENT", + JSON.stringify({ + "anthropic-subscription": { + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 3_600_000, + }, + }), + ) + + const providers = yield* list + const subscription = providers[ProviderV2.ID.make("anthropic-subscription")] + expect(subscription).toBeDefined() + expect(subscription.name).toBe("Claude Pro/Max") + expect(subscription.options.apiKey).toBe("opencode-oauth-dummy-key") + expect(subscription.options.fetch).toBeFunction() + expect(providers[ProviderV2.ID.anthropic]).toBeUndefined() + + const models = Object.values(subscription.models) + expect(models.length).toBeGreaterThan(0) + expect(models.every((model) => model.providerID === "anthropic-subscription")).toBe(true) + expect(models.every((model) => model.api.npm === "@ai-sdk/anthropic")).toBe(true) + expect(models.every((model) => model.cost.input === 0 && model.cost.output === 0)).toBe(true) + + const language = yield* Provider.use.getLanguage(models[0]) + expect(language).toBeDefined() + }), + { + config: { + provider: { + "anthropic-subscription": { + name: "Claude Pro/Max", + npm: "@ai-sdk/anthropic", + api: "https://api.anthropic.com/v1", + models: { + "claude-sonnet-test": { + name: "Claude Sonnet Test", + reasoning: true, + tool_call: true, + limit: { context: 200_000, output: 8_192 }, + cost: { input: 0, output: 0 }, + }, + }, + }, + }, + }, + }, +) + it.instance( "provider loaded from config with apiKey option", Effect.gen(function* () { diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 625ca1896b46..46ca3cae22e8 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -1,5 +1,11 @@ import { describe, expect } from "bun:test" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Credential } from "@opencode-ai/core/credential" +import { + AnthropicSubscriptionIntegrationID, + AnthropicSubscriptionMethodID, +} from "@opencode-ai/core/plugin/provider/anthropic-subscription" import { FSUtil } from "@opencode-ai/core/fs-util" import { Effect, Layer } from "effect" import path from "path" @@ -180,6 +186,42 @@ function writeProviderAuthValidationPlugin(dir: string) { }) } +function writeAnthropicSubscriptionPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FSUtil.Service + yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode"))) + yield* fs.writeWithDirs( + path.join(dir, ".opencode", "plugin", "anthropic-subscription-test.ts"), + [ + "export default {", + ' id: "test.anthropic-subscription",', + " server: async () => ({", + " auth: {", + ' provider: "anthropic-subscription",', + " methods: [{", + ' type: "oauth",', + ' label: "Claude Pro/Max subscription",', + " authorize: async () => ({", + ' url: "https://claude.test/oauth",', + ' method: "code",', + ' instructions: "Paste code",', + " callback: async () => ({", + ' type: "success",', + ' access: "access-token",', + ' refresh: "refresh-token",', + " expires: 4102444800000,", + " }),", + " }),", + " }],", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + function writeFunctionOptionsPlugin(dir: string) { return Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -351,6 +393,66 @@ describe("provider HttpApi", () => { 30000, ) + it.instance( + "stores and removes Claude subscription OAuth in the durable credential registry", + Effect.gen(function* () { + const directory = (yield* TestInstance).directory + const headers = { "x-opencode-directory": directory, "content-type": "application/json" } + expect(yield* requestAuthorize({ providerID: "anthropic-subscription", method: 0, headers })).toMatchObject({ + status: 200, + }) + expect( + yield* requestCallback({ providerID: "anthropic-subscription", method: 0, code: "code", headers }), + ).toEqual({ status: 200, body: "true" }) + + const stored = yield* Effect.gen(function* () { + return yield* (yield* Credential.Service).list(AnthropicSubscriptionIntegrationID) + }).pipe(Effect.provide(AppNodeBuilder.build(Credential.node))) + expect(stored).toHaveLength(1) + expect(stored[0]).toMatchObject({ + integrationID: AnthropicSubscriptionIntegrationID, + label: "Claude Pro/Max", + value: { + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "access-token", + refresh: "refresh-token", + expires: 4102444800000, + }, + }) + const removed = yield* request("/auth/anthropic-subscription", { method: "DELETE", headers }) + expect(removed.status).toBe(200) + expect( + yield* Effect.gen(function* () { + return yield* (yield* Credential.Service).list(AnthropicSubscriptionIntegrationID) + }).pipe(Effect.provide(AppNodeBuilder.build(Credential.node))), + ).toEqual([]) + const set = yield* request("/auth/anthropic-subscription", { + method: "PUT", + headers, + body: JSON.stringify({ + type: "oauth", + access: "access-from-control", + refresh: "refresh-from-control", + expires: 4102444800000, + }), + }) + expect(set.status).toBe(200) + expect( + (yield* Effect.gen(function* () { + return (yield* (yield* Credential.Service).list(AnthropicSubscriptionIntegrationID)).at(-1) + }).pipe(Effect.provide(AppNodeBuilder.build(Credential.node))))?.value, + ).toMatchObject({ + type: "oauth", + access: "access-from-control", + refresh: "refresh-from-control", + }) + expect((yield* request("/auth/anthropic-subscription", { method: "DELETE", headers })).status).toBe(200) + }), + { ...projectOptions, init: writeAnthropicSubscriptionPlugin }, + 30000, + ) + it.instance( "serves provider lists when auth loaders add runtime fetch options", Effect.gen(function* () { diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index dd4d9cc17481..92e43618968d 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -437,6 +437,46 @@ describe("session.llm-native.request", () => { auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 }, }), ).toMatchObject({ type: "supported", apiKey: OAUTH_DUMMY_KEY }) + expect( + LLMNativeRuntime.status({ + model: { + ...baseModel, + providerID: ProviderV2.ID.make("anthropic-subscription"), + api: { ...baseModel.api, npm: "@ai-sdk/anthropic" }, + }, + provider: { + ...providerInfo, + id: ProviderV2.ID.make("anthropic-subscription"), + options: { apiKey: OAUTH_DUMMY_KEY, fetch: async () => new Response() }, + }, + auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 }, + }), + ).toMatchObject({ type: "supported", apiKey: OAUTH_DUMMY_KEY }) + expect( + LLMNativeRuntime.status({ + model: { ...baseModel, providerID: ProviderV2.ID.make("anthropic-subscription") }, + provider: { + ...providerInfo, + id: ProviderV2.ID.make("anthropic-subscription"), + options: { apiKey: OAUTH_DUMMY_KEY, fetch: async () => new Response() }, + }, + auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 }, + }), + ).toEqual({ type: "unsupported", reason: "Claude subscription requires the Anthropic provider package" }) + expect( + LLMNativeRuntime.status({ + model: baseModel, + provider: { ...providerInfo, id: ProviderV2.ID.make("anthropic") }, + auth: undefined, + }), + ).toEqual({ type: "unsupported", reason: "provider does not match model provider" }) + expect( + LLMNativeRuntime.status({ + model: { ...baseModel, api: { ...baseModel.api, npm: "@ai-sdk/anthropic" } }, + provider: { ...providerInfo, options: { apiKey: OAUTH_DUMMY_KEY, fetch: async () => new Response() } }, + auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 }, + }), + ).toEqual({ type: "unsupported", reason: "OpenAI OAuth requires an OpenAI provider package" }) expect( LLMNativeRuntime.status({ @@ -758,4 +798,68 @@ describe("session.llm-native.request", () => { ) }), ) + + it.effect("uses provider fetch override for native Anthropic subscription requests", () => + Effect.gen(function* () { + const captures: Array<{ url: string; body: unknown }> = [] + const customFetch = Object.assign( + async (input: Parameters[0], init: Parameters[1]) => { + const request = input instanceof Request ? input : new Request(input, init) + captures.push({ url: request.url, body: await request.clone().json() }) + return responsesStream([ + { type: "message_start", message: { usage: { input_tokens: 1 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ]) + }, + { preconnect: () => undefined }, + ) satisfies typeof fetch + + const llmClient = yield* LLMClient.Service + const native = LLMNativeRuntime.stream({ + model: { + ...baseModel, + id: ModelV2.ID.make("claude-sonnet-test"), + providerID: ProviderV2.ID.make("anthropic-subscription"), + api: { + id: "claude-sonnet-test", + url: "https://api.anthropic.com/v1", + npm: "@ai-sdk/anthropic", + }, + }, + provider: { + ...providerInfo, + id: ProviderV2.ID.make("anthropic-subscription"), + options: { apiKey: OAUTH_DUMMY_KEY, fetch: customFetch }, + }, + auth: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 }, + llmClient, + messages: [{ role: "user", content: "hello" }], + tools: {}, + headers: {}, + abort: new AbortController().signal, + }) + expect(native.type).toBe("supported") + if (native.type === "unsupported") throw new Error(native.reason) + const events = Array.from(yield* native.stream.pipe(Stream.runCollect)) + + expect(captures).toHaveLength(1) + expect(captures[0]).toMatchObject({ + url: "https://api.anthropic.com/v1/messages", + body: { + model: "claude-sonnet-test", + messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }], + }, + }) + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "text-delta", text: "Hello" }), + expect.objectContaining({ type: "finish" }), + ]), + ) + }), + ) }) diff --git a/packages/tui/src/component/dialog-provider.tsx b/packages/tui/src/component/dialog-provider.tsx index 0fd51e3c1c71..848c5dd14d79 100644 --- a/packages/tui/src/component/dialog-provider.tsx +++ b/packages/tui/src/component/dialog-provider.tsx @@ -21,8 +21,9 @@ const PROVIDER_PRIORITY: Record = { "opencode-go": 1, openai: 2, "github-copilot": 3, - anthropic: 4, - google: 5, + "anthropic-subscription": 4, + anthropic: 5, + google: 6, } const CUSTOM_PROVIDER_OPTION_VALUE = "__opencode_custom_provider__" @@ -61,6 +62,7 @@ export function providerOptions(list: { id: string; name: string }[]): ProviderO description: { opencode: "(Recommended)", anthropic: "(API key)", + "anthropic-subscription": "(Claude Pro/Max)", openai: "(ChatGPT Plus/Pro or API key)", "opencode-go": "Low cost subscription for everyone", }[provider.id], @@ -145,7 +147,18 @@ export function createDialogProviderOptions() { async onSelect() { if (consoleManaged) return - const methods = sync.data.provider_auth[providerID] ?? [ + const discovered = sync.data.provider_auth[providerID] + if (!discovered && providerID === "anthropic-subscription") { + toast.show({ + variant: sync.data.provider_auth_status === "pending" ? "info" : "error", + message: + sync.data.provider_auth_status === "pending" + ? "Provider authentication methods are still loading" + : "Claude subscription authentication is unavailable", + }) + return + } + const methods = discovered ?? [ { type: "api", label: "API key", diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index e789f8dc81e0..7f8d4d3046c2 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -71,6 +71,7 @@ export const { experimentalBackgroundSubagents: boolean } provider_auth: Record + provider_auth_status: "pending" | "complete" | "failed" agent: Agent[] command: Command[] permission: { @@ -116,6 +117,7 @@ export const { experimentalBackgroundSubagents: false, }, provider_auth: {}, + provider_auth_status: "pending", config: {}, status: "loading", agent: [], @@ -525,6 +527,7 @@ export const { async function bootstrap(input: { fatal?: boolean } = {}) { const fatal = input.fatal ?? true + setStore("provider_auth_status", "pending") const workspace = project.workspace.current() const projectPromise = project.sync() const sessionListPromise = projectPromise.then(() => listSessions()) @@ -605,7 +608,13 @@ export const { sdk.client.session.status({ workspace }).then((x) => { setStore("session_status", reconcile(x.data ?? {})) }), - sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), + sdk.client.provider + .auth({ workspace }, { throwOnError: true }) + .then((x) => { + setStore("provider_auth", reconcile(x.data ?? {})) + setStore("provider_auth_status", "complete") + }) + .catch(() => setStore("provider_auth_status", "failed")), sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))), project.workspace.sync(), ]).then(() => { @@ -613,6 +622,7 @@ export const { }) }) .catch(async (e) => { + if (store.provider_auth_status === "pending") setStore("provider_auth_status", "failed") console.error("tui bootstrap failed", { error: e instanceof Error ? e.message : String(e), name: e instanceof Error ? e.name : undefined, diff --git a/packages/tui/test/cli/cmd/tui/provider-options.test.ts b/packages/tui/test/cli/cmd/tui/provider-options.test.ts index 36cc7b6ee4a4..d303098c8236 100644 --- a/packages/tui/test/cli/cmd/tui/provider-options.test.ts +++ b/packages/tui/test/cli/cmd/tui/provider-options.test.ts @@ -19,11 +19,20 @@ describe("providerOptions", () => { providerOptions([ { id: "openai", name: "OpenAI" }, { id: "custom-z", name: "Zebra Provider" }, + { id: "anthropic-subscription", name: "Claude Pro/Max" }, { id: "anthropic", name: "Anthropic" }, { id: "mistral", name: "Mistral" }, { id: "aws", name: "AWS Bedrock" }, ]).map((option) => option.value), - ).toEqual(["openai", "anthropic", "aws", "mistral", "custom-z", "__opencode_custom_provider__"]) + ).toEqual([ + "openai", + "anthropic-subscription", + "anthropic", + "aws", + "mistral", + "custom-z", + "__opencode_custom_provider__", + ]) }) test("does not collide with a configured provider named other", () => { From ba40db7abfe127e95d01b885268c544e11399a7b Mon Sep 17 00:00:00 2001 From: CreatorGhost Date: Wed, 15 Jul 2026 22:02:47 +0530 Subject: [PATCH 2/4] fix(provider): harden subscription credential refresh --- packages/core/src/credential.ts | 23 ++++ packages/core/src/integration.ts | 12 +- .../plugin/provider/anthropic-subscription.ts | 38 ++++-- packages/core/src/session/runner/llm.ts | 2 + packages/core/src/session/runner/model.ts | 3 +- packages/core/test/credential.test.ts | 63 ++++++++++ .../provider-anthropic-subscription.test.ts | 110 ++++++++++++++++++ ...sion-runner-anthropic-subscription.test.ts | 2 +- .../core/test/session-runner-model.test.ts | 24 ++++ .../src/providers/anthropic-subscription.ts | 12 +- .../provider/anthropic-subscription.test.ts | 3 + packages/opencode/src/cli/cmd/providers.ts | 105 ++++++----------- .../src/plugin/anthropic/subscription.ts | 7 +- .../anthropic-subscription-credential.ts | 57 +++++++++ packages/opencode/src/provider/auth.ts | 37 +----- packages/opencode/src/provider/provider.ts | 49 +++----- .../instance/httpapi/handlers/control.ts | 4 +- .../plugin/anthropic-subscription.test.ts | 27 +++++ .../anthropic-subscription-credential.test.ts | 93 +++++++++++++++ .../opencode/test/provider/provider.test.ts | 22 +++- .../test/server/httpapi-provider.test.ts | 5 + 21 files changed, 525 insertions(+), 173 deletions(-) create mode 100644 packages/opencode/test/provider/anthropic-subscription-credential.test.ts diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index 554073970911..90f7f1c2299d 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -42,6 +42,8 @@ export interface Interface { }) => Effect.Effect /** Updates the label or secret value of a stored credential. */ readonly update: (id: ID, updates: Partial>) => Effect.Effect + /** Replaces an OAuth value only when it still matches the expected value. */ + readonly compareAndSetOAuth: (id: ID, expected: OAuth, value: OAuth) => Effect.Effect /** Removes a stored credential. */ readonly remove: (id: ID) => Effect.Effect } @@ -128,6 +130,27 @@ const layer = Layer.effect( .run() .pipe(Effect.orDie) }), + compareAndSetOAuth: Effect.fn("Credential.compareAndSetOAuth")(function* (id, expected, value) { + return yield* db + .transaction((tx) => + Effect.gen(function* () { + const row = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get() + const current = row ? stored(row)?.value : undefined + if ( + current?.type !== "oauth" || + current.methodID !== expected.methodID || + current.access !== expected.access || + current.refresh !== expected.refresh || + current.expires !== expected.expires || + JSON.stringify(current.metadata) !== JSON.stringify(expected.metadata) + ) + return current + yield* tx.update(CredentialTable).set({ value }).where(eq(CredentialTable.id, id)).run() + return value + }), + ) + .pipe(Effect.orDie) + }), remove: Effect.fn("Credential.remove")(function* (id) { yield* db.delete(CredentialTable).where(eq(CredentialTable.id, id)).run().pipe(Effect.orDie) }), diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index f752ad9fcd01..bf4165e682f6 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -418,6 +418,7 @@ export const locationLayer = Layer.effect( const latest = yield* credentials.get(credential.id) if (!latest || latest.value.type === "key") return latest?.value if ( + latest.value.methodID !== oauth.methodID || latest.value.access !== oauth.access || latest.value.refresh !== oauth.refresh || latest.value.expires !== oauth.expires @@ -431,16 +432,7 @@ export const locationLayer = Layer.effect( const now = yield* Clock.currentTimeMillis if (latest.value.expires > now + Duration.toMillis(Duration.minutes(5))) return latest.value const value = yield* authorize(implementation.refresh(latest.value)) - const current = yield* credentials.get(latest.id) - if (!current || current.value.type === "key") return current?.value - if ( - current.value.access !== latest.value.access || - current.value.refresh !== latest.value.refresh || - current.value.expires !== latest.value.expires - ) - return current.value - yield* credentials.update(latest.id, { value }) - return value + return yield* credentials.compareAndSetOAuth(latest.id, latest.value, value) }), ) }), diff --git a/packages/core/src/plugin/provider/anthropic-subscription.ts b/packages/core/src/plugin/provider/anthropic-subscription.ts index 4071ab8afc14..ac24f70dc537 100644 --- a/packages/core/src/plugin/provider/anthropic-subscription.ts +++ b/packages/core/src/plugin/provider/anthropic-subscription.ts @@ -20,6 +20,7 @@ type Options = { readonly tokenEndpoint?: string readonly request?: Fetch readonly now?: () => number + readonly refreshTimeoutMs?: number } export type AnthropicSubscriptionTokenResponse = { @@ -40,16 +41,33 @@ export function refreshAnthropicSubscriptionToken(input: { readonly refresh: string readonly request?: Fetch readonly tokenEndpoint?: string + readonly timeoutMs?: number }) { const endpoint = input.tokenEndpoint ?? tokenEndpoint - const key = `${endpoint}\u0000${input.refresh}` + const timeoutMs = input.timeoutMs ?? 30_000 + const key = `${endpoint}\u0000${input.refresh}\u0000${timeoutMs}` const current = refreshRequests.get(key) if (current) return current - const pending = tokenRequestPromise(input.request ?? fetch, endpoint, { - grant_type: "refresh_token", - refresh_token: input.refresh, - client_id: clientID, - }).finally(() => { + const controller = new AbortController() + const timeout = Promise.withResolvers() + const timer = setTimeout(() => { + controller.abort() + timeout.reject(new Error(`Anthropic OAuth request timed out after ${timeoutMs}ms`)) + }, timeoutMs) + const pending = Promise.race([ + tokenRequestPromise( + input.request ?? fetch, + endpoint, + { + grant_type: "refresh_token", + refresh_token: input.refresh, + client_id: clientID, + }, + controller.signal, + ), + timeout.promise, + ]).finally(() => { + clearTimeout(timer) if (refreshRequests.get(key) === pending) refreshRequests.delete(key) }) refreshRequests.set(key, pending) @@ -110,7 +128,13 @@ export const makeAnthropicSubscriptionPlugin = (options: Options = {}) => { ), refresh: (value) => Effect.tryPromise({ - try: () => refreshAnthropicSubscriptionToken({ refresh: value.refresh, request, tokenEndpoint: tokens }), + try: () => + refreshAnthropicSubscriptionToken({ + refresh: value.refresh, + request, + tokenEndpoint: tokens, + timeoutMs: options.refreshTimeoutMs, + }), catch: (cause) => cause, }).pipe(Effect.map((result) => credential(result, result.refresh_token ?? value.refresh, now))), } satisfies IntegrationOAuthMethodRegistration diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 825130f1ba02..32dca9f60b7b 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -1,3 +1,5 @@ +export * as SessionRunnerLLM from "./llm" + import { LLM, LLMClient, diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 3cdf1b9b2a4c..b380a88a174d 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -4,6 +4,7 @@ import { makeLocationNode } from "../../effect/app-node" import { type Model } from "@opencode-ai/llm" import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" import { AnthropicSubscription } from "@opencode-ai/llm/providers" +import { AnthropicSubscriptionMethodID } from "../../plugin/provider/anthropic-subscription" import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat" import * as OpenAIResponses from "@opencode-ai/llm/protocols/openai-responses" import { Auth, type AnyRoute } from "@opencode-ai/llm/route" @@ -164,7 +165,7 @@ export const fromCatalogModel = ( }), ) } - if (credential?.type !== "oauth") + if (credential?.type !== "oauth" || credential.methodID !== AnthropicSubscriptionMethodID) return Effect.fail( new CredentialRequiredError({ providerID: resolved.providerID, diff --git a/packages/core/test/credential.test.ts b/packages/core/test/credential.test.ts index c6070145d119..af743e6e7dce 100644 --- a/packages/core/test/credential.test.ts +++ b/packages/core/test/credential.test.ts @@ -33,4 +33,67 @@ describe("Credential", () => { expect(yield* credentials.list(integrationID)).toEqual([]) }), ) + + it.effect("atomically replaces only the expected OAuth value", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const integrationID = Integration.ID.make("anthropic-subscription") + const methodID = Integration.MethodID.make("claude-pro-max") + const expected = Credential.OAuth.make({ + type: "oauth", + methodID, + access: "expired", + refresh: "refresh-old", + expires: 0, + metadata: { account: "old" }, + }) + const created = yield* credentials.create({ integrationID, value: expected }) + const replacement = Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("replacement"), + access: "replacement", + refresh: "refresh-replacement", + expires: 10, + metadata: { account: "replacement" }, + }) + yield* credentials.update(created.id, { value: replacement }) + const refreshed = Credential.OAuth.make({ + type: "oauth", + methodID, + access: "refreshed", + refresh: "refresh-new", + expires: 20, + }) + + expect(yield* credentials.compareAndSetOAuth(created.id, expected, refreshed)).toEqual(replacement) + expect((yield* credentials.get(created.id))?.value).toEqual(replacement) + expect(yield* credentials.compareAndSetOAuth(created.id, replacement, refreshed)).toEqual(refreshed) + expect((yield* credentials.get(created.id))?.value).toEqual(refreshed) + + const metadataChanged = Credential.OAuth.make({ ...expected, metadata: { account: "changed" } }) + yield* credentials.update(created.id, { value: metadataChanged }) + expect(yield* credentials.compareAndSetOAuth(created.id, expected, refreshed)).toEqual(metadataChanged) + expect((yield* credentials.get(created.id))?.value).toEqual(metadataChanged) + + yield* credentials.update(created.id, { value: expected }) + const competing = Credential.OAuth.make({ ...refreshed, access: "competing" }) + const results = yield* Effect.all( + [ + credentials.compareAndSetOAuth(created.id, expected, refreshed), + credentials.compareAndSetOAuth(created.id, expected, competing), + ], + { concurrency: "unbounded" }, + ) + const current = (yield* credentials.get(created.id))?.value + if (current?.type !== "oauth") throw new Error("Expected OAuth credential") + expect([refreshed, competing]).toContainEqual(current) + expect(results).toEqual([current, current]) + + const key = Credential.Key.make({ type: "key", key: "replacement-key" }) + yield* credentials.update(created.id, { value: key }) + expect(yield* credentials.compareAndSetOAuth(created.id, expected, refreshed)).toEqual(key) + yield* credentials.remove(created.id) + expect(yield* credentials.compareAndSetOAuth(created.id, expected, refreshed)).toBeUndefined() + }), + ) }) diff --git a/packages/core/test/plugin/provider-anthropic-subscription.test.ts b/packages/core/test/plugin/provider-anthropic-subscription.test.ts index 3bc61e4eedd9..5ec765418362 100644 --- a/packages/core/test/plugin/provider-anthropic-subscription.test.ts +++ b/packages/core/test/plugin/provider-anthropic-subscription.test.ts @@ -9,6 +9,7 @@ import { AnthropicSubscriptionMethodID, AnthropicSubscriptionProviderID, makeAnthropicSubscriptionPlugin, + refreshAnthropicSubscriptionToken, } from "@opencode-ai/core/plugin/provider/anthropic-subscription" import { ProviderV2 } from "@opencode-ai/core/provider" import { ProjectV2 } from "@opencode-ai/core/project" @@ -218,6 +219,67 @@ describe("AnthropicSubscriptionPlugin", () => { }), ) + it.effect("bounds stalled shared refreshes and clears them for retry", () => + Effect.gen(function* () { + let requests = 0 + const signals: AbortSignal[] = [] + const request = (_input: Parameters[0], init?: Parameters[1]) => { + requests += 1 + if (init?.signal) signals.push(init.signal) + return new Promise(() => {}) + } + const input = { + refresh: "refresh-timeout", + request, + tokenEndpoint: "https://tokens.test/oauth/token", + timeoutMs: 20, + } + const first = refreshAnthropicSubscriptionToken(input) + const second = refreshAnthropicSubscriptionToken(input) + expect(second).toBe(first) + expect( + yield* Effect.promise(() => + Promise.allSettled([first, second]).then((results) => results.map((result) => result.status)), + ), + ).toEqual(["rejected", "rejected"]) + expect(requests).toBe(1) + expect(signals[0]?.aborted).toBe(true) + expect( + yield* Effect.promise(() => + refreshAnthropicSubscriptionToken(input).then( + () => "success" as const, + () => "failure" as const, + ), + ), + ).toBe("failure") + expect(requests).toBe(2) + expect(signals[1]?.aborted).toBe(true) + + yield* addPlugin( + makeAnthropicSubscriptionPlugin({ + tokenEndpoint: "https://tokens.test/oauth/token", + refreshTimeoutMs: 20, + request, + }), + ) + yield* (yield* Credential.Service).create({ + integrationID: AnthropicSubscriptionIntegrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "expired", + refresh: "refresh-integration-timeout", + expires: 0, + }), + }) + const integrations = yield* Integration.Service + const connection = required(yield* integrations.connection.active(AnthropicSubscriptionIntegrationID)) + expect((yield* integrations.connection.resolve(connection).pipe(Effect.exit))._tag).toBe("Failure") + expect(requests).toBe(3) + expect(signals[2]?.aborted).toBe(true) + }), + ) + it.effect("does not overwrite a credential reconnected during refresh", () => Effect.gen(function* () { const gate = Promise.withResolvers() @@ -275,4 +337,52 @@ describe("AnthropicSubscriptionPlugin", () => { }) }), ) + + it.effect("does not overwrite an OAuth method changed during refresh", () => + Effect.gen(function* () { + const gate = Promise.withResolvers() + let requests = 0 + yield* addPlugin( + makeAnthropicSubscriptionPlugin({ + tokenEndpoint: "https://tokens.test/oauth/token", + async request() { + requests += 1 + await gate.promise + return Response.json({ access_token: "stale-access", refresh_token: "stale-refresh", expires_in: 3600 }) + }, + }), + ) + const credentials = yield* Credential.Service + const stored = yield* credentials.create({ + integrationID: AnthropicSubscriptionIntegrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "expired", + refresh: "refresh-method-race", + expires: 0, + }), + }) + const integrations = yield* Integration.Service + const connection = required(yield* integrations.connection.active(AnthropicSubscriptionIntegrationID)) + const running = yield* integrations.connection.resolve(connection).pipe(Effect.forkChild) + yield* Effect.promise(async () => { + while (requests === 0) await Bun.sleep(1) + }) + const methodID = Integration.MethodID.make("replacement-method") + yield* credentials.update(stored.id, { + value: Credential.OAuth.make({ + type: "oauth", + methodID, + access: "expired", + refresh: "refresh-method-race", + expires: 0, + }), + }) + gate.resolve() + + expect(yield* Fiber.join(running)).toMatchObject({ methodID, access: "expired" }) + expect((yield* credentials.get(stored.id))?.value).toMatchObject({ methodID, access: "expired" }) + }), + ) }) diff --git a/packages/core/test/session-runner-anthropic-subscription.test.ts b/packages/core/test/session-runner-anthropic-subscription.test.ts index f151abe097f8..5e1f01ef3be7 100644 --- a/packages/core/test/session-runner-anthropic-subscription.test.ts +++ b/packages/core/test/session-runner-anthropic-subscription.test.ts @@ -22,7 +22,7 @@ import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" import { SessionRunner } from "@opencode-ai/core/session/runner" -import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" +import { SessionRunnerLLM } from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 5e9c366e53bd..f521053c4d78 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -305,6 +305,30 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("rejects Claude subscription credentials from another OAuth method", () => + Effect.gen(function* () { + const failure = yield* SessionRunnerModel.fromCatalogModel( + ModelV2.Info.make({ + ...model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }), + providerID: ProviderV2.ID.make("anthropic-subscription"), + }), + Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("other-method"), + access: "wrong-access", + refresh: "wrong-refresh", + expires: Date.now() + 3_600_000, + }), + ).pipe(Effect.flip) + + expect(failure).toMatchObject({ + _tag: "SessionRunnerModel.CredentialRequiredError", + providerID: "anthropic-subscription", + modelID: "test-model", + }) + }), + ) + it.effect("uses resolved credentials for bearer auth", () => Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( diff --git a/packages/llm/src/providers/anthropic-subscription.ts b/packages/llm/src/providers/anthropic-subscription.ts index 84161af607e1..28d63a96ab89 100644 --- a/packages/llm/src/providers/anthropic-subscription.ts +++ b/packages/llm/src/providers/anthropic-subscription.ts @@ -1,6 +1,6 @@ import { Effect, Stream } from "effect" import { Headers } from "effect/unstable/http" -import * as AnthropicMessages from "../protocols/anthropic-messages" +import { AnthropicMessages } from "../protocols/anthropic-messages" import { Auth } from "../route/auth" import { Route, type RouteDefaultsInput } from "../route/client" import { Endpoint } from "../route/endpoint" @@ -46,8 +46,6 @@ const prefixToolName = (name: string) => { return `${toolPrefix}${name.charAt(0).toUpperCase()}${name.slice(1)}` } -const unprefixToolName = (name: string) => `${name.charAt(0).toLowerCase()}${name.slice(1)}` - const toolNames = (tools: RequestBody["tools"]) => { const outbound = new Map() const inbound = new Map() @@ -159,10 +157,10 @@ export const toolNameMap = (body: string): ReadonlyMap => { } export const restoreToolNames = (frame: string, names?: ReadonlyMap) => - frame.replace( - /"name"\s*:\s*"mcp_([^"]+)"/g, - (_match, name: string) => `"name":${JSON.stringify(names?.get(`mcp_${name}`) ?? unprefixToolName(name))}`, - ) + frame.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, (match, name: string) => { + const restored = names?.get(`mcp_${name}`) + return restored === undefined ? match : `"name":${JSON.stringify(restored)}` + }) const transport = (version: string) => { const base = HttpTransport.sseJson.with() diff --git a/packages/llm/test/provider/anthropic-subscription.test.ts b/packages/llm/test/provider/anthropic-subscription.test.ts index 414d8a2662c1..57fd9b6fb151 100644 --- a/packages/llm/test/provider/anthropic-subscription.test.ts +++ b/packages/llm/test/provider/anthropic-subscription.test.ts @@ -10,6 +10,9 @@ import { sseEvents } from "../lib/sse" describe("Anthropic subscription provider", () => { it.effect("rewrites requests and restores streamed tool names", () => Effect.gen(function* () { + expect(AnthropicSubscription.restoreToolNames('{"name":"mcp_ServerTool"}', new Map())).toBe( + '{"name":"mcp_ServerTool"}', + ) const model = AnthropicSubscription.configure({ accessToken: "oauth-access", sessionID: "session-123", diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index c440c69c41c3..29654915d41c 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -9,10 +9,12 @@ import { Credential } from "@opencode-ai/core/credential" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AnthropicSubscriptionIntegrationID, - AnthropicSubscriptionMethodID, AnthropicSubscriptionProviderID, } from "@opencode-ai/core/plugin/provider/anthropic-subscription" -import { withAnthropicSubscriptionCredentialLock } from "@/provider/anthropic-subscription-credential" +import { + saveAnthropicSubscriptionCredential, + withAnthropicSubscriptionCredentialLock, +} from "@/provider/anthropic-subscription-credential" import { map, pipe, sortBy, values } from "remeda" import path from "path" @@ -49,40 +51,31 @@ const putOAuth = Effect.fn("Cli.providers.putOAuth")(function* ( yield* put(provider, { type: "oauth", ...value, ...extra }) return } - yield* withAnthropicSubscriptionCredentialLock( - Effect.gen(function* () { - const durable = yield* Effect.gen(function* () { - const credentials = yield* Credential.Service - const previous = (yield* credentials.list(AnthropicSubscriptionIntegrationID)).at(-1) - const next = Credential.OAuth.make({ - type: "oauth", - methodID: AnthropicSubscriptionMethodID, - ...value, - }) - const created = previous - ? yield* credentials.update(previous.id, { value: next }).pipe(Effect.as(previous)) - : yield* credentials.create({ - integrationID: AnthropicSubscriptionIntegrationID, - label: "Claude Pro/Max", - value: next, - }) - return { created, previous } - }).pipe(Effect.provide(credentialLayer)) - const auth = yield* Auth.Service - yield* Effect.orDie(auth.remove(provider)).pipe( - Effect.onError(() => - Effect.gen(function* () { - const credentials = yield* Credential.Service - if (durable.previous) { - yield* credentials.update(durable.previous.id, { value: durable.previous.value }) - return - } - yield* credentials.remove(durable.created.id) - }).pipe(Effect.provide(credentialLayer)), - ), - ) - }), - ) + const auth = yield* Auth.Service + yield* Effect.gen(function* () { + yield* saveAnthropicSubscriptionCredential(value, { auth, credentials: yield* Credential.Service }) + }).pipe(Effect.provide(credentialLayer), Effect.orDie) +}) + +const withSyntheticSubscriptionAuth = Effect.fn("Cli.providers.withSyntheticSubscriptionAuth")(function* ( + entries: Array<[string, Auth.Info]>, +) { + const subscription = yield* Effect.gen(function* () { + return (yield* (yield* Credential.Service).list(AnthropicSubscriptionIntegrationID)).at(-1) + }).pipe(Effect.provide(credentialLayer)) + if (subscription?.value.type !== "oauth" || entries.some(([id]) => id === AnthropicSubscriptionProviderID)) { + return entries + } + entries.push([ + AnthropicSubscriptionProviderID, + { + type: "oauth", + access: subscription.value.access, + refresh: subscription.value.refresh, + expires: subscription.value.expires, + }, + ]) + return entries }) const cliTry = (message: string, fn: () => PromiseLike) => @@ -303,21 +296,7 @@ export const ProvidersListCommand = effectCmd({ const homedir = os.homedir() const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) - const subscription = yield* Effect.gen(function* () { - return (yield* (yield* Credential.Service).list(AnthropicSubscriptionIntegrationID)).at(-1) - }).pipe(Effect.provide(credentialLayer)) - if (subscription?.value.type === "oauth" && !results.some(([id]) => id === AnthropicSubscriptionProviderID)) { - results.push([ - AnthropicSubscriptionProviderID, - { - type: "oauth", - access: subscription.value.access, - refresh: subscription.value.refresh, - expires: subscription.value.expires, - }, - ]) - } + const results = yield* withSyntheticSubscriptionAuth(Object.entries(yield* Effect.orDie(authSvc.all()))) const database = Provider.withSyntheticProviders(yield* modelsDev.get()) for (const [providerID, result] of results) { @@ -562,21 +541,7 @@ export const ProvidersLogoutCommand = effectCmd({ const modelsDev = yield* ModelsDev.Service UI.empty() - const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) - const subscription = yield* Effect.gen(function* () { - return (yield* (yield* Credential.Service).list(AnthropicSubscriptionIntegrationID)).at(-1) - }).pipe(Effect.provide(credentialLayer)) - if (subscription?.value.type === "oauth" && !credentials.some(([id]) => id === AnthropicSubscriptionProviderID)) { - credentials.push([ - AnthropicSubscriptionProviderID, - { - type: "oauth", - access: subscription.value.access, - refresh: subscription.value.refresh, - expires: subscription.value.expires, - }, - ]) - } + const credentials = yield* withSyntheticSubscriptionAuth(Object.entries(yield* Effect.orDie(authSvc.all()))) yield* Prompt.intro("Remove credential") if (credentials.length === 0) { yield* Prompt.log.error("No credentials found") @@ -606,11 +571,9 @@ export const ProvidersLogoutCommand = effectCmd({ Effect.gen(function* () { const previous = yield* Effect.gen(function* () { const credentials = yield* Credential.Service - const stored = yield* credentials.list(AnthropicSubscriptionIntegrationID) - for (const credential of stored) { - yield* credentials.remove(credential.id) - } - return stored.at(-1) + const stored = (yield* credentials.list(AnthropicSubscriptionIntegrationID)).at(-1) + if (stored) yield* credentials.remove(stored.id) + return stored }).pipe(Effect.provide(credentialLayer)) yield* Effect.orDie(authSvc.remove(provider)).pipe( Effect.onError(() => diff --git a/packages/opencode/src/plugin/anthropic/subscription.ts b/packages/opencode/src/plugin/anthropic/subscription.ts index 69c73a5b01ef..f77a48930d89 100644 --- a/packages/opencode/src/plugin/anthropic/subscription.ts +++ b/packages/opencode/src/plugin/anthropic/subscription.ts @@ -252,10 +252,13 @@ export async function AnthropicSubscriptionAuthPlugin(input: PluginInput, option if (response.status === 401) { response.body?.cancel().catch(() => {}) const latest = await getAuth() - if (latest?.type === "oauth" && latest.access && latest.access !== auth.access) { + if (latest?.type !== "oauth") { + throw new Error("Anthropic subscription is disconnected; reconnect with /connect") + } + if (latest.access && latest.access !== auth.access) { response = await send(latest.access) } else { - const refreshed = await refresh(latest?.type === "oauth" ? latest.refresh : auth.refresh) + const refreshed = await refresh(latest.refresh) response = await send(refreshed.access) } } diff --git a/packages/opencode/src/provider/anthropic-subscription-credential.ts b/packages/opencode/src/provider/anthropic-subscription-credential.ts index e4c66e50e4e5..2172d2cc1af4 100644 --- a/packages/opencode/src/provider/anthropic-subscription-credential.ts +++ b/packages/opencode/src/provider/anthropic-subscription-credential.ts @@ -1,6 +1,63 @@ +import { Credential } from "@opencode-ai/core/credential" +import { + AnthropicSubscriptionIntegrationID, + AnthropicSubscriptionMethodID, + AnthropicSubscriptionProviderID, +} from "@opencode-ai/core/plugin/provider/anthropic-subscription" import { Effect, Semaphore } from "effect" +import { Auth } from "../auth" const lock = Semaphore.makeUnsafe(1) export const withAnthropicSubscriptionCredentialLock = (effect: Effect.Effect) => lock.withPermit(effect) + +const save = Effect.fn("AnthropicSubscriptionCredential.saveUnlocked")(function* ( + value: { readonly access: string; readonly refresh: string; readonly expires: number }, + services: { readonly auth: Auth.Interface; readonly credentials: Credential.Interface }, +) { + const previous = (yield* services.credentials.list(AnthropicSubscriptionIntegrationID)).at(-1) + const next = Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + ...value, + }) + const created = previous + ? yield* services.credentials.update(previous.id, { value: next }).pipe(Effect.as(previous)) + : yield* services.credentials.create({ + integrationID: AnthropicSubscriptionIntegrationID, + label: "Claude Pro/Max", + value: next, + }) + yield* services.auth + .remove(AnthropicSubscriptionProviderID) + .pipe( + Effect.onError(() => + previous + ? services.credentials.update(previous.id, { value: previous.value }) + : services.credentials.remove(created.id), + ), + ) + return next +}) + +export const saveAnthropicSubscriptionCredential = Effect.fn("AnthropicSubscriptionCredential.save")(function* ( + value: { readonly access: string; readonly refresh: string; readonly expires: number }, + services: { readonly auth: Auth.Interface; readonly credentials: Credential.Interface }, +) { + return yield* withAnthropicSubscriptionCredentialLock(save(value, services)) +}) + +export const migrateAnthropicSubscriptionCredential = Effect.fn("AnthropicSubscriptionCredential.migrate")( + function* (services: { readonly auth: Auth.Interface; readonly credentials: Credential.Interface }) { + return yield* withAnthropicSubscriptionCredentialLock( + Effect.gen(function* () { + const credential = (yield* services.credentials.list(AnthropicSubscriptionIntegrationID)).at(-1) + if (credential?.value.type === "oauth") return credential.value + const legacy = yield* services.auth.get(AnthropicSubscriptionProviderID) + if (legacy?.type !== "oauth") return legacy + return yield* save(legacy, services) + }), + ) + }, +) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 4b80bc9b96c7..b2da0d2646f9 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -7,12 +7,8 @@ import { optional } from "@opencode-ai/core/schema" import { Plugin } from "../plugin" import { ProviderV2 } from "@opencode-ai/core/provider" import { Credential } from "@opencode-ai/core/credential" -import { - AnthropicSubscriptionIntegrationID, - AnthropicSubscriptionMethodID, - AnthropicSubscriptionProviderID, -} from "@opencode-ai/core/plugin/provider/anthropic-subscription" -import { withAnthropicSubscriptionCredentialLock } from "./anthropic-subscription-credential" +import { AnthropicSubscriptionProviderID } from "@opencode-ai/core/plugin/provider/anthropic-subscription" +import { saveAnthropicSubscriptionCredential } from "./anthropic-subscription-credential" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" const When = Schema.Struct({ @@ -226,34 +222,7 @@ const layer: Layer.Layer - previous - ? credentials.update(previous.id, { value: previous.value }) - : credentials.remove(created.id), - ), - ) - }), - ) + yield* saveAnthropicSubscriptionCredential({ access, refresh, expires }, { auth, credentials }) return } yield* auth.set(input.providerID, value) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index cc916bb05e24..3d269603159d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -34,10 +34,9 @@ import { ProviderError } from "./error" import { Credential } from "@opencode-ai/core/credential" import { AnthropicSubscriptionIntegrationID, - AnthropicSubscriptionMethodID, AnthropicSubscriptionProviderID, } from "@opencode-ai/core/plugin/provider/anthropic-subscription" -import { withAnthropicSubscriptionCredentialLock } from "./anthropic-subscription-credential" +import { migrateAnthropicSubscriptionCredential } from "./anthropic-subscription-credential" const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000 @@ -1387,40 +1386,18 @@ const layer = Layer.effect( const providers: Record = {} as Record const storedAuth = Effect.fn("Provider.storedAuth")(function* (providerID: ProviderV2.ID) { if (providerID !== AnthropicSubscriptionProviderID) return yield* auth.get(providerID).pipe(Effect.orDie) - return yield* withAnthropicSubscriptionCredentialLock( - Effect.gen(function* () { - const credential = (yield* credentials.list(AnthropicSubscriptionIntegrationID)).at(-1) - if (credential?.value.type === "oauth") { - if (yield* auth.get(providerID).pipe(Effect.orDie)) yield* auth.remove(providerID).pipe(Effect.orDie) - return { - type: "oauth" as const, - access: credential.value.access, - refresh: credential.value.refresh, - expires: credential.value.expires, - } - } - const legacy = yield* auth.get(providerID).pipe(Effect.orDie) - if (legacy?.type !== "oauth") return legacy - yield* credentials.create({ - integrationID: AnthropicSubscriptionIntegrationID, - label: "Claude Pro/Max", - value: Credential.OAuth.make({ - type: "oauth", - methodID: AnthropicSubscriptionMethodID, - access: legacy.access, - refresh: legacy.refresh, - expires: legacy.expires, - }), - }) - yield* auth.remove(providerID).pipe(Effect.orDie) - return { - type: "oauth" as const, - access: legacy.access, - refresh: legacy.refresh, - expires: legacy.expires, - } - }), - ) + const credential = (yield* credentials.list(AnthropicSubscriptionIntegrationID)).at(-1) + const value = + credential?.value.type === "oauth" + ? credential.value + : yield* migrateAnthropicSubscriptionCredential({ auth, credentials }).pipe(Effect.orDie) + if (value?.type !== "oauth") return value + return { + type: "oauth" as const, + access: value.access, + refresh: value.refresh, + expires: value.expires, + } }) const languages = new Map() const modelLoaders: { diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts index 729a1444a0d7..8e626ae27a7c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts @@ -37,9 +37,7 @@ function updateDurable(value?: Auth.Info) { }) return previous } - for (const credential of yield* credentials.list(AnthropicSubscriptionIntegrationID)) { - yield* credentials.remove(credential.id) - } + if (previous) yield* credentials.remove(previous.id) return previous }).pipe(Effect.provide(credentialLayer)) } diff --git a/packages/opencode/test/plugin/anthropic-subscription.test.ts b/packages/opencode/test/plugin/anthropic-subscription.test.ts index 35505a4fa762..1845fa83ed2c 100644 --- a/packages/opencode/test/plugin/anthropic-subscription.test.ts +++ b/packages/opencode/test/plugin/anthropic-subscription.test.ts @@ -228,6 +228,33 @@ describe("plugin.anthropic-subscription", () => { expect(authorizations).toEqual(["Bearer access-token", "Bearer access-new"]) }) + test("does not refresh a credential removed after a 401", async () => { + let reads = 0 + let refreshes = 0 + let apiRequests = 0 + const hooks = await AnthropicSubscriptionAuthPlugin(input(), { + apiOrigin: "https://api.test", + tokenEndpoint: "https://tokens.test/oauth/token", + now: () => 0, + async fetch(request, init) { + const current = new Request(request, init) + if (current.url === "https://tokens.test/oauth/token") { + refreshes += 1 + return Response.json({ access_token: "unexpected", refresh_token: "unexpected", expires_in: 3600 }) + } + apiRequests += 1 + return new Response("{}", { status: 401 }) + }, + }) + const loaded = await hooks.auth!.loader!(async () => (reads++ < 2 ? oauth : undefined) as never, {} as never) + + await expect(loaded.fetch!("https://api.test/v1/messages")).rejects.toThrow( + "Anthropic subscription is disconnected", + ) + expect(apiRequests).toBe(1) + expect(refreshes).toBe(0) + }) + test("handles credentials removed during loader initialization and requests", async () => { const hooks = await AnthropicSubscriptionAuthPlugin(input(), { apiOrigin: "https://api.test" }) expect(await hooks.auth!.loader!(async () => undefined as never, {} as never)).toEqual({}) diff --git a/packages/opencode/test/provider/anthropic-subscription-credential.test.ts b/packages/opencode/test/provider/anthropic-subscription-credential.test.ts new file mode 100644 index 000000000000..2bdac9aa58e4 --- /dev/null +++ b/packages/opencode/test/provider/anthropic-subscription-credential.test.ts @@ -0,0 +1,93 @@ +import { expect } from "bun:test" +import { Credential } from "@opencode-ai/core/credential" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { + AnthropicSubscriptionIntegrationID, + AnthropicSubscriptionMethodID, +} from "@opencode-ai/core/plugin/provider/anthropic-subscription" +import { Effect } from "effect" +import { Auth } from "@/auth" +import { + migrateAnthropicSubscriptionCredential, + saveAnthropicSubscriptionCredential, +} from "@/provider/anthropic-subscription-credential" +import { testEffect } from "../lib/effect" + +const it = testEffect(LayerNode.compile(Credential.node)) + +const auth = Auth.Service.of({ + get: () => Effect.succeed(undefined), + all: () => Effect.succeed({}), + set: () => Effect.void, + remove: () => Effect.fail(new Auth.AuthError({ message: "remove failed" })), +}) + +it.effect("restores durable credentials when legacy auth retirement fails", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const next = { access: "new-access", refresh: "new-refresh", expires: 20 } + + expect((yield* saveAnthropicSubscriptionCredential(next, { auth, credentials }).pipe(Effect.exit))._tag).toBe( + "Failure", + ) + expect(yield* credentials.list(AnthropicSubscriptionIntegrationID)).toEqual([]) + + const previous = yield* credentials.create({ + integrationID: AnthropicSubscriptionIntegrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "old-access", + refresh: "old-refresh", + expires: 10, + }), + }) + expect((yield* saveAnthropicSubscriptionCredential(next, { auth, credentials }).pipe(Effect.exit))._tag).toBe( + "Failure", + ) + expect(yield* credentials.get(previous.id)).toEqual(previous) + }), +) + +it.effect("migrates durable credentials and retires legacy auth", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + let legacy: Auth.Info | undefined = new Auth.Oauth({ + type: "oauth", + access: "legacy-access", + refresh: "legacy-refresh", + expires: 20, + }) + const migratingAuth = Auth.Service.of({ + get: () => Effect.succeed(legacy), + all: () => + Effect.sync(() => { + const result: Record = {} + if (legacy) result["anthropic-subscription"] = legacy + return result + }), + set: (_key, value) => + Effect.sync(() => { + legacy = value + }), + remove: () => + Effect.sync(() => { + legacy = undefined + }), + }) + + expect(yield* migrateAnthropicSubscriptionCredential({ auth: migratingAuth, credentials })).toMatchObject({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "legacy-access", + refresh: "legacy-refresh", + }) + expect(legacy).toBeUndefined() + expect((yield* credentials.list(AnthropicSubscriptionIntegrationID))[0]?.value).toMatchObject({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "legacy-access", + refresh: "legacy-refresh", + }) + }), +) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 855b98700d8d..b4e790e43235 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -3,7 +3,7 @@ import { mkdir, unlink } from "fs/promises" import path from "path" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { ModelsDev } from "@opencode-ai/core/models-dev" import { FSUtil } from "@opencode-ai/core/fs-util" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -23,6 +23,11 @@ import { InstanceStore } from "@/project/instance-store" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { Credential } from "@opencode-ai/core/credential" +import { + AnthropicSubscriptionIntegrationID, + AnthropicSubscriptionMethodID, +} from "@opencode-ai/core/plugin/provider/anthropic-subscription" const originalEnv = new Map() @@ -184,6 +189,21 @@ it.instance( expect(models.every((model) => model.providerID === "anthropic-subscription")).toBe(true) expect(models.every((model) => model.api.npm === "@ai-sdk/anthropic")).toBe(true) expect(models.every((model) => model.cost.input === 0 && model.cost.output === 0)).toBe(true) + expect( + yield* Effect.gen(function* () { + return yield* (yield* Credential.Service).list(AnthropicSubscriptionIntegrationID) + }).pipe(Effect.provide(AppNodeBuilder.build(Credential.node))), + ).toEqual([ + expect.objectContaining({ + integrationID: AnthropicSubscriptionIntegrationID, + value: expect.objectContaining({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + refresh: "refresh-token", + access: "access-token", + }), + }), + ]) const language = yield* Provider.use.getLanguage(models[0]) expect(language).toBeDefined() diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 46ca3cae22e8..ba7cbf32dfaf 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -448,6 +448,11 @@ describe("provider HttpApi", () => { refresh: "refresh-from-control", }) expect((yield* request("/auth/anthropic-subscription", { method: "DELETE", headers })).status).toBe(200) + expect( + yield* Effect.gen(function* () { + return yield* (yield* Credential.Service).list(AnthropicSubscriptionIntegrationID) + }).pipe(Effect.provide(AppNodeBuilder.build(Credential.node))), + ).toEqual([]) }), { ...projectOptions, init: writeAnthropicSubscriptionPlugin }, 30000, From cc16e1edb63f728f61c8b059bbb53a4884b709ef Mon Sep 17 00:00:00 2001 From: CreatorGhost Date: Wed, 15 Jul 2026 23:20:46 +0530 Subject: [PATCH 3/4] fix(provider): make credential transitions race-safe --- packages/core/src/credential.ts | 96 +++++- packages/core/src/integration.ts | 31 +- .../plugin/provider/anthropic-subscription.ts | 10 +- packages/core/test/credential.test.ts | 55 ++- .../provider-anthropic-subscription.test.ts | 81 +++++ packages/opencode/src/cli/cmd/providers.ts | 32 +- .../src/plugin/anthropic/subscription.ts | 30 +- .../anthropic-subscription-credential.ts | 161 ++++++++- .../instance/httpapi/handlers/control.ts | 81 +---- .../anthropic-subscription-credential.test.ts | 313 +++++++++++++++++- 10 files changed, 754 insertions(+), 136 deletions(-) diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index 90f7f1c2299d..130d397a857e 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -1,7 +1,7 @@ export * as Credential from "./credential" import { asc, eq } from "drizzle-orm" -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Equal, Layer, Schema } from "effect" import { Credential } from "@opencode-ai/schema/credential" import { Integration } from "@opencode-ai/schema/integration" import { Database } from "./database/database" @@ -40,10 +40,25 @@ export interface Interface { readonly value: Value readonly label?: string }) => Effect.Effect + /** Creates a credential only when the integration has no stored credential. */ + readonly createIfAbsent: (input: { + readonly integrationID: Integration.ID + readonly value: Value + readonly label?: string + }) => Effect.Effect<{ readonly created: boolean; readonly credential: Info }> /** Updates the label or secret value of a stored credential. */ readonly update: (id: ID, updates: Partial>) => Effect.Effect /** Replaces an OAuth value only when it still matches the expected value. */ - readonly compareAndSetOAuth: (id: ID, expected: OAuth, value: OAuth) => Effect.Effect + readonly compareAndSetOAuth: ( + id: ID, + expected: OAuth, + value: OAuth, + ) => Effect.Effect<{ readonly updated: boolean; readonly value: Value | undefined }> + /** Removes a credential only when its value still matches the expected value. */ + readonly compareAndRemove: ( + id: ID, + expected: Value, + ) => Effect.Effect<{ readonly removed: boolean; readonly value: Value | undefined }> /** Removes a stored credential. */ readonly remove: (id: ID) => Effect.Effect } @@ -55,6 +70,8 @@ const layer = Layer.effect( Effect.gen(function* () { const { db } = yield* Database.Service const decode = Schema.decodeUnknownSync(Value) + const decodePersisted = Schema.decodeUnknownSync(Schema.fromJsonString(Value)) + const persisted = (value: Value) => decodePersisted(JSON.stringify(value)) const stored = (row: typeof CredentialTable.$inferSelect) => { if (!row.integration_id) return return new Info({ @@ -121,6 +138,39 @@ const layer = Layer.effect( .pipe(Effect.orDie) return credential }), + createIfAbsent: Effect.fn("Credential.createIfAbsent")(function* (input) { + return yield* db + .transaction( + (tx) => + Effect.gen(function* () { + const row = yield* tx + .select() + .from(CredentialTable) + .where(eq(CredentialTable.integration_id, input.integrationID)) + .get() + const current = row ? stored(row) : undefined + if (current) return { created: false, credential: current } + const credential = new Info({ + id: ID.create(), + integrationID: input.integrationID, + label: input.label ?? "default", + value: input.value, + }) + yield* tx + .insert(CredentialTable) + .values({ + id: credential.id, + integration_id: credential.integrationID, + label: credential.label, + value: credential.value, + }) + .run() + return { created: true, credential } + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + }), update: Effect.fn("Credential.update")(function* (id, updates) { if (!updates.label && !updates.value) return yield* db @@ -132,22 +182,32 @@ const layer = Layer.effect( }), compareAndSetOAuth: Effect.fn("Credential.compareAndSetOAuth")(function* (id, expected, value) { return yield* db - .transaction((tx) => - Effect.gen(function* () { - const row = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get() - const current = row ? stored(row)?.value : undefined - if ( - current?.type !== "oauth" || - current.methodID !== expected.methodID || - current.access !== expected.access || - current.refresh !== expected.refresh || - current.expires !== expected.expires || - JSON.stringify(current.metadata) !== JSON.stringify(expected.metadata) - ) - return current - yield* tx.update(CredentialTable).set({ value }).where(eq(CredentialTable.id, id)).run() - return value - }), + .transaction( + (tx) => + Effect.gen(function* () { + const row = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get() + const current = row ? stored(row)?.value : undefined + if (current?.type !== "oauth" || !Equal.equals(current, persisted(expected))) + return { updated: false, value: current } + yield* tx.update(CredentialTable).set({ value }).where(eq(CredentialTable.id, id)).run() + return { updated: true, value } + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + }), + compareAndRemove: Effect.fn("Credential.compareAndRemove")(function* (id, expected) { + return yield* db + .transaction( + (tx) => + Effect.gen(function* () { + const row = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get() + const current = row ? stored(row)?.value : undefined + if (!Equal.equals(current, persisted(expected))) return { removed: false, value: current } + yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run() + return { removed: true, value: undefined } + }), + { behavior: "immediate" }, ) .pipe(Effect.orDie) }), diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index bf4165e682f6..66c7e359f255 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -432,7 +432,7 @@ export const locationLayer = Layer.effect( const now = yield* Clock.currentTimeMillis if (latest.value.expires > now + Duration.toMillis(Duration.minutes(5))) return latest.value const value = yield* authorize(implementation.refresh(latest.value)) - return yield* credentials.compareAndSetOAuth(latest.id, latest.value, value) + return yield* persistOAuthRefresh(latest.id, latest.value, value, credentials) }), ) }), @@ -552,4 +552,33 @@ export const locationLayer = Layer.effect( }), ) +function persistOAuthRefresh( + id: Credential.ID, + expected: Credential.OAuth, + value: Credential.OAuth, + credentials: Credential.Interface, +): Effect.Effect { + return credentials.compareAndSetOAuth(id, expected, value).pipe( + Effect.flatMap((result) => { + if (result.updated) return Effect.succeed(value) + const current = result.value + if ( + current?.type !== "oauth" || + current.methodID !== expected.methodID || + current.access !== expected.access || + current.refresh !== expected.refresh || + current.expires !== expected.expires + ) { + return Effect.succeed(current) + } + return persistOAuthRefresh( + id, + current, + Credential.OAuth.make({ ...value, metadata: current.metadata }), + credentials, + ) + }), + ) +} + export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Credential.node, EventV2.node] }) diff --git a/packages/core/src/plugin/provider/anthropic-subscription.ts b/packages/core/src/plugin/provider/anthropic-subscription.ts index ac24f70dc537..ea37a1321d80 100644 --- a/packages/core/src/plugin/provider/anthropic-subscription.ts +++ b/packages/core/src/plugin/provider/anthropic-subscription.ts @@ -136,7 +136,7 @@ export const makeAnthropicSubscriptionPlugin = (options: Options = {}) => { timeoutMs: options.refreshTimeoutMs, }), catch: (cause) => cause, - }).pipe(Effect.map((result) => credential(result, result.refresh_token ?? value.refresh, now))), + }).pipe(Effect.map((result) => credential(result, result.refresh_token ?? value.refresh, now, value.metadata))), } satisfies IntegrationOAuthMethodRegistration return define({ @@ -198,13 +198,19 @@ async function tokenRequestPromise(request: Fetch, url: string, body: Record number) { +function credential( + tokens: AnthropicSubscriptionTokenResponse, + refresh: string, + now: () => number, + metadata?: Credential.OAuth["metadata"], +) { return Credential.OAuth.make({ type: "oauth", methodID: AnthropicSubscriptionMethodID, access: tokens.access_token, refresh, expires: now() + (tokens.expires_in ?? 3600) * 1000, + metadata, }) } diff --git a/packages/core/test/credential.test.ts b/packages/core/test/credential.test.ts index af743e6e7dce..95588dc7b190 100644 --- a/packages/core/test/credential.test.ts +++ b/packages/core/test/credential.test.ts @@ -45,9 +45,19 @@ describe("Credential", () => { access: "expired", refresh: "refresh-old", expires: 0, - metadata: { account: "old" }, + metadata: { account: "old", organization: "example", ignored: undefined }, }) const created = yield* credentials.create({ integrationID, value: expected }) + const reordered = Credential.OAuth.make({ + ...expected, + metadata: { organization: "example", account: "old" }, + }) + const equivalent = Credential.OAuth.make({ ...expected, access: "equivalent" }) + expect(yield* credentials.compareAndSetOAuth(created.id, reordered, equivalent)).toEqual({ + updated: true, + value: equivalent, + }) + yield* credentials.update(created.id, { value: expected }) const replacement = Credential.OAuth.make({ type: "oauth", methodID: Integration.MethodID.make("replacement"), @@ -65,14 +75,23 @@ describe("Credential", () => { expires: 20, }) - expect(yield* credentials.compareAndSetOAuth(created.id, expected, refreshed)).toEqual(replacement) + expect(yield* credentials.compareAndSetOAuth(created.id, expected, refreshed)).toEqual({ + updated: false, + value: replacement, + }) expect((yield* credentials.get(created.id))?.value).toEqual(replacement) - expect(yield* credentials.compareAndSetOAuth(created.id, replacement, refreshed)).toEqual(refreshed) + expect(yield* credentials.compareAndSetOAuth(created.id, replacement, refreshed)).toEqual({ + updated: true, + value: refreshed, + }) expect((yield* credentials.get(created.id))?.value).toEqual(refreshed) const metadataChanged = Credential.OAuth.make({ ...expected, metadata: { account: "changed" } }) yield* credentials.update(created.id, { value: metadataChanged }) - expect(yield* credentials.compareAndSetOAuth(created.id, expected, refreshed)).toEqual(metadataChanged) + expect(yield* credentials.compareAndSetOAuth(created.id, expected, refreshed)).toEqual({ + updated: false, + value: metadataChanged, + }) expect((yield* credentials.get(created.id))?.value).toEqual(metadataChanged) yield* credentials.update(created.id, { value: expected }) @@ -87,13 +106,35 @@ describe("Credential", () => { const current = (yield* credentials.get(created.id))?.value if (current?.type !== "oauth") throw new Error("Expected OAuth credential") expect([refreshed, competing]).toContainEqual(current) - expect(results).toEqual([current, current]) + expect(results.map((result) => result.value)).toEqual([current, current]) + expect(results.filter((result) => result.updated)).toHaveLength(1) const key = Credential.Key.make({ type: "key", key: "replacement-key" }) yield* credentials.update(created.id, { value: key }) - expect(yield* credentials.compareAndSetOAuth(created.id, expected, refreshed)).toEqual(key) + expect(yield* credentials.compareAndSetOAuth(created.id, expected, refreshed)).toEqual({ + updated: false, + value: key, + }) yield* credentials.remove(created.id) - expect(yield* credentials.compareAndSetOAuth(created.id, expected, refreshed)).toBeUndefined() + expect(yield* credentials.compareAndSetOAuth(created.id, expected, refreshed)).toEqual({ + updated: false, + value: undefined, + }) + + const first = yield* credentials.createIfAbsent({ integrationID, value: expected }) + expect(first.created).toBe(true) + expect(yield* credentials.createIfAbsent({ integrationID, value: competing })).toEqual({ + created: false, + credential: first.credential, + }) + expect(yield* credentials.compareAndRemove(first.credential.id, refreshed)).toEqual({ + removed: false, + value: expected, + }) + expect(yield* credentials.compareAndRemove(first.credential.id, expected)).toEqual({ + removed: true, + value: undefined, + }) }), ) }) diff --git a/packages/core/test/plugin/provider-anthropic-subscription.test.ts b/packages/core/test/plugin/provider-anthropic-subscription.test.ts index 5ec765418362..794997f7f28f 100644 --- a/packages/core/test/plugin/provider-anthropic-subscription.test.ts +++ b/packages/core/test/plugin/provider-anthropic-subscription.test.ts @@ -159,6 +159,16 @@ describe("AnthropicSubscriptionPlugin", () => { expect(requests).toHaveLength(1) const connection = required(yield* integrations.connection.active(AnthropicSubscriptionIntegrationID)) + if (connection.type !== "credential") throw new Error("Expected stored credential") + const credentials = yield* Credential.Service + const stored = required(yield* credentials.get(connection.id)) + if (stored.value.type !== "oauth") throw new Error("Expected OAuth credential") + yield* credentials.update(stored.id, { + value: Credential.OAuth.make({ + ...stored.value, + metadata: { "opencode.internal.revision": "pending" }, + }), + }) clock.now = 0 const resolved = yield* Effect.all( [integrations.connection.resolve(connection), integrations.connection.resolve(connection)], @@ -171,6 +181,7 @@ describe("AnthropicSubscriptionPlugin", () => { access: "access-refreshed", refresh: "refresh-rotated", expires: 3_600_000, + metadata: { "opencode.internal.revision": "pending" }, }), ) expect(requests).toHaveLength(2) @@ -178,6 +189,76 @@ describe("AnthropicSubscriptionPlugin", () => { }), ) + it.effect("persists a rotated refresh after pending metadata is finalized", () => + Effect.gen(function* () { + const finalize = { run: undefined as (() => Promise) | undefined } + yield* addPlugin( + makeAnthropicSubscriptionPlugin({ + authorizationOrigin: "https://login.test", + tokenEndpoint: "https://tokens.test/oauth/token", + now: () => 0, + async request(input, init) { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + const request = new Request(url, init) + const body = (await request.clone().json()) as { grant_type: string } + if (body.grant_type === "refresh_token") { + await finalize.run?.() + return Response.json({ + access_token: "access-refreshed", + refresh_token: "refresh-rotated", + expires_in: 3600, + }) + } + return Response.json({ access_token: "access-initial", refresh_token: "refresh-initial", expires_in: 1 }) + }, + }), + ) + const integrations = yield* Integration.Service + const attempt = yield* integrations.connection.oauth({ + integrationID: AnthropicSubscriptionIntegrationID, + methodID: AnthropicSubscriptionMethodID, + inputs: {}, + }) + const state = required(new URL(attempt.url).searchParams.get("state") ?? undefined) + yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: `code#${state}` }) + const connection = required(yield* integrations.connection.active(AnthropicSubscriptionIntegrationID)) + if (connection.type !== "credential") throw new Error("Expected stored credential") + const credentials = yield* Credential.Service + const stored = required(yield* credentials.get(connection.id)) + if (stored.value.type !== "oauth") throw new Error("Expected OAuth credential") + const pending = Credential.OAuth.make({ + ...stored.value, + metadata: { + "opencode.internal.revision": "pending", + "opencode.internal.pendingUntil": Date.now() + 60_000, + }, + }) + yield* credentials.update(stored.id, { value: pending }) + finalize.run = () => + Effect.runPromise( + credentials + .compareAndSetOAuth( + stored.id, + pending, + Credential.OAuth.make({ + ...pending, + metadata: undefined, + }), + ) + .pipe(Effect.asVoid), + ) + + const resolved = required(yield* integrations.connection.resolve(connection)) + expect(resolved).toMatchObject({ + access: "access-refreshed", + refresh: "refresh-rotated", + expires: 3_600_000, + }) + expect(resolved.metadata).toBeUndefined() + expect((yield* credentials.get(stored.id))?.value).toEqual(resolved) + }), + ) + it.effect("shares failed refreshes across concurrent credential resolution", () => Effect.gen(function* () { const gate = Promise.withResolvers() diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 29654915d41c..7d8c2a30f436 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -12,8 +12,8 @@ import { AnthropicSubscriptionProviderID, } from "@opencode-ai/core/plugin/provider/anthropic-subscription" import { + removeAnthropicSubscriptionCredential, saveAnthropicSubscriptionCredential, - withAnthropicSubscriptionCredentialLock, } from "@/provider/anthropic-subscription-credential" import { map, pipe, sortBy, values } from "remeda" @@ -61,7 +61,8 @@ const withSyntheticSubscriptionAuth = Effect.fn("Cli.providers.withSyntheticSubs entries: Array<[string, Auth.Info]>, ) { const subscription = yield* Effect.gen(function* () { - return (yield* (yield* Credential.Service).list(AnthropicSubscriptionIntegrationID)).at(-1) + const credentials = yield* Credential.Service + return (yield* credentials.list(AnthropicSubscriptionIntegrationID)).at(-1) }).pipe(Effect.provide(credentialLayer)) if (subscription?.value.type !== "oauth" || entries.some(([id]) => id === AnthropicSubscriptionProviderID)) { return entries @@ -567,30 +568,9 @@ export const ProvidersLogoutCommand = effectCmd({ ) if (!provider) return yield* fail(`Unknown configured provider "${args.provider}"`) if (provider === AnthropicSubscriptionProviderID) { - yield* withAnthropicSubscriptionCredentialLock( - Effect.gen(function* () { - const previous = yield* Effect.gen(function* () { - const credentials = yield* Credential.Service - const stored = (yield* credentials.list(AnthropicSubscriptionIntegrationID)).at(-1) - if (stored) yield* credentials.remove(stored.id) - return stored - }).pipe(Effect.provide(credentialLayer)) - yield* Effect.orDie(authSvc.remove(provider)).pipe( - Effect.onError(() => - previous - ? Effect.gen(function* () { - const credentials = yield* Credential.Service - yield* credentials.create({ - integrationID: previous.integrationID, - label: previous.label, - value: previous.value, - }) - }).pipe(Effect.provide(credentialLayer)) - : Effect.void, - ), - ) - }), - ) + yield* Effect.gen(function* () { + yield* removeAnthropicSubscriptionCredential({ auth: authSvc, credentials: yield* Credential.Service }) + }).pipe(Effect.provide(credentialLayer), Effect.orDie) yield* Prompt.outro("Logout successful") return } diff --git a/packages/opencode/src/plugin/anthropic/subscription.ts b/packages/opencode/src/plugin/anthropic/subscription.ts index f77a48930d89..5616da29e7cb 100644 --- a/packages/opencode/src/plugin/anthropic/subscription.ts +++ b/packages/opencode/src/plugin/anthropic/subscription.ts @@ -50,6 +50,32 @@ interface TokenResponse { expires_in?: number } +function persistRefresh( + id: Credential.ID, + expected: Credential.OAuth, + value: Credential.OAuth, + credentials: Credential.Interface, +): Effect.Effect { + return credentials.compareAndSetOAuth(id, expected, value).pipe( + Effect.flatMap((result) => { + if (result.updated) return Effect.void + const current = result.value + if (current?.type !== "oauth" || current.methodID !== AnthropicSubscriptionMethodID) { + return Effect.die("Anthropic subscription is disconnected") + } + if (current.access === value.access && current.refresh === value.refresh) return Effect.void + if ( + current.access !== expected.access || + current.refresh !== expected.refresh || + current.expires !== expected.expires + ) { + return Effect.die("Anthropic subscription is disconnected") + } + return persistRefresh(id, current, Credential.OAuth.make({ ...value, metadata: current.metadata }), credentials) + }), + ) +} + function persistDurable(value: { access: string; refresh: string; expires: number; expectedRefresh: string }) { return Effect.runPromise( withAnthropicSubscriptionCredentialLock( @@ -60,11 +86,11 @@ function persistDurable(value: { access: string; refresh: string; expires: numbe const credential = Credential.OAuth.make({ type: "oauth", methodID: AnthropicSubscriptionMethodID, + metadata: current?.value.metadata, ...tokens, }) if (current?.value.type === "oauth" && current.value.refresh === value.expectedRefresh) { - yield* credentials.update(current.id, { value: credential }) - return + return yield* persistRefresh(current.id, current.value, credential, credentials) } if ( current?.value.type === "oauth" && diff --git a/packages/opencode/src/provider/anthropic-subscription-credential.ts b/packages/opencode/src/provider/anthropic-subscription-credential.ts index 2172d2cc1af4..e1668aa88657 100644 --- a/packages/opencode/src/provider/anthropic-subscription-credential.ts +++ b/packages/opencode/src/provider/anthropic-subscription-credential.ts @@ -5,9 +5,109 @@ import { AnthropicSubscriptionProviderID, } from "@opencode-ai/core/plugin/provider/anthropic-subscription" import { Effect, Semaphore } from "effect" +import { randomUUID } from "node:crypto" import { Auth } from "../auth" const lock = Semaphore.makeUnsafe(1) +const revisionKey = "opencode.internal.revision" +const pendingUntilKey = "opencode.internal.pendingUntil" +const pendingTimeoutMs = 5 * 60 * 1000 + +function revision(value: Credential.OAuth) { + const revision = value.metadata?.[revisionKey] + return typeof revision === "string" ? revision : undefined +} + +function pendingUntil(value: Credential.OAuth) { + const pendingUntil = value.metadata?.[pendingUntilKey] + return typeof pendingUntil === "number" ? pendingUntil : 0 +} + +function committed(value: Credential.OAuth) { + return Credential.OAuth.make({ + type: "oauth", + methodID: value.methodID, + access: value.access, + refresh: value.refresh, + expires: value.expires, + }) +} + +function commitOAuth( + id: Credential.ID, + expected: Credential.OAuth, + expectedRevision: string, + credentials: Credential.Interface, +): Effect.Effect { + const value = committed(expected) + return credentials.compareAndSetOAuth(id, expected, value).pipe( + Effect.flatMap((result) => { + if (result.updated) return Effect.succeed(value) + if (result.value?.type !== "oauth" || result.value.methodID !== AnthropicSubscriptionMethodID) { + return Effect.fail(new Auth.AuthError({ message: "Anthropic subscription credential changed during save" })) + } + const currentRevision = revision(result.value) + if (currentRevision === expectedRevision) return commitOAuth(id, result.value, expectedRevision, credentials) + if (currentRevision === undefined) return Effect.succeed(result.value) + return Effect.fail(new Auth.AuthError({ message: "Anthropic subscription credential changed during save" })) + }), + ) +} + +function replaceOAuth( + id: Credential.ID, + expected: Credential.OAuth, + value: Credential.OAuth, + credentials: Credential.Interface, +): Effect.Effect { + if (revision(expected) !== undefined) { + if (pendingUntil(expected) > Date.now()) { + return Effect.fail(new Auth.AuthError({ message: "Anthropic subscription credential changed during save" })) + } + const recovered = committed(expected) + return credentials.compareAndSetOAuth(id, expected, recovered).pipe( + Effect.flatMap((result) => { + if (result.updated) return replaceOAuth(id, recovered, value, credentials) + if (result.value?.type !== "oauth" || result.value.methodID !== AnthropicSubscriptionMethodID) { + return Effect.fail(new Auth.AuthError({ message: "Anthropic subscription credential changed during save" })) + } + return replaceOAuth(id, result.value, value, credentials) + }), + ) + } + return credentials.compareAndSetOAuth(id, expected, value).pipe( + Effect.flatMap((result) => { + if (result.updated) return Effect.succeed(expected) + if (result.value?.type !== "oauth" || result.value.methodID !== AnthropicSubscriptionMethodID) { + return Effect.fail(new Auth.AuthError({ message: "Anthropic subscription credential changed during save" })) + } + if (revision(result.value) !== undefined) { + return Effect.fail(new Auth.AuthError({ message: "Anthropic subscription credential changed during save" })) + } + return replaceOAuth(id, result.value, value, credentials) + }), + ) +} + +function retireLegacy( + id: Credential.ID, + previous: Credential.OAuth | undefined, + pending: Credential.OAuth, + value: Credential.OAuth, + services: { readonly auth: Auth.Interface; readonly credentials: Credential.Interface }, +): Effect.Effect { + const pendingRevision = revision(pending) + if (!pendingRevision) { + return Effect.fail(new Auth.AuthError({ message: "Anthropic subscription credential changed during save" })) + } + const rollback = previous + ? services.credentials.compareAndSetOAuth(id, pending, previous).pipe(Effect.asVoid) + : services.credentials.compareAndRemove(id, pending).pipe(Effect.asVoid) + return services.auth.remove(AnthropicSubscriptionProviderID).pipe( + Effect.onError(() => rollback), + Effect.flatMap(() => commitOAuth(id, pending, pendingRevision, services.credentials)), + ) +} export const withAnthropicSubscriptionCredentialLock = (effect: Effect.Effect) => lock.withPermit(effect) @@ -22,23 +122,40 @@ const save = Effect.fn("AnthropicSubscriptionCredential.saveUnlocked")(function* methodID: AnthropicSubscriptionMethodID, ...value, }) - const created = previous - ? yield* services.credentials.update(previous.id, { value: next }).pipe(Effect.as(previous)) - : yield* services.credentials.create({ - integrationID: AnthropicSubscriptionIntegrationID, - label: "Claude Pro/Max", - value: next, - }) - yield* services.auth - .remove(AnthropicSubscriptionProviderID) - .pipe( - Effect.onError(() => - previous - ? services.credentials.update(previous.id, { value: previous.value }) - : services.credentials.remove(created.id), - ), + const pending = Credential.OAuth.make({ + ...next, + metadata: { [revisionKey]: randomUUID(), [pendingUntilKey]: Date.now() + pendingTimeoutMs }, + }) + if (!previous) { + const result = yield* services.credentials.createIfAbsent({ + integrationID: AnthropicSubscriptionIntegrationID, + label: "Claude Pro/Max", + value: pending, + }) + if (result.created) { + return yield* retireLegacy(result.credential.id, undefined, pending, next, services) + } + if ( + result.credential.value.type !== "oauth" || + result.credential.value.methodID !== AnthropicSubscriptionMethodID + ) { + return yield* Effect.fail( + new Auth.AuthError({ message: "Anthropic subscription credential changed during save" }), + ) + } + const previousValue = yield* replaceOAuth( + result.credential.id, + result.credential.value, + pending, + services.credentials, ) - return next + return yield* retireLegacy(result.credential.id, previousValue, pending, next, services) + } + if (previous.value.type !== "oauth") { + return yield* Effect.fail(new Auth.AuthError({ message: "Anthropic subscription credential changed during save" })) + } + const previousValue = yield* replaceOAuth(previous.id, previous.value, pending, services.credentials) + return yield* retireLegacy(previous.id, previousValue, pending, next, services) }) export const saveAnthropicSubscriptionCredential = Effect.fn("AnthropicSubscriptionCredential.save")(function* ( @@ -61,3 +178,15 @@ export const migrateAnthropicSubscriptionCredential = Effect.fn("AnthropicSubscr ) }, ) + +export const removeAnthropicSubscriptionCredential = Effect.fn("AnthropicSubscriptionCredential.remove")( + function* (services: { readonly auth: Auth.Interface; readonly credentials: Credential.Interface }) { + return yield* withAnthropicSubscriptionCredentialLock( + Effect.gen(function* () { + const previous = (yield* services.credentials.list(AnthropicSubscriptionIntegrationID)).at(-1) + yield* services.auth.remove(AnthropicSubscriptionProviderID) + if (previous) yield* services.credentials.compareAndRemove(previous.id, previous.value) + }), + ) + }, +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts index 8e626ae27a7c..3419b57b2acd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts @@ -7,55 +7,14 @@ import { LogInput } from "../groups/control" import { ProviderV2 } from "@opencode-ai/core/provider" import { Credential } from "@opencode-ai/core/credential" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { AnthropicSubscriptionProviderID } from "@opencode-ai/core/plugin/provider/anthropic-subscription" import { - AnthropicSubscriptionIntegrationID, - AnthropicSubscriptionMethodID, - AnthropicSubscriptionProviderID, -} from "@opencode-ai/core/plugin/provider/anthropic-subscription" -import { withAnthropicSubscriptionCredentialLock } from "@/provider/anthropic-subscription-credential" + removeAnthropicSubscriptionCredential, + saveAnthropicSubscriptionCredential, +} from "@/provider/anthropic-subscription-credential" const credentialLayer = AppNodeBuilder.build(Credential.node) -function updateDurable(value?: Auth.Info) { - return Effect.gen(function* () { - const credentials = yield* Credential.Service - const previous = (yield* credentials.list(AnthropicSubscriptionIntegrationID)).at(-1) - if (value?.type === "oauth") { - const next = Credential.OAuth.make({ - type: "oauth", - methodID: AnthropicSubscriptionMethodID, - access: value.access, - refresh: value.refresh, - expires: value.expires, - }) - if (previous) yield* credentials.update(previous.id, { value: next }) - else - yield* credentials.create({ - integrationID: AnthropicSubscriptionIntegrationID, - label: "Claude Pro/Max", - value: next, - }) - return previous - } - if (previous) yield* credentials.remove(previous.id) - return previous - }).pipe(Effect.provide(credentialLayer)) -} - -function restoreDurable(previous: Credential.Info | undefined) { - if (!previous) return updateDurable() - return Effect.gen(function* () { - const credentials = yield* Credential.Service - if (yield* credentials.get(previous.id)) yield* credentials.update(previous.id, { value: previous.value }) - else - yield* credentials.create({ - integrationID: previous.integrationID, - label: previous.label, - value: previous.value, - }) - }).pipe(Effect.provide(credentialLayer)) -} - export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (handlers) => Effect.gen(function* () { const auth = yield* Auth.Service @@ -68,15 +27,17 @@ export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (han yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie) return true } - yield* withAnthropicSubscriptionCredentialLock( - Effect.gen(function* () { - const previous = yield* updateDurable(ctx.payload) - yield* auth.remove(ctx.params.providerID).pipe( - Effect.tapError(() => restoreDurable(previous)), - Effect.orDie, - ) - }), - ) + yield* Effect.gen(function* () { + const credentials = yield* Credential.Service + if (ctx.payload.type !== "oauth") { + yield* removeAnthropicSubscriptionCredential({ auth, credentials }) + return + } + yield* saveAnthropicSubscriptionCredential( + { access: ctx.payload.access, refresh: ctx.payload.refresh, expires: ctx.payload.expires }, + { auth, credentials }, + ) + }).pipe(Effect.provide(credentialLayer), Effect.orDie) return true }) @@ -87,15 +48,9 @@ export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (han yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie) return true } - yield* withAnthropicSubscriptionCredentialLock( - Effect.gen(function* () { - const previous = yield* updateDurable() - yield* auth.remove(ctx.params.providerID).pipe( - Effect.tapError(() => restoreDurable(previous)), - Effect.orDie, - ) - }), - ) + yield* Effect.gen(function* () { + yield* removeAnthropicSubscriptionCredential({ auth, credentials: yield* Credential.Service }) + }).pipe(Effect.provide(credentialLayer), Effect.orDie) return true }) diff --git a/packages/opencode/test/provider/anthropic-subscription-credential.test.ts b/packages/opencode/test/provider/anthropic-subscription-credential.test.ts index 2bdac9aa58e4..21bc90c4b839 100644 --- a/packages/opencode/test/provider/anthropic-subscription-credential.test.ts +++ b/packages/opencode/test/provider/anthropic-subscription-credential.test.ts @@ -5,10 +5,11 @@ import { AnthropicSubscriptionIntegrationID, AnthropicSubscriptionMethodID, } from "@opencode-ai/core/plugin/provider/anthropic-subscription" -import { Effect } from "effect" +import { Effect, Fiber } from "effect" import { Auth } from "@/auth" import { migrateAnthropicSubscriptionCredential, + removeAnthropicSubscriptionCredential, saveAnthropicSubscriptionCredential, } from "@/provider/anthropic-subscription-credential" import { testEffect } from "../lib/effect" @@ -46,6 +47,8 @@ it.effect("restores durable credentials when legacy auth retirement fails", () = "Failure", ) expect(yield* credentials.get(previous.id)).toEqual(previous) + expect((yield* removeAnthropicSubscriptionCredential({ auth, credentials }).pipe(Effect.exit))._tag).toBe("Failure") + expect(yield* credentials.get(previous.id)).toEqual(previous) }), ) @@ -89,5 +92,313 @@ it.effect("migrates durable credentials and retires legacy auth", () => access: "legacy-access", refresh: "legacy-refresh", }) + expect( + yield* saveAnthropicSubscriptionCredential( + { access: "updated-access", refresh: "updated-refresh", expires: 30 }, + { auth: migratingAuth, credentials }, + ), + ).toMatchObject({ access: "updated-access", refresh: "updated-refresh" }) + expect((yield* credentials.list(AnthropicSubscriptionIntegrationID))[0]?.value).toMatchObject({ + access: "updated-access", + refresh: "updated-refresh", + }) + }), +) + +it.effect("does not roll back a credential replaced while legacy retirement is pending", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const previous = yield* credentials.create({ + integrationID: AnthropicSubscriptionIntegrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "old-access", + refresh: "old-refresh", + expires: 10, + }), + }) + const started = Promise.withResolvers() + const release = Promise.withResolvers() + const blockedAuth = Auth.Service.of({ + get: () => Effect.succeed(undefined), + all: () => Effect.succeed({}), + set: () => Effect.void, + remove: () => + Effect.gen(function* () { + started.resolve() + yield* Effect.promise(() => release.promise) + return yield* Effect.fail(new Auth.AuthError({ message: "remove failed" })) + }), + }) + const running = yield* saveAnthropicSubscriptionCredential( + { access: "new-access", refresh: "new-refresh", expires: 20 }, + { auth: blockedAuth, credentials }, + ).pipe(Effect.exit, Effect.forkChild) + yield* Effect.promise(() => started.promise) + const owned = (yield* credentials.get(previous.id))?.value + if (owned?.type !== "oauth") throw new Error("Expected OAuth credential") + const concurrent = Credential.OAuth.make({ + ...owned, + metadata: { "opencode.internal.revision": "concurrent" }, + }) + yield* credentials.update(previous.id, { value: concurrent }) + release.resolve() + + expect((yield* Fiber.join(running))._tag).toBe("Failure") + expect((yield* credentials.get(previous.id))?.value).toEqual(concurrent) + }), +) + +it.effect("retries a save when a token refresh wins the first credential update", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const original = Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "old-access", + refresh: "old-refresh", + expires: 10, + }) + const previous = yield* credentials.create({ + integrationID: AnthropicSubscriptionIntegrationID, + value: original, + }) + const refreshed = Credential.OAuth.make({ ...original, access: "refreshed-access", expires: 15 }) + const race = { pending: true } + const racingCredentials = Credential.Service.of({ + ...credentials, + compareAndSetOAuth: (id, expected, value) => + Effect.gen(function* () { + if (race.pending) { + race.pending = false + yield* credentials.update(id, { value: refreshed }) + } + return yield* credentials.compareAndSetOAuth(id, expected, value) + }), + }) + const successfulAuth = Auth.Service.of({ + get: () => Effect.succeed(undefined), + all: () => Effect.succeed({}), + set: () => Effect.void, + remove: () => Effect.void, + }) + + expect( + yield* saveAnthropicSubscriptionCredential( + { access: "new-access", refresh: "new-refresh", expires: 20 }, + { auth: successfulAuth, credentials: racingCredentials }, + ), + ).toMatchObject({ access: "new-access", refresh: "new-refresh" }) + expect((yield* credentials.get(previous.id))?.value).toMatchObject({ + access: "new-access", + refresh: "new-refresh", + }) + }), +) + +it.effect("does not overwrite a credential claimed by a concurrent save", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const original = Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "old-access", + refresh: "old-refresh", + expires: 10, + }) + const previous = yield* credentials.create({ + integrationID: AnthropicSubscriptionIntegrationID, + value: original, + }) + const concurrent = Credential.OAuth.make({ + ...original, + access: "concurrent-access", + metadata: { "opencode.internal.revision": "concurrent" }, + }) + const race = { pending: true } + const racingCredentials = Credential.Service.of({ + ...credentials, + compareAndSetOAuth: (id, expected, value) => + Effect.gen(function* () { + if (race.pending) { + race.pending = false + yield* credentials.update(id, { value: concurrent }) + } + return yield* credentials.compareAndSetOAuth(id, expected, value) + }), + }) + const successfulAuth = Auth.Service.of({ + get: () => Effect.succeed(undefined), + all: () => Effect.succeed({}), + set: () => Effect.void, + remove: () => Effect.void, + }) + + expect( + (yield* saveAnthropicSubscriptionCredential( + { access: "new-access", refresh: "new-refresh", expires: 20 }, + { auth: successfulAuth, credentials: racingCredentials }, + ).pipe(Effect.exit))._tag, + ).toBe("Failure") + expect((yield* credentials.get(previous.id))?.value).toEqual(concurrent) + }), +) + +it.effect("rejects a save started while another save is pending", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const pending = Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "pending-access", + refresh: "pending-refresh", + expires: 20, + metadata: { + "opencode.internal.revision": "pending", + "opencode.internal.pendingUntil": Date.now() + 60_000, + }, + }) + const previous = yield* credentials.create({ + integrationID: AnthropicSubscriptionIntegrationID, + value: pending, + }) + const successfulAuth = Auth.Service.of({ + get: () => Effect.succeed(undefined), + all: () => Effect.succeed({}), + set: () => Effect.void, + remove: () => Effect.void, + }) + + expect( + (yield* saveAnthropicSubscriptionCredential( + { access: "new-access", refresh: "new-refresh", expires: 30 }, + { auth: successfulAuth, credentials }, + ).pipe(Effect.exit))._tag, + ).toBe("Failure") + expect((yield* credentials.get(previous.id))?.value).toEqual(pending) + }), +) + +it.effect("recovers a pending save whose owner lease expired", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const pending = Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "pending-access", + refresh: "pending-refresh", + expires: 20, + metadata: { + "opencode.internal.revision": "abandoned", + "opencode.internal.pendingUntil": 0, + }, + }) + const previous = yield* credentials.create({ + integrationID: AnthropicSubscriptionIntegrationID, + value: pending, + }) + const successfulAuth = Auth.Service.of({ + get: () => Effect.succeed(undefined), + all: () => Effect.succeed({}), + set: () => Effect.void, + remove: () => Effect.void, + }) + + const saved = yield* saveAnthropicSubscriptionCredential( + { access: "new-access", refresh: "new-refresh", expires: 30 }, + { auth: successfulAuth, credentials }, + ) + expect(saved).toMatchObject({ access: "new-access", refresh: "new-refresh" }) + expect(saved.metadata).toBeUndefined() + const stored = (yield* credentials.get(previous.id))?.value + expect(stored).toMatchObject({ + access: "new-access", + refresh: "new-refresh", + }) + expect(stored?.metadata).toBeUndefined() + }), +) + +it.effect("commits a token refresh that wins during legacy retirement", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + yield* credentials.create({ + integrationID: AnthropicSubscriptionIntegrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "old-access", + refresh: "old-refresh", + expires: 10, + }), + }) + const refreshingAuth = Auth.Service.of({ + get: () => Effect.succeed(undefined), + all: () => Effect.succeed({}), + set: () => Effect.void, + remove: () => + Effect.gen(function* () { + const pending = (yield* credentials.list(AnthropicSubscriptionIntegrationID))[0] + if (pending?.value.type !== "oauth") throw new Error("Expected pending OAuth credential") + yield* credentials.update(pending.id, { + value: Credential.OAuth.make({ + ...pending.value, + access: "refreshed-access", + refresh: "refreshed-refresh", + expires: 40, + }), + }) + }), + }) + + expect( + yield* saveAnthropicSubscriptionCredential( + { access: "new-access", refresh: "new-refresh", expires: 30 }, + { auth: refreshingAuth, credentials }, + ), + ).toMatchObject({ access: "refreshed-access", refresh: "refreshed-refresh", expires: 40 }) + const stored = (yield* credentials.list(AnthropicSubscriptionIntegrationID))[0]?.value + expect(stored).toMatchObject({ access: "refreshed-access", refresh: "refreshed-refresh", expires: 40 }) + expect(stored?.metadata).toBeUndefined() + }), +) + +it.effect("does not remove a credential replaced while legacy logout is pending", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const original = Credential.OAuth.make({ + type: "oauth", + methodID: AnthropicSubscriptionMethodID, + access: "old-access", + refresh: "old-refresh", + expires: 10, + }) + const previous = yield* credentials.create({ + integrationID: AnthropicSubscriptionIntegrationID, + value: original, + }) + const started = Promise.withResolvers() + const release = Promise.withResolvers() + const blockedAuth = Auth.Service.of({ + get: () => Effect.succeed(undefined), + all: () => Effect.succeed({}), + set: () => Effect.void, + remove: () => + Effect.gen(function* () { + started.resolve() + yield* Effect.promise(() => release.promise) + }), + }) + const running = yield* removeAnthropicSubscriptionCredential({ auth: blockedAuth, credentials }).pipe( + Effect.forkChild, + ) + yield* Effect.promise(() => started.promise) + const concurrent = Credential.OAuth.make({ ...original, access: "concurrent-access" }) + yield* credentials.update(previous.id, { value: concurrent }) + release.resolve() + + yield* Fiber.join(running) + expect((yield* credentials.get(previous.id))?.value).toEqual(concurrent) }), ) From 5ac7813ba0e6c5468fd16f9b5da8f737850986f9 Mon Sep 17 00:00:00 2001 From: CreatorGhost Date: Wed, 15 Jul 2026 23:34:25 +0530 Subject: [PATCH 4/4] fix(provider): guard subscription credential method --- packages/opencode/src/cli/cmd/providers.ts | 3 +- .../anthropic-subscription-credential.ts | 2 +- .../instance/httpapi/handlers/control.ts | 3 +- .../anthropic-subscription-credential.test.ts | 31 +++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 7d8c2a30f436..df5a0446ed5a 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -569,7 +569,8 @@ export const ProvidersLogoutCommand = effectCmd({ if (!provider) return yield* fail(`Unknown configured provider "${args.provider}"`) if (provider === AnthropicSubscriptionProviderID) { yield* Effect.gen(function* () { - yield* removeAnthropicSubscriptionCredential({ auth: authSvc, credentials: yield* Credential.Service }) + const credentials = yield* Credential.Service + yield* removeAnthropicSubscriptionCredential({ auth: authSvc, credentials }) }).pipe(Effect.provide(credentialLayer), Effect.orDie) yield* Prompt.outro("Logout successful") return diff --git a/packages/opencode/src/provider/anthropic-subscription-credential.ts b/packages/opencode/src/provider/anthropic-subscription-credential.ts index e1668aa88657..14132a4c0ba5 100644 --- a/packages/opencode/src/provider/anthropic-subscription-credential.ts +++ b/packages/opencode/src/provider/anthropic-subscription-credential.ts @@ -151,7 +151,7 @@ const save = Effect.fn("AnthropicSubscriptionCredential.saveUnlocked")(function* ) return yield* retireLegacy(result.credential.id, previousValue, pending, next, services) } - if (previous.value.type !== "oauth") { + if (previous.value.type !== "oauth" || previous.value.methodID !== AnthropicSubscriptionMethodID) { return yield* Effect.fail(new Auth.AuthError({ message: "Anthropic subscription credential changed during save" })) } const previousValue = yield* replaceOAuth(previous.id, previous.value, pending, services.credentials) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts index 3419b57b2acd..d644651e6a7d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/control.ts @@ -49,7 +49,8 @@ export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (han return true } yield* Effect.gen(function* () { - yield* removeAnthropicSubscriptionCredential({ auth, credentials: yield* Credential.Service }) + const credentials = yield* Credential.Service + yield* removeAnthropicSubscriptionCredential({ auth, credentials }) }).pipe(Effect.provide(credentialLayer), Effect.orDie) return true }) diff --git a/packages/opencode/test/provider/anthropic-subscription-credential.test.ts b/packages/opencode/test/provider/anthropic-subscription-credential.test.ts index 21bc90c4b839..8a29800a73a2 100644 --- a/packages/opencode/test/provider/anthropic-subscription-credential.test.ts +++ b/packages/opencode/test/provider/anthropic-subscription-credential.test.ts @@ -1,6 +1,7 @@ import { expect } from "bun:test" import { Credential } from "@opencode-ai/core/credential" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Integration } from "@opencode-ai/core/integration" import { AnthropicSubscriptionIntegrationID, AnthropicSubscriptionMethodID, @@ -245,6 +246,36 @@ it.effect("does not overwrite a credential claimed by a concurrent save", () => }), ) +it.effect("does not replace a credential owned by another OAuth method", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const existing = yield* credentials.create({ + integrationID: AnthropicSubscriptionIntegrationID, + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("other-method"), + access: "other-access", + refresh: "other-refresh", + expires: 20, + }), + }) + const successfulAuth = Auth.Service.of({ + get: () => Effect.succeed(undefined), + all: () => Effect.succeed({}), + set: () => Effect.void, + remove: () => Effect.void, + }) + + expect( + (yield* saveAnthropicSubscriptionCredential( + { access: "new-access", refresh: "new-refresh", expires: 30 }, + { auth: successfulAuth, credentials }, + ).pipe(Effect.exit))._tag, + ).toBe("Failure") + expect(yield* credentials.get(existing.id)).toEqual(existing) + }), +) + it.effect("rejects a save started while another save is pending", () => Effect.gen(function* () { const credentials = yield* Credential.Service