@@ -569,6 +569,18 @@ describe('fetchStartupModels', () => {
569569 expect ( cachedModels . copilot ) . toBeUndefined ( ) ;
570570 } ) ;
571571
572+ it ( 'should populate cachedModels.copilot when BYOK key + custom provider target (adapter-based path)' , async ( ) => {
573+ mockHttpsRequestWithBody ( 200 , '{"data":[{"id":"minimax/minimax-m2.5:free"},{"id":"openai/gpt-4o"}]}' ) ;
574+ const adapter = createCopilotAdapter ( {
575+ COPILOT_API_KEY : 'sk-or-byok-key' ,
576+ COPILOT_API_TARGET : 'openrouter.ai' ,
577+ COPILOT_API_BASE_PATH : '/api/v1' ,
578+ } ) ;
579+ await fetchStartupModels ( [ adapter ] ) ;
580+ // Models from the custom BYOK provider should be cached
581+ expect ( cachedModels . copilot ) . toEqual ( [ 'minimax/minimax-m2.5:free' , 'openai/gpt-4o' ] ) ;
582+ } ) ;
583+
572584 it ( 'should skip fetching when no keys are configured' , async ( ) => {
573585 const spy = jest . spyOn ( https , 'request' ) ;
574586 await fetchStartupModels ( {
@@ -1508,6 +1520,129 @@ describe('provider adapter alwaysBind', () => {
15081520 } ) ;
15091521} ) ;
15101522
1523+ // ── Copilot adapter BYOK model fetch ──────────────────────────────────────────
1524+ //
1525+ // These tests verify that the Copilot adapter fetches models from a custom
1526+ // BYOK provider (e.g. OpenRouter) at startup, and that the reflect response
1527+ // includes the correct base-path-aware models URL.
1528+ //
1529+
1530+ describe ( 'copilot adapter BYOK model fetch' , ( ) => {
1531+ it ( 'getModelsFetchConfig returns null for BYOK key on standard Copilot API (no GitHub token)' , ( ) => {
1532+ const adapter = createCopilotAdapter ( {
1533+ COPILOT_API_KEY : 'sk-byok-key' ,
1534+ COPILOT_API_TARGET : 'api.githubcopilot.com' ,
1535+ } ) ;
1536+ expect ( adapter . getModelsFetchConfig ( ) ) . toBeNull ( ) ;
1537+ } ) ;
1538+
1539+ it ( 'getModelsFetchConfig returns fetch config for BYOK key on custom target' , ( ) => {
1540+ const adapter = createCopilotAdapter ( {
1541+ COPILOT_API_KEY : 'sk-or-key' ,
1542+ COPILOT_API_TARGET : 'openrouter.ai' ,
1543+ COPILOT_API_BASE_PATH : '/api/v1' ,
1544+ } ) ;
1545+ const config = adapter . getModelsFetchConfig ( ) ;
1546+ expect ( config ) . not . toBeNull ( ) ;
1547+ expect ( config . url ) . toBe ( 'https://openrouter.ai/api/v1/models' ) ;
1548+ expect ( config . opts . method ) . toBe ( 'GET' ) ;
1549+ expect ( config . opts . headers [ 'Authorization' ] ) . toBe ( 'Bearer sk-or-key' ) ;
1550+ expect ( config . cacheKey ) . toBe ( 'copilot' ) ;
1551+ } ) ;
1552+
1553+ it ( 'getModelsFetchConfig uses github token for standard Copilot API target' , ( ) => {
1554+ const adapter = createCopilotAdapter ( {
1555+ COPILOT_GITHUB_TOKEN : 'ghu_token' ,
1556+ } ) ;
1557+ const config = adapter . getModelsFetchConfig ( ) ;
1558+ expect ( config ) . not . toBeNull ( ) ;
1559+ expect ( config . url ) . toBe ( 'https://api.githubcopilot.com/models' ) ;
1560+ expect ( config . opts . headers [ 'Authorization' ] ) . toBe ( 'Bearer ghu_token' ) ;
1561+ expect ( config . opts . headers [ 'Copilot-Integration-Id' ] ) . toBeDefined ( ) ;
1562+ expect ( config . cacheKey ) . toBe ( 'copilot' ) ;
1563+ } ) ;
1564+
1565+ it ( 'getModelsFetchConfig returns null when no auth token is configured' , ( ) => {
1566+ const adapter = createCopilotAdapter ( { } ) ;
1567+ expect ( adapter . getModelsFetchConfig ( ) ) . toBeNull ( ) ;
1568+ } ) ;
1569+
1570+ it ( 'getModelsFetchConfig uses /models directly when basePath is not configured' , ( ) => {
1571+ // When no basePath is set, /models is used directly (no prefix)
1572+ const adapter = createCopilotAdapter ( {
1573+ COPILOT_API_KEY : 'sk-custom-key' ,
1574+ COPILOT_API_TARGET : 'custom.llm.example.com' ,
1575+ } ) ;
1576+ const config = adapter . getModelsFetchConfig ( ) ;
1577+ expect ( config ) . not . toBeNull ( ) ;
1578+ expect ( config . url ) . toBe ( 'https://custom.llm.example.com/models' ) ;
1579+ } ) ;
1580+
1581+ it ( 'getModelsFetchConfig uses /models (not //models) when basePath is "/"' , ( ) => {
1582+ // normalizeBasePath('/') returns '/' — ensure we don't produce //models
1583+ const adapter = createCopilotAdapter ( {
1584+ COPILOT_API_KEY : 'sk-custom-key' ,
1585+ COPILOT_API_TARGET : 'custom.llm.example.com' ,
1586+ COPILOT_API_BASE_PATH : '/' ,
1587+ } ) ;
1588+ const config = adapter . getModelsFetchConfig ( ) ;
1589+ expect ( config ) . not . toBeNull ( ) ;
1590+ expect ( config . url ) . toBe ( 'https://custom.llm.example.com/models' ) ;
1591+ expect ( config . url ) . not . toContain ( '//models' ) ;
1592+ } ) ;
1593+
1594+ it ( 'getModelsFetchConfig uses COPILOT_API_KEY (not GitHub token) for custom targets even when both are set' , ( ) => {
1595+ // Verify that the GitHub OAuth token is never sent to third-party BYOK providers
1596+ const adapter = createCopilotAdapter ( {
1597+ COPILOT_GITHUB_TOKEN : 'ghu_github_token' ,
1598+ COPILOT_API_KEY : 'sk-byok-key' ,
1599+ COPILOT_API_TARGET : 'openrouter.ai' ,
1600+ COPILOT_API_BASE_PATH : '/api/v1' ,
1601+ } ) ;
1602+ const config = adapter . getModelsFetchConfig ( ) ;
1603+ expect ( config ) . not . toBeNull ( ) ;
1604+ expect ( config . opts . headers [ 'Authorization' ] ) . toBe ( 'Bearer sk-byok-key' ) ;
1605+ expect ( config . opts . headers [ 'Authorization' ] ) . not . toContain ( 'ghu_github_token' ) ;
1606+ } ) ;
1607+
1608+ it ( 'getModelsFetchConfig returns null for custom target when only github token is set (no BYOK key)' , ( ) => {
1609+ // Without an explicit COPILOT_API_KEY there is nothing to authenticate with
1610+ // at the custom provider — skip the fetch rather than forward the GitHub token.
1611+ const adapter = createCopilotAdapter ( {
1612+ COPILOT_GITHUB_TOKEN : 'ghu_token' ,
1613+ COPILOT_API_TARGET : 'openrouter.ai' ,
1614+ } ) ;
1615+ expect ( adapter . getModelsFetchConfig ( ) ) . toBeNull ( ) ;
1616+ } ) ;
1617+
1618+ it ( 'getReflectionInfo includes /models for standard Copilot API (no base path)' , ( ) => {
1619+ const adapter = createCopilotAdapter ( { COPILOT_GITHUB_TOKEN : 'ghu_token' } ) ;
1620+ const info = adapter . getReflectionInfo ( ) ;
1621+ expect ( info . models_url ) . toBe ( 'http://api-proxy:10002/models' ) ;
1622+ } ) ;
1623+
1624+ it ( 'getReflectionInfo includes base path in models_url for BYOK providers' , ( ) => {
1625+ const adapter = createCopilotAdapter ( {
1626+ COPILOT_API_KEY : 'sk-or-key' ,
1627+ COPILOT_API_TARGET : 'openrouter.ai' ,
1628+ COPILOT_API_BASE_PATH : '/api/v1' ,
1629+ } ) ;
1630+ const info = adapter . getReflectionInfo ( ) ;
1631+ expect ( info . models_url ) . toBe ( 'http://api-proxy:10002/api/v1/models' ) ;
1632+ } ) ;
1633+
1634+ it ( 'getReflectionInfo uses /models (not //models) when basePath is "/"' , ( ) => {
1635+ const adapter = createCopilotAdapter ( {
1636+ COPILOT_API_KEY : 'sk-or-key' ,
1637+ COPILOT_API_TARGET : 'openrouter.ai' ,
1638+ COPILOT_API_BASE_PATH : '/' ,
1639+ } ) ;
1640+ const info = adapter . getReflectionInfo ( ) ;
1641+ expect ( info . models_url ) . toBe ( 'http://api-proxy:10002/models' ) ;
1642+ expect ( info . models_url ) . not . toContain ( '//models' ) ;
1643+ } ) ;
1644+ } ) ;
1645+
15111646describe ( 'extractBillingHeaders' , ( ) => {
15121647 it ( 'returns null when no billing headers present' , ( ) => {
15131648 expect ( extractBillingHeaders ( { 'content-type' : 'application/json' } ) ) . toBeNull ( ) ;
0 commit comments