Skip to content

Commit a450534

Browse files
committed
fix: support missing OpenAI env vars
1 parent cc1064d commit a450534

4 files changed

Lines changed: 195 additions & 5 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,11 +334,13 @@ skill-validator score evaluate --provider claude-cli <path>
334334
| Provider | Env var | Default model | Covers |
335335
|---|---|---|---|
336336
| `anthropic` (default) | `ANTHROPIC_API_KEY` | `claude-sonnet-4-5-20250929` | Anthropic |
337-
| `openai` | `OPENAI_API_KEY` | `gpt-5.2` | OpenAI, Ollama, Together, Groq, Azure, etc. |
337+
| `openai` | `OPENAI_API_KEY` | `gpt-5.2` | OpenAI, Ollama, Together, Groq, Azure, etc. \*\* |
338338
| `claude-cli` | _(none)_ | `sonnet` | Claude CLI (uses locally authenticated `claude` binary) \* |
339339
340340
\* **Accuracy note:** The `claude-cli` provider shells out to the `claude` CLI, which loads local context (CLAUDE.md files, project memory, rules) into each scoring call. This extra context may influence scores, making them less reproducible across environments compared to the API-based providers. For the most consistent results, use the `anthropic` or `openai` providers with an API key.
341341
342+
\*\* The `openai` provider also reads these optional environment variables: `OPENAI_BASE_URL` (API base URL, overridden by `--base-url`), `OPENAI_ORG_ID` (sent as `OpenAI-Organization` header), and `OPENAI_PROJECT_ID` (sent as `OpenAI-Project` header). The org/project headers are only sent when targeting an OpenAI endpoint (e.g. `api.openai.com`, `us.api.openai.com`), not when using third-party compatible APIs.
343+
342344
Use `--model` to override the default model and `--base-url` to point at any OpenAI-compatible endpoint (e.g. `http://localhost:11434/v1` for Ollama). If the endpoint requires a specific token limit parameter, use `--max-tokens-style` to override auto-detection:
343345
344346
| Value | Behavior |

cmd/score_evaluate.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,13 @@ The path can be:
3838
- A specific .md file — scores just that reference file
3939
4040
Requires an API key via environment variable:
41-
ANTHROPIC_API_KEY (for --provider anthropic, the default)
42-
OPENAI_API_KEY (for --provider openai)
41+
ANTHROPIC_API_KEY (for --provider anthropic, the default)
42+
OPENAI_API_KEY (for --provider openai)
43+
44+
Optional OpenAI environment variables:
45+
OPENAI_BASE_URL (API base URL; overridden by --base-url flag)
46+
OPENAI_ORG_ID (organization ID; sent as OpenAI-Organization header)
47+
OPENAI_PROJECT_ID (project ID; sent as OpenAI-Project header)
4348
4449
The claude-cli provider uses the locally installed "claude" CLI and does not
4550
require an API key. This is useful when the CLI is already authenticated
@@ -88,12 +93,25 @@ func runScoreEvaluate(cmd *cobra.Command, args []string) error {
8893
}
8994
}
9095

96+
// For the openai provider, read optional env vars for base URL, org, and project.
97+
baseURL := evalBaseURL
98+
var orgID, projectID string
99+
if strings.ToLower(evalProvider) == "openai" {
100+
if baseURL == "" {
101+
baseURL = os.Getenv("OPENAI_BASE_URL")
102+
}
103+
orgID = os.Getenv("OPENAI_ORG_ID")
104+
projectID = os.Getenv("OPENAI_PROJECT_ID")
105+
}
106+
91107
client, err := judge.NewClient(judge.ClientOptions{
92108
Provider: evalProvider,
93109
APIKey: apiKey,
94-
BaseURL: evalBaseURL,
110+
BaseURL: baseURL,
95111
Model: evalModel,
96112
MaxTokensStyle: evalMaxTokensStyle,
113+
OrgID: orgID,
114+
ProjectID: projectID,
97115
})
98116
if err != nil {
99117
return err

judge/client.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"io"
99
"net/http"
10+
"net/url"
1011
"os/exec"
1112
"strings"
1213
"time"
@@ -38,6 +39,8 @@ type ClientOptions struct {
3839
Model string // Optional; defaults per provider
3940
MaxTokensStyle string // "auto", "max_tokens", or "max_completion_tokens"
4041
MaxResponseTokens int // Maximum tokens in the LLM response; 0 defaults to 500
42+
OrgID string // Optional OpenAI organization ID; sent as OpenAI-Organization header
43+
ProjectID string // Optional OpenAI project ID; sent as OpenAI-Project header
4144
}
4245

4346
// NewClient creates an LLMClient for the given options.
@@ -84,7 +87,7 @@ func NewClient(opts ClientOptions) (LLMClient, error) {
8487
baseURL = "https://api.openai.com/v1"
8588
}
8689
baseURL = strings.TrimRight(baseURL, "/")
87-
return &openaiClient{apiKey: opts.APIKey, baseURL: baseURL, model: model, maxTokensStyle: opts.MaxTokensStyle, maxTokens: maxResp}, nil
90+
return &openaiClient{apiKey: opts.APIKey, baseURL: baseURL, model: model, maxTokensStyle: opts.MaxTokensStyle, maxTokens: maxResp, orgID: opts.OrgID, projectID: opts.ProjectID}, nil
8891
default:
8992
return nil, fmt.Errorf("unsupported provider %q (use \"anthropic\", \"openai\", or \"claude-cli\")", opts.Provider)
9093
}
@@ -186,11 +189,25 @@ type openaiClient struct {
186189
model string
187190
maxTokensStyle string
188191
maxTokens int
192+
orgID string
193+
projectID string
189194
}
190195

