mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* feat(providers): add AI/ML API preset * fix(providers): relocate aimlapi preset and drop referral header Addresses the two review asks on #402: - Move the AI/ML API descriptor out of catalog slot #2 (it sat above the first-party OpenAI/Anthropic/Google entries) down next to openrouter in the aggregator cluster. Re-sync expectedCatalogIDs and the TransportOpenAICompat order-list to match. - Drop the always-on X-AIMLAPI-Partner-ID / Integration-Repo / Integration-Version attribution headers. There's no partner arrangement behind the preset, so a non-overridable referral tag on every user's request isn't something to ship on a community preset. aimlapi now rides through plain CopyHeaders like every other provider. Removes HeadersForCatalog (it existed only for this special-case), reverts the factory and discovery call sites to CopyHeaders, and drops the header test. The factory/discovery tests now assert the endpoint, auth, and the user's own custom headers survive while no attribution header is sent. * fix(providers): address CodeRabbit review on #621 - Document AIMLAPIID for docstring coverage. - Apply providerio.CopyHeaders for Anthropic and Google provider paths, matching the OpenAI path introduced for aimlapi header isolation. Co-authored-by: Cursor <[email protected]> * fix(providers): gofmt factory.go to unblock CI smoke Remove mixed tab/space indentation in the openai.New options block so ubuntu-latest fmt-check passes. --------- Co-authored-by: Dmitry Tumanov <[email protected]> Co-authored-by: Vasanthdev2004 <[email protected]> Co-authored-by: Cursor <[email protected]> Co-authored-by: KRATOS <[email protected]>
797 lines
28 KiB
Go
797 lines
28 KiB
Go
package providers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/oauth"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
func TestNewCreatesOpenAIProviderWithFactoryOptions(t *testing.T) {
|
|
transport := &captureTransport{
|
|
responseBody: "data: [DONE]\n\n",
|
|
}
|
|
client := &http.Client{Transport: transport}
|
|
|
|
provider, err := New(config.ProviderProfile{
|
|
Name: "custom",
|
|
ProviderKind: config.ProviderKindOpenAICompatible,
|
|
BaseURL: "https://provider.example/v1/",
|
|
APIKey: "sk-factory",
|
|
Model: "factory-model",
|
|
}, Options{
|
|
HTTPClient: client,
|
|
UserAgent: "zero-factory-test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
|
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("StreamCompletion() error = %v", err)
|
|
}
|
|
for range stream {
|
|
}
|
|
|
|
if transport.request == nil {
|
|
t.Fatal("HTTP client was not used")
|
|
}
|
|
if transport.request.URL.String() != "https://provider.example/v1/chat/completions" {
|
|
t.Fatalf("request URL = %q, want provider base URL", transport.request.URL.String())
|
|
}
|
|
if transport.request.Header.Get("Authorization") != "Bearer sk-factory" {
|
|
t.Fatalf("Authorization = %q, want bearer token", transport.request.Header.Get("Authorization"))
|
|
}
|
|
if transport.request.Header.Get("User-Agent") != "zero-factory-test" {
|
|
t.Fatalf("User-Agent = %q, want factory user agent", transport.request.Header.Get("User-Agent"))
|
|
}
|
|
}
|
|
|
|
func TestNewUsesMiniMaxCompatibleEndpoints(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
catalogID string
|
|
providerKind config.ProviderKind
|
|
baseURL string
|
|
responseBody string
|
|
wantURL string
|
|
}{
|
|
{
|
|
name: "global Anthropic",
|
|
catalogID: "minimax",
|
|
providerKind: config.ProviderKindAnthropicCompat,
|
|
responseBody: "data: {\"type\":\"message_stop\"}\n\n",
|
|
wantURL: "https://api.minimax.io/anthropic/v1/messages",
|
|
},
|
|
{
|
|
name: "China Anthropic",
|
|
catalogID: "minimaxi-cn",
|
|
providerKind: config.ProviderKindAnthropicCompat,
|
|
responseBody: "data: {\"type\":\"message_stop\"}\n\n",
|
|
wantURL: "https://api.minimaxi.com/anthropic/v1/messages",
|
|
},
|
|
{
|
|
name: "global OpenAI",
|
|
catalogID: "custom-openai-compatible",
|
|
providerKind: config.ProviderKindOpenAICompatible,
|
|
baseURL: "https://api.minimax.io/v1",
|
|
responseBody: "data: [DONE]\n\n",
|
|
wantURL: "https://api.minimax.io/v1/chat/completions",
|
|
},
|
|
{
|
|
name: "China OpenAI",
|
|
catalogID: "custom-openai-compatible",
|
|
providerKind: config.ProviderKindOpenAICompatible,
|
|
baseURL: "https://api.minimaxi.com/v1",
|
|
responseBody: "data: [DONE]\n\n",
|
|
wantURL: "https://api.minimaxi.com/v1/chat/completions",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
transport := &captureTransport{responseBody: test.responseBody}
|
|
provider, err := New(config.ProviderProfile{
|
|
Name: test.name,
|
|
CatalogID: test.catalogID,
|
|
ProviderKind: test.providerKind,
|
|
BaseURL: test.baseURL,
|
|
APIKey: "sk-minimax",
|
|
Model: "MiniMax-M3",
|
|
}, Options{HTTPClient: &http.Client{Transport: transport}})
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
|
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("StreamCompletion() error = %v", err)
|
|
}
|
|
for range stream {
|
|
}
|
|
|
|
if transport.request == nil {
|
|
t.Fatal("HTTP client was not used")
|
|
}
|
|
if got := transport.request.URL.String(); got != test.wantURL {
|
|
t.Fatalf("request URL = %q, want %q", got, test.wantURL)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewPassesOpenGatewayHY3ModelThrough(t *testing.T) {
|
|
transport := &captureTransport{
|
|
responseBody: "data: [DONE]\n\n",
|
|
}
|
|
client := &http.Client{Transport: transport}
|
|
|
|
provider, err := New(config.ProviderProfile{
|
|
Name: "opengateway",
|
|
CatalogID: "gitlawb-opengateway",
|
|
ProviderKind: config.ProviderKindOpenAICompatible,
|
|
APIKey: "ogw_live_test",
|
|
Model: "tencent/hy3",
|
|
}, Options{HTTPClient: client})
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
|
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("StreamCompletion() error = %v", err)
|
|
}
|
|
for range stream {
|
|
}
|
|
|
|
if transport.request.URL.String() != "https://opengateway.gitlawb.com/v1/chat/completions" {
|
|
t.Fatalf("request URL = %q, want OpenGateway chat completions", transport.request.URL.String())
|
|
}
|
|
var body map[string]any
|
|
if err := json.NewDecoder(transport.body()).Decode(&body); err != nil {
|
|
t.Fatalf("decode request body: %v", err)
|
|
}
|
|
if body["model"] != "tencent/hy3" {
|
|
t.Fatalf("model = %q, want tencent/hy3 passthrough", body["model"])
|
|
}
|
|
|
|
metadata, err := ResolveRuntimeMetadata(config.ProviderProfile{
|
|
Name: "opengateway",
|
|
CatalogID: "gitlawb-opengateway",
|
|
ProviderKind: config.ProviderKindOpenAICompatible,
|
|
Model: "tencent/hy3",
|
|
}, Options{})
|
|
if err != nil {
|
|
t.Fatalf("ResolveRuntimeMetadata() error = %v", err)
|
|
}
|
|
if metadata.ProviderKind != config.ProviderKindOpenAICompatible || metadata.APIModel != "tencent/hy3" {
|
|
t.Fatalf("runtime metadata = %#v, want OpenAI-compatible tencent/hy3", metadata)
|
|
}
|
|
}
|
|
|
|
func TestNewThreadsCustomProviderHeaders(t *testing.T) {
|
|
transport := &captureTransport{
|
|
responseBody: "data: [DONE]\n\n",
|
|
}
|
|
client := &http.Client{Transport: transport}
|
|
|
|
provider, err := New(config.ProviderProfile{
|
|
Name: "gateway",
|
|
ProviderKind: config.ProviderKindOpenAICompatible,
|
|
BaseURL: "https://gateway.example/v1",
|
|
APIKey: "sk-gateway",
|
|
AuthHeader: "X-API-Key",
|
|
AuthScheme: "Token",
|
|
CustomHeaders: map[string]string{"HTTP-Referer": "https://zero.dev"},
|
|
Model: "gateway-model",
|
|
}, Options{HTTPClient: client})
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
|
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("StreamCompletion() error = %v", err)
|
|
}
|
|
for range stream {
|
|
}
|
|
|
|
if transport.request.Header.Get("Authorization") != "" {
|
|
t.Fatalf("Authorization = %q, want empty when custom auth header is set", transport.request.Header.Get("Authorization"))
|
|
}
|
|
if transport.request.Header.Get("X-API-Key") != "Token sk-gateway" {
|
|
t.Fatalf("X-API-Key = %q, want custom auth header", transport.request.Header.Get("X-API-Key"))
|
|
}
|
|
if transport.request.Header.Get("HTTP-Referer") != "https://zero.dev" {
|
|
t.Fatalf("HTTP-Referer = %q, want custom provider header", transport.request.Header.Get("HTTP-Referer"))
|
|
}
|
|
}
|
|
|
|
func TestNewAIMLAPIProviderSendsEndpointAndAuthWithoutAttribution(t *testing.T) {
|
|
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
|
|
provider, err := New(config.ProviderProfile{
|
|
Name: "aimlapi",
|
|
CatalogID: "aimlapi",
|
|
ProviderKind: config.ProviderKindOpenAICompatible,
|
|
APIKey: "aimlapi-test-key",
|
|
Model: "openai/gpt-5-chat",
|
|
CustomHeaders: map[string]string{"X-Trace": "test"},
|
|
}, Options{HTTPClient: &http.Client{Transport: transport}})
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
|
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("StreamCompletion() error = %v", err)
|
|
}
|
|
for range stream {
|
|
}
|
|
|
|
if transport.request == nil {
|
|
t.Fatal("HTTP client was not used")
|
|
}
|
|
if got := transport.request.URL.String(); got != "https://api.aimlapi.com/v1/chat/completions" {
|
|
t.Fatalf("request URL = %q, want AI/ML API endpoint", got)
|
|
}
|
|
for header, want := range map[string]string{
|
|
"Authorization": "Bearer aimlapi-test-key",
|
|
"X-Trace": "test",
|
|
} {
|
|
if got := transport.request.Header.Get(header); got != want {
|
|
t.Fatalf("%s = %q, want %q", header, got, want)
|
|
}
|
|
}
|
|
// No first-party referral/attribution headers are injected for catalog
|
|
// presets; aimlapi rides through CopyHeaders like every other provider.
|
|
for _, header := range []string{
|
|
"X-AIMLAPI-Partner-ID",
|
|
"X-AIMLAPI-Integration-Repo",
|
|
"X-AIMLAPI-Integration-Version",
|
|
} {
|
|
if got := transport.request.Header.Get(header); got != "" {
|
|
t.Fatalf("%s = %q, want no attribution header", header, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewSupportsOpenAIProviderKind(t *testing.T) {
|
|
provider, err := New(config.ProviderProfile{
|
|
Name: "openai",
|
|
ProviderKind: config.ProviderKindOpenAI,
|
|
Model: "gpt-test",
|
|
}, Options{})
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
if provider == nil {
|
|
t.Fatal("New() returned nil provider")
|
|
}
|
|
}
|
|
|
|
// TestPromptCacheKeyOnlyOnOfficialOpenAI locks in #624: session-backed TUI
|
|
// turns always carry a PromptCacheKey, but openai-compatible gateways (NVIDIA
|
|
// NIM, strict local proxies) reject the OpenAI-only prompt_cache_key field.
|
|
// The factory must omit it for openai-compatible profiles while still
|
|
// forwarding it for official OpenAI so multi-turn cache routing stays intact.
|
|
func TestPromptCacheKeyOnlyOnOfficialOpenAI(t *testing.T) {
|
|
requestWithSession := zeroruntime.CompletionRequest{
|
|
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
|
PromptCacheKey: "sess_tui_123",
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
kind config.ProviderKind
|
|
wantCacheKey bool
|
|
}{
|
|
{name: "openai", kind: config.ProviderKindOpenAI, wantCacheKey: true},
|
|
{name: "openai-compatible", kind: config.ProviderKindOpenAICompatible, wantCacheKey: false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
|
|
provider, err := New(config.ProviderProfile{
|
|
Name: "test",
|
|
ProviderKind: tc.kind,
|
|
BaseURL: "https://provider.example/v1",
|
|
APIKey: "sk-test",
|
|
Model: "test-model",
|
|
}, Options{HTTPClient: &http.Client{Transport: transport}})
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
stream, err := provider.StreamCompletion(context.Background(), requestWithSession)
|
|
if err != nil {
|
|
t.Fatalf("StreamCompletion() error = %v", err)
|
|
}
|
|
for range stream {
|
|
}
|
|
var body map[string]any
|
|
if err := json.NewDecoder(transport.body()).Decode(&body); err != nil {
|
|
t.Fatalf("decode request body: %v", err)
|
|
}
|
|
_, hasKey := body["prompt_cache_key"]
|
|
if hasKey != tc.wantCacheKey {
|
|
t.Fatalf("prompt_cache_key present = %v, want %v; body = %#v", hasKey, tc.wantCacheKey, body)
|
|
}
|
|
if tc.wantCacheKey && body["prompt_cache_key"] != "sess_tui_123" {
|
|
t.Fatalf("prompt_cache_key = %#v, want sess_tui_123", body["prompt_cache_key"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseThinkTagsForProfileUsesConservativeDefaultsAndOverride(t *testing.T) {
|
|
openAICompatible := resolvedProfile{providerKind: config.ProviderKindOpenAICompatible, apiModel: "qwen3-coder:480b"}
|
|
if !parseThinkTagsForProfile(config.ProviderProfile{}, openAICompatible) {
|
|
t.Fatal("qwen3 OpenAI-compatible model should parse inline think tags by default")
|
|
}
|
|
minimaxM27 := resolvedProfile{providerKind: config.ProviderKindOpenAICompatible, apiModel: "MiniMax-M2.7"}
|
|
if !parseThinkTagsForProfile(config.ProviderProfile{}, minimaxM27) {
|
|
t.Fatal("MiniMax-M2.7 OpenAI-compatible model should parse inline think tags by default")
|
|
}
|
|
|
|
generic := resolvedProfile{providerKind: config.ProviderKindOpenAICompatible, apiModel: "factory-model"}
|
|
if parseThinkTagsForProfile(config.ProviderProfile{}, generic) {
|
|
t.Fatal("generic OpenAI-compatible model should preserve literal think tags by default")
|
|
}
|
|
|
|
official := resolvedProfile{providerKind: config.ProviderKindOpenAI, apiModel: "gpt-4.1"}
|
|
if parseThinkTagsForProfile(config.ProviderProfile{}, official) {
|
|
t.Fatal("official OpenAI model should preserve literal think tags by default")
|
|
}
|
|
|
|
enabled := true
|
|
if !parseThinkTagsForProfile(config.ProviderProfile{ParseThinkTags: &enabled}, generic) {
|
|
t.Fatal("explicit parseThinkTags=true should enable inline think parsing")
|
|
}
|
|
|
|
disabled := false
|
|
if parseThinkTagsForProfile(config.ProviderProfile{ParseThinkTags: &disabled}, openAICompatible) {
|
|
t.Fatal("explicit parseThinkTags=false should disable inline think parsing")
|
|
}
|
|
}
|
|
|
|
func TestNewResolvesKnownModelToAPIModelAndProvider(t *testing.T) {
|
|
transport := &captureTransport{
|
|
responseBody: "data: {\"type\":\"message_stop\"}\n\n",
|
|
}
|
|
client := &http.Client{Transport: transport}
|
|
|
|
provider, err := New(config.ProviderProfile{
|
|
Name: "claude",
|
|
APIKey: "sk-ant",
|
|
Model: "claude-sonnet-4.5",
|
|
}, Options{
|
|
HTTPClient: client,
|
|
UserAgent: "zero-factory-test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
|
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("StreamCompletion() error = %v", err)
|
|
}
|
|
for range stream {
|
|
}
|
|
|
|
if transport.request == nil {
|
|
t.Fatal("HTTP client was not used")
|
|
}
|
|
if transport.request.URL.String() != "https://api.anthropic.com/v1/messages" {
|
|
t.Fatalf("request URL = %q, want Anthropic Messages API", transport.request.URL.String())
|
|
}
|
|
if transport.request.Header.Get("x-api-key") != "sk-ant" {
|
|
t.Fatalf("x-api-key = %q, want Anthropic key", transport.request.Header.Get("x-api-key"))
|
|
}
|
|
if transport.request.Header.Get("User-Agent") != "zero-factory-test" {
|
|
t.Fatalf("User-Agent = %q, want factory user agent", transport.request.Header.Get("User-Agent"))
|
|
}
|
|
var body map[string]any
|
|
if err := json.NewDecoder(transport.body()).Decode(&body); err != nil {
|
|
t.Fatalf("decode request body: %v", err)
|
|
}
|
|
if body["model"] != "claude-sonnet-4-5-20250929" {
|
|
t.Fatalf("model = %q, want registry API model", body["model"])
|
|
}
|
|
if body["max_tokens"] != float64(64000) {
|
|
t.Fatalf("max_tokens = %#v, want registry output ceiling", body["max_tokens"])
|
|
}
|
|
}
|
|
|
|
func TestNewCreatesGeminiProviderFromFactoryOptions(t *testing.T) {
|
|
transport := &captureTransport{
|
|
responseBody: "data: {}\n\n",
|
|
}
|
|
client := &http.Client{Transport: transport}
|
|
|
|
provider, err := New(config.ProviderProfile{
|
|
Name: "google",
|
|
ProviderKind: config.ProviderKindGoogle,
|
|
APIKey: "sk-google",
|
|
Model: "gemini-2.5-flash",
|
|
}, Options{
|
|
HTTPClient: client,
|
|
UserAgent: "zero-factory-test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
|
|
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
|
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("StreamCompletion() error = %v", err)
|
|
}
|
|
for range stream {
|
|
}
|
|
|
|
if transport.request == nil {
|
|
t.Fatal("HTTP client was not used")
|
|
}
|
|
wantURL := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
|
if transport.request.URL.String() != wantURL {
|
|
t.Fatalf("request URL = %q, want %s", transport.request.URL.String(), wantURL)
|
|
}
|
|
if transport.request.Header.Get("x-goog-api-key") != "sk-google" {
|
|
t.Fatalf("x-goog-api-key = %q, want Google key", transport.request.Header.Get("x-goog-api-key"))
|
|
}
|
|
var body map[string]any
|
|
if err := json.NewDecoder(transport.body()).Decode(&body); err != nil {
|
|
t.Fatalf("decode request body: %v", err)
|
|
}
|
|
generationConfig := body["generationConfig"].(map[string]any)
|
|
if generationConfig["maxOutputTokens"] != float64(65536) {
|
|
t.Fatalf("maxOutputTokens = %#v, want registry output ceiling", generationConfig["maxOutputTokens"])
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsMismatchedOfficialProviderAndKnownModel(t *testing.T) {
|
|
_, err := New(config.ProviderProfile{
|
|
Name: "openai",
|
|
ProviderKind: config.ProviderKindOpenAI,
|
|
Model: "claude-sonnet-4.5",
|
|
}, Options{})
|
|
if err == nil {
|
|
t.Fatal("New() error = nil, want provider/model mismatch")
|
|
}
|
|
if !strings.Contains(err.Error(), "belongs to anthropic, not openai") {
|
|
t.Fatalf("error = %q, want model/provider mismatch", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsUnsupportedProviderKind(t *testing.T) {
|
|
_, err := New(config.ProviderProfile{
|
|
Name: "bad",
|
|
ProviderKind: "bedrock",
|
|
Model: "model",
|
|
}, Options{})
|
|
if err == nil {
|
|
t.Fatal("New() error = nil, want unsupported kind error")
|
|
}
|
|
if !strings.Contains(err.Error(), `unsupported provider kind "bedrock"`) {
|
|
t.Fatalf("error = %q, want unsupported provider kind", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestNewRoutesChatGPTCatalogToCodexProvider(t *testing.T) {
|
|
// Isolate the OAuth token store to an empty temp path so the factory reads no
|
|
// stored login — otherwise this test picks up the developer's real chatgpt
|
|
// OAuth token and the "want empty chatgpt-account-id" assertion fails locally
|
|
// (it still passes in CI, where no login is stored). Mirrors the isolation in
|
|
// TestNewRoutesChatGPTCatalogWithStoredAccountID.
|
|
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
|
t.Setenv("ZERO_OAUTH_TOKENS_PATH", t.TempDir()+"/tokens.json")
|
|
|
|
transport := &captureTransport{
|
|
responseBody: "data: [DONE]\n\n",
|
|
}
|
|
client := &http.Client{Transport: transport}
|
|
|
|
provider, err := New(config.ProviderProfile{
|
|
Name: "chatgpt",
|
|
CatalogID: "chatgpt",
|
|
Model: "gpt-5",
|
|
}, Options{
|
|
HTTPClient: client,
|
|
UserAgent: "zero-factory-test",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
|
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("StreamCompletion() error = %v", err)
|
|
}
|
|
for range stream {
|
|
}
|
|
if transport.request == nil {
|
|
t.Fatal("HTTP client was not used")
|
|
}
|
|
// The chatgpt catalog's baseURL is the Codex backend. The Codex
|
|
// provider targets the Responses API at `{baseURL}/responses`, not
|
|
// `/chat/completions` (a chat-completions body on this path would 404
|
|
// or be misrouted by the Codex gateway).
|
|
if !strings.HasSuffix(transport.request.URL.Path, "/responses") {
|
|
t.Fatalf("request URL path = %q, want .../responses", transport.request.URL.Path)
|
|
}
|
|
wantHost := "chatgpt.com"
|
|
if !strings.Contains(transport.request.URL.Host, wantHost) {
|
|
t.Fatalf("request URL host = %q, want the Codex backend (chatgpt.com)", transport.request.URL.Host)
|
|
}
|
|
// The Codex-required headers must be present even when the OAuth token
|
|
// has no account id (the AccountResolver returns ok=false in that case,
|
|
// so the chatgpt-account-id header is just omitted, not wrongly set).
|
|
if got := transport.request.Header.Get("originator"); got != "codex_cli_rs" {
|
|
t.Fatalf("originator = %q, want codex_cli_rs", got)
|
|
}
|
|
if got := transport.request.Header.Get("chatgpt-account-id"); got != "" {
|
|
t.Fatalf("chatgpt-account-id = %q, want empty when no OAuth login is stored", got)
|
|
}
|
|
}
|
|
|
|
func TestNewRoutesChatGPTCatalogWithStoredAccountID(t *testing.T) {
|
|
// The factory reads the stored OAuth token's Account field for the
|
|
// chatgpt-account-id header, from the login key the CALLER supplies in
|
|
// Options.OAuthLoginKey (the same key the bearer resolver bound). Seed a
|
|
// token in an isolated temp store, then pass that key.
|
|
store, err := newOAuthStoreForTest(t)
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{
|
|
AccessToken: "tok-1",
|
|
Account: "acc-stored-42",
|
|
}); err != nil {
|
|
t.Fatalf("Save: %v", err)
|
|
}
|
|
transport := &captureTransport{
|
|
responseBody: "data: [DONE]\n\n",
|
|
}
|
|
provider, err := New(config.ProviderProfile{
|
|
Name: "chatgpt",
|
|
CatalogID: "chatgpt",
|
|
Model: "gpt-5",
|
|
}, Options{
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
UserAgent: "zero-factory-test",
|
|
OAuthLoginKey: oauth.ProviderKey("chatgpt"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v", err)
|
|
}
|
|
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
|
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("StreamCompletion() error = %v", err)
|
|
}
|
|
for range stream {
|
|
}
|
|
if got := transport.request.Header.Get("chatgpt-account-id"); got != "acc-stored-42" {
|
|
t.Fatalf("chatgpt-account-id = %q, want acc-stored-42", got)
|
|
}
|
|
}
|
|
|
|
func TestIsCodexCatalog(t *testing.T) {
|
|
cases := []struct {
|
|
catalogID string
|
|
want bool
|
|
}{
|
|
{"chatgpt", true},
|
|
{"ChatGPT", true},
|
|
{"openai", false},
|
|
{"", false},
|
|
{"chatgpt-proxy", false}, // the local proxy catalog stays on the openai path
|
|
}
|
|
for _, tc := range cases {
|
|
got := isCodexCatalog(config.ProviderProfile{CatalogID: tc.catalogID}, resolvedProfile{})
|
|
if got != tc.want {
|
|
t.Errorf("isCodexCatalog(%q) = %v, want %v", tc.catalogID, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
type captureTransport struct {
|
|
request *http.Request
|
|
requestBody string
|
|
responseBody string
|
|
}
|
|
|
|
func (transport *captureTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
transport.request = request
|
|
if request.Body != nil {
|
|
body, _ := io.ReadAll(request.Body)
|
|
transport.requestBody = string(body)
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Status: "200 OK",
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(transport.responseBody)),
|
|
Request: request,
|
|
}, nil
|
|
}
|
|
|
|
func (transport *captureTransport) body() io.Reader {
|
|
return strings.NewReader(transport.requestBody)
|
|
}
|
|
|
|
// newOAuthStoreForTest pins the OAuth token store to a plain temp FILE and
|
|
// returns a Store on it. Pinning ZERO_OAUTH_STORAGE matters as much as the
|
|
// path: an inherited "keyring" value would send NewStore to the OS keychain
|
|
// and ignore ZERO_OAUTH_TOKENS_PATH entirely, making the test read/write the
|
|
// developer's real logins. Exists so the chatgpt factory tests can seed a
|
|
// token without copying the path-handling dance from internal/cli.
|
|
func newOAuthStoreForTest(t *testing.T) (*oauth.Store, error) {
|
|
t.Helper()
|
|
t.Setenv("ZERO_OAUTH_STORAGE", "file")
|
|
t.Setenv("ZERO_OAUTH_TOKENS_PATH", t.TempDir()+"/tokens.json")
|
|
return oauth.NewStore(oauth.StoreOptions{})
|
|
}
|
|
|
|
// codexAccountForKey reads the chatgpt-account-id from the token stored under a
|
|
// FIXED key — the key the caller (cli.oauthLoginForProfile) already bound for the
|
|
// bearer token, passed through providers.Options.OAuthLoginKey. The account is
|
|
// therefore always read from the same login that issued the bearer (no second,
|
|
// independent selection). It re-reads per call, so an in-place token refresh (new
|
|
// account claim under the SAME key) is picked up; an empty key (no OAuth login)
|
|
// or a missing/account-less token yields "".
|
|
func TestCodexAccountForKey(t *testing.T) {
|
|
store, err := newOAuthStoreForTest(t)
|
|
if err != nil {
|
|
t.Fatalf("NewStore: %v", err)
|
|
}
|
|
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok-1", Account: "acc-catalog-7"}); err != nil {
|
|
t.Fatalf("Save chatgpt: %v", err)
|
|
}
|
|
// A different login WITHOUT an account claim, to prove the read stays on the
|
|
// requested key and never crosses to another login's account.
|
|
if err := store.Save(oauth.ProviderKey("codex"), oauth.Token{AccessToken: "tok-codex"}); err != nil {
|
|
t.Fatalf("Save codex: %v", err)
|
|
}
|
|
|
|
if got := codexAccountForKey(oauth.ProviderKey("chatgpt")); got != "acc-catalog-7" {
|
|
t.Fatalf("account = %q, want acc-catalog-7", got)
|
|
}
|
|
// The bound key's token has no account → "", NOT the other login's account.
|
|
if got := codexAccountForKey(oauth.ProviderKey("codex")); got != "" {
|
|
t.Fatalf("account = %q, want empty (must not cross to another login's account)", got)
|
|
}
|
|
// Empty key (no OAuth login) → header omitted.
|
|
if got := codexAccountForKey(""); got != "" {
|
|
t.Fatalf("account for empty key = %q, want empty", got)
|
|
}
|
|
// Unknown key → "".
|
|
if got := codexAccountForKey(oauth.ProviderKey("nope")); got != "" {
|
|
t.Fatalf("account for unknown key = %q, want empty", got)
|
|
}
|
|
|
|
// An in-place refresh under the bound key IS reflected per request.
|
|
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "tok-refreshed", Account: "acc-rotated"}); err != nil {
|
|
t.Fatalf("refresh chatgpt: %v", err)
|
|
}
|
|
if got := codexAccountForKey(oauth.ProviderKey("chatgpt")); got != "acc-rotated" {
|
|
t.Fatalf("account = %q, want acc-rotated (in-place refresh must be picked up)", got)
|
|
}
|
|
}
|
|
|
|
// TestNewRejectsModelOutsideProviderAllowlist guards the PR #544 P2 fix:
|
|
// opencode-go-anthropic-compatible only accepts Qwen and MiniMax model IDs,
|
|
// so a Claude model routed through this catalog-backed profile must be rejected
|
|
// at runtime, not silently forwarded to https://opencode.ai/zen/go/v1/messages.
|
|
func TestNewRejectsModelOutsideProviderAllowlist(t *testing.T) {
|
|
_, err := New(config.ProviderProfile{
|
|
Name: "opencode-go-anthropic",
|
|
CatalogID: "opencode-go-anthropic-compatible",
|
|
APIKey: "sk-test",
|
|
Model: "claude-sonnet-4.5",
|
|
}, Options{})
|
|
if err == nil {
|
|
t.Fatal("New() error = nil, want claude-sonnet-4.5 rejected for opencode-go-anthropic-compatible")
|
|
}
|
|
if !strings.Contains(err.Error(), "opencode-go-anthropic-compatible") {
|
|
t.Fatalf("error = %q, want it to name the provider", err.Error())
|
|
}
|
|
if !strings.Contains(err.Error(), "claude-sonnet-4.5") {
|
|
t.Fatalf("error = %q, want it to name the rejected model", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestNewRejectsRawModelOutsideProviderAllowlist covers the registry-miss
|
|
// branch of resolveProfile: an unknown raw model id (not in the model registry)
|
|
// typed by the user into a restricted-provider profile must still be rejected
|
|
// rather than passthrough-sent.
|
|
func TestNewRejectsRawModelOutsideProviderAllowlist(t *testing.T) {
|
|
_, err := New(config.ProviderProfile{
|
|
Name: "opencode-go-anthropic",
|
|
CatalogID: "opencode-go-anthropic-compatible",
|
|
APIKey: "sk-test",
|
|
Model: "totally-custom-not-in-allowlist-12345",
|
|
}, Options{})
|
|
if err == nil {
|
|
t.Fatal("New() error = nil, want unknown raw model rejected for opencode-go-anthropic-compatible")
|
|
}
|
|
if !strings.Contains(err.Error(), "does not allow model") {
|
|
t.Fatalf("error = %q, want allowlist rejection", err.Error())
|
|
}
|
|
if !strings.Contains(err.Error(), "totally-custom-not-in-allowlist-12345") {
|
|
t.Fatalf("error = %q, want the raw model name to appear in the error", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestNewAllowsCuratedModelsForRestrictedProvider proves the gate is not
|
|
// over-eager: Qwen and MiniMax curated models pass through unchanged for
|
|
// opencode-go-anthropic-compatible.
|
|
func TestNewAllowsCuratedModelsForRestrictedProvider(t *testing.T) {
|
|
for _, model := range []string{"minimax-m3", "qwen3.7-plus", "qwen3.7-max", "minimax-m2.7"} {
|
|
_, err := New(config.ProviderProfile{
|
|
Name: "opencode-go-anthropic",
|
|
CatalogID: "opencode-go-anthropic-compatible",
|
|
APIKey: "sk-test",
|
|
Model: model,
|
|
}, Options{})
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v, want curated model %q to be allowed", err, model)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNewAllowsAnyModelForUnrestrictedProvider is the regression guard:
|
|
// unrestricted providers (no catalog or default catalog id) must continue to
|
|
// accept any model id, including those outside the opencode allowlist.
|
|
func TestNewAllowsAnyModelForUnrestrictedProvider(t *testing.T) {
|
|
_, err := New(config.ProviderProfile{
|
|
Name: "anthropic",
|
|
ProviderKind: config.ProviderKindAnthropic,
|
|
APIKey: "sk-ant",
|
|
Model: "claude-sonnet-4.5",
|
|
}, Options{})
|
|
if err != nil {
|
|
t.Fatalf("New() error = %v, want unrestricted anthropic provider to accept claude-sonnet-4.5", err)
|
|
}
|
|
}
|
|
|
|
// TestResolveRuntimeMetadataRejectsModelOutsideProviderAllowlist guards the
|
|
// read-only metadata path (called from TUI command_center, exec, provider
|
|
// health, context report). The metadata path must enforce the same gate as
|
|
// New() so a config-time override cannot escape through it.
|
|
func TestResolveRuntimeMetadataRejectsModelOutsideProviderAllowlist(t *testing.T) {
|
|
_, err := ResolveRuntimeMetadata(config.ProviderProfile{
|
|
Name: "opencode-go-anthropic",
|
|
CatalogID: "opencode-go-anthropic-compatible",
|
|
APIKey: "sk-test",
|
|
Model: "claude-sonnet-4.5",
|
|
}, Options{})
|
|
if err == nil {
|
|
t.Fatal("ResolveRuntimeMetadata error = nil, want claude-sonnet-4.5 rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "opencode-go-anthropic-compatible") {
|
|
t.Fatalf("error = %q, want it to name the provider", err.Error())
|
|
}
|
|
if !strings.Contains(err.Error(), "claude-sonnet-4.5") {
|
|
t.Fatalf("error = %q, want it to name the rejected model", err.Error())
|
|
}
|
|
}
|