mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
fix(llm): support Anthropic auth header (#77)
This commit is contained in:
parent
fb819510ca
commit
48dc1b37d4
11 changed files with 428 additions and 32 deletions
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
27
cmd/opencodereview/config_cmd_test.go
Normal file
27
cmd/opencodereview/config_cmd_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue