From 48dc1b37d4be0be10e733ac717634ecde4a1de29 Mon Sep 17 00:00:00 2001 From: zhouzhihao Date: Mon, 8 Jun 2026 19:35:12 +0800 Subject: [PATCH] fix(llm): support Anthropic auth header (#77) --- README.ja-JP.md | 6 ++ README.ko-KR.md | 6 ++ README.md | 6 ++ README.zh-CN.md | 6 ++ cmd/opencodereview/config_cmd.go | 18 +++- cmd/opencodereview/config_cmd_test.go | 27 +++++ cmd/opencodereview/flags.go | 3 +- internal/llm/client.go | 53 +++++++--- internal/llm/client_test.go | 137 ++++++++++++++++++++++++++ internal/llm/resolver.go | 69 ++++++++++--- internal/llm/resolver_test.go | 129 ++++++++++++++++++++++++ 11 files changed, 428 insertions(+), 32 deletions(-) create mode 100644 cmd/opencodereview/config_cmd_test.go diff --git a/README.ja-JP.md b/README.ja-JP.md index 3d7d799..963e08b 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -117,18 +117,22 @@ sudo cp dist/opencodereview /usr/local/bin/ocr # オプションA: 対話的な設定 ocr config set llm.url https://api.anthropic.com/v1/messages ocr config set llm.auth_token your-api-key-here +ocr config set llm.auth_header x-api-key ocr config set llm.model claude-opus-4-6 ocr config set llm.use_anthropic true # オプションB: 環境変数(最優先) export OCR_LLM_URL=https://api.anthropic.com/v1/messages export OCR_LLM_TOKEN=your-api-key-here +export OCR_LLM_AUTH_HEADER=x-api-key export OCR_LLM_MODEL=claude-opus-4-6 export OCR_USE_ANTHROPIC=true ``` 設定は`~/.opencodereview/config.json`に保存されます。 +Anthropicでは、標準の`sk-ant-*` APIキーは`x-api-key`を使用し、OAuthトークンは`authorization`を使用します。`llm.auth_header` / `OCR_LLM_AUTH_HEADER`を省略した場合、OCRは既存の`authorization` bearer-token動作を維持します。 + また、Claude Codeの環境変数(`ANTHROPIC_BASE_URL`、`ANTHROPIC_AUTH_TOKEN`、`ANTHROPIC_MODEL`)とも互換性があり、`~/.zshrc` / `~/.bashrc`からこれらのexportをパースします。 > **CC-Switchユーザー向けの注意**: [CC-Switch](https://github.com/farion1231/cc-switch)を[ルーティングサービス](https://www.ccswitch.io/en/docs?section=proxy&item=service)有効で使用している場合、追加設定なしで`llm.url`をCC-Switchのプロキシアドレスに向けることができます: @@ -413,6 +417,7 @@ OCRは4層の優先度チェーンを使ってレビュールールを解決し |-----|------|---------| | `llm.url` | string | `https://api.openai.com/v1/chat/completions` | | `llm.auth_token` | string | `sk-xxxxxxx` | +| `llm.auth_header` | string | Anthropicのみ:`x-api-key` \| `authorization` | | `llm.model` | string | `claude-opus-4-6` | | `llm.use_anthropic` | boolean | `true` \| `false` | | `language` | string | `English` \| `Chinese`(デフォルト:Chinese) | @@ -429,6 +434,7 @@ OCRは4層の優先度チェーンを使ってレビュールールを解決し |----------|---------| | `OCR_LLM_URL` | LLM APIエンドポイントURL | | `OCR_LLM_TOKEN` | APIキー / 認証トークン | +| `OCR_LLM_AUTH_HEADER` | Anthropic認証ヘッダー(`x-api-key`または`authorization`) | | `OCR_LLM_MODEL` | モデル名 | | `OCR_USE_ANTHROPIC` | `true` = Anthropic、`false` = OpenAI | diff --git a/README.ko-KR.md b/README.ko-KR.md index 9870de3..a38d413 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -117,18 +117,22 @@ sudo cp dist/opencodereview /usr/local/bin/ocr # Option A: 대화형 config ocr config set llm.url https://api.anthropic.com/v1/messages ocr config set llm.auth_token your-api-key-here +ocr config set llm.auth_header x-api-key ocr config set llm.model claude-opus-4-6 ocr config set llm.use_anthropic true # Option B: 환경 변수(가장 높은 우선순위) export OCR_LLM_URL=https://api.anthropic.com/v1/messages export OCR_LLM_TOKEN=your-api-key-here +export OCR_LLM_AUTH_HEADER=x-api-key export OCR_LLM_MODEL=claude-opus-4-6 export OCR_USE_ANTHROPIC=true ``` config는 `~/.opencodereview/config.json`에 저장됩니다. +Anthropic에서는 표준 `sk-ant-*` API key는 `x-api-key`를 사용하고, OAuth token은 `authorization`을 사용합니다. `llm.auth_header` / `OCR_LLM_AUTH_HEADER`를 생략하면 OCR은 기존 `authorization` bearer-token 동작을 유지합니다. + Claude Code 환경 변수(`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`)와도 호환되며, `~/.zshrc` / `~/.bashrc`의 export도 파싱합니다. > **CC-Switch 사용자 참고**: [CC-Switch](https://github.com/farion1231/cc-switch)를 [routing service](https://www.ccswitch.io/en/docs?section=proxy&item=service)와 함께 사용한다면, 추가 설정 없이 `llm.url`을 CC-Switch proxy 주소로 지정할 수 있습니다. @@ -371,6 +375,7 @@ Config file: `~/.opencodereview/config.json` |-----|------|---------| | `llm.url` | string | `https://api.openai.com/v1/chat/completions` | | `llm.auth_token` | string | `sk-xxxxxxx` | +| `llm.auth_header` | string | Anthropic only: `x-api-key` \| `authorization` | | `llm.model` | string | `claude-opus-4-6` | | `llm.use_anthropic` | boolean | `true` \| `false` | | `language` | string | `English` \| `Chinese` (default: Chinese) | @@ -387,6 +392,7 @@ Config file: `~/.opencodereview/config.json` |----------|---------| | `OCR_LLM_URL` | LLM API endpoint URL | | `OCR_LLM_TOKEN` | API key / auth token | +| `OCR_LLM_AUTH_HEADER` | Anthropic auth header (`x-api-key` 또는 `authorization`) | | `OCR_LLM_MODEL` | Model name | | `OCR_USE_ANTHROPIC` | `true` = Anthropic, `false` = OpenAI | diff --git a/README.md b/README.md index 4df7c9c..9223656 100644 --- a/README.md +++ b/README.md @@ -117,18 +117,22 @@ sudo cp dist/opencodereview /usr/local/bin/ocr # Option A: Interactive config ocr config set llm.url https://api.anthropic.com/v1/messages ocr config set llm.auth_token your-api-key-here +ocr config set llm.auth_header x-api-key ocr config set llm.model claude-opus-4-6 ocr config set llm.use_anthropic true # Option B: Environment variables (highest priority) export OCR_LLM_URL=https://api.anthropic.com/v1/messages export OCR_LLM_TOKEN=your-api-key-here +export OCR_LLM_AUTH_HEADER=x-api-key export OCR_LLM_MODEL=claude-opus-4-6 export OCR_USE_ANTHROPIC=true ``` Config is stored in `~/.opencodereview/config.json`. +For Anthropic, standard `sk-ant-*` API keys use `x-api-key`; OAuth tokens use `authorization`. If `llm.auth_header` / `OCR_LLM_AUTH_HEADER` is omitted, OCR keeps the existing `authorization` bearer-token behavior. + It is also compatible with Claude Code environment variables (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`) and parses `~/.zshrc` / `~/.bashrc` for those exports. > **Note for CC-Switch Users**: If you are using [CC-Switch](https://github.com/farion1231/cc-switch) with [routing service](https://www.ccswitch.io/en/docs?section=proxy&item=service) enabled, you can point `llm.url` to the CC-Switch proxy address without additional configuration: @@ -413,6 +417,7 @@ Config file: `~/.opencodereview/config.json` |-----|------|---------| | `llm.url` | string | `https://api.openai.com/v1/chat/completions` | | `llm.auth_token` | string | `sk-xxxxxxx` | +| `llm.auth_header` | string | Anthropic only: `x-api-key` \| `authorization` | | `llm.model` | string | `claude-opus-4-6` | | `llm.use_anthropic` | boolean | `true` \| `false` | | `language` | string | `English` \| `Chinese` (default: Chinese) | @@ -429,6 +434,7 @@ Environment variables take precedence over the config file. |----------|---------| | `OCR_LLM_URL` | LLM API endpoint URL | | `OCR_LLM_TOKEN` | API key / auth token | +| `OCR_LLM_AUTH_HEADER` | Anthropic auth header (`x-api-key` or `authorization`) | | `OCR_LLM_MODEL` | Model name | | `OCR_USE_ANTHROPIC` | `true` = Anthropic, `false` = OpenAI | diff --git a/README.zh-CN.md b/README.zh-CN.md index 82e9162..d4c448c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -117,18 +117,22 @@ sudo cp dist/opencodereview /usr/local/bin/ocr # 方式 A:交互式配置 ocr config set llm.url https://api.anthropic.com/v1/messages ocr config set llm.auth_token your-api-key-here +ocr config set llm.auth_header x-api-key ocr config set llm.model claude-opus-4-6 ocr config set llm.use_anthropic true # 方式 B:环境变量(优先级最高) export OCR_LLM_URL=https://api.anthropic.com/v1/messages export OCR_LLM_TOKEN=your-api-key-here +export OCR_LLM_AUTH_HEADER=x-api-key export OCR_LLM_MODEL=claude-opus-4-6 export OCR_USE_ANTHROPIC=true ``` 配置存储于 `~/.opencodereview/config.json`。 +对于 Anthropic,标准 `sk-ant-*` API key 使用 `x-api-key`;OAuth token 使用 `authorization`。如果未配置 `llm.auth_header` / `OCR_LLM_AUTH_HEADER`,OCR 会保留现有的 `authorization` bearer-token 行为。 + 同时兼容了 Claude Code 环境变量(`ANTHROPIC_BASE_URL`、`ANTHROPIC_AUTH_TOKEN`、`ANTHROPIC_MODEL`),并解析 `~/.zshrc` / `~/.bashrc` 中的相关导出。 > **CC-Switch 用户特别提醒**:如果你使用 [CC-Switch](https://github.com/farion1231/cc-switch) 并开启了[路由服务](https://www.ccswitch.io/zh/docs?section=proxy&item=service),可以将 `llm.url` 配置成 CC-Switch 启动的代理地址,无需额外配置: @@ -403,6 +407,7 @@ OCR 通过四层优先级链解析评审规则。每层采用首次匹配原则 |----|------|------| | `llm.url` | string | `https://api.openai.com/v1/chat/completions` | | `llm.auth_token` | string | `sk-xxxxxxx` | +| `llm.auth_header` | string | 仅 Anthropic:`x-api-key` \| `authorization` | | `llm.model` | string | `claude-opus-4-6` | | `llm.use_anthropic` | boolean | `true` \| `false` | | `language` | string | `English` \| `Chinese`(默认:Chinese) | @@ -419,6 +424,7 @@ OCR 通过四层优先级链解析评审规则。每层采用首次匹配原则 |------|------| | `OCR_LLM_URL` | LLM API 端点 URL | | `OCR_LLM_TOKEN` | API 密钥 / 认证令牌 | +| `OCR_LLM_AUTH_HEADER` | Anthropic 认证头(`x-api-key` 或 `authorization`) | | `OCR_LLM_MODEL` | 模型名称 | | `OCR_USE_ANTHROPIC` | `true` = Anthropic,`false` = OpenAI | diff --git a/cmd/opencodereview/config_cmd.go b/cmd/opencodereview/config_cmd.go index 14bd4ac..ccbd1df 100644 --- a/cmd/opencodereview/config_cmd.go +++ b/cmd/opencodereview/config_cmd.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strconv" + "strings" ) // Default config file location: ~/.opencodereview/config.json @@ -79,6 +80,7 @@ type Config struct { type LlmConfig struct { URL string `json:"url,omitempty"` AuthToken string `json:"auth_token,omitempty"` + AuthHeader string `json:"auth_header,omitempty"` Model string `json:"model,omitempty"` UseAnthropic *bool `json:"use_anthropic,omitempty"` // nil = default true; false = OpenAI protocol ExtraBody map[string]any `json:"extra_body,omitempty"` @@ -129,6 +131,8 @@ func setConfigValue(cfg *Config, key, value string) error { cfg.Llm.URL = value case "llm.auth_token", "llm.AuthToken": cfg.Llm.AuthToken = value + case "llm.auth_header", "llm.AuthHeader": + cfg.Llm.AuthHeader = normalizeConfigAuthHeader(value) case "llm.model", "llm.Model": cfg.Llm.Model = value case "llm.use_anthropic", "llm.UseAnthropic": @@ -166,11 +170,23 @@ func setConfigValue(cfg *Config, key, value string) error { } cfg.Llm.ExtraBody = m default: - return fmt.Errorf("unknown config key: %s\nSupported keys: llm.url, llm.auth_token, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging", key) + return fmt.Errorf("unknown config key: %s\nSupported keys: llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging", key) } return nil } +func normalizeConfigAuthHeader(header string) string { + header = strings.TrimSpace(header) + switch strings.ToLower(header) { + case "x-api-key": + return "x-api-key" + case "authorization", "bearer": + return "authorization" + default: + return header + } +} + func (c *Config) ensureTelemetry() { if c.Telemetry == nil { c.Telemetry = &TelemetryConfig{} diff --git a/cmd/opencodereview/config_cmd_test.go b/cmd/opencodereview/config_cmd_test.go new file mode 100644 index 0000000..463fa6c --- /dev/null +++ b/cmd/opencodereview/config_cmd_test.go @@ -0,0 +1,27 @@ +package main + +import "testing" + +func TestSetConfigValueAuthHeaderNormalizesKnownValues(t *testing.T) { + cfg := &Config{} + + if err := setConfigValue(cfg, "llm.auth_header", " bearer "); err != nil { + t.Fatalf("setConfigValue: %v", err) + } + + if cfg.Llm.AuthHeader != "authorization" { + t.Errorf("AuthHeader = %q, want %q", cfg.Llm.AuthHeader, "authorization") + } +} + +func TestSetConfigValueAuthHeaderTrimsCustomHeader(t *testing.T) { + cfg := &Config{} + + if err := setConfigValue(cfg, "llm.auth_header", " X-Custom-Auth "); err != nil { + t.Fatalf("setConfigValue: %v", err) + } + + if cfg.Llm.AuthHeader != "X-Custom-Auth" { + t.Errorf("AuthHeader = %q, want %q", cfg.Llm.AuthHeader, "X-Custom-Auth") + } +} diff --git a/cmd/opencodereview/flags.go b/cmd/opencodereview/flags.go index a15e3fa..9200488 100644 --- a/cmd/opencodereview/flags.go +++ b/cmd/opencodereview/flags.go @@ -256,10 +256,11 @@ Usage: Examples: ocr config set llm.url https://xx/v1/openai/chat/completions ocr config set llm.auth_token xxxxxxxxxx + ocr config set llm.auth_header x-api-key ocr config set llm.model claude-opus-4-6 ocr config set llm.extra_body '{"thinking":{"type":"disabled"}}' ocr config set language English ocr config set telemetry.enabled true -Supported keys: llm.url, llm.auth_token, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging`) +Supported keys: llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging`) } diff --git a/internal/llm/client.go b/internal/llm/client.go index 0562287..5e6833b 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -179,11 +179,12 @@ type FunctionDef struct { // ClientConfig holds configuration for connecting to an LLM service. type ClientConfig struct { - URL string // Full API endpoint URL - APIKey string // Bearer token / API key - Model string // Default model override - Timeout time.Duration // Request timeout - ExtraBody map[string]any // Vendor-specific fields merged into every request body + URL string // Full API endpoint URL + APIKey string // Bearer token / API key + Model string // Default model override + AuthHeader string // Anthropic auth header: "x-api-key" or "authorization" + Timeout time.Duration // Request timeout + ExtraBody map[string]any // Vendor-specific fields merged into every request body } // --- Factory --- @@ -192,10 +193,11 @@ type ClientConfig struct { // protocol: "anthropic" -> AnthropicClient, anything else -> OpenAIClient. func NewLLMClient(ep ResolvedEndpoint) LLMClient { cfg := ClientConfig{ - URL: ep.URL, - APIKey: ep.Token, - Model: ep.Model, - ExtraBody: ep.ExtraBody, + URL: ep.URL, + APIKey: ep.Token, + Model: ep.Model, + AuthHeader: ep.AuthHeader, + ExtraBody: ep.ExtraBody, } if ep.Protocol == "anthropic" { return NewAnthropicClient(cfg) @@ -482,16 +484,35 @@ func NewAnthropicClient(cfg ClientConfig) *AnthropicClient { } sdkBaseURL := strings.TrimSuffix(strings.TrimRight(cfg.URL, "/"), "/v1/messages") + authHeader := normalizeAuthHeader(cfg.AuthHeader) + if authHeader == "" { + authHeader = "authorization" + } + cfg.AuthHeader = authHeader + + opts := []option.RequestOption{ + option.WithBaseURL(sdkBaseURL), + option.WithMaxRetries(5), + option.WithHeader("User-Agent", userAgent("claude")), + option.WithRequestTimeout(cfg.Timeout), + } + + switch authHeader { + case "authorization": + opts = append(opts, option.WithHeaderDel("X-Api-Key"), option.WithAuthToken(cfg.APIKey)) + case "x-api-key": + opts = append(opts, option.WithHeaderDel("Authorization"), option.WithAPIKey(cfg.APIKey)) + default: + opts = append(opts, + option.WithHeaderDel("Authorization"), + option.WithHeaderDel("X-Api-Key"), + option.WithHeader(authHeader, cfg.APIKey), + ) + } return &AnthropicClient{ cfg: cfg, - sdk: anthropic.NewClient( - option.WithAuthToken(cfg.APIKey), - option.WithBaseURL(sdkBaseURL), - option.WithMaxRetries(5), - option.WithHeader("User-Agent", userAgent("claude")), - option.WithRequestTimeout(cfg.Timeout), - ), + sdk: anthropic.NewClient(opts...), } } diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go index 6661ac4..377e680 100644 --- a/internal/llm/client_test.go +++ b/internal/llm/client_test.go @@ -1,6 +1,9 @@ package llm import ( + "context" + "net/http" + "net/http/httptest" "testing" anthropic "github.com/anthropics/anthropic-sdk-go" @@ -205,5 +208,139 @@ func TestBuildAnthropicParams_CacheControl_NoSystem(t *testing.T) { } } +func TestAnthropicClient_UsesConfiguredXAPIKeyHeader(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "") + t.Setenv("ANTHROPIC_AUTH_TOKEN", "env-oauth-token") + + var gotXAPIKey string + var gotAuthorization string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotXAPIKey = r.Header.Get("X-Api-Key") + gotAuthorization = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "id":"msg_test", + "type":"message", + "role":"assistant", + "model":"claude-test", + "content":[{"type":"text","text":"ok"}], + "stop_reason":"end_turn", + "usage":{"input_tokens":1,"output_tokens":1} + }`)) + })) + defer server.Close() + + client := NewAnthropicClient(ClientConfig{ + URL: server.URL + "/v1/messages", + APIKey: "sk-ant-api03-test", + Model: "claude-test", + AuthHeader: "x-api-key", + }) + + _, err := client.CompletionsWithCtx(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "ping"}}, + MaxTokens: 64, + }) + if err != nil { + t.Fatalf("CompletionsWithCtx: %v", err) + } + if gotXAPIKey != "sk-ant-api03-test" { + t.Errorf("X-Api-Key = %q, want %q", gotXAPIKey, "sk-ant-api03-test") + } + if gotAuthorization != "" { + t.Errorf("Authorization = %q, want empty", gotAuthorization) + } +} + +func TestAnthropicClient_UsesConfiguredAuthorizationHeader(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "env-api-key") + t.Setenv("ANTHROPIC_AUTH_TOKEN", "") + + var gotXAPIKey string + var gotAuthorization string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotXAPIKey = r.Header.Get("X-Api-Key") + gotAuthorization = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "id":"msg_test", + "type":"message", + "role":"assistant", + "model":"claude-test", + "content":[{"type":"text","text":"ok"}], + "stop_reason":"end_turn", + "usage":{"input_tokens":1,"output_tokens":1} + }`)) + })) + defer server.Close() + + client := NewAnthropicClient(ClientConfig{ + URL: server.URL + "/v1/messages", + APIKey: "oauth-token", + Model: "claude-test", + AuthHeader: "authorization", + }) + + _, err := client.CompletionsWithCtx(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "ping"}}, + MaxTokens: 64, + }) + if err != nil { + t.Fatalf("CompletionsWithCtx: %v", err) + } + if gotAuthorization != "Bearer oauth-token" { + t.Errorf("Authorization = %q, want %q", gotAuthorization, "Bearer oauth-token") + } + if gotXAPIKey != "" { + t.Errorf("X-Api-Key = %q, want empty", gotXAPIKey) + } +} + +func TestAnthropicClient_DefaultsToAuthorizationHeader(t *testing.T) { + var gotXAPIKey string + var gotAuthorization string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotXAPIKey = r.Header.Get("X-Api-Key") + gotAuthorization = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "id":"msg_test", + "type":"message", + "role":"assistant", + "model":"claude-test", + "content":[{"type":"text","text":"ok"}], + "stop_reason":"end_turn", + "usage":{"input_tokens":1,"output_tokens":1} + }`)) + })) + defer server.Close() + + client := NewAnthropicClient(ClientConfig{ + URL: server.URL + "/v1/messages", + APIKey: "oauth-token", + Model: "claude-test", + }) + + _, err := client.CompletionsWithCtx(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "ping"}}, + MaxTokens: 64, + }) + if err != nil { + t.Fatalf("CompletionsWithCtx: %v", err) + } + if gotAuthorization != "Bearer oauth-token" { + t.Errorf("Authorization = %q, want %q", gotAuthorization, "Bearer oauth-token") + } + if gotXAPIKey != "" { + t.Errorf("X-Api-Key = %q, want empty", gotXAPIKey) + } +} + // Verify the SDK constant is accessible (compile-time check). var _ anthropic.CacheControlEphemeralParam = anthropic.NewCacheControlEphemeralParam() diff --git a/internal/llm/resolver.go b/internal/llm/resolver.go index 442ff81..1e1269a 100644 --- a/internal/llm/resolver.go +++ b/internal/llm/resolver.go @@ -11,20 +11,22 @@ import ( // ResolvedEndpoint holds the resolved LLM endpoint configuration. type ResolvedEndpoint struct { - URL string - Token string - Model string - Protocol string // "anthropic" or "openai" - Source string // human-readable config source label - ExtraBody map[string]any // vendor-specific request body fields + URL string + Token string + Model string + Protocol string // "anthropic" or "openai" + AuthHeader string // Anthropic auth header: "x-api-key" or "authorization" + Source string // human-readable config source label + ExtraBody map[string]any // vendor-specific request body fields } // Environment variable names for OCR-specific configuration. const ( - envOCRLLMURL = "OCR_LLM_URL" - envOCRLLMToken = "OCR_LLM_TOKEN" - envOCRLLMModel = "OCR_LLM_MODEL" - envOCRUseAnthropic = "OCR_USE_ANTHROPIC" + envOCRLLMURL = "OCR_LLM_URL" + envOCRLLMToken = "OCR_LLM_TOKEN" + envOCRLLMModel = "OCR_LLM_MODEL" + envOCRLLMAuthHeader = "OCR_LLM_AUTH_HEADER" + envOCRUseAnthropic = "OCR_USE_ANTHROPIC" ) // Environment variable names from Claude Code configuration. @@ -83,13 +85,22 @@ func tryOCREnv() (ResolvedEndpoint, bool, error) { protocol = "openai" } - return ResolvedEndpoint{URL: url, Token: token, Model: model, Protocol: protocol, Source: "OCR environment"}, true, nil + var authHeader string + if protocol == "anthropic" { + authHeader = normalizeAuthHeader(os.Getenv(envOCRLLMAuthHeader)) + if authHeader == "" { + authHeader = defaultAuthHeader(protocol) + } + } + + return ResolvedEndpoint{URL: url, Token: token, Model: model, Protocol: protocol, AuthHeader: authHeader, Source: "OCR environment"}, true, nil } // llmFileConfig represents the llm section in config.json. type llmFileConfig struct { URL string `json:"url,omitempty"` AuthToken string `json:"auth_token,omitempty"` + AuthHeader string `json:"auth_header,omitempty"` Model string `json:"model,omitempty"` UseAnthropic *bool `json:"use_anthropic,omitempty"` // pointer to distinguish unset from false ExtraBody map[string]any `json:"extra_body,omitempty"` @@ -128,7 +139,15 @@ func tryOCRConfig(path string) (ResolvedEndpoint, bool, error) { protocol = "openai" } - return ResolvedEndpoint{URL: cfg.Llm.URL, Token: cfg.Llm.AuthToken, Model: cfg.Llm.Model, Protocol: protocol, Source: "OCR config file", ExtraBody: cfg.Llm.ExtraBody}, true, nil + var authHeader string + if protocol == "anthropic" { + authHeader = normalizeAuthHeader(cfg.Llm.AuthHeader) + if authHeader == "" { + authHeader = defaultAuthHeader(protocol) + } + } + + return ResolvedEndpoint{URL: cfg.Llm.URL, Token: cfg.Llm.AuthToken, Model: cfg.Llm.Model, Protocol: protocol, AuthHeader: authHeader, Source: "OCR config file", ExtraBody: cfg.Llm.ExtraBody}, true, nil } // tryCCEnv reads Claude Code environment variables. @@ -142,7 +161,8 @@ func tryCCEnv() (ResolvedEndpoint, bool, error) { url := ensureMessagesSuffix(baseURL) - return ResolvedEndpoint{URL: url, Token: token, Model: model, Protocol: "anthropic", Source: "Claude Code environment"}, true, nil + // Claude Code environment tokens are OAuth/Bearer-style credentials. + return ResolvedEndpoint{URL: url, Token: token, Model: model, Protocol: "anthropic", AuthHeader: "authorization", Source: "Claude Code environment"}, true, nil } // tryShellRC parses ~/.zshrc and ~/.bashrc for ANTHROPIC_* exports. @@ -224,7 +244,28 @@ func parseShellRC(path string) (ResolvedEndpoint, bool, error) { url := ensureMessagesSuffix(baseURL) - return ResolvedEndpoint{URL: url, Token: token, Model: model, Protocol: "anthropic", Source: "Shell rc file"}, true, nil + // Claude Code shell rc tokens are OAuth/Bearer-style credentials. + return ResolvedEndpoint{URL: url, Token: token, Model: model, Protocol: "anthropic", AuthHeader: "authorization", Source: "Shell rc file"}, true, nil +} + +func defaultAuthHeader(protocol string) string { + // auth_header is Anthropic-only; OpenAI-compatible clients keep API key auth. + if protocol == "anthropic" { + return "authorization" + } + return "" +} + +func normalizeAuthHeader(header string) string { + header = strings.TrimSpace(header) + switch strings.ToLower(header) { + case "x-api-key": + return "x-api-key" + case "authorization", "bearer": + return "authorization" + default: + return header + } } // ensureMessagesSuffix appends /v1/messages to base URLs that lack a versioned path. diff --git a/internal/llm/resolver_test.go b/internal/llm/resolver_test.go index 61ca101..919eac0 100644 --- a/internal/llm/resolver_test.go +++ b/internal/llm/resolver_test.go @@ -115,3 +115,132 @@ func TestResolveEndpoint_ConfigFileStripsModelSuffix(t *testing.T) { t.Errorf("expected source %q, got %q", "OCR config file", ep.Source) } } + +func TestResolveEndpoint_ConfigAnthropicDefaultsToAuthorization(t *testing.T) { + t.Setenv("OCR_LLM_URL", "") + t.Setenv("OCR_LLM_TOKEN", "") + t.Setenv("OCR_LLM_MODEL", "") + t.Setenv("ANTHROPIC_BASE_URL", "") + t.Setenv("ANTHROPIC_AUTH_TOKEN", "") + t.Setenv("ANTHROPIC_MODEL", "") + + useAnthropic := true + cfg := configFile{ + Llm: llmFileConfig{ + URL: "https://api.anthropic.com", + AuthToken: "sk-ant-api03-test", + Model: "claude-opus-4-6", + UseAnthropic: &useAnthropic, + }, + } + data, _ := json.Marshal(cfg) + cfgPath := filepath.Join(t.TempDir(), "config.json") + os.WriteFile(cfgPath, data, 0644) + + ep, err := ResolveEndpoint(cfgPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep.AuthHeader != "authorization" { + t.Errorf("expected auth header %q, got %q", "authorization", ep.AuthHeader) + } +} + +func TestResolveEndpoint_ConfigAuthHeaderOverrideToXAPIKey(t *testing.T) { + t.Setenv("OCR_LLM_URL", "") + t.Setenv("OCR_LLM_TOKEN", "") + t.Setenv("OCR_LLM_MODEL", "") + t.Setenv("ANTHROPIC_BASE_URL", "") + t.Setenv("ANTHROPIC_AUTH_TOKEN", "") + t.Setenv("ANTHROPIC_MODEL", "") + + useAnthropic := true + cfg := configFile{ + Llm: llmFileConfig{ + URL: "https://api.anthropic.com", + AuthToken: "sk-ant-api03-test", + AuthHeader: "x-api-key", + Model: "claude-opus-4-6", + UseAnthropic: &useAnthropic, + }, + } + data, _ := json.Marshal(cfg) + cfgPath := filepath.Join(t.TempDir(), "config.json") + os.WriteFile(cfgPath, data, 0644) + + ep, err := ResolveEndpoint(cfgPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep.AuthHeader != "x-api-key" { + t.Errorf("expected auth header %q, got %q", "x-api-key", ep.AuthHeader) + } +} + +func TestResolveEndpoint_ConfigOpenAIIgnoresAuthHeader(t *testing.T) { + t.Setenv("OCR_LLM_URL", "") + t.Setenv("OCR_LLM_TOKEN", "") + t.Setenv("OCR_LLM_MODEL", "") + t.Setenv("ANTHROPIC_BASE_URL", "") + t.Setenv("ANTHROPIC_AUTH_TOKEN", "") + t.Setenv("ANTHROPIC_MODEL", "") + + useAnthropic := false + cfg := configFile{ + Llm: llmFileConfig{ + URL: "https://api.openai.com/v1", + AuthToken: "openai-token", + AuthHeader: "x-api-key", + Model: "gpt-4", + UseAnthropic: &useAnthropic, + }, + } + data, _ := json.Marshal(cfg) + cfgPath := filepath.Join(t.TempDir(), "config.json") + os.WriteFile(cfgPath, data, 0644) + + ep, err := ResolveEndpoint(cfgPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep.Protocol != "openai" { + t.Errorf("expected protocol %q, got %q", "openai", ep.Protocol) + } + if ep.AuthHeader != "" { + t.Errorf("expected empty auth header for OpenAI protocol, got %q", ep.AuthHeader) + } +} + +func TestResolveEndpoint_OCREnvAuthHeader(t *testing.T) { + t.Setenv("OCR_LLM_URL", "https://api.anthropic.com") + t.Setenv("OCR_LLM_TOKEN", "oauth-token") + t.Setenv("OCR_LLM_MODEL", "claude-opus-4-6") + t.Setenv("OCR_LLM_AUTH_HEADER", "authorization") + + ep, err := ResolveEndpoint(filepath.Join(t.TempDir(), "nonexistent.json")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep.AuthHeader != "authorization" { + t.Errorf("expected auth header %q, got %q", "authorization", ep.AuthHeader) + } +} + +func TestResolveEndpoint_OCREnvOpenAIIgnoresAuthHeader(t *testing.T) { + t.Setenv("OCR_LLM_URL", "https://api.openai.com/v1") + t.Setenv("OCR_LLM_TOKEN", "openai-token") + t.Setenv("OCR_LLM_MODEL", "gpt-4") + t.Setenv("OCR_LLM_AUTH_HEADER", "x-api-key") + t.Setenv("OCR_USE_ANTHROPIC", "false") + + ep, err := ResolveEndpoint(filepath.Join(t.TempDir(), "nonexistent.json")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep.Protocol != "openai" { + t.Errorf("expected protocol %q, got %q", "openai", ep.Protocol) + } + if ep.AuthHeader != "" { + t.Errorf("expected empty auth header for OpenAI protocol, got %q", ep.AuthHeader) + } +}