Skip to content
Open
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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,17 @@ ocr config set llm.model claude-opus-4-6
ocr config set llm.use_anthropic true
```

For OpenAI Responses-compatible endpoints, including Codex/GPT-5 reasoning endpoints:

```bash
ocr config set llm.url https://api.openai.com/v1/responses
ocr config set llm.auth_token "$OPENAI_API_KEY"
ocr config set llm.model gpt-5.5
ocr config set llm.use_anthropic false
```

Azure OpenAI Responses endpoints are supported when `llm.url` is the full endpoint URL, including the `api-version` query string.

Config is stored in `~/.opencodereview/config.json`.

**`auth_header` (optional):** Controls which HTTP header carries the API key when using Anthropic. Defaults to `authorization` (Bearer token) if omitted. If you use a standard `sk-ant-*` API key, you must set it to `x-api-key`:
Expand All @@ -166,6 +177,8 @@ export OCR_LLM_MODEL=claude-opus-4-6
export OCR_USE_ANTHROPIC=true
```

Use `OCR_USE_ANTHROPIC=false` with `/responses` URLs for OpenAI Responses-compatible endpoints. `OCR_LLM_AUTH_TOKEN` and `OCR_LLM_USE_ANTHROPIC` are also accepted as compatibility aliases.

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:
Expand Down Expand Up @@ -494,9 +507,11 @@ 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_TOKEN` | Compatibility alias for `OCR_LLM_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 |
| `OCR_LLM_USE_ANTHROPIC` | Compatibility alias for `OCR_USE_ANTHROPIC` |


## Telemetry
Expand Down
7 changes: 7 additions & 0 deletions cmd/opencodereview/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,13 @@ Examples:
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"}}'

# OpenAI Responses-compatible endpoint
ocr config set llm.url https://api.openai.com/v1/responses
ocr config set llm.auth_token "$OPENAI_API_KEY"
ocr config set llm.model gpt-5.5
ocr config set llm.use_anthropic false

ocr config set language English
ocr config set telemetry.enabled true

Expand Down
59 changes: 52 additions & 7 deletions internal/llm/client.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// Package llm provides LLM client interfaces supporting multiple protocols.
// Supported protocols: Anthropic Messages API, OpenAI Chat Completions API.
// Supported protocols: Anthropic Messages API, OpenAI Chat Completions API,
// and OpenAI Responses API.
package llm

