diff --git a/README.md b/README.md index c13e2e6e..67ccdd01 100644 --- a/README.md +++ b/README.md @@ -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`: @@ -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: @@ -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 diff --git a/cmd/opencodereview/flags.go b/cmd/opencodereview/flags.go index 6c3d4f00..e0011206 100644 --- a/cmd/opencodereview/flags.go +++ b/cmd/opencodereview/flags.go @@ -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 diff --git a/internal/llm/client.go b/internal/llm/client.go index 1773282e..6e7aadec 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -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" @@ -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, @@ -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) } @@ -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 --- diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go index 2aedfa3c..cf1423e2 100644 --- a/internal/llm/client_test.go +++ b/internal/llm/client_test.go @@ -2,6 +2,7 @@ package llm import ( "context" + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -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 diff --git a/internal/llm/resolver.go b/internal/llm/resolver.go index 5e2432c3..456df3f5 100644 --- a/internal/llm/resolver.go +++ b/internal/llm/resolver.go @@ -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. @@ -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 @@ -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" } @@ -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"` diff --git a/internal/llm/resolver_test.go b/internal/llm/resolver_test.go index 7830ffd3..9f295d53 100644 --- a/internal/llm/resolver_test.go +++ b/internal/llm/resolver_test.go @@ -246,12 +246,35 @@ func TestResolveEndpoint_OCREnvOpenAIIgnoresAuthHeader(t *testing.T) { } } +func TestResolveEndpoint_OCREnvCompatibilityNames(t *testing.T) { + t.Setenv("OCR_LLM_URL", "https://api.example.com/v1/responses") + t.Setenv("OCR_LLM_TOKEN", "") + t.Setenv("OCR_LLM_AUTH_TOKEN", "compat-token") + t.Setenv("OCR_LLM_MODEL", "gpt-5.5") + t.Setenv("OCR_USE_ANTHROPIC", "") + t.Setenv("OCR_LLM_USE_ANTHROPIC", "false") + t.Setenv("ANTHROPIC_BASE_URL", "") + t.Setenv("ANTHROPIC_AUTH_TOKEN", "") + t.Setenv("ANTHROPIC_MODEL", "") + + ep, err := ResolveEndpoint(filepath.Join(t.TempDir(), "nonexistent.json")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep.Token != "compat-token" { + t.Fatalf("token = %q, want compatibility token", ep.Token) + } + if ep.Protocol != "openai" { + t.Fatalf("protocol = %q, want openai", ep.Protocol) + } +} + // --- Provider-based resolution tests --- func clearAllEnv(t *testing.T) { t.Helper() for _, k := range []string{ - "OCR_LLM_URL", "OCR_LLM_TOKEN", "OCR_LLM_MODEL", "OCR_LLM_AUTH_HEADER", "OCR_USE_ANTHROPIC", + "OCR_LLM_URL", "OCR_LLM_TOKEN", "OCR_LLM_AUTH_TOKEN", "OCR_LLM_MODEL", "OCR_LLM_AUTH_HEADER", "OCR_USE_ANTHROPIC", "OCR_LLM_USE_ANTHROPIC", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_MODEL", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", } { diff --git a/internal/llm/responses_client.go b/internal/llm/responses_client.go new file mode 100644 index 00000000..b65303ee --- /dev/null +++ b/internal/llm/responses_client.go @@ -0,0 +1,291 @@ +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// OpenAIResponsesClient sends requests to the OpenAI Responses API. +type OpenAIResponsesClient struct { + cfg ClientConfig + client *http.Client +} + +// NewOpenAIResponsesClient creates a new OpenAI Responses API client. +func NewOpenAIResponsesClient(cfg ClientConfig) *OpenAIResponsesClient { + if cfg.Timeout <= 0 { + cfg.Timeout = 5 * time.Minute + } + cfg.URL = ensureURLPathSuffix(cfg.URL, "/responses") + return &OpenAIResponsesClient{ + cfg: cfg, + client: &http.Client{ + Timeout: cfg.Timeout, + }, + } +} + +// CompletionsWithCtx sends a Responses API request with context support. +func (c *OpenAIResponsesClient) CompletionsWithCtx(ctx context.Context, req ChatRequest) (*ChatResponse, error) { + model := req.Model + if model == "" { + model = c.cfg.Model + } + + payload, err := c.buildRequestPayload(model, req) + if err != nil { + return nil, fmt.Errorf("marshal request body: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.URL, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("User-Agent", userAgent("")) + c.setAuthHeader(httpReq) + + resp, err := c.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, extractResponsesErrorMessage(bodyBytes)) + } + + chatResp, err := parseResponsesAPIResponse(bodyBytes) + if err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return chatResp, nil +} + +func (c *OpenAIResponsesClient) buildRequestPayload(model string, req ChatRequest) ([]byte, error) { + body := map[string]any{ + "model": model, + "input": buildResponsesInput(req.Messages), + } + if len(req.Tools) > 0 { + body["tools"] = buildResponsesTools(req.Tools) + } + if req.Temperature != nil { + body["temperature"] = req.Temperature + } + if req.MaxTokens > 0 { + body["max_output_tokens"] = req.MaxTokens + } + for k, v := range c.cfg.ExtraBody { + body[k] = v + } + return json.Marshal(body) +} + +func (c *OpenAIResponsesClient) setAuthHeader(req *http.Request) { + if c.cfg.APIKey == "" { + return + } + if isAzureOpenAIEndpoint(c.cfg.URL) { + req.Header.Set("api-key", c.cfg.APIKey) + return + } + req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey) +} + +func buildResponsesInput(messages []Message) []map[string]any { + items := make([]map[string]any, 0, len(messages)) + for _, msg := range messages { + switch msg.Role { + case "tool": + items = append(items, map[string]any{ + "type": "function_call_output", + "call_id": msg.ToolCallID, + "output": responseContentAsString(msg.Content), + }) + case "assistant": + content := msg.ExtractText() + if content != "" { + items = append(items, map[string]any{ + "role": "assistant", + "content": content, + }) + } + for _, tc := range msg.ToolCalls { + items = append(items, map[string]any{ + "type": "function_call", + "call_id": tc.ID, + "name": tc.Function.Name, + "arguments": tc.Function.Arguments, + }) + } + default: + items = append(items, map[string]any{ + "role": msg.Role, + "content": responseContentAsString(msg.Content), + }) + } + } + return items +} + +func responseContentAsString(content any) string { + switch v := content.(type) { + case string: + return v + case []ContentBlock: + msg := Message{Content: v} + return msg.ExtractText() + case nil: + return "" + default: + return fmt.Sprintf("%v", v) + } +} + +func buildResponsesTools(tools []ToolDef) []map[string]any { + items := make([]map[string]any, 0, len(tools)) + for _, t := range tools { + items = append(items, map[string]any{ + "type": "function", + "name": t.Function.Name, + "description": t.Function.Description, + "parameters": t.Function.Parameters, + "strict": false, + }) + } + return items +} + +func parseResponsesAPIResponse(body []byte) (*ChatResponse, error) { + type responseContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + } + type responseOutput struct { + Type string `json:"type"` + Role string `json:"role,omitempty"` + Content []responseContent `json:"content,omitempty"` + ID string `json:"id,omitempty"` + CallID string `json:"call_id,omitempty"` + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + } + var resp struct { + ID string `json:"id"` + Model string `json:"model"` + Status string `json:"status,omitempty"` + Output []responseOutput `json:"output"` + OutputText string `json:"output_text,omitempty"` + IncompleteDetails struct { + Reason string `json:"reason,omitempty"` + } `json:"incomplete_details,omitempty"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, err + } + + var textParts []string + var toolCalls []ToolCall + role := "assistant" + finishReason := "" + for _, item := range resp.Output { + switch item.Type { + case "message": + if item.Role != "" { + role = item.Role + } + if item.StopReason != "" { + finishReason = item.StopReason + } + for _, content := range item.Content { + if content.Text != "" { + textParts = append(textParts, content.Text) + } + } + case "function_call": + callID := item.CallID + if callID == "" { + callID = item.ID + } + toolCalls = append(toolCalls, ToolCall{ + ID: callID, + Type: "function", + Function: FunctionCall{ + Name: item.Name, + Arguments: item.Arguments, + }, + }) + } + } + if len(textParts) == 0 && resp.OutputText != "" { + textParts = append(textParts, resp.OutputText) + } + + var contentStr *string + if len(textParts) > 0 { + s := strings.Join(textParts, "\n") + contentStr = &s + } + + if finishReason == "" && resp.IncompleteDetails.Reason != "" { + finishReason = resp.IncompleteDetails.Reason + } + if finishReason == "" && len(toolCalls) > 0 { + finishReason = "tool_calls" + } + if finishReason == "" { + finishReason = "stop" + } + + return &ChatResponse{ + ID: resp.ID, + Model: resp.Model, + Choices: []Choice{{ + Message: ResponseMessage{ + Role: role, + Content: contentStr, + ToolCalls: toolCalls, + }, + FinishReason: finishReason, + }}, + Usage: resolveUsage(body), + }, nil +} + +func extractResponsesErrorMessage(body []byte) string { + if len(body) == 0 { + return "(empty body)" + } + var resp struct { + Error any `json:"error"` + } + if err := json.Unmarshal(body, &resp); err == nil { + switch e := resp.Error.(type) { + case map[string]any: + if msg, ok := e["message"].(string); ok && msg != "" { + return msg + } + case string: + if e != "" { + return e + } + } + } + bodyText := string(body) + if len(bodyText) > 512 { + bodyText = bodyText[:512] + "... (truncated)" + } + return bodyText +} diff --git a/internal/llm/usage_resolver.go b/internal/llm/usage_resolver.go index 13454207..aadcdde7 100644 --- a/internal/llm/usage_resolver.go +++ b/internal/llm/usage_resolver.go @@ -16,14 +16,20 @@ type UsageInfo struct { var promptTokensPaths = []string{ "usage.prompt_tokens", // OpenAI standard + "usage.input_tokens", // OpenAI Responses API "prompt_tokens", // flat at root + "input_tokens", // flat Responses-style "data.usage.prompt_tokens", // wrapped in data layer + "data.usage.input_tokens", // wrapped Responses-style } var completionTokensPaths = []string{ "usage.completion_tokens", // OpenAI standard + "usage.output_tokens", // OpenAI Responses API "completion_tokens", // flat at root + "output_tokens", // flat Responses-style "data.usage.completion_tokens", // wrapped in data layer + "data.usage.output_tokens", // wrapped Responses-style } var cacheReadTokensPaths = []string{ diff --git a/internal/tool/code_search.go b/internal/tool/code_search.go index 2c7c7a9d..aa150805 100644 --- a/internal/tool/code_search.go +++ b/internal/tool/code_search.go @@ -50,6 +50,10 @@ func (p *CodeSearchProvider) Execute(ctx context.Context, args map[string]any) ( } func (p *CodeSearchProvider) buildGrepArgs(searchText string, caseSensitive bool, usePerlRegexp bool, pathspec []string) []string { + return p.buildGrepArgsForRef(searchText, caseSensitive, usePerlRegexp, p.FileReader.Ref, pathspec) +} + +func (p *CodeSearchProvider) buildGrepArgsForRef(searchText string, caseSensitive bool, usePerlRegexp bool, ref string, pathspec []string) []string { cmdArgs := []string{"--no-pager", "grep"} if !caseSensitive { @@ -66,8 +70,7 @@ func (p *CodeSearchProvider) buildGrepArgs(searchText string, caseSensitive bool cmdArgs = append(cmdArgs, "-e", searchText) - if ref := p.FileReader.Ref; ref != "" { - cmdArgs = append(cmdArgs, "--end-of-options") + if ref != "" { cmdArgs = append(cmdArgs, ref) } @@ -77,6 +80,40 @@ func (p *CodeSearchProvider) buildGrepArgs(searchText string, caseSensitive bool return cmdArgs } +func (p *CodeSearchProvider) resolveGrepRef(parentCtx context.Context, ref string) (string, string, error) { + ctx, cancel := context.WithTimeout(parentCtx, gitGrepTimeout) + defer cancel() + + cmdArgs := []string{"rev-parse", "--verify", "--end-of-options", ref + "^{tree}"} + var stdout, stderr string + var err error + if p.FileReader.Runner != nil { + stdout, stderr, err = p.FileReader.Runner.RunSplit(ctx, p.FileReader.RepoDir, cmdArgs...) + } else { + cmd := exec.CommandContext(ctx, "git", cmdArgs...) + cmd.Dir = p.FileReader.RepoDir + + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + err = cmd.Run() + stdout = stdoutBuf.String() + stderr = stderrBuf.String() + } + if ctx.Err() != nil && err != nil { + return "", "", ctx.Err() + } + if err != nil { + detail := strings.TrimSpace(stderr) + if detail == "" { + detail = strings.TrimSpace(stdout) + } + return "", detail, err + } + return strings.TrimSpace(stdout), "", nil +} + func (p *CodeSearchProvider) runGitGrep(parentCtx context.Context, cmdArgs []string) (string, string, error) { ctx, cancel := context.WithTimeout(parentCtx, gitGrepTimeout) defer cancel() @@ -104,7 +141,25 @@ func (p *CodeSearchProvider) runGitGrep(parentCtx context.Context, cmdArgs []str } func (p *CodeSearchProvider) gitGrep(ctx context.Context, searchText string, caseSensitive bool, usePerlRegexp bool, pathspec []string) (string, error) { - cmdArgs := p.buildGrepArgs(searchText, caseSensitive, usePerlRegexp, pathspec) + ref := p.FileReader.Ref + if ref != "" { + resolvedRef, errStr, err := p.resolveGrepRef(ctx, ref) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return "code_search timed out. Try narrowing file_patterns to a more specific path.", nil + } + if errors.Is(err, context.Canceled) { + return "", err + } + if errStr == "" { + errStr = err.Error() + } + return fmt.Sprintf("Error: %s", strings.TrimSpace(errStr)), nil + } + ref = resolvedRef + } + + cmdArgs := p.buildGrepArgsForRef(searchText, caseSensitive, usePerlRegexp, ref, pathspec) outStr, errStr, err := p.runGitGrep(ctx, cmdArgs) diff --git a/internal/tool/code_search_test.go b/internal/tool/code_search_test.go index ee4abb9c..cb8ceaff 100644 --- a/internal/tool/code_search_test.go +++ b/internal/tool/code_search_test.go @@ -29,14 +29,14 @@ func TestBuildGrepArgs_CommitMode(t *testing.T) { p := NewCodeSearch(&FileReader{RepoDir: "/tmp", Ref: "abc1234"}) args := p.buildGrepArgs("myFunc", false, false, []string{"pkg/"}) - assertContainsInOrder(t, args, "-e", "myFunc", "--end-of-options", "abc1234", "--", "pkg/") + assertContainsInOrder(t, args, "-e", "myFunc", "abc1234", "--", "pkg/") } -func TestBuildGrepArgs_RefUsesEndOfOptions(t *testing.T) { +func TestBuildGrepArgs_RefIsPositional(t *testing.T) { p := NewCodeSearch(&FileReader{RepoDir: "/tmp", Ref: "-O./pwn.sh"}) args := p.buildGrepArgs("myFunc", false, false, nil) - assertContainsInOrder(t, args, "-e", "myFunc", "--end-of-options", "-O./pwn.sh", "--") + assertContainsInOrder(t, args, "-e", "myFunc", "-O./pwn.sh", "--") } func TestBuildGrepArgs_PatternStartingWithDash(t *testing.T) { @@ -207,7 +207,9 @@ func TestGitGrep_OptionLikeRefDoesNotLaunchPager(t *testing.T) { if err != nil { t.Fatal(err) } - if !strings.Contains(result, "unable to resolve revision") && !strings.Contains(result, "Not a valid object name") { + if !strings.Contains(result, "unable to resolve revision") && + !strings.Contains(result, "Not a valid object name") && + !strings.Contains(result, "Needed a single revision") { t.Fatalf("expected invalid revision error, got: %s", result) } if _, err := os.Stat(proofPath); err == nil { diff --git a/skills/open-code-review/SKILL.md b/skills/open-code-review/SKILL.md index ff614733..7743c47d 100644 --- a/skills/open-code-review/SKILL.md +++ b/skills/open-code-review/SKILL.md @@ -61,6 +61,17 @@ ocr config set llm.model claude-opus-4-6 ocr config set llm.use_anthropic true ``` +For OpenAI Codex / GPT-5 reasoning models, use: + +```bash +ocr config set llm.url https://api.openai.com/v1/responses +ocr config set llm.auth_token +ocr config set llm.model gpt-5.5 +ocr config set llm.use_anthropic false +``` + +For Azure OpenAI Responses, use the full endpoint URL including the `api-version` query string. + Stop here and ask the user to provide credentials — never invent or hardcode API keys. ## Workflow