fix(llm): support Anthropic auth header (#77)

This commit is contained in:
zhouzhihao 2026-06-08 19:35:12 +08:00 committed by GitHub
parent fb819510ca
commit 48dc1b37d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 428 additions and 32 deletions

View file

@ -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 |

View file

@ -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 |

View file

@ -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 |

View file

@ -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 |

View file

@ -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{}

View file

@ -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")
}
}

View file

@ -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`)
}

View file

@ -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...),
}
}

View file

@ -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()

View file

@ -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.

View file

@ -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)
}
}