88 isAnthropicModel ,
99 isBlockedModelId ,
1010 isCloudflareModel ,
11+ pickAllowedModel ,
1112} from "./gateway-models" ;
1213
1314const model = ( id : string , owned_by = "" ) : GatewayModel => ( {
@@ -16,6 +17,7 @@ const model = (id: string, owned_by = ""): GatewayModel => ({
1617 context_window : 128000 ,
1718 supports_streaming : true ,
1819 supports_vision : false ,
20+ allowed : true ,
1921} ) ;
2022
2123describe ( "formatGatewayModelName" , ( ) => {
@@ -27,6 +29,7 @@ describe("formatGatewayModelName", () => {
2729 context_window : 200000 ,
2830 supports_streaming : true ,
2931 supports_vision : true ,
32+ allowed : true ,
3033 } ) ,
3134 ) . toBe ( "Claude Opus 4.8" ) ;
3235 } ) ;
@@ -39,6 +42,7 @@ describe("formatGatewayModelName", () => {
3942 context_window : 200000 ,
4043 supports_streaming : true ,
4144 supports_vision : true ,
45+ allowed : true ,
4246 } ) ,
4347 ) . toBe ( "gpt-5.5" ) ;
4448 } ) ;
@@ -51,6 +55,7 @@ describe("formatGatewayModelName", () => {
5155 context_window : 200000 ,
5256 supports_streaming : true ,
5357 supports_vision : true ,
58+ allowed : true ,
5459 } ) ,
5560 ) . toBe ( "gpt-5.5" ) ;
5661 } ) ;
@@ -63,6 +68,7 @@ describe("formatGatewayModelName", () => {
6368 context_window : 128000 ,
6469 supports_streaming : true ,
6570 supports_vision : false ,
71+ allowed : true ,
6672 } ) ,
6773 ) . toBe ( "glm-5.2" ) ;
6874 } ) ;
@@ -167,6 +173,60 @@ describe("gateway model fetch timeout", () => {
167173 ) ;
168174} ) ;
169175
176+ describe ( "gateway models cache" , ( ) => {
177+ afterEach ( ( ) => {
178+ vi . restoreAllMocks ( ) ;
179+ } ) ;
180+
181+ const modelsResponse = ( allowed : boolean ) =>
182+ new Response (
183+ JSON . stringify ( {
184+ object : "list" ,
185+ data : [
186+ {
187+ id : "claude-opus-4-8" ,
188+ owned_by : "anthropic" ,
189+ context_window : 200000 ,
190+ supports_streaming : true ,
191+ supports_vision : true ,
192+ allowed,
193+ } ,
194+ ] ,
195+ } ) ,
196+ { status : 200 , headers : { "Content-Type" : "application/json" } } ,
197+ ) ;
198+
199+ // Restriction marks are org-scoped: an org switch swaps the token in the
200+ // same process, and the old org's marks must not be served to the new one.
201+ it ( "does not serve one token's marks to another token" , async ( ) => {
202+ const fetchMock = vi
203+ . spyOn ( globalThis , "fetch" )
204+ . mockResolvedValueOnce ( modelsResponse ( false ) )
205+ . mockResolvedValueOnce ( modelsResponse ( true ) ) ;
206+ const gatewayUrl = "https://gateway.token-key-test" ;
207+
208+ const first = await fetchGatewayModels ( { gatewayUrl, authToken : "tok-a" } ) ;
209+ const second = await fetchGatewayModels ( { gatewayUrl, authToken : "tok-b" } ) ;
210+
211+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 2 ) ;
212+ expect ( first [ 0 ] ?. allowed ) . toBe ( false ) ;
213+ expect ( second [ 0 ] ?. allowed ) . toBe ( true ) ;
214+ } ) ;
215+
216+ it ( "serves the cached list to the same token without refetching" , async ( ) => {
217+ const fetchMock = vi
218+ . spyOn ( globalThis , "fetch" )
219+ . mockResolvedValue ( modelsResponse ( false ) ) ;
220+ const gatewayUrl = "https://gateway.token-cache-hit-test" ;
221+
222+ await fetchGatewayModels ( { gatewayUrl, authToken : "tok-a" } ) ;
223+ const cached = await fetchGatewayModels ( { gatewayUrl, authToken : "tok-a" } ) ;
224+
225+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 1 ) ;
226+ expect ( cached [ 0 ] ?. allowed ) . toBe ( false ) ;
227+ } ) ;
228+ } ) ;
229+
170230describe ( "isCloudflareModel" , ( ) => {
171231 it . each ( [
172232 { id : "@cf/zai-org/glm-5.2" , owned_by : "cloudflare" , expected : true } ,
@@ -189,3 +249,46 @@ describe("isCloudflareModel", () => {
189249 expect ( isAnthropicModel ( glm ) ) . toBe ( false ) ;
190250 } ) ;
191251} ) ;
252+
253+ describe ( "pickAllowedModel" , ( ) => {
254+ const entry = ( id : string , allowed : boolean ) => ( { id, allowed } ) ;
255+
256+ it . each ( [
257+ [
258+ "keeps an allowed preferred model" ,
259+ [ entry ( "claude-opus-4-8" , true ) ] ,
260+ "claude-opus-4-8" ,
261+ "claude-opus-4-8" ,
262+ ] ,
263+ [
264+ "keeps a preferred model absent from the list" ,
265+ [ entry ( "claude-opus-4-8" , true ) ] ,
266+ "claude-sonnet-5" ,
267+ "claude-sonnet-5" ,
268+ ] ,
269+ [
270+ "moves a restricted preferred model to the newest allowed one" ,
271+ [
272+ entry ( "claude-opus-4-8" , false ) ,
273+ entry ( "claude-sonnet-4-6" , true ) ,
274+ entry ( "@cf/zai-org/glm-5.2" , true ) ,
275+ ] ,
276+ "claude-opus-4-8" ,
277+ "@cf/zai-org/glm-5.2" ,
278+ ] ,
279+ [
280+ "keeps the preferred model when everything is restricted" ,
281+ [ entry ( "claude-opus-4-8" , false ) ] ,
282+ "claude-opus-4-8" ,
283+ "claude-opus-4-8" ,
284+ ] ,
285+ [
286+ "keeps the preferred model when the list is empty" ,
287+ [ ] ,
288+ "claude-opus-4-8" ,
289+ "claude-opus-4-8" ,
290+ ] ,
291+ ] as const ) ( "%s" , ( _name , models , preferred , expected ) => {
292+ expect ( pickAllowedModel ( models , preferred ) ) . toBe ( expected ) ;
293+ } ) ;
294+ } ) ;
0 commit comments