import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -190,7 +192,8 @@ type ClientConfig struct {
// --- Factory ---

// NewLLMClient creates the appropriate client based on the resolved endpoint protocol.
// protocol: "anthropic" -> AnthropicClient, anything else -> OpenAIClient.
// protocol: "anthropic" -> AnthropicClient; OpenAI /responses URLs -> OpenAIResponsesClient;
// anything else -> OpenAIClient.
func NewLLMClient(ep ResolvedEndpoint) LLMClient {
cfg := ClientConfig{
URL: ep.URL,
Expand All @@ -202,6 +205,9 @@ func NewLLMClient(ep ResolvedEndpoint) LLMClient {
if ep.Protocol == "anthropic" {
return NewAnthropicClient(cfg)
}
if isResponsesEndpoint(ep.URL) {
return NewOpenAIResponsesClient(cfg)
}
return NewOpenAIClient(cfg)
}

Expand Down Expand Up @@ -262,13 +268,52 @@ func CountTokensForModel(text string, modelName string) int {
}

func encodingForModel(modelName string) string {
lower := strings.ToLower(modelName)
switch {
case strings.Contains(lower, "o1") || strings.Contains(lower, "o3") || strings.Contains(lower, "o4"):
if isAdvancedReasoningModel(modelName) {
return "o200k_base"
default:
return "cl100k_base"
}
return "cl100k_base"
}

func isResponsesEndpoint(rawURL string) bool {
return hasURLPathSuffix(rawURL, "/responses")
}

func hasURLPathSuffix(rawURL, suffix string) bool {
if u, err := url.Parse(rawURL); err == nil && u.Path != "" {
return strings.HasSuffix(strings.TrimRight(u.Path, "/"), suffix)
}
return strings.HasSuffix(strings.TrimRight(rawURL, "/"), suffix)
}

func ensureURLPathSuffix(rawURL, suffix string) string {
if hasURLPathSuffix(rawURL, suffix) {
return rawURL
}
if u, err := url.Parse(rawURL); err == nil && u.Scheme != "" && u.Host != "" {
u.Path = strings.TrimRight(u.Path, "/") + suffix
return u.String()
}
return strings.TrimRight(rawURL, "/") + suffix
}

func isAzureOpenAIEndpoint(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
host := strings.ToLower(u.Hostname())
return strings.Contains(host, ".openai.azure.com") ||
strings.Contains(host, ".cognitiveservices.azure.com") ||
u.Query().Get("api-version") != ""
}

func isAdvancedReasoningModel(model string) bool {
lower := strings.ToLower(model)
return strings.Contains(lower, "gpt-5") ||
strings.Contains(lower, "codex") ||
strings.Contains(lower, "o1") ||
strings.Contains(lower, "o3") ||
strings.Contains(lower, "o4")
}

// --- OpenAIClient ---
Expand Down
162 changes: 162 additions & 0 deletions internal/llm/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package llm

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -52,6 +53,167 @@ func TestNewOpenAIClient_URLNormalization(t *testing.T) {
}
}

func TestNewOpenAIResponsesClient_URLNormalization(t *testing.T) {
tests := []struct {
name string
inputURL string
wantURL string
}{
{
name: "base URL without trailing slash",
inputURL: "https://api.example.com/v1",
wantURL: "https://api.example.com/v1/responses",
},
{
name: "full URL already has responses",
inputURL: "https://api.example.com/v1/responses",
wantURL: "https://api.example.com/v1/responses",
},
{
name: "preserves Azure api version query",
inputURL: "https://example.cognitiveservices.azure.com/openai?api-version=2025-04-01-preview",
wantURL: "https://example.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview",
},
{
name: "recognizes responses before query",
inputURL: "https://example.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview",
wantURL: "https://example.cognitiveservices.azure.com/openai/responses?api-version=2025-04-01-preview",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewOpenAIResponsesClient(ClientConfig{URL: tt.inputURL})
if client.cfg.URL != tt.wantURL {
t.Errorf("got URL %q, want %q", client.cfg.URL, tt.wantURL)
}
})
}
}

func TestNewLLMClient_UsesResponsesClientForResponsesEndpoint(t *testing.T) {
client := NewLLMClient(ResolvedEndpoint{
URL: "https://api.example.com/v1/responses?api-version=2025-04-01-preview",
Token: "test-token",
Model: "gpt-5.5",
Protocol: "openai",
})
if _, ok := client.(*OpenAIResponsesClient); !ok {
t.Fatalf("client type = %T, want *OpenAIResponsesClient", client)
}
}

func TestOpenAIResponsesClient_RequestAndResponseMapping(t *testing.T) {
var gotPath string
var gotQuery string
var gotAPIKey string
var gotAuthorization string
var gotBody map[string]any

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotQuery = r.URL.RawQuery
gotAPIKey = r.Header.Get("api-key")
gotAuthorization = r.Header.Get("Authorization")
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id": "resp_test",
"model": "gpt-5.5",
"output": [
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "looks good"}],
"stop_reason": "stop"
},
{
"type": "function_call",
"call_id": "call_test",
"name": "lookup",
"arguments": "{\"q\":\"x\"}"
}
],
"usage": {"input_tokens": 3, "output_tokens": 4, "total_tokens": 7}
}`))
}))
defer server.Close()

temp := 0.2
client := NewOpenAIResponsesClient(ClientConfig{
URL: server.URL + "/openai/responses?api-version=2025-04-01-preview",
APIKey: "azure-key",
Model: "gpt-5.5",
ExtraBody: map[string]any{
"reasoning": map[string]any{"effort": "low"},
},
})

resp, err := client.CompletionsWithCtx(context.Background(), ChatRequest{
Messages: []Message{
{Role: "system", Content: "be terse"},
{Role: "user", Content: []ContentBlock{{Type: "text", Text: "hello"}}},
},
Tools: []ToolDef{{
Type: "function",
Function: FunctionDef{
Name: "lookup",
Description: "search",
Parameters: map[string]any{"type": "object"},
},
}},
Temperature: &temp,
MaxTokens: 42,
})
if err != nil {
t.Fatalf("CompletionsWithCtx: %v", err)
}

if gotPath != "/openai/responses" {
t.Errorf("path = %q, want /openai/responses", gotPath)
}
if gotQuery != "api-version=2025-04-01-preview" {
t.Errorf("query = %q, want api-version query", gotQuery)
}
if gotAPIKey != "azure-key" {
t.Errorf("api-key header = %q, want azure-key", gotAPIKey)
}
if gotAuthorization != "" {
t.Errorf("Authorization header = %q, want empty", gotAuthorization)
}
if gotBody["model"] != "gpt-5.5" {
t.Errorf("model = %v, want gpt-5.5", gotBody["model"])
}
if gotBody["max_output_tokens"] != float64(42) {
t.Errorf("max_output_tokens = %v, want 42", gotBody["max_output_tokens"])
}
input := gotBody["input"].([]any)
if input[1].(map[string]any)["content"] != "hello" {
t.Errorf("second input content = %v, want hello", input[1].(map[string]any)["content"])
}
if gotBody["reasoning"].(map[string]any)["effort"] != "low" {
t.Errorf("reasoning effort = %v, want low", gotBody["reasoning"])
}

if resp.Content() != "looks good" {
t.Errorf("content = %q, want looks good", resp.Content())
}
if len(resp.ToolCalls()) != 1 || resp.ToolCalls()[0].ID != "call_test" {
t.Fatalf("tool calls = %#v, want call_test", resp.ToolCalls())
}
if resp.Usage == nil || resp.Usage.PromptTokens != 3 || resp.Usage.CompletionTokens != 4 || resp.Usage.TotalTokens != 7 {
t.Fatalf("usage = %#v, want 3/4/7", resp.Usage)
}
}

func TestResponseContentAsString_Nil(t *testing.T) {
if got := responseContentAsString(nil); got != "" {
t.Fatalf("responseContentAsString(nil) = %q, want empty", got)
}
}

func TestNewAnthropicClient_URLNormalization(t *testing.T) {
tests := []struct {
name string
Expand Down
26 changes: 20 additions & 6 deletions internal/llm/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ type ResolvedEndpoint struct {

// Environment variable names for OCR-specific configuration.
const (
envOCRLLMURL = "OCR_LLM_URL"
envOCRLLMToken = "OCR_LLM_TOKEN"
envOCRLLMModel = "OCR_LLM_MODEL"
envOCRLLMAuthHeader = "OCR_LLM_AUTH_HEADER"
envOCRUseAnthropic = "OCR_USE_ANTHROPIC"
envOCRLLMURL = "OCR_LLM_URL"
envOCRLLMToken = "OCR_LLM_TOKEN"
envOCRLLMAuthToken = "OCR_LLM_AUTH_TOKEN"
envOCRLLMModel = "OCR_LLM_MODEL"
envOCRLLMAuthHeader = "OCR_LLM_AUTH_HEADER"
envOCRUseAnthropic = "OCR_USE_ANTHROPIC"
envOCRLLMUseAnthropic = "OCR_LLM_USE_ANTHROPIC"
)

// Environment variable names from Claude Code configuration.
Expand Down Expand Up @@ -80,6 +82,9 @@ func ResolveEndpointWithModelOverride(configPath, modelOverride string) (Resolve
func tryOCREnv(modelOverride string) (ResolvedEndpoint, bool, error) {
url := os.Getenv(envOCRLLMURL)
token := os.Getenv(envOCRLLMToken)
if token == "" {
token = os.Getenv(envOCRLLMAuthToken)
}
model := os.Getenv(envOCRLLMModel)
if modelOverride != "" {
model = modelOverride
Expand All @@ -89,7 +94,7 @@ func tryOCREnv(modelOverride string) (ResolvedEndpoint, bool, error) {
}

useAnthropic := true // default true
if v := os.Getenv(envOCRUseAnthropic); v != "" {
if v := firstNonEmptyEnv(envOCRUseAnthropic, envOCRLLMUseAnthropic); v != "" {
lower := strings.ToLower(v)
useAnthropic = lower == "true" || lower == "1" || lower == "yes"
}
Expand All @@ -114,6 +119,15 @@ func tryOCREnv(modelOverride string) (ResolvedEndpoint, bool, error) {
return ResolvedEndpoint{URL: url, Token: token, Model: model, Protocol: protocol, AuthHeader: authHeader, Source: "OCR environment"}, true, nil
}

func firstNonEmptyEnv(keys ...string) string {
for _, key := range keys {
if v := os.Getenv(key); v != "" {
return v
}
}
return ""
}

// llmFileConfig represents the llm section in config.json.
type llmFileConfig struct {
URL string `json:"url,omitempty"`
Expand Down
Loading