Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,14 @@ prism anthropic auth remove <credential-id>
browser handles account selection, SSO, and MFA. Prism does not import the
credential used by an existing Claude Code login.

This first release stores and refreshes Anthropic credentials for account and
usage management. `prism claude` inference continues to use Prism's existing
providers until native Anthropic routing is added separately.
This stores and refreshes Anthropic credentials for account and usage management,
and `prism claude` forwards requests through a local bridge while keeping
Claude Code’s existing OAuth/login mode.

`prism anthropic auth remove` deletes the Prism grant and its routing/usage
state. Anthropic does not document a revocation endpoint for this grant, so the
command does not claim provider-side revocation; use Anthropic account security
settings when provider-side invalidation is required.

## ChatGPT

Expand Down Expand Up @@ -149,7 +154,13 @@ prism claude --model gpt-5.6-sol --effort ultracode

These examples use `gpt-5.6-sol`; replace it with another Prism-supported
model when needed. `prism claude` launches the installed Claude Code CLI and
passes its arguments through unchanged.
passes its arguments through unchanged. Claude models use the registered
Anthropic account pool automatically. To start a session on one account, pass
its alias or redacted id before the Claude Code arguments:

```sh
prism claude --account work-admin --model claude-fable-5
```

Verify the setup:

Expand Down
82 changes: 82 additions & 0 deletions internal/anthropic/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,85 @@ func TestLoginRejectsACallbackWithTheWrongState(t *testing.T) {
t.Fatalf("error = %v", err)
}
}

func TestLoginRejectsTheWrongCallbackHostWithoutConsumingTheGrant(t *testing.T) {
oauth := OAuth{
Timeout: time.Second,
OpenBrowser: func(target string) error {
authorize, _ := url.Parse(target)
redirect := authorize.Query().Get("redirect_uri")
state := authorize.Query().Get("state")
go func() {
request, _ := http.NewRequest(http.MethodGet, redirect+"?code=wrong-host&state="+url.QueryEscape(state), nil)
request.Host = "attacker.example"
response, err := http.DefaultClient.Do(request)
if err == nil {
_, _ = io.Copy(io.Discard, response.Body)
_ = response.Body.Close()
}
response, err = http.Get(redirect + "?code=valid-code&state=" + url.QueryEscape(state))
if err == nil {
_, _ = io.Copy(io.Discard, response.Body)
_ = response.Body.Close()
}
}()
return nil
},
}
grant, err := oauth.Login(context.Background())
if err != nil {
t.Fatal(err)
}
if grant.AuthorizationCode != "valid-code" {
t.Fatalf("authorization code = %q", grant.AuthorizationCode)
}
}

func TestLoginConsumesOnlyTheFirstValidCallback(t *testing.T) {
oauth := OAuth{
Timeout: time.Second,
OpenBrowser: func(target string) error {
authorize, _ := url.Parse(target)
redirect := authorize.Query().Get("redirect_uri")
state := url.QueryEscape(authorize.Query().Get("state"))
for _, code := range []string{"first-code", "second-code"} {
response, err := http.Get(redirect + "?code=" + code + "&state=" + state)
if err != nil {
return err
}
_, _ = io.Copy(io.Discard, response.Body)
_ = response.Body.Close()
}
return nil
},
}
grant, err := oauth.Login(context.Background())
if err != nil {
t.Fatal(err)
}
if grant.AuthorizationCode != "first-code" {
t.Fatalf("authorization code = %q", grant.AuthorizationCode)
}
}

func TestLoginTimesOutAndHonorsCancellation(t *testing.T) {
for _, test := range []struct {
name string
ctx func() context.Context
}{
{name: "timeout", ctx: func() context.Context { return context.Background() }},
{name: "cancelled", ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}},
} {
t.Run(test.name, func(t *testing.T) {
oauth := OAuth{Timeout: 20 * time.Millisecond, OpenBrowser: func(string) error { return nil }}
_, err := oauth.Login(test.ctx())
if err == nil || !strings.Contains(err.Error(), "timed out or was cancelled") {
t.Fatalf("error = %v", err)
}
})
}
}
88 changes: 71 additions & 17 deletions internal/cli/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
Expand All @@ -20,9 +21,10 @@ import (
)

type claudeBridge struct {
server *http.Server
url string
credential string
server *http.Server
url string
headerName string
headerValue string
}

func runClaudeCommand(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) error {
Expand All @@ -44,17 +46,22 @@ func runClaudeCommand(ctx context.Context, args []string, stdout io.Writer, stde
}
return loginProvider(ctx, "anthropic", client, stdout)
}
account, remainingArgs, err := parseClaudeOptions(args)
if err != nil {
return err
}
client, err := prismClient(ctx, commonOptions{})
if err != nil {
return err
}
return runClaude(ctx, client.BaseURL, client.Token, args, os.Stdin, stdout, stderr)
return runClaude(ctx, client.BaseURL, client.Token, account, remainingArgs, os.Stdin, stdout, stderr)
}

