| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187 |
- package service
- import (
- "context"
- "encoding/json"
- "errors"
- "io"
- "net/http"
- "strings"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/stretchr/testify/require"
- )
- type upstreamModelMetadataRepoStub struct {
- AccountRepository
- accountID int64
- updates map[string]any
- err error
- }
- func headerValuesEqualFold(header http.Header, name string) []string {
- for key, values := range header {
- if strings.EqualFold(key, name) {
- return values
- }
- }
- return nil
- }
- func (r *upstreamModelMetadataRepoStub) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
- r.accountID = id
- r.updates = updates
- return r.err
- }
- func upstreamModelSyncTestConfig() *config.Config {
- return &config.Config{
- Security: config.SecurityConfig{
- URLAllowlist: config.URLAllowlistConfig{Enabled: false},
- },
- }
- }
- func grokOAuthModelSyncTestAccount(baseURL string) *Account {
- credentials := map[string]any{
- "access_token": "oauth-access-token",
- "refresh_token": "oauth-refresh-token",
- "expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
- "sub": "grok-user-id",
- "email": "grok-user@example.com",
- }
- if strings.TrimSpace(baseURL) != "" {
- credentials["base_url"] = baseURL
- }
- return &Account{
- ID: 10,
- Platform: PlatformGrok,
- Type: AccountTypeOAuth,
- Credentials: credentials,
- }
- }
- func TestBuildV1ModelsURL(t *testing.T) {
- t.Parallel()
- require.Equal(t, "https://api.anthropic.com/v1/models", buildV1ModelsURL("https://api.anthropic.com"))
- require.Equal(t, "https://api.anthropic.com/v1/models", buildV1ModelsURL("https://api.anthropic.com/v1"))
- require.Equal(t, "https://api.anthropic.com/v1/models", buildV1ModelsURL("https://api.anthropic.com/v1/models"))
- require.Equal(t, "https://gateway.example.com/antigravity/v1/models", buildV1ModelsURL("https://gateway.example.com/antigravity/"))
- }
- func TestBuildOpenAIModelsURL(t *testing.T) {
- t.Parallel()
- tests := []struct {
- name string
- base string
- want string
- }{
- {
- name: "zhipu v4 coding base url",
- base: "https://open.bigmodel.cn/api/coding/paas/v4",
- want: "https://open.bigmodel.cn/api/coding/paas/v4/models",
- },
- {
- name: "openai v1 base url",
- base: "https://api.openai.com/v1",
- want: "https://api.openai.com/v1/models",
- },
- {
- name: "models url unchanged",
- base: "https://api.openai.com/v1/models",
- want: "https://api.openai.com/v1/models",
- },
- {
- name: "host fallback uses v1",
- base: "https://api.openai.com",
- want: "https://api.openai.com/v1/models",
- },
- {
- name: "trailing slash on v4",
- base: "https://open.bigmodel.cn/api/coding/paas/v4/",
- want: "https://open.bigmodel.cn/api/coding/paas/v4/models",
- },
- {
- name: "v2 base url",
- base: "https://gateway.example.com/openai/v2",
- want: "https://gateway.example.com/openai/v2/models",
- },
- {
- name: "v3 base url",
- base: "https://gateway.example.com/openai/v3",
- want: "https://gateway.example.com/openai/v3/models",
- },
- }
- for _, tt := range tests {
- tt := tt
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- require.Equal(t, tt.want, buildOpenAIModelsURL(tt.base))
- })
- }
- }
- func TestBuildGeminiModelsURL(t *testing.T) {
- t.Parallel()
- require.Equal(t, "https://generativelanguage.googleapis.com/v1beta/models", buildGeminiModelsURL("https://generativelanguage.googleapis.com"))
- require.Equal(t, "https://generativelanguage.googleapis.com/v1beta/models", buildGeminiModelsURL("https://generativelanguage.googleapis.com/v1beta"))
- require.Equal(t, "https://generativelanguage.googleapis.com/v1beta/models", buildGeminiModelsURL("https://generativelanguage.googleapis.com/v1beta/models"))
- }
- func TestExtractUpstreamModelIDs(t *testing.T) {
- t.Parallel()
- tests := []struct {
- name string
- body string
- want []string
- }{
- {
- name: "openai and anthropic data array",
- body: `{"data":[{"id":"claude-sonnet-4-5"},{"id":"gpt-5"},{"id":"gpt-5"},{"id":""}]}`,
- want: []string{"claude-sonnet-4-5", "gpt-5"},
- },
- {
- name: "gemini models array strips prefix",
- body: `{"models":[{"name":"models/gemini-2.5-pro"},{"name":"gemini-2.5-flash"}]}`,
- want: []string{"gemini-2.5-flash", "gemini-2.5-pro"},
- },
- {
- name: "top level array",
- body: `[{"id":"z-model"},{"name":"models/a-model"}]`,
- want: []string{"a-model", "z-model"},
- },
- {
- name: "standard id wins over provider-specific model field",
- body: `{"data":[{"id":"canonical-id","model":"display-model"}]}`,
- want: []string{"canonical-id"},
- },
- {
- name: "codex manifest uses slug",
- body: `{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-5.5-codex"}]}`,
- want: []string{"gpt-5.5-codex", "gpt-5.6-sol"},
- },
- }
- for _, tt := range tests {
- tt := tt
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- got, err := extractUpstreamModelIDs([]byte(tt.body))
- require.NoError(t, err)
- require.Equal(t, tt.want, got)
- })
- }
- }
- func TestBuildUpstreamModelsRequestSupportsOpenAIOAuth(t *testing.T) {
- svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()}
- account := &Account{
- ID: 11,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "access_token": "openai-oauth-token",
- "chatgpt_account_id": "chatgpt-account",
- },
- }
- req, err := svc.buildUpstreamModelsRequest(context.Background(), account)
- require.NoError(t, err)
- require.Equal(t, chatgptCodexModelsURL, req.URL.Scheme+"://"+req.URL.Host+req.URL.Path)
- require.NotEmpty(t, req.URL.Query().Get("client_version"))
- require.Equal(t, "Bearer openai-oauth-token", req.Header.Get("Authorization"))
- require.Equal(t, "chatgpt-account", req.Header.Get("chatgpt-account-id"))
- require.NotEmpty(t, req.Header.Get("Originator"))
- require.NotEmpty(t, req.Header.Get("User-Agent"))
- require.NotEmpty(t, req.Header.Get("Version"))
- }
- func TestFetchUpstreamSupportedModelsParsesOpenAIOAuthManifest(t *testing.T) {
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-5.5-codex"}]}`)),
- }}
- svc := &AccountTestService{
- httpUpstream: upstream,
- cfg: upstreamModelSyncTestConfig(),
- }
- models, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{
- ID: 12,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "access_token": "openai-oauth-token",
- },
- })
- require.NoError(t, err)
- require.Equal(t, []string{"gpt-5.5-codex", "gpt-5.6-sol"}, models)
- require.Equal(t, "Bearer openai-oauth-token", upstream.lastReq.Header.Get("Authorization"))
- }
- func TestExtractGrokUpstreamModelIDs(t *testing.T) {
- t.Parallel()
- models, err := extractGrokUpstreamModelIDs([]byte(`{"data":[{"id":"display-id","model":"grok-4.5"},{"modelId":"grok-build-0.1"},{"model_id":"grok-composer-2.5-fast"},{"name":"Grok Meta Display Name","_meta":{"model":"grok-meta"}},{"name":"grok-name"},{"id":"grok-safe","_meta":"not-an-object"}]}`))
- require.NoError(t, err)
- require.Equal(t, []string{"grok-4.5", "grok-build-0.1", "grok-composer-2.5-fast", "grok-meta", "grok-name", "grok-safe"}, models)
- }
- func TestBuildUpstreamModelsRequestsForAPIKeyAccounts(t *testing.T) {
- t.Parallel()
- svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()}
- ctx := context.Background()
- anthropicReq, err := svc.buildAnthropicUpstreamModelsRequest(ctx, &Account{
- Platform: PlatformAnthropic,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "anthropic-key",
- "base_url": "https://anthropic.example.com/v1",
- },
- })
- require.NoError(t, err)
- require.Equal(t, "https://anthropic.example.com/v1/models", anthropicReq.URL.String())
- require.Equal(t, "anthropic-key", anthropicReq.Header.Get("x-api-key"))
- require.Equal(t, "2023-06-01", anthropicReq.Header.Get("anthropic-version"))
- anthropicBearerReq, err := svc.buildAnthropicUpstreamModelsRequest(ctx, &Account{
- Platform: PlatformAnthropic,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "ollama-key",
- "base_url": "https://ollama.com",
- },
- Extra: map[string]any{
- "anthropic_apikey_auth_scheme": AnthropicAPIKeyAuthSchemeAuthorizationBearer,
- },
- })
- require.NoError(t, err)
- require.Equal(t, "https://ollama.com/v1/models", anthropicBearerReq.URL.String())
- require.Equal(t, "Bearer ollama-key", anthropicBearerReq.Header.Get("Authorization"))
- require.Empty(t, anthropicBearerReq.Header.Get("x-api-key"))
- require.Equal(t, "2023-06-01", anthropicBearerReq.Header.Get("anthropic-version"))
- openAIReq, err := svc.buildOpenAIUpstreamModelsRequest(ctx, &Account{
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "openai-key",
- "base_url": "https://openai.example.com",
- },
- })
- require.NoError(t, err)
- require.Equal(t, "https://openai.example.com/v1/models", openAIReq.URL.String())
- require.Equal(t, "Bearer openai-key", openAIReq.Header.Get("Authorization"))
- grokReq, err := svc.buildUpstreamModelsRequest(ctx, &Account{
- Platform: PlatformGrok,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "xai-key",
- "base_url": "https://xai.example.com/v1",
- },
- })
- require.NoError(t, err)
- require.Equal(t, "https://xai.example.com/v1/models", grokReq.URL.String())
- require.Equal(t, "Bearer xai-key", grokReq.Header.Get("Authorization"))
- geminiReq, err := svc.buildGeminiUpstreamModelsRequest(ctx, &Account{
- Platform: PlatformGemini,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "gemini-key",
- "base_url": "https://generativelanguage.googleapis.com/v1beta",
- },
- })
- require.NoError(t, err)
- require.Equal(t, "https://generativelanguage.googleapis.com/v1beta/models", geminiReq.URL.String())
- require.Equal(t, "gemini-key", geminiReq.Header.Get("x-goog-api-key"))
- antigravityReq, err := svc.buildAntigravityAPIKeyModelsRequest(ctx, &Account{
- Platform: PlatformAntigravity,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "antigravity-key",
- "base_url": "https://gateway.example.com/antigravity",
- },
- })
- require.NoError(t, err)
- require.Equal(t, "https://gateway.example.com/antigravity/v1/models", antigravityReq.URL.String())
- require.Equal(t, "antigravity-key", antigravityReq.Header.Get("x-api-key"))
- }
- func TestBuildUpstreamModelsRequestSupportsGrokOAuth(t *testing.T) {
- t.Parallel()
- svc := &AccountTestService{
- cfg: upstreamModelSyncTestConfig(),
- grokTokenProvider: NewGrokTokenProvider(nil, nil),
- }
- req, err := svc.buildUpstreamModelsRequest(context.Background(), grokOAuthModelSyncTestAccount(""))
- require.NoError(t, err)
- require.Equal(t, "https://cli-chat-proxy.grok.com/v1/models", req.URL.String())
- require.Equal(t, "Bearer oauth-access-token", req.Header.Get("Authorization"))
- require.Equal(t, grokCLIVersion, req.Header.Get("X-Grok-Client-Version"))
- require.Equal(t, "interactive", req.Header.Get("X-Grok-Client-Mode"))
- require.Equal(t, defaultGrokUpstreamUserAgent(), req.Header.Get("User-Agent"))
- require.Equal(t, "grok-user-id", req.Header.Get("X-UserID"))
- require.Equal(t, "grok-user@example.com", req.Header.Get("X-Email"))
- require.NotContains(t, req.Header.Get("Authorization"), "oauth-refresh-token")
- }
- func TestBuildUpstreamModelsRequestGrokOAuthRequiresTokenProvider(t *testing.T) {
- t.Parallel()
- svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()}
- _, err := svc.buildUpstreamModelsRequest(context.Background(), grokOAuthModelSyncTestAccount(""))
- require.Error(t, err)
- var syncErr *UpstreamModelSyncError
- require.True(t, errors.As(err, &syncErr))
- require.Equal(t, UpstreamModelSyncErrorConfiguration, syncErr.Kind)
- require.Contains(t, syncErr.SafeMessage(), "token provider")
- }
- func TestBuildAntigravityAPIKeyModelsRequestRejectsOfficialCloudCodeBase(t *testing.T) {
- t.Parallel()
- svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()}
- _, err := svc.buildAntigravityAPIKeyModelsRequest(context.Background(), &Account{
- Platform: PlatformAntigravity,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "antigravity-key",
- "base_url": "https://cloudcode-pa.googleapis.com",
- },
- })
- require.Error(t, err)
- var syncErr *UpstreamModelSyncError
- require.True(t, errors.As(err, &syncErr))
- require.Equal(t, UpstreamModelSyncErrorUnsupported, syncErr.Kind)
- require.Contains(t, syncErr.SafeMessage(), "compatible gateway")
- }
- func TestBuildAnthropicUpstreamModelsRequestRejectsBedrock(t *testing.T) {
- t.Parallel()
- svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()}
- _, err := svc.buildAnthropicUpstreamModelsRequest(context.Background(), &Account{
- Platform: PlatformAnthropic,
- Type: AccountTypeBedrock,
- })
- require.Error(t, err)
- var syncErr *UpstreamModelSyncError
- require.True(t, errors.As(err, &syncErr))
- require.Equal(t, UpstreamModelSyncErrorUnsupported, syncErr.Kind)
- }
- func TestFetchUpstreamSupportedModelsParsesOpenAIResponse(t *testing.T) {
- t.Parallel()
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"gpt-5"},{"id":"gpt-5"},{"name":"o3"}]}`)),
- }}
- svc := &AccountTestService{
- httpUpstream: upstream,
- cfg: upstreamModelSyncTestConfig(),
- }
- models, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{
- ID: 7,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "openai-key",
- "base_url": "https://openai.example.com/v1",
- },
- })
- require.NoError(t, err)
- require.Equal(t, []string{"gpt-5", "o3"}, models)
- require.Equal(t, "https://openai.example.com/v1/models", upstream.lastReq.URL.String())
- require.Equal(t, "Bearer openai-key", upstream.lastReq.Header.Get("Authorization"))
- }
- // Scenario: ID-only 模型列表从 Models.dev 补齐能力。
- func TestSyncUpstreamModelCatalogEnrichesOpenCodeIDOnlyListAndPersistsSnapshot(t *testing.T) {
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"x-preview-f-free","object":"model"}]}`)),
- },
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{
- "opencode": {
- "id": "opencode",
- "name": "OpenCode Zen",
- "api": "https://opencode.ai/zen/v1",
- "models": {
- "x-preview-f-free": {
- "id": "x-preview-f-free",
- "name": "Ox Alpha Free (Unlimited)",
- "description": "Stealth reasoning model for coding, agentic tasks, and tool use",
- "reasoning": true,
- "reasoning_options": [{"type":"effort","values":["low","high","max"]}],
- "modalities": {"input":["text","image","video"],"output":["text"]},
- "limit": {"context":1000000,"output":131072}
- }
- }
- }
- }`)),
- },
- }}
- repo := &upstreamModelMetadataRepoStub{}
- svc := &AccountTestService{
- accountRepo: repo,
- httpUpstream: upstream,
- cfg: upstreamModelSyncTestConfig(),
- }
- account := &Account{
- ID: 91,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "opencode-key",
- "base_url": "https://opencode.ai/zen/v1",
- "header_override_enabled": true,
- "header_overrides": map[string]any{
- "X-Custom-Account-Header": "account-secret",
- },
- },
- }
- catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
- require.NoError(t, err)
- require.Equal(t, []string{"x-preview-f-free"}, catalog.Models)
- require.Len(t, upstream.requests, 2)
- require.Equal(t, "https://opencode.ai/zen/v1/models", upstream.requests[0].URL.String())
- require.Equal(t, []string{"account-secret"}, headerValuesEqualFold(upstream.requests[0].Header, "X-Custom-Account-Header"))
- require.Equal(t, modelsDevRegistryURL, upstream.requests[1].URL.String())
- require.Empty(t, upstream.requests[1].Header.Get("Authorization"))
- require.Empty(t, upstream.requests[1].Header.Get("x-api-key"))
- require.Empty(t, headerValuesEqualFold(upstream.requests[1].Header, "X-Custom-Account-Header"))
- metadata := catalog.Metadata["x-preview-f-free"]
- require.Equal(t, "Ox Alpha Free (Unlimited)", metadata.DisplayName)
- require.NotNil(t, metadata.Reasoning)
- require.True(t, *metadata.Reasoning)
- require.Equal(t, []string{"low", "high", "max"}, metadata.SupportedReasoningLevels)
- require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
- require.Equal(t, int64(1_000_000), metadata.ContextWindow)
- require.Equal(t, int64(131_072), metadata.MaxOutputTokens)
- require.Equal(t, int64(91), repo.accountID)
- rawSnapshot, ok := repo.updates[UpstreamModelMetadataExtraKey]
- require.True(t, ok)
- encoded, err := json.Marshal(rawSnapshot)
- require.NoError(t, err)
- var snapshot UpstreamModelMetadataSnapshot
- require.NoError(t, json.Unmarshal(encoded, &snapshot))
- require.Equal(t, "models.dev", snapshot.Source)
- require.Equal(t, metadata, snapshot.Models["x-preview-f-free"])
- }
- // Scenario: 不提供 /models 的兼容上游使用管理员已配置模型继续同步能力。
- func TestSyncUpstreamModelCatalogUsesConfiguredModelsWhenListEndpointUnsupported(t *testing.T) {
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {
- StatusCode: http.StatusNotFound,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)),
- },
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{
- "configured-provider": {
- "id": "configured-provider",
- "name": "Configured Provider",
- "api": "https://provider.example/v1",
- "models": {
- "glm-5.3": {
- "id": "glm-5.3",
- "name": "GLM-5.3",
- "reasoning": true,
- "reasoning_options": [{"type":"effort","values":["low","medium","high"]}],
- "modalities": {"input":["text"],"output":["text"]},
- "limit": {"context":1000000,"output":131072}
- }
- }
- }
- }`)),
- },
- }}
- repo := &upstreamModelMetadataRepoStub{}
- svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
- account := &Account{
- ID: 97, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "key",
- "base_url": "https://provider.example/v1",
- "model_mapping": map[string]any{
- "public-glm": "glm-5.3",
- "duplicate": "glm-5.3",
- "wildcard": "glm-*",
- "empty": "",
- },
- },
- }
- account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
- "old-live-model": {ID: "old-live-model", ContextWindow: 256000},
- }})
- catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
- require.NoError(t, err)
- require.Contains(t, account.GetUpstreamModelMetadataSnapshot().Models, "old-live-model", "an unavailable model-list endpoint is not evidence of removal")
- require.Equal(t, []string{"glm-5.3"}, catalog.Models)
- require.Empty(t, catalog.Warnings)
- require.Len(t, upstream.requests, 2)
- require.Equal(t, "https://provider.example/v1/models", upstream.requests[0].URL.String())
- require.Equal(t, modelsDevRegistryURL, upstream.requests[1].URL.String())
- metadata := catalog.Metadata["glm-5.3"]
- require.Equal(t, []string{"low", "medium", "high"}, metadata.SupportedReasoningLevels)
- require.Equal(t, []string{"text"}, metadata.InputModalities)
- require.Equal(t, int64(1_000_000), metadata.ContextWindow)
- require.NotNil(t, repo.updates)
- }
- func TestSyncUpstreamModelCatalogDoesNotUseConfiguredModelsForRealUpstreamFailures(t *testing.T) {
- tests := []struct {
- name string
- statusCode int
- }{
- {name: "unauthorized", statusCode: http.StatusUnauthorized},
- {name: "rate limited", statusCode: http.StatusTooManyRequests},
- {name: "server error", statusCode: http.StatusBadGateway},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: tt.statusCode,
- Body: io.NopCloser(strings.NewReader(`{"error":"failed"}`)),
- }}
- svc := &AccountTestService{httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
- _, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
- ID: 98, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "key",
- "base_url": "https://provider.example/v1",
- "model_mapping": map[string]any{"public-glm": "glm-5.3"},
- },
- })
- require.Error(t, err)
- require.Len(t, upstream.requests, 1)
- require.Equal(t, tt.statusCode, upstreamModelSyncStatusCode(err))
- })
- }
- }
- func TestSyncUpstreamModelCatalogRequiresConfiguredModelsForUnsupportedListEndpoint(t *testing.T) {
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusMethodNotAllowed,
- Body: io.NopCloser(strings.NewReader(`{"error":"method not allowed"}`)),
- }}
- svc := &AccountTestService{httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
- _, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
- ID: 99, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
- })
- require.Error(t, err)
- require.Equal(t, http.StatusMethodNotAllowed, upstreamModelSyncStatusCode(err))
- require.Len(t, upstream.requests, 1)
- }
- // Scenario: 完整上游模型清单优先保存能力。
- func TestSyncUpstreamModelCatalogPrefersDirectUpstreamMetadata(t *testing.T) {
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"models":[{
- "slug":"custom-thinking-model",
- "display_name":"Upstream Display",
- "description":"Upstream description",
- "default_reasoning_level":"high",
- "supported_reasoning_levels":[{"effort":"low"},{"effort":"high"},{"effort":"ultra"}],
- "input_modalities":["text","image"],
- "context_window":256000
- }]}`)),
- }}
- repo := &upstreamModelMetadataRepoStub{}
- svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
- catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
- ID: 92, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
- })
- require.NoError(t, err)
- require.Len(t, upstream.requests, 1, "complete upstream metadata must not be replaced by a registry fetch")
- metadata := catalog.Metadata["custom-thinking-model"]
- require.Equal(t, "Upstream Display", metadata.DisplayName)
- require.Equal(t, "high", metadata.DefaultReasoningLevel)
- require.Equal(t, []string{"low", "high", "ultra"}, metadata.SupportedReasoningLevels)
- require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
- require.Equal(t, int64(256_000), metadata.ContextWindow)
- }
- // Scenario: 上游 /models 增删型号后,正式同步用最新清单替换能力快照。
- func TestSyncUpstreamModelCatalogReplacesSnapshotWhenUpstreamModelsChange(t *testing.T) {
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"data":[
- {"id":"old-model","reasoning":false,"input_modalities":["text"],"context_window":128000},
- {"id":"kept-model","reasoning":false,"input_modalities":["text"],"context_window":128000}
- ]}`)),
- },
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"data":[
- {"id":"kept-model","reasoning":false,"input_modalities":["text"],"context_window":128000},
- {"id":"new-model","reasoning":false,"input_modalities":["text"],"context_window":256000}
- ]}`)),
- },
- }}
- repo := &upstreamModelMetadataRepoStub{}
- svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
- account := &Account{
- ID: 101, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "key",
- "base_url": "https://provider.example/v1",
- },
- }
- first, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
- require.NoError(t, err)
- require.Equal(t, []string{"kept-model", "old-model"}, first.Models)
- second, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
- require.NoError(t, err)
- require.Equal(t, []string{"kept-model", "new-model"}, second.Models)
- require.NotContains(t, second.Metadata, "old-model")
- require.Contains(t, second.Metadata, "new-model")
- encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey])
- require.NoError(t, err)
- var snapshot UpstreamModelMetadataSnapshot
- require.NoError(t, json.Unmarshal(encoded, &snapshot))
- require.NotContains(t, snapshot.Models, "old-model")
- require.Contains(t, snapshot.Models, "new-model")
- }
- // Scenario: 上游明确声明无推理能力时保存 false。
- func TestSyncUpstreamModelCatalogPersistsExplicitNonReasoningCapability(t *testing.T) {
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"models":[{
- "id":"company-coding-model",
- "display_name":"Company Coding Model",
- "reasoning":false,
- "input_modalities":["text"],
- "context_window":64000
- }]}`)),
- }}
- repo := &upstreamModelMetadataRepoStub{}
- svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
- catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
- ID: 94, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
- })
- require.NoError(t, err)
- require.Len(t, upstream.requests, 1)
- metadata := catalog.Metadata["company-coding-model"]
- require.NotNil(t, metadata.Reasoning)
- require.False(t, *metadata.Reasoning)
- require.Empty(t, metadata.SupportedReasoningLevels)
- require.Equal(t, []string{"text"}, metadata.InputModalities)
- require.Equal(t, int64(64_000), metadata.ContextWindow)
- require.NotNil(t, repo.updates)
- }
- func TestSyncUpstreamModelCatalogClassifiesSnapshotPersistenceFailureAsInternal(t *testing.T) {
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"models":[{
- "id":"company-coding-model",
- "reasoning":false,
- "input_modalities":["text"],
- "context_window":64000
- }]}`)),
- }}
- repo := &upstreamModelMetadataRepoStub{err: errors.New("database unavailable")}
- svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
- _, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
- ID: 95, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
- })
- require.Error(t, err)
- var syncErr *UpstreamModelSyncError
- require.ErrorAs(t, err, &syncErr)
- require.Equal(t, UpstreamModelSyncErrorInternal, syncErr.Kind)
- }
- // Scenario: 元数据源失败时保留已有快照。
- func TestSyncUpstreamModelCatalogDoesNotOverwriteSnapshotWhenRegistryFails(t *testing.T) {
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`))},
- {StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"unavailable"}`))},
- }}
- repo := &upstreamModelMetadataRepoStub{}
- svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
- catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
- ID: 93, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{"api_key": "key", "base_url": "https://opencode.ai/zen/v1"},
- Extra: map[string]any{UpstreamModelMetadataExtraKey: map[string]any{
- "source": "models.dev", "models": map[string]any{"x-preview-f-free": map[string]any{"reasoning": true}},
- }},
- })
- require.NoError(t, err)
- require.Equal(t, []string{"x-preview-f-free"}, catalog.Models)
- require.Empty(t, catalog.Metadata)
- require.Equal(t, []UpstreamModelSyncWarning{{
- Code: UpstreamModelMetadataIncompleteCode,
- Message: "Model IDs were synced, but capability metadata is incomplete.",
- }}, catalog.Warnings)
- require.Nil(t, repo.updates, "a failed metadata enrichment must not erase a previously saved snapshot")
- }
- func TestSyncUpstreamModelCatalogDoesNotPersistPartialMetadataWhenRegistryFails(t *testing.T) {
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"models":[{
- "id":"partially-described-model",
- "display_name":"Partial Model"
- }]}`))},
- {StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"unavailable"}`))},
- }}
- repo := &upstreamModelMetadataRepoStub{}
- svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
- catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
- ID: 96, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
- Extra: map[string]any{UpstreamModelMetadataExtraKey: map[string]any{
- "source": "upstream", "models": map[string]any{"partially-described-model": map[string]any{
- "reasoning": true, "supported_reasoning_levels": []any{"low", "high"},
- }},
- }},
- })
- require.NoError(t, err)
- require.Equal(t, []string{"partially-described-model"}, catalog.Models)
- require.Equal(t, "Partial Model", catalog.Metadata["partially-described-model"].DisplayName)
- require.Equal(t, UpstreamModelMetadataIncompleteCode, catalog.Warnings[0].Code)
- require.Nil(t, repo.updates, "partial metadata must not replace a more complete persisted snapshot")
- }
- // Scenario: 图片专用模型缺少 context 时,不阻止 agent 模型能力落库,也不误报整批失败。
- func TestSyncUpstreamModelCatalogIgnoresDedicatedMediaModelsForCompleteness(t *testing.T) {
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[
- {"id":"gpt-6-astra","object":"model"},
- {"id":"gpt-image-2","object":"model"}
- ]}`)),
- },
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{
- "openai": {
- "id": "openai",
- "models": {
- "gpt-6-astra": {
- "id": "gpt-6-astra",
- "name": "GPT-6 Astra",
- "reasoning": true,
- "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
- "modalities": {"input":["text","image"],"output":["text"]},
- "limit": {"context":1050000,"output":128000}
- },
- "gpt-image-2": {
- "id": "gpt-image-2",
- "name": "gpt-image-2",
- "reasoning": false,
- "modalities": {"input":["text","image"],"output":["image"]},
- "limit": {"context":0,"output":0}
- }
- }
- }
- }`)),
- },
- }}
- repo := &upstreamModelMetadataRepoStub{}
- svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
- catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
- ID: 113, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "sk-test",
- "base_url": "https://api.openai.com/v1",
- "model_mapping": map[string]any{
- "gpt-6-astra": "gpt-6-astra",
- "gpt-image-2": "gpt-image-2",
- },
- },
- })
- require.NoError(t, err)
- require.Empty(t, catalog.Warnings, "media generators must not keep agent capability sync in a failed state")
- require.NotNil(t, repo.updates)
- encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey])
- require.NoError(t, err)
- var snapshot UpstreamModelMetadataSnapshot
- require.NoError(t, json.Unmarshal(encoded, &snapshot))
- require.Contains(t, snapshot.Models, "gpt-6-astra")
- require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, snapshot.Models["gpt-6-astra"].SupportedReasoningLevels)
- require.NotContains(t, snapshot.Models, "gpt-image-2")
- }
- func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) {
- t.Parallel()
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"gpt-5"}]}`)),
- }}
- cfg := upstreamModelSyncTestConfig()
- cfg.Gateway.ModelsListReadMaxBytes = 8
- svc := &AccountTestService{httpUpstream: upstream, cfg: cfg}
- _, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{
- ID: 7,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "openai-key",
- "base_url": "https://openai.example.com/v1",
- },
- })
- require.Error(t, err)
- require.Contains(t, err.Error(), "response exceeds 8 bytes")
- }
- func TestMatchModelsDevProviderFallsBackToOpenAIProviderWithoutAPIField(t *testing.T) {
- t.Parallel()
- registry := map[string]modelsDevProvider{
- "openai": {
- ID: "openai",
- Name: "OpenAI",
- // Official models.dev entry currently omits `api`.
- Models: map[string]modelsDevModel{
- "gpt-6-astra": {ID: "gpt-6-astra", Name: "GPT-6 Astra"},
- },
- },
- "opencode": {
- ID: "opencode",
- API: "https://opencode.ai/zen/v1",
- Models: map[string]modelsDevModel{
- "x-preview-f-free": {ID: "x-preview-f-free", Name: "Ox"},
- },
- },
- }
- for _, baseURL := range []string{
- "https://api.openai.com",
- "https://api.openai.com/v1",
- "https://chatgpt.com/backend-api/codex",
- } {
- provider, ok := matchModelsDevProvider(registry, baseURL)
- require.True(t, ok, baseURL)
- require.Equal(t, "openai", provider.ID, baseURL)
- require.Contains(t, provider.Models, "gpt-6-astra", baseURL)
- }
- _, ok := matchModelsDevProvider(registry, "https://compatible.example/v1")
- require.False(t, ok, "custom hosts must not inherit the official OpenAI provider by name")
- provider, ok := matchModelsDevProvider(registry, "https://opencode.ai/zen/v1")
- require.True(t, ok)
- require.Equal(t, "opencode", provider.ID)
- }
- // Scenario: 官方 OpenAI ID-only /models + 无 api 字段的 models.dev openai 条目仍能补齐并落库。
- func TestSyncUpstreamModelCatalogEnrichesOfficialOpenAIHostWithoutRegistryAPIField(t *testing.T) {
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[
- {"id":"gpt-6-astra","object":"model"},
- {"id":"gpt-5.6-sol","object":"model"}
- ]}`)),
- },
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{
- "openai": {
- "id": "openai",
- "name": "OpenAI",
- "models": {
- "gpt-6-astra": {
- "id": "gpt-6-astra",
- "name": "GPT-6 Astra",
- "description": "OpenAI GPT-6 Astra",
- "reasoning": true,
- "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
- "modalities": {"input":["text","image","pdf"],"output":["text"]},
- "limit": {"context":1050000,"output":128000}
- },
- "gpt-5.6-sol": {
- "id": "gpt-5.6-sol",
- "name": "GPT-5.6 Sol",
- "reasoning": true,
- "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
- "modalities": {"input":["text","image"],"output":["text"]},
- "limit": {"context":1050000,"output":128000}
- }
- }
- }
- }`)),
- },
- }}
- repo := &upstreamModelMetadataRepoStub{}
- svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
- catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
- ID: 110, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "sk-test",
- "base_url": "https://api.openai.com/v1",
- },
- })
- require.NoError(t, err)
- require.Empty(t, catalog.Warnings)
- require.Equal(t, []string{"gpt-5.6-sol", "gpt-6-astra"}, catalog.Models)
- astra := catalog.Metadata["gpt-6-astra"]
- require.Equal(t, "GPT-6 Astra", astra.DisplayName)
- require.NotNil(t, astra.Reasoning)
- require.True(t, *astra.Reasoning)
- require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, astra.SupportedReasoningLevels)
- require.Equal(t, []string{"text", "image"}, astra.InputModalities)
- require.Equal(t, int64(1_050_000), astra.ContextWindow)
- require.NotNil(t, repo.updates)
- encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey])
- require.NoError(t, err)
- var snapshot UpstreamModelMetadataSnapshot
- require.NoError(t, json.Unmarshal(encoded, &snapshot))
- require.Equal(t, "models.dev", snapshot.Source)
- require.Equal(t, astra, snapshot.Models["gpt-6-astra"])
- }
- // Scenario: 同一批同步里部分模型能力完整时仍落库完整条目,并对不完整条目告警。
- func TestSyncUpstreamModelCatalogPersistsCompleteModelsWhenSomeRemainIncomplete(t *testing.T) {
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"models":[
- {
- "id":"complete-model",
- "display_name":"Complete",
- "reasoning":true,
- "supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}],
- "input_modalities":["text","image"],
- "context_window":128000
- },
- {
- "id":"incomplete-model",
- "display_name":"Incomplete Only"
- }
- ]}`)),
- },
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{
- "provider": {
- "id": "provider",
- "api": "https://provider.example/v1",
- "models": {}
- }
- }`)),
- },
- }}
- repo := &upstreamModelMetadataRepoStub{}
- svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
- catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
- ID: 111, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
- })
- require.NoError(t, err)
- require.Equal(t, []string{"complete-model", "incomplete-model"}, catalog.Models)
- require.Equal(t, UpstreamModelMetadataPartialCode, catalog.Warnings[0].Code)
- require.NotNil(t, repo.updates)
- encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey])
- require.NoError(t, err)
- var snapshot UpstreamModelMetadataSnapshot
- require.NoError(t, json.Unmarshal(encoded, &snapshot))
- require.Contains(t, snapshot.Models, "complete-model")
- require.NotContains(t, snapshot.Models, "incomplete-model")
- require.Equal(t, []string{"low", "high"}, snapshot.Models["complete-model"].SupportedReasoningLevels)
- }
- // Scenario: 上游清单未包含管理员 mapping 目标时,仍按 mapping 补齐并写入快照。
- func TestSyncUpstreamModelCatalogEnrichesConfiguredMappingModelsMissingFromUpstreamList(t *testing.T) {
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"gpt-5.6-sol","object":"model"}]}`)),
- },
- {
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{
- "openai": {
- "id": "openai",
- "models": {
- "gpt-5.6-sol": {
- "id": "gpt-5.6-sol",
- "name": "GPT-5.6 Sol",
- "reasoning": true,
- "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
- "modalities": {"input":["text","image"],"output":["text"]},
- "limit": {"context":1050000,"output":128000}
- },
- "gpt-6-astra": {
- "id": "gpt-6-astra",
- "name": "GPT-6 Astra",
- "reasoning": true,
- "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
- "modalities": {"input":["text","image"],"output":["text"]},
- "limit": {"context":1050000,"output":128000}
- }
- }
- }
- }`)),
- },
- }}
- repo := &upstreamModelMetadataRepoStub{}
- svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
- catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
- ID: 112, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "api_key": "sk-test",
- "base_url": "https://api.openai.com/v1",
- "model_mapping": map[string]any{
- "gpt-6-astra": "gpt-6-astra",
- },
- },
- })
- require.NoError(t, err)
- require.Equal(t, []string{"gpt-5.6-sol"}, catalog.Models, "UI model list stays upstream-only")
- require.Empty(t, catalog.Warnings)
- require.Contains(t, catalog.Metadata, "gpt-6-astra")
- require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, catalog.Metadata["gpt-6-astra"].SupportedReasoningLevels)
- encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey])
- require.NoError(t, err)
- var snapshot UpstreamModelMetadataSnapshot
- require.NoError(t, json.Unmarshal(encoded, &snapshot))
- require.Contains(t, snapshot.Models, "gpt-5.6-sol")
- require.Contains(t, snapshot.Models, "gpt-6-astra")
- }
- func TestSyncUpstreamModelCatalogAstraPartialRefreshPreservesKnownCapabilities(t *testing.T) {
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":[
- {"id":"gpt-6-astra","supports_search_tool":false,"apply_patch_tool_type":null},
- {"id":"still-listed"},{"id":"gpt-image-2"}
- ]}`))},
- {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"openai":{"id":"openai","models":{
- "gpt-6-astra":{"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"modalities":{"input":["text","image"]},"limit":{"context":1050000,"output":128000}},
- "mapped-only":{"reasoning":false,"modalities":{"input":["text"]},"limit":{"context":64000}},
- "gpt-image-2":{"reasoning":false,"modalities":{"input":["text","image"]},"limit":{"context":0}}
- }}}`))},
- }}
- repo := &upstreamModelMetadataRepoStub{}
- svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
- account := &Account{ID: 114, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{"api_key": "test", "base_url": "https://api.openai.com/v1",
- "model_mapping": map[string]any{"public-model": "mapped-only"}},
- }
- old := UpstreamModelMetadata{ID: "still-listed", ContextWindow: 256000}
- account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
- "still-listed": old, "removed": {ID: "removed", ContextWindow: 128000},
- "gpt-6-astra": {ID: "gpt-6-astra", CodexToolCapabilities: map[string]json.RawMessage{
- "supports_search_tool": json.RawMessage("true"), "apply_patch_tool_type": json.RawMessage(`"freeform"`),
- "comp_hash": json.RawMessage(`"3000"`),
- }},
- }})
- catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
- require.NoError(t, err)
- require.Equal(t, UpstreamModelMetadataPartialCode, catalog.Warnings[0].Code)
- require.NotContains(t, catalog.Models, "mapped-only", "capability enrichment must not change discovery")
- snapshot := account.GetUpstreamModelMetadataSnapshot()
- require.Equal(t, old, snapshot.Models["still-listed"])
- require.NotContains(t, snapshot.Models, "removed")
- require.NotContains(t, snapshot.Models, "gpt-image-2")
- require.Contains(t, snapshot.Models, "mapped-only")
- astra := snapshot.Models["gpt-6-astra"]
- require.Equal(t, int64(1050000), astra.ContextWindow)
- require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, astra.SupportedReasoningLevels)
- require.JSONEq(t, "false", string(astra.CodexToolCapabilities["supports_search_tool"]))
- require.JSONEq(t, "null", string(astra.CodexToolCapabilities["apply_patch_tool_type"]))
- require.JSONEq(t, `"3000"`, string(astra.CodexToolCapabilities["comp_hash"]))
- require.NotNil(t, repo.updates)
- }
- func TestMatchModelsDevProviderOfficialHostsWithoutAPI(t *testing.T) {
- registry := map[string]modelsDevProvider{
- "openai": {ID: "openai", Models: map[string]modelsDevModel{"gpt-6-astra": {ID: "gpt-6-astra"}}},
- "relay": {ID: "relay", API: "https://relay.example/v1"},
- }
- for _, baseURL := range []string{"https://api.openai.com/v1", "https://chatgpt.com/backend-api/codex"} {
- provider, ok := matchModelsDevProvider(registry, baseURL)
- require.True(t, ok)
- require.Equal(t, "openai", provider.ID)
- }
- for _, baseURL := range []string{"https://api.openai.com.evil.example/v1", "https://unknown.example/v1"} {
- _, ok := matchModelsDevProvider(registry, baseURL)
- require.False(t, ok)
- }
- provider, ok := matchModelsDevProvider(registry, "https://relay.example/v1")
- require.True(t, ok)
- require.Equal(t, "relay", provider.ID)
- metadata := map[string]UpstreamModelMetadata{"gpt-6-astra": {
- Reasoning: new(bool), InputModalities: []string{"text"}, ContextWindow: 1050000,
- }}
- require.False(t, upstreamCatalogNeedsRegistry(capabilitySyncModelIDs([]string{"gpt-6-astra", "gpt-image-2"}), metadata))
- }
|