mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
feat(llmclient): support OPENAI_API_KEY, openrouter wins
This commit is contained in:
1 parent
2bd40cc4ee
commit
8eabaaa842
2 files changed
+80
-21
No files matched your search
@@ -1,8 +1,12 @@
|
|||||||
// Package openrouter is a minimal client for the OpenRouter chat-completions
|
// Package llmclient is a minimal client for the OpenAI-compatible
|
||||||
// API, covering only what the LLM action backend needs: a single multimodal
|
// chat-completions API, covering only what the LLM action backend needs: a
|
||||||
// (text + one image) request per step with strict json_schema structured
|
// single multimodal (text + one image) request per step with strict
|
||||||
// output. No streaming, tools, or other extras.
|
// json_schema structured output. No streaming, tools, or other extras.
|
||||||
package openrouter
|
//
|
||||||
|
// The provider comes from the environment: OPENROUTER_API_KEY routes to
|
||||||
|
// OpenRouter, OPENAI_API_KEY to OpenAI; OpenRouter wins when both are set.
|
||||||
|
// Both speak the same wire format, so there is no provider-specific code.
|
||||||
|
package llmclient
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -16,30 +20,41 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultBaseURL = "https://openrouter.ai/api/v1"
|
const (
|
||||||
|
openRouterBaseURL = "https://openrouter.ai/api/v1"
|
||||||
|
openAIBaseURL = "https://api.openai.com/v1"
|
||||||
|
)
|
||||||
|
|
||||||
// requestTimeout bounds a single chat-completion round-trip. Vision + strict
|
// requestTimeout bounds a single chat-completion round-trip. Vision + strict
|
||||||
// structured output is slower than a plain text call, so this is generous; the
|
// structured output is slower than a plain text call, so this is generous; the
|
||||||
// runner also passes a context the caller can cancel.
|
// runner also passes a context the caller can cancel.
|
||||||
const requestTimeout = 60 * time.Second
|
const requestTimeout = 60 * time.Second
|
||||||
|
|
||||||
// Client talks to the OpenRouter chat-completions endpoint.
|
// Client talks to an OpenAI-compatible chat-completions endpoint.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
apiKey string
|
apiKey string
|
||||||
baseURL string
|
baseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds a Client from the environment. OPENROUTER_API_KEY is required;
|
// New builds a Client from the environment. OPENROUTER_API_KEY selects
|
||||||
// OPENROUTER_BASE_URL overrides the default endpoint (e.g. for tests).
|
// OpenRouter, OPENAI_API_KEY selects OpenAI; OpenRouter wins when both are
|
||||||
|
// set. OPENROUTER_BASE_URL / OPENAI_BASE_URL override the chosen provider's
|
||||||
|
// endpoint (tests, local OpenAI-compatible servers).
|
||||||
func New() (*Client, error) {
|
func New() (*Client, error) {
|
||||||
apiKey := os.Getenv("OPENROUTER_API_KEY")
|
apiKey := os.Getenv("OPENROUTER_API_KEY")
|
||||||
|
baseURL := openRouterBaseURL
|
||||||
|
override := os.Getenv("OPENROUTER_BASE_URL")
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
return nil, errors.New("openrouter: OPENROUTER_API_KEY is not set")
|
apiKey = os.Getenv("OPENAI_API_KEY")
|
||||||
|
baseURL = openAIBaseURL
|
||||||
|
override = os.Getenv("OPENAI_BASE_URL")
|
||||||
}
|
}
|
||||||
baseURL := os.Getenv("OPENROUTER_BASE_URL")
|
if apiKey == "" {
|
||||||
if baseURL == "" {
|
return nil, errors.New("llmclient: neither OPENROUTER_API_KEY nor OPENAI_API_KEY is set")
|
||||||
baseURL = defaultBaseURL
|
}
|
||||||
|
if override != "" {
|
||||||
|
baseURL = override
|
||||||
}
|
}
|
||||||
return &Client{
|
return &Client{
|
||||||
httpClient: &http.Client{Timeout: requestTimeout},
|
httpClient: &http.Client{Timeout: requestTimeout},
|
||||||
@@ -120,32 +135,32 @@ type ResponseMessage struct {
|
|||||||
func (c *Client) ChatCompletion(ctx context.Context, req Request) (*Response, error) {
|
func (c *Client) ChatCompletion(ctx context.Context, req Request) (*Response, error) {
|
||||||
body, err := json.Marshal(req)
|
body, err := json.Marshal(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("openrouter: marshal request: %w", err)
|
return nil, fmt.Errorf("llmclient: marshal request: %w", err)
|
||||||
}
|
}
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(body))
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat/completions", bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("openrouter: build request: %w", err)
|
return nil, fmt.Errorf("llmclient: build request: %w", err)
|
||||||
}
|
}
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(httpReq)
|
resp, err := c.httpClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("openrouter: request failed: %w", err)
|
return nil, fmt.Errorf("llmclient: request failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
responseBody, err := io.ReadAll(resp.Body)
|
responseBody, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("openrouter: read response: %w", err)
|
return nil, fmt.Errorf("llmclient: read response: %w", err)
|
||||||
}
|
}
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
return nil, fmt.Errorf("openrouter: status %d: %s", resp.StatusCode, string(responseBody))
|
return nil, fmt.Errorf("llmclient: status %d: %s", resp.StatusCode, string(responseBody))
|
||||||
}
|
}
|
||||||
|
|
||||||
var out Response
|
var out Response
|
||||||
if err := json.Unmarshal(responseBody, &out); err != nil {
|
if err := json.Unmarshal(responseBody, &out); err != nil {
|
||||||
return nil, fmt.Errorf("openrouter: decode response: %w", err)
|
return nil, fmt.Errorf("llmclient: decode response: %w", err)
|
||||||
}
|
}
|
||||||
return &out, nil
|
return &out, nil
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package openrouter
|
package llmclient
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -108,8 +108,52 @@ func TestChatCompletionRequestShapeAndParse(t *testing.T) {
|
|||||||
|
|
||||||
func TestNewRequiresAPIKey(t *testing.T) {
|
func TestNewRequiresAPIKey(t *testing.T) {
|
||||||
t.Setenv("OPENROUTER_API_KEY", "")
|
t.Setenv("OPENROUTER_API_KEY", "")
|
||||||
|
t.Setenv("OPENAI_API_KEY", "")
|
||||||
if _, err := New(); err == nil {
|
if _, err := New(); err == nil {
|
||||||
t.Fatal("expected error when OPENROUTER_API_KEY is unset")
|
t.Fatal("expected error when neither API key is set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFallsBackToOpenAIKey(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer openai-key" {
|
||||||
|
t.Errorf("Authorization = %q, want Bearer openai-key", got)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
t.Setenv("OPENROUTER_API_KEY", "")
|
||||||
|
t.Setenv("OPENAI_API_KEY", "openai-key")
|
||||||
|
t.Setenv("OPENAI_BASE_URL", server.URL)
|
||||||
|
client, err := New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := client.ChatCompletion(context.Background(), Request{Model: "m"}); err != nil {
|
||||||
|
t.Fatalf("ChatCompletion: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPrefersOpenRouterOverOpenAI(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer router-key" {
|
||||||
|
t.Errorf("Authorization = %q, want Bearer router-key (OpenRouter must win)", got)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
t.Setenv("OPENROUTER_API_KEY", "router-key")
|
||||||
|
t.Setenv("OPENROUTER_BASE_URL", server.URL)
|
||||||
|
t.Setenv("OPENAI_API_KEY", "openai-key")
|
||||||
|
t.Setenv("OPENAI_BASE_URL", "http://127.0.0.1:1") // unreachable; must not be used
|
||||||
|
client, err := New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := client.ChatCompletion(context.Background(), Request{Model: "m"}); err != nil {
|
||||||
|
t.Fatalf("ChatCompletion: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user