func runClaude(
ctx context.Context,
prismURL string,
prismCredential string,
prismAccount string,
args []string,
stdin io.Reader,
stdout io.Writer,
Expand All @@ -64,7 +71,7 @@ func runClaude(
if err != nil {
return errors.New("Claude Code is not installed or is not on PATH")
}
bridge, err := startClaudeBridge(prismURL, prismCredential, stderr)
bridge, err := startClaudeBridge(prismURL, prismCredential, prismAccount, stderr)
if err != nil {
return err
}
Expand All @@ -74,7 +81,11 @@ func runClaude(
command.Stdin = stdin
command.Stdout = stdout
command.Stderr = stderr
command.Env = claudeEnvironment(os.Environ(), bridge.url, bridge.credential)
command.Env = claudeEnvironment(
os.Environ(),
bridge.url,
bridge.headerName+": "+bridge.headerValue,
)
if err := command.Run(); err != nil {
var exitError *exec.ExitError
if errors.As(err, &exitError) {
Expand All @@ -85,27 +96,35 @@ func runClaude(
return nil
}

func startClaudeBridge(prismURL string, prismCredential string, stderr io.Writer) (*claudeBridge, error) {
func startClaudeBridge(prismURL string, prismCredential string, prismAnthropicAccount string, stderr io.Writer) (*claudeBridge, error) {
target, err := url.Parse(prismURL)
if err != nil || (target.Scheme != "https" && target.Scheme != "http") || target.Host == "" {
return nil, errors.New("Prism URL is invalid")
}
if strings.TrimSpace(prismCredential) == "" || strings.ContainsAny(prismCredential, " \t\r\n") {
return nil, errors.New("Circles credential is invalid")
}
if strings.ContainsAny(prismAnthropicAccount, "\r\n") {
return nil, errors.New("Anthropic account selector is invalid")
}
credentialBytes := make([]byte, 32)
if _, err := rand.Read(credentialBytes); err != nil {
return nil, errors.New("could not create a local Claude credential")
}
localCredential := hex.EncodeToString(credentialBytes)
localHeaderName := "X-Prism-Claude-Bridge"
localHeaderValue := hex.EncodeToString(credentialBytes)

proxy := httputil.NewSingleHostReverseProxy(target)
director := proxy.Director
proxy.Director = func(request *http.Request) {
director(request)
request.Host = target.Host
request.Header.Del("X-Api-Key")
request.Header.Del(localHeaderName)
request.Header.Set("Authorization", "Bearer "+prismCredential)
if prismAnthropicAccount != "" {
request.Header.Set("X-Prism-Anthropic-Account", "b64:"+base64.RawURLEncoding.EncodeToString([]byte(prismAnthropicAccount)))
}
}
proxy.ErrorLog = log.New(stderr, "prism: ", 0)
proxy.ErrorHandler = func(response http.ResponseWriter, _ *http.Request, _ error) {
Expand All @@ -114,8 +133,8 @@ func startClaudeBridge(prismURL string, prismCredential string, stderr io.Writer

handler := http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if subtle.ConstantTimeCompare(
[]byte(request.Header.Get("Authorization")),
[]byte("Bearer "+localCredential),
[]byte(request.Header.Get(localHeaderName)),
[]byte(localHeaderValue),
) != 1 {
http.Error(response, "Unauthorized", http.StatusUnauthorized)
return
Expand All @@ -136,9 +155,10 @@ func startClaudeBridge(prismURL string, prismCredential string, stderr io.Writer
_ = server.Serve(listener)
}()
return &claudeBridge{
server: server,
url: "http://" + listener.Addr().String(),
credential: localCredential,
server: server,
url: "http://" + listener.Addr().String(),
headerName: localHeaderName,
headerValue: localHeaderValue,
}, nil
}

Expand All @@ -148,37 +168,71 @@ func (bridge *claudeBridge) close() {
_ = bridge.server.Shutdown(ctx)
}

func claudeEnvironment(environment []string, baseURL string, credential string) []string {
func claudeEnvironment(environment []string, baseURL string, customHeaders string) []string {
filtered := make([]string, 0, len(environment)+2)
for _, entry := range environment {
name, _, _ := strings.Cut(entry, "=")
switch strings.ToUpper(name) {
case "ANTHROPIC_BASE_URL",
"ANTHROPIC_CUSTOM_HEADERS",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
"ANTHROPIC_BEDROCK_BASE_URL",
"ANTHROPIC_VERTEX_BASE_URL",
"ANTHROPIC_VERTEX_PROJECT_ID",
"CLOUD_ML_REGION":
"CLOUD_ML_REGION",
"_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL":
continue
}
filtered = append(filtered, entry)
}
return append(filtered,
"ANTHROPIC_BASE_URL="+baseURL,
"ANTHROPIC_AUTH_TOKEN="+credential,
"ANTHROPIC_CUSTOM_HEADERS="+customHeaders,
"_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=1",
)
}

func printClaudeHelp(output io.Writer) {
_, _ = fmt.Fprintln(output, `Usage:
prism claude login [--profile <name>]
prism claude [claude arguments...]
prism claude [--account <alias-or-id>] [claude arguments...]

Pass --model with any model supported by Prism.
Use --account to target a specific Claude account on Prism.
Uses the current Circles profile. Run 'crcl auth status' to list profiles and
'crcl use <profile>' to switch before launching Claude Code.
Run 'claude --help' for Claude Code options.`)
}

func parseClaudeOptions(args []string) (account string, passthroughArgs []string, err error) {
for index := 0; index < len(args); index++ {
argument := args[index]
switch {
case argument == "--":
return account, append(passthroughArgs, args[index:]...), nil
case argument == "--account":
if account != "" {
return "", nil, errors.New("--account may be specified only once")
}
index++
if index >= len(args) || strings.TrimSpace(args[index]) == "" || args[index] == "--" {
return "", nil, errors.New("--account requires a value")
}
account = strings.TrimSpace(args[index])
case strings.HasPrefix(argument, "--account="):
if account != "" {
return "", nil, errors.New("--account may be specified only once")
}
account = strings.TrimSpace(strings.TrimPrefix(argument, "--account="))
if account == "" {
return "", nil, errors.New("--account requires a value")
}
default:
passthroughArgs = append(passthroughArgs, argument)
}
}
return account, passthroughArgs, nil
}
Loading
Loading