55 CredentialType ,
66 BUBBLE_CREDENTIAL_OPTIONS ,
77 RECOMMENDED_MODELS ,
8+ getCanonicalCredentialType ,
9+ getSiblingCredentialTypes ,
810} from '@bubblelab/shared-schemas' ;
911import { StateGraph , MessagesAnnotation } from '@langchain/langgraph' ;
1012import { ChatOpenAI } from '@langchain/openai' ;
@@ -363,6 +365,8 @@ const AIAgentParamsSchema = z.object({
363365 id : z . number ( ) ,
364366 name : z . string ( ) ,
365367 value : z . string ( ) ,
368+ isDefault : z . boolean ( ) . optional ( ) ,
369+ attributes : z . record ( z . string ( ) , z . string ( ) ) . optional ( ) ,
366370 } )
367371 )
368372 )
@@ -1494,44 +1498,75 @@ export class AIAgentBubble extends ServiceBubble<
14941498 ? { ...this . params . credentials }
14951499 : undefined ;
14961500
1501+ type PoolEntry = { id : number ; name : string ; value : string } ;
1502+ const findInPool = (
1503+ pool : PoolEntry [ ] | undefined ,
1504+ selector : string | number
1505+ ) : PoolEntry | undefined => {
1506+ if ( ! pool ) return undefined ;
1507+ let match : PoolEntry | undefined ;
1508+ if ( typeof selector === 'string' ) {
1509+ const sel = selector . toLowerCase ( ) ;
1510+ match = pool . find ( ( c ) => c . name . toLowerCase ( ) === sel ) ;
1511+ if ( ! match ) {
1512+ match = pool . find ( ( c ) => c . name . toLowerCase ( ) . includes ( sel ) ) ;
1513+ }
1514+ }
1515+ if ( ! match && typeof selector === 'number' ) {
1516+ match = pool . find ( ( c ) => c . id === selector ) ;
1517+ }
1518+ if ( ! match && typeof selector === 'string' ) {
1519+ const asNum = Number ( selector ) ;
1520+ if ( ! Number . isNaN ( asNum ) ) {
1521+ match = pool . find ( ( c ) => c . id === asNum ) ;
1522+ }
1523+ }
1524+ return match ;
1525+ } ;
1526+
1527+ let overrideTypes : string [ ] | undefined ;
14971528 if (
14981529 credentialOverrides &&
14991530 this . params . credentialPool &&
15001531 subAgentCredentials
15011532 ) {
1533+ overrideTypes = [ ] ;
15021534 for ( const [ credType , credSelector ] of Object . entries (
15031535 credentialOverrides
15041536 ) ) {
1505- const pool =
1506- this . params . credentialPool [ credType as CredentialType ] ;
1507- if ( ! pool ) continue ;
1508-
1509- // Match by name first (string), fall back to ID (number)
1510- let match : ( typeof pool ) [ number ] | undefined ;
1511- if ( typeof credSelector === 'string' ) {
1512- const sel = credSelector . toLowerCase ( ) ;
1513- // Exact match first, then substring (handles "email (label)" format)
1514- match = pool . find ( ( c ) => c . name . toLowerCase ( ) === sel ) ;
1515- if ( ! match ) {
1516- match = pool . find ( ( c ) =>
1517- c . name . toLowerCase ( ) . includes ( sel )
1537+ let matchedType = credType as CredentialType ;
1538+ let match = findInPool (
1539+ this . params . credentialPool [ matchedType ] ,
1540+ credSelector
1541+ ) ;
1542+ // Sibling fallback: when the requested type's pool has no
1543+ // match, walk paired sibling types (e.g. SLACK_CRED ↔
1544+ // SLACK_API) so the master can route across OAuth/API-key
1545+ // accounts of the same logical provider.
1546+ if ( ! match ) {
1547+ for ( const sibling of getSiblingCredentialTypes (
1548+ credType as CredentialType
1549+ ) ) {
1550+ if ( sibling === matchedType ) continue ;
1551+ const found = findInPool (
1552+ this . params . credentialPool [ sibling ] ,
1553+ credSelector
15181554 ) ;
1519- }
1520- }
1521- if ( ! match && typeof credSelector === 'number' ) {
1522- match = pool . find ( ( c ) => c . id === credSelector ) ;
1523- }
1524- // Also try parsing string as number for ID fallback
1525- if ( ! match && typeof credSelector === 'string' ) {
1526- const asNum = Number ( credSelector ) ;
1527- if ( ! Number . isNaN ( asNum ) ) {
1528- match = pool . find ( ( c ) => c . id === asNum ) ;
1555+ if ( found ) {
1556+ match = found ;
1557+ matchedType = sibling ;
1558+ break ;
1559+ }
15291560 }
15301561 }
15311562
15321563 if ( match ) {
1533- subAgentCredentials [ credType as CredentialType ] = match . value ;
1564+ subAgentCredentials [ matchedType ] = match . value ;
15341565 }
1566+ // Record the resolved real type (or the requested type when
1567+ // unmatched) so downstream provider routing
1568+ // (data-analyst/utils.ts) sees what was actually selected.
1569+ overrideTypes . push ( matchedType ) ;
15351570 }
15361571 }
15371572
@@ -1540,7 +1575,8 @@ export class AIAgentBubble extends ServiceBubble<
15401575 if ( credentialOverrides && this . context ?. executionMeta ) {
15411576 (
15421577 this . context . executionMeta as Record < string , unknown >
1543- ) . _credentialOverrideTypes = Object . keys ( credentialOverrides ) ;
1578+ ) . _credentialOverrideTypes =
1579+ overrideTypes ?? Object . keys ( credentialOverrides ) ;
15441580 }
15451581
15461582 // Dynamic credentials (from manage_capability set_credential) are
@@ -1810,6 +1846,55 @@ export class AIAgentBubble extends ServiceBubble<
18101846 }
18111847 }
18121848
1849+ // Prune non-pinned siblings when a sibling pair is in play and the master
1850+ // (via use-capability `credentials` override) or a saved-flow capConfig
1851+ // explicitly pinned a specific sibling type. Without this, capabilities
1852+ // whose runtime helper does `oauth ?? api` would silently pick the OAuth
1853+ // default and ignore the pin.
1854+ const overrideTypes = (
1855+ this . context ?. executionMeta as Record < string , unknown > | undefined
1856+ ) ?. _credentialOverrideTypes as string [ ] | undefined ;
1857+ const pinned = new Set < string > ( [
1858+ ...Object . keys ( capConfig . credentials ?? { } ) ,
1859+ ...( overrideTypes ?? [ ] ) ,
1860+ ] ) ;
1861+ if ( pinned . size > 0 ) {
1862+ for ( const ct of Object . keys ( resolved ) ) {
1863+ if ( pinned . has ( ct ) ) continue ;
1864+ const siblings = getSiblingCredentialTypes ( ct as CredentialType ) ;
1865+ if ( siblings . some ( ( s ) => pinned . has ( s ) ) ) {
1866+ delete resolved [ ct as CredentialType ] ;
1867+ }
1868+ }
1869+ }
1870+
1871+ // Sibling default pre-prune: when no explicit pin AND a sibling pair is
1872+ // fully declared AND exactly one sibling type has a credential marked as
1873+ // user default (`isDefault: true` in the pool), drop the unmarked sibling
1874+ // so the cap's `oauth ?? api` collapses to the user's preferred auth
1875+ // method without requiring an override.
1876+ if ( pinned . size === 0 && this . params . credentialPool ) {
1877+ const pool = this . params . credentialPool ;
1878+ const seenCanonicals = new Set < CredentialType > ( ) ;
1879+ for ( const ct of [ ...Object . keys ( resolved ) ] as CredentialType [ ] ) {
1880+ const canonical = getCanonicalCredentialType ( ct ) ;
1881+ if ( seenCanonicals . has ( canonical ) ) continue ;
1882+ seenCanonicals . add ( canonical ) ;
1883+ const siblings = getSiblingCredentialTypes ( ct ) ;
1884+ if ( siblings . length < 2 ) continue ;
1885+ const present = siblings . filter ( ( s ) => resolved [ s ] !== undefined ) ;
1886+ if ( present . length < 2 ) continue ;
1887+ const marked = present . filter ( ( s ) =>
1888+ pool [ s ] ?. some ( ( e ) => e . isDefault === true )
1889+ ) ;
1890+ if ( marked . length === 1 ) {
1891+ for ( const s of siblings ) {
1892+ if ( s !== marked [ 0 ] ) delete resolved [ s as CredentialType ] ;
1893+ }
1894+ }
1895+ }
1896+ }
1897+
18131898 return resolved ;
18141899 }
18151900
0 commit comments