191196
func (c *openaiClient) Provider() string { return "openai" }
192197
func (c *openaiClient) ModelName() string { return c.model }
193198

199+
// isOpenAIHost reports whether the given base URL points to an OpenAI endpoint
200+
// (api.openai.com, us.api.openai.com, etc.) as opposed to a third-party
201+
// compatible API like Ollama or vLLM.
202+
func isOpenAIHost(baseURL string) bool {
203+
u, err := url.Parse(baseURL)
204+
if err != nil {
205+
return false
206+
}
207+
host := u.Hostname()
208+
return strings.HasSuffix(host, ".openai.com")
209+
}
210+
194211
type openaiRequest struct {
195212
Model string `json:"model"`
196213
MaxTokens int `json:"max_tokens,omitempty"`
@@ -266,6 +283,17 @@ func (c *openaiClient) Complete(ctx context.Context, systemPrompt, userContent s
266283
req.Header.Set("Content-Type", "application/json")
267284
req.Header.Set("Authorization", "Bearer "+c.apiKey)
268285

286+
// Only send OpenAI-specific org/project headers when targeting an
287+
// OpenAI endpoint, so they don't leak to Ollama or other compatible APIs.
288+
if isOpenAIHost(c.baseURL) {
289+
if c.orgID != "" {
290+
req.Header.Set("OpenAI-Organization", c.orgID)
291+
}
292+
if c.projectID != "" {
293+
req.Header.Set("OpenAI-Project", c.projectID)
294+
}
295+
}
296+
269297
resp, err := defaultHTTPClient.Do(req)
270298
if err != nil {
271299
return "", fmt.Errorf("API request failed: %w", err)

judge/client_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import (
55
"fmt"
66
"net/http"
77
"net/http/httptest"
8+
"net/url"
89
"strings"
910
"testing"
11+
"time"
1012
)
1113

1214
// stubLookPath replaces the lookPath variable for the duration of a test,
@@ -140,6 +142,146 @@ func TestUseMaxCompletionTokens(t *testing.T) {
140142
}
141143
}
142144

145+
func TestIsOpenAIHost(t *testing.T) {
146+
tests := []struct {
147+
baseURL string
148+
want bool
149+
}{
150+
{"https://api.openai.com/v1", true},
151+
{"https://us.api.openai.com/v1", true},
152+
{"https://eu.api.openai.com/v1", true},
153+
{"http://localhost:11434/v1", false},
154+
{"https://my-proxy.example.com/v1", false},
155+
{"https://notopenai.com/v1", false},
156+
{"not a url", false},
157+
}
158+
159+
for _, tt := range tests {
160+
t.Run(tt.baseURL, func(t *testing.T) {
161+
got := isOpenAIHost(tt.baseURL)
162+
if got != tt.want {
163+
t.Errorf("isOpenAIHost(%q) = %v, want %v", tt.baseURL, got, tt.want)
164+
}
165+
})
166+
}
167+
}
168+
169+
func TestOpenAIClient_OrgProjectHeaders(t *testing.T) {
170+
t.Run("headers sent for openai.com", func(t *testing.T) {
171+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
172+
if got := r.Header.Get("OpenAI-Organization"); got != "org-123" {
173+
t.Errorf("OpenAI-Organization = %q, want %q", got, "org-123")
174+
}
175+
if got := r.Header.Get("OpenAI-Project"); got != "proj-456" {
176+
t.Errorf("OpenAI-Project = %q, want %q", got, "proj-456")
177+
}
178+
w.Header().Set("Content-Type", "application/json")
179+
_, _ = fmt.Fprint(w, `{"choices": [{"message": {"content": "ok"}}]}`)
180+
}))
181+
defer server.Close()
182+
183+
// The test server isn't on openai.com, so we construct the client directly
184+
// to test the header logic with an openai.com baseURL that actually points
185+
// at the test server. Instead, we test the client with the test server URL
186+
// and verify via a different approach: construct the openaiClient directly.
187+
c := &openaiClient{
188+
apiKey: "test-key",
189+
baseURL: "https://api.openai.com/v1",
190+
model: "gpt-4o",
191+
maxTokens: 500,
192+
orgID: "org-123",
193+
projectID: "proj-456",
194+
}
195+
196+
// Override defaultHTTPClient to proxy to test server
197+
origClient := defaultHTTPClient
198+
defer func() { defaultHTTPClient = origClient }()
199+
200+
// Use a custom transport that rewrites the host to the test server
201+
testURL := server.URL
202+
defaultHTTPClient = &http.Client{
203+
Timeout: 5 * time.Second,
204+
Transport: &rewriteTransport{target: testURL},
205+
}
206+
207+
_, err := c.Complete(t.Context(), "system", "user")
208+
if err != nil {
209+
t.Fatalf("Complete failed: %v", err)
210+
}
211+
})
212+
213+
t.Run("headers not sent for custom base URL", func(t *testing.T) {
214+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
215+
if got := r.Header.Get("OpenAI-Organization"); got != "" {
216+
t.Errorf("OpenAI-Organization should be empty for non-OpenAI host, got %q", got)
217+
}
218+
if got := r.Header.Get("OpenAI-Project"); got != "" {
219+
t.Errorf("OpenAI-Project should be empty for non-OpenAI host, got %q", got)
220+
}
221+
w.Header().Set("Content-Type", "application/json")
222+
_, _ = fmt.Fprint(w, `{"choices": [{"message": {"content": "ok"}}]}`)
223+
}))
224+
defer server.Close()
225+
226+
client, err := NewClient(ClientOptions{
227+
Provider: "openai",
228+
APIKey: "test-key",
229+
BaseURL: server.URL,
230+
Model: "llama3",
231+
OrgID: "org-123",
232+
ProjectID: "proj-456",
233+
})
234+
if err != nil {
235+
t.Fatalf("NewClient: %v", err)
236+
}
237+
_, err = client.Complete(t.Context(), "system", "user")
238+
if err != nil {
239+
t.Fatalf("Complete failed: %v", err)
240+
}
241+
})
242+
243+
t.Run("empty org and project not sent", func(t *testing.T) {
244+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
245+
if got := r.Header.Get("OpenAI-Organization"); got != "" {
246+
t.Errorf("OpenAI-Organization should be empty, got %q", got)
247+
}
248+
if got := r.Header.Get("OpenAI-Project"); got != "" {
249+
t.Errorf("OpenAI-Project should be empty, got %q", got)
250+
}
251+
w.Header().Set("Content-Type", "application/json")
252+
_, _ = fmt.Fprint(w, `{"choices": [{"message": {"content": "ok"}}]}`)
253+
}))
254+
defer server.Close()
255+
256+
client, err := NewClient(ClientOptions{
257+
Provider: "openai",
258+
APIKey: "test-key",
259+
BaseURL: server.URL,
260+
Model: "gpt-4o",
261+
})
262+
if err != nil {
263+
t.Fatalf("NewClient: %v", err)
264+
}
265+
_, err = client.Complete(t.Context(), "system", "user")
266+
if err != nil {
267+
t.Fatalf("Complete failed: %v", err)
268+
}
269+
})
270+
}
271+
272+
// rewriteTransport rewrites requests to a different target URL while
273+
// preserving the original Host header for testing.
274+
type rewriteTransport struct {
275+
target string
276+
}
277+
278+
func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
279+
targetURL, _ := url.Parse(t.target)
280+
req.URL.Scheme = targetURL.Scheme
281+
req.URL.Host = targetURL.Host
282+
return http.DefaultTransport.RoundTrip(req)
283+
}
284+
143285
func TestMaxTokensStyleOverride(t *testing.T) {
144286
tests := []struct {
145287
name string

0 commit comments

Comments
 (0)